feat: add Claude subagent model config (#4830)

* feat: add Claude subagent takeover config

* feat: add Claude subagent model field

* i18n: add Claude subagent model labels

* fix(proxy): preserve configured subagent model mapping

* fix(providers): exclude subagent model from Claude common config

* style: format rust code
This commit is contained in:
秋澪Akimio
2026-07-07 10:28:36 +08:00
committed by GitHub
parent e606adfae7
commit b3e5e32c89
12 changed files with 257 additions and 33 deletions
+50
View File
@@ -12,6 +12,7 @@ pub struct ModelMapping {
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub fable_model: Option<String>,
pub subagent_model: Option<String>,
pub default_model: Option<String>,
}
@@ -41,6 +42,11 @@ impl ModelMapping {
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
subagent_model: env
.and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
default_model: env
.and_then(|e| e.get("ANTHROPIC_MODEL"))
.and_then(|v| v.as_str())
@@ -55,6 +61,7 @@ impl ModelMapping {
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.fable_model.is_some()
|| self.subagent_model.is_some()
|| self.default_model.is_some()
}
@@ -89,6 +96,13 @@ impl ModelMapping {
}
}
if let Some(ref m) = self.subagent_model {
if strip_one_m_suffix_for_upstream(original_model) == strip_one_m_suffix_for_upstream(m)
{
return original_model.to_string();
}
}
// 2. 默认模型
if let Some(ref m) = self.default_model {
return m.clone();
@@ -327,6 +341,42 @@ mod tests {
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_subagent_model_preserved_before_default_fallback() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini"
}
});
let body = json!({"model": "gpt-5.4-mini"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "gpt-5.4-mini");
assert_eq!(original, Some("gpt-5.4-mini".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_subagent_model_preserved_with_one_m_suffix_before_default_fallback() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini"
}
});
let body = json!({"model": "gpt-5.4-mini[1M]"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "gpt-5.4-mini[1M]");
assert_eq!(original, Some("gpt-5.4-mini[1M]".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_no_mapping_configured() {
let provider = create_provider_without_mapping();
+5
View File
@@ -672,6 +672,7 @@ mod tests {
"AWS_BEARER_TOKEN_BEDROCK": "bedrock-tok",
"ANTHROPIC_BASE_URL": "https://example.com",
"ANTHROPIC_MODEL": "claude-x",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini",
// 可共享、非机密配置(复数 _TOKENS 不应被误剥)
"ENABLE_TOOL_SEARCH": "true",
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192"
@@ -714,6 +715,9 @@ mod tests {
// 端点/模型(provider-specific 非机密)也应剥掉
assert!(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")).is_none());
assert!(env.and_then(|e| e.get("ANTHROPIC_MODEL")).is_none());
assert!(env
.and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL"))
.is_none());
// 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥)
assert_eq!(
@@ -2635,6 +2639,7 @@ impl ProviderService {
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
"CLAUDE_CODE_SUBAGENT_MODEL",
"ANTHROPIC_BASE_URL",
];
+67 -8
View File
@@ -26,7 +26,7 @@ const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,
/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,
/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 10] = [
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
@@ -35,8 +35,8 @@ const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
// Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型
"ANTHROPIC_SMALL_FAST_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL", // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型
"CLAUDE_CODE_SUBAGENT_MODEL",
];
const CLAUDE_TAKEOVER_HAIKU_MODEL: &str = "claude-haiku-4-5";
@@ -222,7 +222,9 @@ impl ProxyService {
.or(default_model)
.or(small_fast_model);
let mut fields = Vec::with_capacity(6);
let subagent_model = Self::claude_env_string(env, "CLAUDE_CODE_SUBAGENT_MODEL");
let mut fields = Vec::with_capacity(7);
Self::push_claude_takeover_role_fields(
&mut fields,
env,
@@ -250,6 +252,9 @@ impl ProxyService {
true,
opus_model,
);
if let Some(subagent_model) = subagent_model {
fields.push(("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.to_string()));
}
fields
}
@@ -2857,7 +2862,8 @@ mod tests {
"ANTHROPIC_MODEL": "claude-sonnet-4.6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4.5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4.6",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-sonnet-4.6"
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-sonnet-4.6",
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4.6[1M]"
}
}),
None,
@@ -2877,7 +2883,8 @@ mod tests {
"ANTHROPIC_DEFAULT_SONNET_MODEL": "stale-sonnet",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "stale-opus",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Stale Opus"
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Stale Opus",
"CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent"
}
});
ProxyService::apply_claude_takeover_fields_for_provider(
@@ -2917,10 +2924,53 @@ mod tests {
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
Some("claude-sonnet-4.6"),
);
assert_env_str(
env,
"CLAUDE_CODE_SUBAGENT_MODEL",
Some("claude-sonnet-4.6[1M]"),
);
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
}
#[test]
fn managed_account_claude_takeover_removes_stale_subagent_model_when_provider_omits_it() {
let mut provider = Provider::with_id(
"codex".to_string(),
"Codex".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "provider-sonnet"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
..Default::default()
});
let mut live_config = json!({
"env": {
"ANTHROPIC_BASE_URL": "https://stale.example.com",
"ANTHROPIC_API_KEY": "stale-key",
"CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent"
}
});
ProxyService::apply_claude_takeover_fields_for_provider(
&mut live_config,
"http://127.0.0.1:15721",
&provider,
);
let env = live_config
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
assert_env_str(env, "CLAUDE_CODE_SUBAGENT_MODEL", None);
}
#[test]
fn managed_account_claude_takeover_sources_codex_models_from_provider() {
let mut provider = Provider::with_id(
@@ -4564,7 +4614,8 @@ model = "gpt-5.1-codex"
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "DeepSeek V4 Flash",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1M]",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "DeepSeek V4 Pro",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-ultra [1m]"
"ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-ultra [1m]",
"CLAUDE_CODE_SUBAGENT_MODEL": "deepseek-v4-pro[1M]"
},
"permissions": { "allow": ["Read"] }
}),
@@ -4591,7 +4642,8 @@ model = "gpt-5.1-codex"
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
"ANTHROPIC_API_KEY": PROXY_TOKEN_PLACEHOLDER,
"ANTHROPIC_MODEL": "stale-model",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet"
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet",
"CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent"
},
"permissions": { "allow": ["Bash"] }
}))
@@ -4674,6 +4726,13 @@ model = "gpt-5.1-codex"
Some("deepseek-v4-ultra"),
"implicit display names should strip the local 1M marker"
);
assert_eq!(
live_env
.get("CLAUDE_CODE_SUBAGENT_MODEL")
.and_then(|v| v.as_str()),
Some("deepseek-v4-pro[1M]"),
"subagent model should follow the target provider during hot switch"
);
let backup = db
.get_live_backup("claude")
@@ -126,6 +126,7 @@ interface ClaudeFormFieldsProps {
defaultOpusModelName: string;
defaultFableModel: string;
defaultFableModelName: string;
subagentModel: string;
onModelChange: (field: ClaudeModelEnvField, value: string) => void;
// Speed Test Endpoints
@@ -196,6 +197,7 @@ export function ClaudeFormFields({
defaultOpusModelName,
defaultFableModel,
defaultFableModelName,
subagentModel,
onModelChange,
speedTestEndpoints,
apiFormat,
@@ -221,6 +223,7 @@ export function ClaudeFormFields({
defaultSonnetModel ||
defaultOpusModel ||
defaultFableModel ||
subagentModel ||
apiFormat !== "anthropic" ||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
customUserAgent ||
@@ -513,12 +516,12 @@ export function ClaudeFormFields({
};
type ModelRoleRow = {
role: "sonnet" | "opus" | "fable" | "haiku";
role: "sonnet" | "opus" | "fable" | "haiku" | "subagent";
label: string;
model: string;
displayName: string;
displayName?: string;
modelField: ClaudeModelEnvField;
displayNameField: ClaudeModelEnvField;
displayNameField?: ClaudeModelEnvField;
inputId: string;
supportsOneM: boolean;
};
@@ -564,6 +567,16 @@ export function ClaudeFormFields({
inputId: "claudeDefaultHaikuModel",
supportsOneM: false,
},
{
role: "subagent",
label: t("providerForm.modelRoleSubagent", {
defaultValue: "Subagent",
}),
model: subagentModel,
modelField: "CLAUDE_CODE_SUBAGENT_MODEL",
inputId: "claudeCodeSubagentModel",
supportsOneM: true,
},
];
const handleRoleModelChange = (row: ModelRoleRow, value: string) => {
@@ -572,10 +585,10 @@ export function ClaudeFormFields({
? value
: stripClaudeOneMMarker(value);
const nextModelBase = stripClaudeOneMMarker(normalizedValue).trim();
const displayName = row.displayName.trim();
const displayName = row.displayName?.trim() ?? "";
const shouldSyncDisplayName = !displayName || displayName === oldModelBase;
onModelChange(row.modelField, normalizedValue);
if (shouldSyncDisplayName) {
if (row.displayNameField && shouldSyncDisplayName) {
onModelChange(row.displayNameField, nextModelBase);
}
};
@@ -815,17 +828,20 @@ export function ClaudeFormFields({
defaultSonnetModel ||
defaultOpusModel ||
defaultFableModel ||
defaultHaikuModel;
defaultHaikuModel ||
subagentModel;
if (value) {
for (const row of modelRoleRows) {
const roleValue = row.supportsOneM
? value
: stripClaudeOneMMarker(value);
onModelChange(row.modelField, roleValue);
onModelChange(
row.displayNameField,
stripClaudeOneMMarker(roleValue),
);
if (row.displayNameField) {
onModelChange(
row.displayNameField,
stripClaudeOneMMarker(roleValue),
);
}
}
toast.success(
t("providerForm.quickSetSuccess", {
@@ -839,7 +855,8 @@ export function ClaudeFormFields({
!defaultHaikuModel &&
!defaultSonnetModel &&
!defaultOpusModel &&
!defaultFableModel
!defaultFableModel &&
!subagentModel
}
className="h-7 gap-1"
>
@@ -907,19 +924,30 @@ export function ClaudeFormFields({
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm font-medium text-muted-foreground">
{row.label}
</div>
<Input
value={row.displayName}
onChange={(event) =>
onModelChange(row.displayNameField, event.target.value)
}
placeholder={
modelBase ||
t("providerForm.modelDisplayNamePlaceholder", {
defaultValue: "例如 DeepSeek V4 Pro",
})
}
autoComplete="off"
/>
{row.displayNameField ? (
<Input
value={row.displayName ?? ""}
onChange={(event) =>
onModelChange(
row.displayNameField!,
event.target.value,
)
}
placeholder={
modelBase ||
t("providerForm.modelDisplayNamePlaceholder", {
defaultValue: "例如 DeepSeek V4 Pro",
})
}
autoComplete="off"
/>
) : (
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
{t("providerForm.modelNoDisplayName", {
defaultValue: "不显示在 /model 菜单",
})}
</div>
)}
{renderModelInput(
row.inputId,
modelBase,
@@ -489,6 +489,7 @@ function ProviderFormFull({
defaultOpusModelName,
defaultFableModel,
defaultFableModelName,
subagentModel,
handleModelChange,
} = useModelState({
settingsConfig: form.getValues("settingsConfig"),
@@ -2099,6 +2100,7 @@ function ProviderFormFull({
defaultOpusModelName={defaultOpusModelName}
defaultFableModel={defaultFableModel}
defaultFableModelName={defaultFableModelName}
subagentModel={subagentModel}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
apiFormat={localApiFormat}
@@ -14,7 +14,8 @@ export type ClaudeModelEnvField =
| "ANTHROPIC_DEFAULT_OPUS_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"
| "ANTHROPIC_DEFAULT_FABLE_MODEL"
| "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME";
| "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME"
| "CLAUDE_CODE_SUBAGENT_MODEL";
export const CLAUDE_ONE_M_MARKER = "[1M]";
@@ -81,6 +82,10 @@ function parseModelsFromConfig(settingsConfig: string) {
typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME === "string"
? env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME
: stripClaudeOneMMarker(fable);
const subagent =
typeof env.CLAUDE_CODE_SUBAGENT_MODEL === "string"
? env.CLAUDE_CODE_SUBAGENT_MODEL
: "";
return {
model,
@@ -92,6 +97,7 @@ function parseModelsFromConfig(settingsConfig: string) {
opusName,
fable,
fableName,
subagent,
};
} catch {
return {
@@ -104,6 +110,7 @@ function parseModelsFromConfig(settingsConfig: string) {
opusName: "",
fable: "",
fableName: "",
subagent: "",
};
}
}
@@ -134,6 +141,7 @@ export function useModelState({
const [defaultFableModelName, setDefaultFableModelName] = useState(
initial.fableName,
);
const [subagentModel, setSubagentModel] = useState(initial.subagent);
const isUserEditingRef = useRef(false);
const lastConfigRef = useRef(settingsConfig);
@@ -164,6 +172,7 @@ export function useModelState({
setDefaultOpusModelName(parsed.opusName);
setDefaultFableModel(parsed.fable);
setDefaultFableModelName(parsed.fableName);
setSubagentModel(parsed.subagent);
}, [settingsConfig]);
const handleModelChange = useCallback(
@@ -186,6 +195,7 @@ export function useModelState({
setDefaultFableModel(value);
if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME")
setDefaultFableModelName(value);
if (field === "CLAUDE_CODE_SUBAGENT_MODEL") setSubagentModel(value);
try {
const currentConfig = latestConfigRef.current
@@ -233,6 +243,8 @@ export function useModelState({
setDefaultFableModel,
defaultFableModelName,
setDefaultFableModelName,
subagentModel,
setSubagentModel,
handleModelChange,
};
}
+2
View File
@@ -1124,8 +1124,10 @@
"modelRoleOpus": "Opus",
"modelRoleFable": "Fable",
"modelRoleHaiku": "Haiku",
"modelRoleSubagent": "Subagent",
"modelDisplayNameLabel": "Display name",
"modelDisplayNamePlaceholder": "e.g. DeepSeek V4 Pro",
"modelNoDisplayName": "Not shown in /model",
"requestModelLabel": "Requested model",
"modelOneMHeader": "Declare 1M",
"modelOneMLabel": "1M",
+2
View File
@@ -1124,8 +1124,10 @@
"modelRoleOpus": "Opus",
"modelRoleFable": "Fable",
"modelRoleHaiku": "Haiku",
"modelRoleSubagent": "Subagent",
"modelDisplayNameLabel": "表示名",
"modelDisplayNamePlaceholder": "例: DeepSeek V4 Pro",
"modelNoDisplayName": "/model には表示されません",
"requestModelLabel": "リクエストモデル",
"modelOneMHeader": "1M 対応を宣言",
"modelOneMLabel": "1M",
+2
View File
@@ -1096,8 +1096,10 @@
"modelRoleOpus": "Opus",
"modelRoleFable": "Fable",
"modelRoleHaiku": "Haiku",
"modelRoleSubagent": "Subagent",
"modelDisplayNameLabel": "顯示名稱",
"modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro",
"modelNoDisplayName": "不顯示於 /model 選單",
"requestModelLabel": "實際請求模型",
"modelOneMHeader": "聲明支援 1M",
"modelOneMLabel": "1M",
+2
View File
@@ -1124,8 +1124,10 @@
"modelRoleOpus": "Opus",
"modelRoleFable": "Fable",
"modelRoleHaiku": "Haiku",
"modelRoleSubagent": "Subagent",
"modelDisplayNameLabel": "显示名称",
"modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro",
"modelNoDisplayName": "不显示在 /model 菜单",
"requestModelLabel": "实际请求模型",
"modelOneMHeader": "声明支持 1M",
"modelOneMLabel": "1M",
@@ -85,6 +85,7 @@ const renderCopilotForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) => {
defaultOpusModelName: "",
defaultFableModel: "",
defaultFableModelName: "",
subagentModel: "",
onModelChange: vi.fn(),
speedTestEndpoints: [],
apiFormat: "anthropic",
@@ -173,4 +174,25 @@ describe("ClaudeFormFields", () => {
);
});
});
it("一键设置会同时写入 Subagent 模型", () => {
const onModelChange = vi.fn();
renderCopilotForm({
claudeModel: "shared-model[1M]",
defaultSonnetModel: "",
defaultSonnetModelName: "",
onModelChange,
});
fireEvent.click(
screen.getByRole("button", {
name: "一键设置",
}),
);
expect(onModelChange).toHaveBeenCalledWith(
"CLAUDE_CODE_SUBAGENT_MODEL",
"shared-model[1M]",
);
});
});
+38
View File
@@ -17,6 +17,7 @@ describe("useModelState", () => {
ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: "DeepSeek V4 Pro",
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2",
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: "Kimi K2",
CLAUDE_CODE_SUBAGENT_MODEL: "subagent-model[1M]",
},
});
@@ -34,6 +35,7 @@ describe("useModelState", () => {
expect(result.current.defaultOpusModelName).toBe("Kimi K2");
expect(result.current.defaultHaikuModel).toBe("legacy-small");
expect(result.current.defaultHaikuModelName).toBe("legacy-small");
expect(result.current.subagentModel).toBe("subagent-model[1M]");
});
it("writes and clears role display-name env fields without changing model mapping", () => {
@@ -94,6 +96,42 @@ describe("useModelState", () => {
expect(result.current.defaultSonnetModelName).toBe("deepseek-v4-pro");
});
it("writes and clears the Claude Code subagent model env field", () => {
let latestConfig = JSON.stringify({
env: {
ANTHROPIC_MODEL: "fallback-model",
},
});
const onConfigChange = vi.fn((config: string) => {
latestConfig = config;
});
const { result } = renderHook(() =>
useModelState({
settingsConfig: latestConfig,
onConfigChange,
}),
);
act(() => {
result.current.handleModelChange(
"CLAUDE_CODE_SUBAGENT_MODEL",
"subagent-model[1M]",
);
});
let env = JSON.parse(latestConfig).env;
expect(env.ANTHROPIC_MODEL).toBe("fallback-model");
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("subagent-model[1M]");
act(() => {
result.current.handleModelChange("CLAUDE_CODE_SUBAGENT_MODEL", "");
});
env = JSON.parse(latestConfig).env;
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBeUndefined();
});
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);