From 5509ac45ca26ce556fafdb25a39bf253765350e1 Mon Sep 17 00:00:00 2001 From: baichuan335 Date: Mon, 24 Aug 2026 23:29:24 +0800 Subject: [PATCH] feat: add custom context option --- .../components/cursor/CursorModelEditor.tsx | 5 +- apps/desktop/src/i18n/generated/catalog.json | 116 +++++++++++++----- apps/desktop/src/i18n/locales/en-US.json | 5 + apps/desktop/src/i18n/locales/zh-CN.json | 5 + apps/desktop/src/pages/CursorSettingsPage.tsx | 3 +- server/src/cursor/model_catalog.rs | 81 +++++++++--- 6 files changed, 171 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/components/cursor/CursorModelEditor.tsx b/apps/desktop/src/components/cursor/CursorModelEditor.tsx index 753858c..49b5a01 100644 --- a/apps/desktop/src/components/cursor/CursorModelEditor.tsx +++ b/apps/desktop/src/components/cursor/CursorModelEditor.tsx @@ -78,10 +78,13 @@ export function CursorModelEditor({ draft, providers, editing, modelOptions, dis { value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon }, ]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /> {(editing || draft.modelIds.length <= 1) && setModel({ display_name: event.target.value })} />} - {editing + {editing ? {discovering ? t("获取中…") : t("获取模型")}} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /> : {discovering ? t("获取中…") : t("获取模型")}} onChange={setModelIds} /> } + + setModel({ context_window_tokens: event.target.value === "" ? null : Math.trunc(Number(event.target.value)) })} /> +
onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} />
{draft.customRequestUrl && setModel({ request_url: event.target.value })} />} {!editing && draft.providerMode === "new" && <> diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index e44b9b8..32969c8 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -38,7 +38,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 92, + "line": 95, "column": 39 } ] @@ -190,7 +190,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 256, + "line": 257, "column": 89 } ] @@ -243,7 +243,7 @@ { "file": "components/cursor/CursorModelEditor.tsx", "line": 81, - "column": 119 + "column": 90 } ] }, @@ -390,7 +390,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 255, + "line": 256, "column": 36 }, { @@ -511,12 +511,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 89, + "line": 92, "column": 56 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 89, + "line": 92, "column": 141 }, { @@ -731,12 +731,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 88, + "line": 91, "column": 56 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 88, + "line": 91, "column": 123 }, { @@ -912,7 +912,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 85, + "line": 88, "column": 58 } ] @@ -1596,7 +1596,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 88, + "line": 91, "column": 78 }, { @@ -1632,6 +1632,18 @@ } ] }, + "5cca0b7972a11f3a": { + "source": "自定义上下文必须是大于 0 的整数", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "pages/CursorSettingsPage.tsx", + "line": 238, + "column": 171 + } + ] + }, "5d59857bf039cac9": { "source": "Cursor 助手 v{version}", "kind": "template", @@ -1777,7 +1789,7 @@ "refs": [ { "file": "api.ts", - "line": 289, + "line": 290, "column": 43 } ] @@ -2070,7 +2082,7 @@ "refs": [ { "file": "api.ts", - "line": 284, + "line": 285, "column": 43 } ] @@ -2132,12 +2144,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 92, + "line": 95, "column": 73 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 92, + "line": 95, "column": 122 } ] @@ -2209,7 +2221,7 @@ "refs": [ { "file": "api.ts", - "line": 230, + "line": 231, "column": 21 } ] @@ -2255,7 +2267,7 @@ "refs": [ { "file": "pages/CursorSettingsPage.tsx", - "line": 250, + "line": 251, "column": 21 } ] @@ -2318,7 +2330,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 89, + "line": 92, "column": 85 } ] @@ -2443,7 +2455,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 86, + "line": 89, "column": 100 } ] @@ -2479,7 +2491,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 86, + "line": 89, "column": 81 } ] @@ -2559,7 +2571,7 @@ { "file": "components/cursor/CursorModelEditor.tsx", "line": 81, - "column": 81 + "column": 52 } ] }, @@ -2592,6 +2604,18 @@ } ] }, + "9d8f2ef4e85ea665": { + "source": "自定义上下文 tokens", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/cursor/CursorModelEditor.tsx", + "line": 86, + "column": 63 + } + ] + }, "9e46da6923836182": { "source": "如:2026-08-23 09:00、1小时前", "kind": "text", @@ -3117,12 +3141,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 93, + "line": 96, "column": 66 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 93, + "line": 96, "column": 115 } ] @@ -3286,16 +3310,28 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 94, + "line": 97, "column": 66 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 94, + "line": 97, "column": 115 } ] }, + "c6cc835023617457": { + "source": "自定义上下文", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/cursor/CursorModelEditor.tsx", + "line": 85, + "column": 25 + } + ] + }, "c7ea2c9bc43134bd": { "source": "编辑模型", "kind": "text", @@ -3386,7 +3422,7 @@ { "file": "components/cursor/CursorModelEditor.tsx", "line": 81, - "column": 54 + "column": 25 } ] }, @@ -3443,7 +3479,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 94, + "line": 97, "column": 39 } ] @@ -3477,6 +3513,18 @@ } ] }, + "d27db596b73a0a66": { + "source": "例如:272000", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/cursor/CursorModelEditor.tsx", + "line": 86, + "column": 96 + } + ] + }, "d2d648bd1c94b7f9": { "source": "认证", "kind": "text", @@ -3555,7 +3603,7 @@ { "file": "components/ProviderEditor.tsx", "line": 26, - "column": 190 + "column": 180 } ] }, @@ -3758,7 +3806,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 93, + "line": 96, "column": 39 } ] @@ -3866,6 +3914,18 @@ } ] }, + "e671f8c7598139ef": { + "source": "输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/cursor/CursorModelEditor.tsx", + "line": 85, + "column": 44 + } + ] + }, "e6ca887f22288cde": { "source": "Model ID 和显示名称不能为空", "kind": "text", diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index bd18bfe..2f6f72b 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -110,6 +110,7 @@ "5ae715656ffbc35d": "Merge into the request body for every model from this provider.", "5b17f59d33bde39e": "Error: {error}", "5c55a67935af8f45": "All", + "5cca0b7972a11f3a": "Custom context must be an integer greater than 0", "5d59857bf039cac9": "Cursor Assistant v{version}", "5f8d556a9c47da3c": "Launch at login disabled", "5f9acfb945229062": "Are you sure you no longer want to see this ad?", @@ -180,6 +181,7 @@ "9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.", "9ac0ac940982895d": "Select provider", "9c41b3a9e12ac994": "Reasoning effort", + "9d8f2ef4e85ea665": "Custom context tokens", "9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago", "9f6fee1aba17a565": "Language", "9fb48101d237ff96": "Last week", @@ -227,6 +229,7 @@ "c1e98892a77f7a19": "{count} per page", "c3760858cdb6d9f4": "Request body", "c62a58459251b02c": "Image generation", + "c6cc835023617457": "Custom context", "c7ea2c9bc43134bd": "Edit model", "c8df3c14a003bfcd": "Unable to load call details", "c98e118e0a43f078": "Model", @@ -239,6 +242,7 @@ "cfe999e50be8ef54": "Whether the model declares image-generation support.", "d0bfccc77315d887": "Last month", "d15a909c3490a7e0": "Endpoint type", + "d27db596b73a0a66": "For example: 272000", "d2d648bd1c94b7f9": "Authentication", "d30d35c5ec4a888d": "Select or enter at least one model", "d34335433395cd3a": "Start Cursor BYOK automatically after signing in.", @@ -269,6 +273,7 @@ "e5043c7a2b408271": "Last 10 minutes", "e59ae97924d62f01": "First page", "e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.", + "e671f8c7598139ef": "After entering a token count, it is added as an extra option to the Cursor model Context list. It only takes effect when selected in Cursor.", "e6ca887f22288cde": "Model ID and display name are required", "e77e3d58b0dcffaa": "Duration", "e828bd3a0151edc2": "The local CA must be trusted by the system", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 9ea7ce3..589fa4b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -110,6 +110,7 @@ "5ae715656ffbc35d": "合并到该上游所有模型的请求体。", "5b17f59d33bde39e": "错误:{error}", "5c55a67935af8f45": "全部", + "5cca0b7972a11f3a": "自定义上下文必须是大于 0 的整数", "5d59857bf039cac9": "Cursor 助手 v{version}", "5f8d556a9c47da3c": "已关闭开机启动", "5f9acfb945229062": "你确认不想再看到此广告吗?", @@ -180,6 +181,7 @@ "9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。", "9ac0ac940982895d": "选择上游", "9c41b3a9e12ac994": "思考强度", + "9d8f2ef4e85ea665": "自定义上下文 tokens", "9e46da6923836182": "如:2026-08-23 09:00、1小时前", "9f6fee1aba17a565": "语言", "9fb48101d237ff96": "近一周", @@ -227,6 +229,7 @@ "c1e98892a77f7a19": "{count} 条/页", "c3760858cdb6d9f4": "请求体", "c62a58459251b02c": "图片生成", + "c6cc835023617457": "自定义上下文", "c7ea2c9bc43134bd": "编辑模型", "c8df3c14a003bfcd": "无法加载调用详情", "c98e118e0a43f078": "模型", @@ -239,6 +242,7 @@ "cfe999e50be8ef54": "是否声明模型支持图片生成。", "d0bfccc77315d887": "近一个月", "d15a909c3490a7e0": "端点类型", + "d27db596b73a0a66": "例如:272000", "d2d648bd1c94b7f9": "认证", "d30d35c5ec4a888d": "请至少选择或输入一个模型", "d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。", @@ -269,6 +273,7 @@ "e5043c7a2b408271": "近10分钟", "e59ae97924d62f01": "第一页", "e5b9961a0d5242e3": "端口设置已保存,重启软件后生效", + "e671f8c7598139ef": "输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。", "e6ca887f22288cde": "Model ID 和显示名称不能为空", "e77e3d58b0dcffaa": "耗时", "e828bd3a0151edc2": "需要在系统中信任本地 CA", diff --git a/apps/desktop/src/pages/CursorSettingsPage.tsx b/apps/desktop/src/pages/CursorSettingsPage.tsx index 0577f7f..c0e0d0f 100644 --- a/apps/desktop/src/pages/CursorSettingsPage.tsx +++ b/apps/desktop/src/pages/CursorSettingsPage.tsx @@ -55,7 +55,7 @@ export function CursorSettingsPage() { next.model = { model_id: model.model_id, display_name: model.display_name, enabled: model.enabled, sort_order: model.sort_order, endpoint_type: model.endpoint_type, request_url: model.request_url, - context_window_tokens: null, max_output_tokens: null, + context_window_tokens: model.context_window_tokens, max_output_tokens: null, reasoning_enabled: model.reasoning_enabled, reasoning_effort: null, supports_image_generation: model.supports_image_generation, }; @@ -235,6 +235,7 @@ function cursorModelInputs(draft: CursorModelDraft, editing: boolean) { if (!modelIds.length) throw new Error(t("请至少选择或输入一个模型")); if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空")); if (draft.customRequestUrl && !draft.model.request_url.trim()) throw new Error(t("请求完整地址不能为空")); + if (draft.model.context_window_tokens !== null && (!Number.isSafeInteger(draft.model.context_window_tokens) || draft.model.context_window_tokens <= 0)) throw new Error(t("自定义上下文必须是大于 0 的整数")); return modelIds.map((modelId, index) => ({ ...draft.model, model_id: modelId, diff --git a/server/src/cursor/model_catalog.rs b/server/src/cursor/model_catalog.rs index 3b29e9f..2a7364e 100644 --- a/server/src/cursor/model_catalog.rs +++ b/server/src/cursor/model_catalog.rs @@ -205,6 +205,43 @@ const EFFORTS: [(&str, &str); 5] = [ ]; const DEFAULT_CONTEXT: &str = "200k"; +fn context_options(model: &ProviderModel) -> Vec<(String, String)> { + let mut contexts = CONTEXTS + .into_iter() + .map(|(value, display_name)| (value.to_owned(), display_name.to_owned())) + .collect::>(); + if let Some(tokens) = model.context_window_tokens { + let value = tokens.to_string(); + let duplicate = contexts + .iter() + .any(|(existing, _)| parse_context_tokens(existing) == Some(tokens)); + if !duplicate { + contexts.push((value, format!("{} (Custom)", format_token_count(tokens)))); + } + } + contexts +} + +fn parse_context_tokens(value: &str) -> Option { + let value = value.trim().to_ascii_lowercase(); + let (number, multiplier) = match value.chars().last()? { + 'k' => (&value[..value.len() - 1], 1_000), + 'm' => (&value[..value.len() - 1], 1_000_000), + _ => (value.as_str(), 1), + }; + number.parse::().ok()?.checked_mul(multiplier) +} + +fn format_token_count(tokens: u64) -> String { + if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) { + format!("{}M", tokens / 1_000_000) + } else if tokens >= 1_000 && tokens.is_multiple_of(1_000) { + format!("{}K", tokens / 1_000) + } else { + tokens.to_string() + } +} + pub async fn available_models( State(registry): State, Extension(proxy): Extension, @@ -324,7 +361,8 @@ fn unary_payload(body: &Bytes) -> Result<(bool, &[u8])> { } fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel { - let variants = model_variants(model); + let contexts = context_options(model); + let variants = model_variants(model, &contexts); let legacy_slugs = variants .iter() .filter_map(|variant| variant.legacy_slug.clone()) @@ -348,7 +386,7 @@ fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel inputbox_short_model_name: Some(model.display_name.clone()), supports_sandboxing: Some(true), supports_cmd_k: Some(false), - parameter_definitions: model_parameters(), + parameter_definitions: model_parameters(&contexts), variants, legacy_slugs, named_model_section_index: Some(1), @@ -365,7 +403,7 @@ fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel } } -fn model_parameters() -> Vec { +fn model_parameters(contexts: &[(String, String)]) -> Vec { vec![ ModelParameterDefinition { id: "context".into(), @@ -374,11 +412,11 @@ fn model_parameters() -> Vec { parameter_type: Some(ModelParameterType { boolean_parameter: None, enum_parameter: Some(EnumParameter { - values: CONTEXTS - .into_iter() + values: contexts + .iter() .map(|(value, display_name)| EnumParameterValue { - value: value.into(), - display_name: Some(display_name.into()), + value: value.clone(), + display_name: Some(display_name.clone()), }) .collect(), }), @@ -429,9 +467,9 @@ fn model_parameters() -> Vec { ] } -fn model_variants(model: &ProviderModel) -> Vec { - let mut variants = Vec::with_capacity(CONTEXTS.len() * EFFORTS.len() * 2); - for (context, context_name) in CONTEXTS { +fn model_variants(model: &ProviderModel, contexts: &[(String, String)]) -> Vec { + let mut variants = Vec::with_capacity(contexts.len() * EFFORTS.len() * 2); + for (context, context_name) in contexts { for (effort, effort_name) in EFFORTS { for fast in [false, true] { variants.push(model_variant( @@ -546,7 +584,7 @@ mod tests { request_url: String::new(), enabled: true, sort_order: 0, - context_window_tokens: Some(200_000), + context_window_tokens: Some(272_000), max_output_tokens: None, reasoning_enabled: false, reasoning_effort: None, @@ -591,7 +629,22 @@ mod tests { .iter() .map(|value| value.value.as_str()) .collect::>(); - assert_eq!(context_values, ["200k", "356k", "800k", "1m"]); + assert_eq!(context_values, ["200k", "356k", "800k", "1m", "272000"]); + let custom_context = context + .parameter_type + .as_ref() + .unwrap() + .enum_parameter + .as_ref() + .unwrap() + .values + .iter() + .find(|value| value.value == "272000") + .unwrap(); + assert_eq!( + custom_context.display_name.as_deref(), + Some("272K (Custom)") + ); let effort = mapped .parameter_definitions .iter() @@ -607,8 +660,8 @@ mod tests { .values .iter() .any(|value| value.value == "max")); - assert_eq!(mapped.variants.len(), 40); - assert_eq!(mapped.legacy_slugs.len(), 40); + assert_eq!(mapped.variants.len(), 50); + assert_eq!(mapped.legacy_slugs.len(), 50); assert_eq!(mapped.model_picker_badges.len(), 1); assert_eq!(mapped.model_picker_badges[0].label, "OpenRouter"); assert!(!mapped.model_picker_badges[0].dismiss_on_selection);