From 4b57f7e113215a0c5bac4b2a8d7c23a596b7d70a Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 13 May 2026 17:06:05 +0800 Subject: [PATCH] feat(claude-code): role-based model mapping with display names and 1M flag - Replace the four flat env inputs with a Sonnet/Opus/Haiku role table. Each row exposes ANTHROPIC_DEFAULT_*_MODEL plus a new display name field ANTHROPIC_DEFAULT_*_MODEL_NAME, and Sonnet/Opus gain a "Declare 1M" checkbox that toggles the [1M] suffix. - Strip the [1M] context-capability marker before forwarding non-Copilot requests upstream. Copilot keeps its existing [1m]->-1m normalization. - Claude Desktop import now consumes ANTHROPIC_DEFAULT_*_MODEL_NAME as label_override, closing the Claude Code -> Claude Desktop displayName pipeline; add_route's merge logic is shared between hashmap branches. - Unify the [1M] marker as ONE_M_CONTEXT_MARKER across claude_desktop_config and proxy::model_mapper; rename the strip helper to strip_one_m_suffix_for_upstream. - Collapse useModelState's seven duplicated useState initializers and the useEffect parse block into a single parseModelsFromConfig call. - Add tests/hooks/useModelState.test.tsx and a Claude Desktop import test covering Kimi K2 -> label_override. i18n (en/ja/zh) updated. --- src-tauri/src/claude_desktop_config.rs | 7 +- src-tauri/src/commands/provider.rs | 60 +++- src-tauri/src/proxy/forwarder.rs | 20 +- src-tauri/src/proxy/model_mapper.rs | 58 ++++ .../providers/forms/ClaudeFormFields.tsx | 274 +++++++++++++----- .../providers/forms/ProviderForm.tsx | 6 + .../providers/forms/hooks/useModelState.ts | 139 +++++---- src/i18n/locales/en.json | 15 +- src/i18n/locales/ja.json | 15 +- src/i18n/locales/zh.json | 15 +- tests/hooks/useModelState.test.tsx | 110 +++++++ 11 files changed, 550 insertions(+), 169 deletions(-) create mode 100644 tests/hooks/useModelState.test.tsx diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 5d53b1394..2d777dbe3 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -26,8 +26,9 @@ const DEFAULT_CREATED_AT: &str = "2024-01-01T00:00:00Z"; pub const CLAUDE_ROUTE_PREFIX: &str = "claude-"; /// 替代前缀(与前端 `ANTHROPIC_CLAUDE_ROUTE_PREFIX` 一致)。 pub const ANTHROPIC_CLAUDE_ROUTE_PREFIX: &str = "anthropic/claude-"; -/// cc-switch 历史约定的 1M 上下文标记(ASCII 小写形式,匹配时用 `eq_ignore_ascii_case`)。 -pub const LEGACY_ONE_M_MARKER: &str = "[1m]"; +/// Claude Code env 中通过 `[1M]` 后缀声明 1M 上下文能力(匹配用 `eq_ignore_ascii_case`)。 +/// Claude Desktop schema 不接受此后缀,import 边界翻译为 `supports1m` 字段。 +pub const ONE_M_CONTEXT_MARKER: &str = "[1m]"; const NON_ANTHROPIC_ROUTE_MARKERS: &[&str] = &[ "ark-code", @@ -267,7 +268,7 @@ pub fn is_claude_safe_model_id(model: &str) -> bool { || (normalized.starts_with("opus-") && normalized.len() > "opus-".len()) || (normalized.starts_with("haiku-") && normalized.len() > "haiku-".len()); has_allowed_shape - && !normalized.contains(LEGACY_ONE_M_MARKER) + && !normalized.contains(ONE_M_CONTEXT_MARKER) && !NON_ANTHROPIC_ROUTE_MARKERS .iter() .any(|marker| normalized.contains(marker)) diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 28b1d6b17..ab55167b9 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -275,7 +275,7 @@ pub(crate) fn suggested_claude_desktop_routes( // Claude 端 env 值可能带 [1M] 后缀;Claude Desktop schema 不接受后缀, // 改用 supports1m 字段表达 1M 能力。在 import 边界做单向翻译。 - let marker = crate::claude_desktop_config::LEGACY_ONE_M_MARKER.as_bytes(); + let marker = crate::claude_desktop_config::ONE_M_CONTEXT_MARKER.as_bytes(); let raw_bytes = raw_model.as_bytes(); let has_1m_marker = raw_bytes.len() >= marker.len() && raw_bytes[raw_bytes.len() - marker.len()..].eq_ignore_ascii_case(marker); @@ -288,31 +288,44 @@ pub(crate) fn suggested_claude_desktop_routes( return; } let effective_supports_1m = supports_1m_default || has_1m_marker; - let label_override = + let explicit_label_override = env + .get(&format!("{env_key}_NAME")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let label_override = explicit_label_override.clone().or_else(|| { (!crate::claude_desktop_config::is_claude_safe_model_id(stripped_model)) - .then(|| stripped_model.to_string()); + .then(|| stripped_model.to_string()) + }); + + // 何时覆盖既有 label_override:原本为空 / 这次来的是 explicit _NAME / + // 既有值只是 stripped_model 派生的占位(被 explicit 或更具体的值挤掉)。 + let should_overwrite = |existing: Option<&str>| { + existing.is_none() + || explicit_label_override.is_some() + || existing == Some(stripped_model) + }; + + let merge_into = |existing: &mut crate::provider::ClaudeDesktopModelRoute| { + let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m; + existing.supports_1m = Some(merged); + if should_overwrite(existing.label_override.as_deref()) { + existing.label_override = label_override.clone(); + } + }; if let Some(existing) = routes .values_mut() .find(|existing| existing.model == stripped_model) { - let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m; - existing.supports_1m = Some(merged); - if existing.label_override.is_none() { - existing.label_override = label_override; - } + merge_into(existing); return; } routes .entry(route_key.to_string()) - .and_modify(|existing| { - let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m; - existing.supports_1m = Some(merged); - if existing.label_override.is_none() { - existing.label_override = label_override.clone(); - } - }) + .and_modify(merge_into) .or_insert_with(|| crate::provider::ClaudeDesktopModelRoute { model: stripped_model.to_string(), label_override, @@ -791,6 +804,23 @@ mod import_claude_desktop_tests { assert_eq!(r.supports_1m, Some(true)); } + #[test] + fn route_uses_claude_code_model_name_as_label_override() { + let p = make_provider( + json!({ + "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2", + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Kimi K2", + }), + None, + ); + let routes = suggested_claude_desktop_routes(&p).expect("routes built"); + let r = routes + .get("claude-sonnet-4-6") + .expect("sonnet route present"); + assert_eq!(r.model, "kimi-k2"); + assert_eq!(r.label_override.as_deref(), Some("Kimi K2")); + } + #[test] fn route_1m_suffix_overrides_provider_type_default() { // github_copilot 默认 supports_1m_default = false,但 [1M] 后缀应强制 true diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index a9dfd6323..780d4a5bf 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -803,6 +803,14 @@ impl RequestForwarder { .and_then(|meta| meta.is_full_url) .unwrap_or(false); + // GitHub Copilot API 使用 /chat/completions(无 /v1 前缀) + let is_copilot = provider + .meta + .as_ref() + .and_then(|m| m.provider_type.as_deref()) + == Some("github_copilot") + || base_url.contains("githubcopilot.com"); + // 应用模型映射(独立于格式转换) // Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route // 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。 @@ -818,20 +826,14 @@ impl RequestForwarder { // 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口) let mut mapped_body = normalize_thinking_type(mapped_body); - // 确定有效端点 - // GitHub Copilot API 使用 /chat/completions(无 /v1 前缀) - let is_copilot = provider - .meta - .as_ref() - .and_then(|m| m.provider_type.as_deref()) - == Some("github_copilot") - || base_url.contains("githubcopilot.com"); - if is_copilot { mapped_body = super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body); self.apply_copilot_live_model_resolution(provider, &mut mapped_body) .await; + } else { + mapped_body = + super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body); } // --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) --- diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs index c7f543e1e..e7652cc97 100644 --- a/src-tauri/src/proxy/model_mapper.rs +++ b/src-tauri/src/proxy/model_mapper.rs @@ -2,6 +2,7 @@ //! //! 在请求转发前,根据 Provider 配置替换请求中的模型名称 +use crate::claude_desktop_config::ONE_M_CONTEXT_MARKER; use crate::provider::Provider; use serde_json::Value; @@ -112,6 +113,33 @@ pub fn apply_model_mapping( (body, original_model, None) } +/// Claude Code 通过 `[1M]` 后缀声明 100 万上下文能力;上游 API +/// 通常不接受这个本地能力标记,转发前需要剥离。 +pub fn strip_one_m_suffix_for_upstream(model: &str) -> &str { + let trimmed = model.trim_end(); + let marker = ONE_M_CONTEXT_MARKER.as_bytes(); + let bytes = trimmed.as_bytes(); + if bytes.len() >= marker.len() + && bytes[bytes.len() - marker.len()..].eq_ignore_ascii_case(marker) + { + return trimmed[..trimmed.len() - marker.len()].trim_end(); + } + model +} + +pub fn strip_one_m_suffix_for_upstream_from_body(mut body: Value) -> Value { + let Some(model) = body.get("model").and_then(Value::as_str) else { + return body; + }; + + let stripped = strip_one_m_suffix_for_upstream(model); + if stripped != model { + log::debug!("[ModelMapper] 去除本地 1M 标记: {model} → {stripped}"); + body["model"] = serde_json::json!(stripped); + } + body +} + #[cfg(test)] mod tests { use super::*; @@ -251,4 +279,34 @@ mod tests { assert_eq!(result["model"], "sonnet-mapped"); assert_eq!(mapped, Some("sonnet-mapped".to_string())); } + + #[test] + fn strips_one_m_suffix_before_upstream() { + let body = json!({"model": "deepseek-v4-pro[1M]"}); + let result = strip_one_m_suffix_for_upstream_from_body(body); + assert_eq!(result["model"], "deepseek-v4-pro"); + } + + #[test] + fn strips_one_m_suffix_after_mapping() { + let mut provider = create_provider_with_mapping(); + provider.settings_config = json!({ + "env": { + "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro [1M]" + } + }); + + let body = json!({"model": "claude-sonnet-4-6"}); + let (mapped, _, _) = apply_model_mapping(body, &provider); + let result = strip_one_m_suffix_for_upstream_from_body(mapped); + + assert_eq!(result["model"], "deepseek-v4-pro"); + } + + #[test] + fn keeps_model_without_one_m_suffix() { + let body = json!({"model": "deepseek-v4-pro"}); + let result = strip_one_m_suffix_for_upstream_from_body(body); + assert_eq!(result["model"], "deepseek-v4-pro"); + } } diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index c2504c845..a6189773e 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -7,6 +7,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { toast } from "sonner"; +import { Checkbox } from "@/components/ui/checkbox"; import { FormLabel } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { @@ -50,6 +51,12 @@ import type { ClaudeApiFormat, ClaudeApiKeyField, } from "@/types"; +import { + hasClaudeOneMMarker, + setClaudeOneMMarker, + stripClaudeOneMMarker, + type ClaudeModelEnvField, +} from "./hooks/useModelState"; import { providerPresets, type TemplateValueConfig, @@ -109,16 +116,12 @@ interface ClaudeFormFieldsProps { shouldShowModelSelector: boolean; claudeModel: string; defaultHaikuModel: string; + defaultHaikuModelName: string; defaultSonnetModel: string; + defaultSonnetModelName: string; defaultOpusModel: string; - onModelChange: ( - field: - | "ANTHROPIC_MODEL" - | "ANTHROPIC_DEFAULT_HAIKU_MODEL" - | "ANTHROPIC_DEFAULT_SONNET_MODEL" - | "ANTHROPIC_DEFAULT_OPUS_MODEL", - value: string, - ) => void; + defaultOpusModelName: string; + onModelChange: (field: ClaudeModelEnvField, value: string) => void; // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; @@ -172,8 +175,11 @@ export function ClaudeFormFields({ shouldShowModelSelector, claudeModel, defaultHaikuModel, + defaultHaikuModelName, defaultSonnetModel, + defaultSonnetModelName, defaultOpusModel, + defaultOpusModelName, onModelChange, speedTestEndpoints, apiFormat, @@ -285,14 +291,13 @@ export function ClaudeFormFields({ const renderModelInput = ( id: string, value: string, - field: ClaudeFormFieldsProps["onModelChange"] extends ( - f: infer F, - v: string, - ) => void - ? F - : never, + field: ClaudeModelEnvField, placeholder?: string, + onValueChange?: (value: string) => void, ) => { + const updateValue = + onValueChange ?? ((next: string) => onModelChange(field, next)); + if (isCopilotPreset && copilotModels.length > 0) { // 按 vendor 分组 const grouped: Record = {}; @@ -309,7 +314,7 @@ export function ClaudeFormFields({ id={id} type="text" value={value} - onChange={(e) => onModelChange(field, e.target.value)} + onChange={(e) => updateValue(e.target.value)} placeholder={placeholder} autoComplete="off" className="flex-1" @@ -331,7 +336,7 @@ export function ClaudeFormFields({ {grouped[vendor].map((model) => ( onModelChange(field, model.id)} + onSelect={() => updateValue(model.id)} > {model.id} @@ -351,7 +356,7 @@ export function ClaudeFormFields({ id={id} type="text" value={value} - onChange={(e) => onModelChange(field, e.target.value)} + onChange={(e) => updateValue(e.target.value)} placeholder={placeholder} autoComplete="off" className="flex-1" @@ -368,7 +373,7 @@ export function ClaudeFormFields({ onModelChange(field, v)} + onChange={updateValue} placeholder={placeholder} fetchedModels={fetchedModels} isLoading={isFetchingModels} @@ -376,6 +381,69 @@ export function ClaudeFormFields({ ); }; + type ModelRoleRow = { + role: "sonnet" | "opus" | "haiku"; + label: string; + model: string; + displayName: string; + modelField: ClaudeModelEnvField; + displayNameField: ClaudeModelEnvField; + inputId: string; + supportsOneM: boolean; + }; + + const modelRoleRows: ModelRoleRow[] = [ + { + role: "sonnet", + label: t("providerForm.modelRoleSonnet", { defaultValue: "Sonnet" }), + model: defaultSonnetModel, + displayName: defaultSonnetModelName, + modelField: "ANTHROPIC_DEFAULT_SONNET_MODEL", + displayNameField: "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", + inputId: "claudeDefaultSonnetModel", + supportsOneM: true, + }, + { + role: "opus", + label: t("providerForm.modelRoleOpus", { defaultValue: "Opus" }), + model: defaultOpusModel, + displayName: defaultOpusModelName, + modelField: "ANTHROPIC_DEFAULT_OPUS_MODEL", + displayNameField: "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", + inputId: "claudeDefaultOpusModel", + supportsOneM: true, + }, + { + role: "haiku", + label: t("providerForm.modelRoleHaiku", { defaultValue: "Haiku" }), + model: defaultHaikuModel, + displayName: defaultHaikuModelName, + modelField: "ANTHROPIC_DEFAULT_HAIKU_MODEL", + displayNameField: "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", + inputId: "claudeDefaultHaikuModel", + supportsOneM: false, + }, + ]; + + const handleRoleModelChange = (row: ModelRoleRow, value: string) => { + const oldModelBase = stripClaudeOneMMarker(row.model).trim(); + const normalizedValue = row.supportsOneM + ? value + : stripClaudeOneMMarker(value); + const nextModelBase = stripClaudeOneMMarker(normalizedValue).trim(); + const displayName = row.displayName.trim(); + const shouldSyncDisplayName = !displayName || displayName === oldModelBase; + onModelChange(row.modelField, normalizedValue); + if (shouldSyncDisplayName) { + onModelChange(row.displayNameField, nextModelBase); + } + }; + + const handleRoleOneMChange = (row: ModelRoleRow, enabled: boolean) => { + if (!row.supportsOneM) return; + handleRoleModelChange(row, setClaudeOneMMarker(row.model, enabled)); + }; + return ( <> {/* GitHub Copilot OAuth 认证 */} @@ -604,17 +672,23 @@ export function ClaudeFormFields({ onClick={() => { const value = claudeModel || - defaultHaikuModel || defaultSonnetModel || - defaultOpusModel; + defaultOpusModel || + defaultHaikuModel; if (value) { - onModelChange("ANTHROPIC_MODEL", value); - onModelChange("ANTHROPIC_DEFAULT_HAIKU_MODEL", value); - onModelChange("ANTHROPIC_DEFAULT_SONNET_MODEL", value); - onModelChange("ANTHROPIC_DEFAULT_OPUS_MODEL", value); + for (const row of modelRoleRows) { + const roleValue = row.supportsOneM + ? value + : stripClaudeOneMMarker(value); + onModelChange(row.modelField, roleValue); + onModelChange( + row.displayNameField, + stripClaudeOneMMarker(roleValue), + ); + } toast.success( t("providerForm.quickSetSuccess", { - defaultValue: "已将模型名称应用到所有字段", + defaultValue: "已将模型名称应用到所有角色", }), ); } @@ -655,66 +729,106 @@ export function ClaudeFormFields({ {t("providerForm.modelMappingHint")}

-
- {/* 主模型 */} -
- - {t("providerForm.anthropicModel", { - defaultValue: "主模型", + +
+
+ + {t("providerForm.modelRoleLabel", { + defaultValue: "模型角色", })} - - {renderModelInput( - "claudeModel", - claudeModel, - "ANTHROPIC_MODEL", - t("providerForm.modelPlaceholder", { defaultValue: "" }), - )} + + + {t("providerForm.modelDisplayNameLabel", { + defaultValue: "显示名称", + })} + + + {t("providerForm.requestModelLabel", { + defaultValue: "实际请求模型", + })} + + + {t("providerForm.modelOneMHeader", { + defaultValue: "声明支持 1M", + })} +
- {/* 默认 Haiku */} -
- - {t("providerForm.anthropicDefaultHaikuModel", { - defaultValue: "Haiku 默认模型", - })} - - {renderModelInput( - "claudeDefaultHaikuModel", - defaultHaikuModel, - "ANTHROPIC_DEFAULT_HAIKU_MODEL", - t("providerForm.haikuModelPlaceholder", { defaultValue: "" }), - )} -
+ {modelRoleRows.map((row) => { + const modelBase = stripClaudeOneMMarker(row.model); + const usesOneM = + row.supportsOneM && hasClaudeOneMMarker(row.model); - {/* 默认 Sonnet */} -
- - {t("providerForm.anthropicDefaultSonnetModel", { - defaultValue: "Sonnet 默认模型", - })} - - {renderModelInput( - "claudeDefaultSonnetModel", - defaultSonnetModel, - "ANTHROPIC_DEFAULT_SONNET_MODEL", - t("providerForm.modelPlaceholder", { defaultValue: "" }), - )} -
+ return ( +
+
+ {row.label} +
+ + onModelChange(row.displayNameField, event.target.value) + } + placeholder={ + modelBase || + t("providerForm.modelDisplayNamePlaceholder", { + defaultValue: "例如 DeepSeek V4 Pro", + }) + } + autoComplete="off" + /> + {renderModelInput( + row.inputId, + modelBase, + row.modelField, + t("providerForm.modelPlaceholder", { defaultValue: "" }), + (value) => + handleRoleModelChange( + row, + row.supportsOneM + ? setClaudeOneMMarker(value, usesOneM) + : stripClaudeOneMMarker(value), + ), + )} + {row.supportsOneM && ( + + )} +
+ ); + })} +
- {/* 默认 Opus */} -
- - {t("providerForm.anthropicDefaultOpusModel", { - defaultValue: "Opus 默认模型", - })} - - {renderModelInput( - "claudeDefaultOpusModel", - defaultOpusModel, - "ANTHROPIC_DEFAULT_OPUS_MODEL", - t("providerForm.modelPlaceholder", { defaultValue: "" }), - )} -
+
+ + {t("providerForm.fallbackModelLabel", { + defaultValue: "默认兜底模型", + })} + + {renderModelInput( + "claudeModel", + claudeModel, + "ANTHROPIC_MODEL", + t("providerForm.modelPlaceholder", { defaultValue: "" }), + )} +

+ {t("providerForm.fallbackModelHint", { + defaultValue: + "仅在 Claude Code 请求没有明确落到 Sonnet、Opus 或 Haiku 角色时使用;通常可以留空。", + })} +

diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 08d5cf2c7..cc19abd3b 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -346,8 +346,11 @@ function ProviderFormFull({ const { claudeModel, defaultHaikuModel, + defaultHaikuModelName, defaultSonnetModel, + defaultSonnetModelName, defaultOpusModel, + defaultOpusModelName, handleModelChange, } = useModelState({ settingsConfig: form.getValues("settingsConfig"), @@ -1815,8 +1818,11 @@ function ProviderFormFull({ shouldShowModelSelector={category !== "official"} claudeModel={claudeModel} defaultHaikuModel={defaultHaikuModel} + defaultHaikuModelName={defaultHaikuModelName} defaultSonnetModel={defaultSonnetModel} + defaultSonnetModelName={defaultSonnetModelName} defaultOpusModel={defaultOpusModel} + defaultOpusModelName={defaultOpusModelName} onModelChange={handleModelChange} speedTestEndpoints={speedTestEndpoints} apiFormat={localApiFormat} diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 7d6c7dd98..2a8a33d19 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -5,6 +5,33 @@ interface UseModelStateProps { onConfigChange: (config: string) => void; } +export type ClaudeModelEnvField = + | "ANTHROPIC_MODEL" + | "ANTHROPIC_DEFAULT_HAIKU_MODEL" + | "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME" + | "ANTHROPIC_DEFAULT_SONNET_MODEL" + | "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME" + | "ANTHROPIC_DEFAULT_OPUS_MODEL" + | "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"; + +export const CLAUDE_ONE_M_MARKER = "[1M]"; + +export function hasClaudeOneMMarker(model: string): boolean { + return model.trimEnd().toLowerCase().endsWith("[1m]"); +} + +export function stripClaudeOneMMarker(model: string): string { + const trimmedEnd = model.trimEnd(); + if (!trimmedEnd.toLowerCase().endsWith("[1m]")) return model; + return trimmedEnd.slice(0, -CLAUDE_ONE_M_MARKER.length).trimEnd(); +} + +export function setClaudeOneMMarker(model: string, enabled: boolean): string { + const base = stripClaudeOneMMarker(model).trim(); + if (!base) return ""; + return enabled ? `${base}${CLAUDE_ONE_M_MARKER}` : base; +} + /** * Parse model values from settings config JSON */ @@ -22,18 +49,38 @@ function parseModelsFromConfig(settingsConfig: string) { typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === "string" ? env.ANTHROPIC_DEFAULT_HAIKU_MODEL : small || model; + const haikuName = + typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME === "string" + ? env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME + : stripClaudeOneMMarker(haiku); const sonnet = typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === "string" ? env.ANTHROPIC_DEFAULT_SONNET_MODEL : model || small; + const sonnetName = + typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME === "string" + ? env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME + : stripClaudeOneMMarker(sonnet); const opus = typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === "string" ? env.ANTHROPIC_DEFAULT_OPUS_MODEL : model || small; + const opusName = + typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME === "string" + ? env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME + : stripClaudeOneMMarker(opus); - return { model, haiku, sonnet, opus }; + return { model, haiku, haikuName, sonnet, sonnetName, opus, opusName }; } catch { - return { model: "", haiku: "", sonnet: "", opus: "" }; + return { + model: "", + haiku: "", + haikuName: "", + sonnet: "", + sonnetName: "", + opus: "", + opusName: "", + }; } } @@ -45,18 +92,19 @@ export function useModelState({ settingsConfig, onConfigChange, }: UseModelStateProps) { - // Initialize state by parsing config directly (fixes edit mode backfill) - const [claudeModel, setClaudeModel] = useState( - () => parseModelsFromConfig(settingsConfig).model, + const initial = useState(() => parseModelsFromConfig(settingsConfig))[0]; + const [claudeModel, setClaudeModel] = useState(initial.model); + const [defaultHaikuModel, setDefaultHaikuModel] = useState(initial.haiku); + const [defaultHaikuModelName, setDefaultHaikuModelName] = useState( + initial.haikuName, ); - const [defaultHaikuModel, setDefaultHaikuModel] = useState( - () => parseModelsFromConfig(settingsConfig).haiku, + const [defaultSonnetModel, setDefaultSonnetModel] = useState(initial.sonnet); + const [defaultSonnetModelName, setDefaultSonnetModelName] = useState( + initial.sonnetName, ); - const [defaultSonnetModel, setDefaultSonnetModel] = useState( - () => parseModelsFromConfig(settingsConfig).sonnet, - ); - const [defaultOpusModel, setDefaultOpusModel] = useState( - () => parseModelsFromConfig(settingsConfig).opus, + const [defaultOpusModel, setDefaultOpusModel] = useState(initial.opus); + const [defaultOpusModelName, setDefaultOpusModelName] = useState( + initial.opusName, ); const isUserEditingRef = useRef(false); @@ -65,72 +113,45 @@ export function useModelState({ latestConfigRef.current = settingsConfig; - // 初始化读取:读新键;若缺失,按兼容优先级回退 - // Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL - // Sonnet: DEFAULT_SONNET || MODEL || SMALL_FAST - // Opus: DEFAULT_OPUS || MODEL || SMALL_FAST - // 仅在 settingsConfig 变化时同步一次(表单加载/切换预设时) + // 仅在 settingsConfig 外部变化时同步(表单加载 / 切换预设); + // 用户正在编辑时 (isUserEditingRef) 跳过一次以避免回填覆盖。 useEffect(() => { if (lastConfigRef.current === settingsConfig) { return; } - if (isUserEditingRef.current) { isUserEditingRef.current = false; lastConfigRef.current = settingsConfig; return; } - lastConfigRef.current = settingsConfig; - try { - const cfg = settingsConfig ? JSON.parse(settingsConfig) : {}; - const env = cfg?.env || {}; - const model = - typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; - const small = - typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" - ? env.ANTHROPIC_SMALL_FAST_MODEL - : ""; - const haiku = - typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === "string" - ? env.ANTHROPIC_DEFAULT_HAIKU_MODEL - : small || model; - const sonnet = - typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === "string" - ? env.ANTHROPIC_DEFAULT_SONNET_MODEL - : model || small; - const opus = - typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === "string" - ? env.ANTHROPIC_DEFAULT_OPUS_MODEL - : model || small; - - setClaudeModel(model || ""); - setDefaultHaikuModel(haiku || ""); - setDefaultSonnetModel(sonnet || ""); - setDefaultOpusModel(opus || ""); - } catch { - // ignore - } + const parsed = parseModelsFromConfig(settingsConfig); + setClaudeModel(parsed.model); + setDefaultHaikuModel(parsed.haiku); + setDefaultHaikuModelName(parsed.haikuName); + setDefaultSonnetModel(parsed.sonnet); + setDefaultSonnetModelName(parsed.sonnetName); + setDefaultOpusModel(parsed.opus); + setDefaultOpusModelName(parsed.opusName); }, [settingsConfig]); const handleModelChange = useCallback( - ( - field: - | "ANTHROPIC_MODEL" - | "ANTHROPIC_DEFAULT_HAIKU_MODEL" - | "ANTHROPIC_DEFAULT_SONNET_MODEL" - | "ANTHROPIC_DEFAULT_OPUS_MODEL", - value: string, - ) => { + (field: ClaudeModelEnvField, value: string) => { isUserEditingRef.current = true; if (field === "ANTHROPIC_MODEL") setClaudeModel(value); if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL") setDefaultHaikuModel(value); + if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME") + setDefaultHaikuModelName(value); if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL") setDefaultSonnetModel(value); + if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME") + setDefaultSonnetModelName(value); if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL") setDefaultOpusModel(value); + if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME") + setDefaultOpusModelName(value); try { const currentConfig = latestConfigRef.current @@ -164,10 +185,16 @@ export function useModelState({ setClaudeModel, defaultHaikuModel, setDefaultHaikuModel, + defaultHaikuModelName, + setDefaultHaikuModelName, defaultSonnetModel, setDefaultSonnetModel, + defaultSonnetModelName, + setDefaultSonnetModelName, defaultOpusModel, setDefaultOpusModel, + defaultOpusModelName, + setDefaultOpusModelName, handleModelChange, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 89dfdaaad..b4f54bc3c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -908,9 +908,20 @@ "haikuModelPlaceholder": "", "modelHelper": "Optional: Specify default Claude model to use, leave blank to use system default.", "modelMappingLabel": "Model Mapping", - "modelMappingHint": "Usually not needed if the provider natively serves Claude models. Only configure when you need to map requests to different model names.", + "modelMappingHint": "Display names only affect the /model menu; 1M is only a Claude Code context-capability declaration.", + "modelRoleLabel": "Model role", + "modelRoleSonnet": "Sonnet", + "modelRoleOpus": "Opus", + "modelRoleHaiku": "Haiku", + "modelDisplayNameLabel": "Display name", + "modelDisplayNamePlaceholder": "e.g. DeepSeek V4 Pro", + "requestModelLabel": "Requested model", + "modelOneMHeader": "Declare 1M", + "modelOneMLabel": "1M", + "fallbackModelLabel": "Default fallback model", + "fallbackModelHint": "Used only when a Claude Code request does not clearly map to Sonnet, Opus, or Haiku. Usually safe to leave blank.", "quickSetModels": "Quick Set", - "quickSetSuccess": "Model name applied to all fields", + "quickSetSuccess": "Model name applied to all roles", "advancedOptionsToggle": "Advanced Options", "advancedOptionsHint": "Includes API format, auth field, and model mapping. Defaults work for most use cases.", "categoryOfficial": "Official", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1b684088b..94a7c5557 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -908,9 +908,20 @@ "haikuModelPlaceholder": "", "modelHelper": "任意: 既定で使いたい Claude モデルを指定。空欄ならシステム既定を使用します。", "modelMappingLabel": "モデルマッピング", - "modelMappingHint": "プロバイダーが Claude モデルをネイティブ提供している場合、通常は設定不要です。リクエストを別のモデル名にマッピングする場合のみ設定してください。", + "modelMappingHint": "表示名は /model メニューだけに影響します。1M は Claude Code へのコンテキスト能力宣言です。", + "modelRoleLabel": "モデル役割", + "modelRoleSonnet": "Sonnet", + "modelRoleOpus": "Opus", + "modelRoleHaiku": "Haiku", + "modelDisplayNameLabel": "表示名", + "modelDisplayNamePlaceholder": "例: DeepSeek V4 Pro", + "requestModelLabel": "リクエストモデル", + "modelOneMHeader": "1M 対応を宣言", + "modelOneMLabel": "1M", + "fallbackModelLabel": "既定フォールバックモデル", + "fallbackModelHint": "Claude Code のリクエストが Sonnet、Opus、Haiku のいずれにも明確に対応しない場合のみ使われます。通常は空欄で構いません。", "quickSetModels": "一括設定", - "quickSetSuccess": "モデル名をすべてのフィールドに適用しました", + "quickSetSuccess": "モデル名をすべての役割に適用しました", "advancedOptionsToggle": "高級オプション", "advancedOptionsHint": "API フォーマット、認証フィールド、モデルマッピングの設定を含みます。通常はデフォルトのままで問題ありません。", "categoryOfficial": "公式", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ace3e4941..f82dc25cc 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -908,9 +908,20 @@ "haikuModelPlaceholder": "", "modelHelper": "可选:指定默认使用的 Claude 模型,留空则使用系统默认。", "modelMappingLabel": "模型映射", - "modelMappingHint": "如果供应商原生提供 Claude 系列模型,通常无需配置。仅在需要将请求映射到不同模型名称时填写。", + "modelMappingHint": "显示名称只影响 /model 菜单;1M 只是给 Claude Code 的上下文能力声明。", + "modelRoleLabel": "模型角色", + "modelRoleSonnet": "Sonnet", + "modelRoleOpus": "Opus", + "modelRoleHaiku": "Haiku", + "modelDisplayNameLabel": "显示名称", + "modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro", + "requestModelLabel": "实际请求模型", + "modelOneMHeader": "声明支持 1M", + "modelOneMLabel": "1M", + "fallbackModelLabel": "默认兜底模型", + "fallbackModelHint": "仅在 Claude Code 请求没有明确落到 Sonnet、Opus 或 Haiku 角色时使用;通常可以留空。", "quickSetModels": "一键设置", - "quickSetSuccess": "已将模型名称应用到所有字段", + "quickSetSuccess": "已将模型名称应用到所有角色", "advancedOptionsToggle": "高级选项", "advancedOptionsHint": "包含 API 格式、认证字段、模型映射等配置。大多数场景下保持默认即可。", "categoryOfficial": "官方", diff --git a/tests/hooks/useModelState.test.tsx b/tests/hooks/useModelState.test.tsx new file mode 100644 index 000000000..c3cd0b504 --- /dev/null +++ b/tests/hooks/useModelState.test.tsx @@ -0,0 +1,110 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + hasClaudeOneMMarker, + setClaudeOneMMarker, + stripClaudeOneMMarker, + useModelState, +} from "@/components/providers/forms/hooks/useModelState"; + +describe("useModelState", () => { + it("hydrates role models and display names from Claude Code env", () => { + const settingsConfig = JSON.stringify({ + env: { + ANTHROPIC_MODEL: "fallback-model", + ANTHROPIC_SMALL_FAST_MODEL: "legacy-small", + ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek-v4-pro", + ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: "DeepSeek V4 Pro", + ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2", + ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: "Kimi K2", + }, + }); + + const { result } = renderHook(() => + useModelState({ + settingsConfig, + onConfigChange: vi.fn(), + }), + ); + + expect(result.current.claudeModel).toBe("fallback-model"); + expect(result.current.defaultSonnetModel).toBe("deepseek-v4-pro"); + expect(result.current.defaultSonnetModelName).toBe("DeepSeek V4 Pro"); + expect(result.current.defaultOpusModel).toBe("kimi-k2"); + expect(result.current.defaultOpusModelName).toBe("Kimi K2"); + expect(result.current.defaultHaikuModel).toBe("legacy-small"); + expect(result.current.defaultHaikuModelName).toBe("legacy-small"); + }); + + it("writes and clears role display-name env fields without changing model mapping", () => { + let latestConfig = JSON.stringify({ + env: { + ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek-v4-pro", + }, + }); + const onConfigChange = vi.fn((config: string) => { + latestConfig = config; + }); + + const { result } = renderHook(() => + useModelState({ + settingsConfig: latestConfig, + onConfigChange, + }), + ); + + act(() => { + result.current.handleModelChange( + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", + "DeepSeek V4 Pro", + ); + }); + + let env = JSON.parse(latestConfig).env; + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("deepseek-v4-pro"); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME).toBe("DeepSeek V4 Pro"); + + act(() => { + result.current.handleModelChange( + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", + "", + ); + }); + + env = JSON.parse(latestConfig).env; + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("deepseek-v4-pro"); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME).toBeUndefined(); + }); + + it("keeps the 1M marker on request models but strips it from fallback display names", () => { + const settingsConfig = JSON.stringify({ + env: { + ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek-v4-pro[1M]", + }, + }); + + const { result } = renderHook(() => + useModelState({ + settingsConfig, + onConfigChange: vi.fn(), + }), + ); + + expect(result.current.defaultSonnetModel).toBe("deepseek-v4-pro[1M]"); + expect(result.current.defaultSonnetModelName).toBe("deepseek-v4-pro"); + }); + + it("normalizes Claude Code 1M markers for UI toggles", () => { + expect(hasClaudeOneMMarker("deepseek-v4-pro[1m]")).toBe(true); + expect(hasClaudeOneMMarker("deepseek-v4-pro [1M] ")).toBe(true); + expect(stripClaudeOneMMarker("deepseek-v4-pro [1M] ")).toBe( + "deepseek-v4-pro", + ); + expect(setClaudeOneMMarker("deepseek-v4-pro [1M]", false)).toBe( + "deepseek-v4-pro", + ); + expect(setClaudeOneMMarker("deepseek-v4-pro", true)).toBe( + "deepseek-v4-pro[1M]", + ); + }); +});