From ad1d97efab238668df0dd67686dc95dc0d533495 Mon Sep 17 00:00:00 2001 From: dcdyouget <1376372464@qq.com> Date: Fri, 11 Sep 2026 00:28:29 +0800 Subject: [PATCH] Honor Claude Code's provider model mapping for Claude sessions cc-switch (and Anthropic-compatible gateways) switch Claude Code's provider by rewriting the env block of ~/.claude/settings.json: ANTHROPIC_BASE_URL/AUTH_TOKEN plus ANTHROPIC_DEFAULT_{FABLE,OPUS,SONNET, HAIKU}_MODEL. Claude Code applies that family mapping only to FAMILY ALIASES (opus, sonnet, ...); a full model id passed via --model (claude-opus-5) goes to the provider verbatim. Zeron's curated catalog is exactly those full ids, so the mapping was bypassed: requests carried the Claude model name and some providers rejected them with model_not_found (issue #305). Read the user-level settings and resolve the selected id through the family mapping before passing --model. When a mapping is configured, models() now surfaces the provider models (deduped by display name) so the picker names what actually runs; native Anthropic settings are untouched. Also stop disabling the user settings file for title runs: --setting-sources "" dropped the provider env, so titles failed with "Not logged in" for third-party providers. `user` keeps them while still skipping project/local. --- crates/harness/src/claude/mod.rs | 117 +++++- crates/harness/src/claude/settings.rs | 373 +++++++++++++++++++ crates/harness/tests/fixtures/fake-claude.sh | 6 +- 3 files changed, 483 insertions(+), 13 deletions(-) create mode 100644 crates/harness/src/claude/settings.rs diff --git a/crates/harness/src/claude/mod.rs b/crates/harness/src/claude/mod.rs index 5b996b28d..86ae3b882 100644 --- a/crates/harness/src/claude/mod.rs +++ b/crates/harness/src/claude/mod.rs @@ -33,6 +33,7 @@ pub mod catalog; mod normalize; +mod settings; mod wire; use std::path::PathBuf; @@ -56,6 +57,7 @@ use zeron_proto::{ use crate::{Harness, HarnessError, RunControls, Signal, send_signal, shutdown_child}; use catalog::{apply_ultrathink, static_models, to_effort}; use normalize::Normalizer; +use settings::ClaudeSettings; use wire::{ControlRequestFrame, Frame, allow_response, control_response_line}; /// Locate the device's installed Claude Code CLI: `CLAUDE_CODE_EXECUTABLE`, @@ -169,6 +171,17 @@ impl ClaudeHarness { } fn build_command(&self, exe: &PathBuf, request: &RunRequest) -> Command { + self.build_command_with_settings(exe, request, &ClaudeSettings::load()) + } + + /// [`Self::build_command`] with the Claude settings injected, so tests can + /// exercise the model resolution without touching `~/.claude`. + fn build_command_with_settings( + &self, + exe: &PathBuf, + request: &RunRequest, + claude_settings: &ClaudeSettings, + ) -> Command { let mut cmd = Command::new(exe); crate::compose_child_path(&mut cmd, exe); cmd.args([ @@ -194,17 +207,27 @@ impl ClaudeHarness { // (`sonnet[1m]`), exactly how the CLI itself does it; fast mode and // always-on thinking are settings overrides. if let Some(model) = &request.model { - let one_m = request - .model_options - .get("contextWindow") - .and_then(Value::as_str) - == Some("1m"); - cmd.arg("--model"); - cmd.arg(if one_m { - format!("{model}[1m]") + // cc-switch / gateways map the Claude *families* to provider models + // through `ANTHROPIC_DEFAULT_*_MODEL`, but the CLI applies that + // mapping only to family aliases — never to the full ids this + // catalog hands out (issue #305). Resolve through the mapping when + // one is configured; otherwise keep the id exactly as before. + if let Some(resolved) = claude_settings.resolve_request_model(model) { + cmd.arg("--model"); + cmd.arg(resolved); } else { - model.clone() - }); + let one_m = request + .model_options + .get("contextWindow") + .and_then(Value::as_str) + == Some("1m"); + cmd.arg("--model"); + cmd.arg(if one_m { + format!("{model}[1m]") + } else { + model.clone() + }); + } } if let Some(effort) = to_effort(request.reasoning, request.model.as_deref()) { cmd.args(["--effort", effort]); @@ -394,7 +417,12 @@ impl Harness for ClaudeHarness { /// like the discovery call would. async fn models(&self) -> Result, HarnessError> { self.resolve_executable()?; - Ok(static_models()) + let catalog = static_models(); + // cc-switch / gateways: name the provider models that actually run + // instead of the Claude ids they are mapped from (issue #305). + Ok(ClaudeSettings::load() + .provider_rows(&catalog) + .unwrap_or(catalog)) } /// Slash commands from the CLI's `initialize` control-request handshake — @@ -449,8 +477,13 @@ impl ClaudeHarness { "--strict-mcp-config", "--mcp-config", "{\"mcpServers\":{}}", + // Keep USER settings (the `env` block carries a third-party + // provider's base URL / token / model — cc-switch) but skip + // project/local settings for a scratch title run. `""` here + // dropped the user env too, so titles failed with "Not + // logged in" for anyone on a third-party provider. "--setting-sources", - "", + "user", ]); } let mut child = cmd.spawn().map_err(|e| { @@ -952,4 +985,64 @@ mod tests { // Original input is preserved alongside the answers. assert!(updated["questions"].is_array()); } + + fn request_with_model(model: &str) -> RunRequest { + RunRequest { + prompt: "hi".into(), + harness: None, + model: Some(model.into()), + reasoning: None, + model_options: serde_json::Map::new(), + cwd: String::new(), + sandbox: zeron_proto::SandboxLevel::DangerFullAccess, + auto_approve: true, + attachments: Vec::new(), + worktree: None, + resume: None, + } + } + + /// The value that follows `--model` in the spawned argv. + fn model_arg(request: &RunRequest, settings: &ClaudeSettings) -> Option { + let command = ClaudeHarness::new().build_command_with_settings( + &PathBuf::from("/bin/true"), + request, + settings, + ); + let args: Vec = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + let index = args.iter().position(|arg| arg == "--model")?; + args.get(index + 1).cloned() + } + + #[test] + fn provider_mapping_rewrites_the_model_flag() { + // A full Claude id is remapped to the family's provider model in the + // `ANTHROPIC_DEFAULT_*_MODEL` settings cc-switch writes (issue #305). + let mapped = ClaudeSettings::from_json(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-flash[1M]", + } + })); + assert_eq!( + model_arg(&request_with_model("claude-opus-5"), &mapped).as_deref(), + Some("deepseek-v4-flash[1M]") + ); + + // Native settings leave the id — and the `[1m]` context suffix — as + // they were. + let native = ClaudeSettings::from_json(&json!({ "env": {} })); + let mut one_m = request_with_model("claude-opus-5"); + one_m + .model_options + .insert("contextWindow".into(), json!("1m")); + assert_eq!( + model_arg(&one_m, &native).as_deref(), + Some("claude-opus-5[1m]") + ); + } } diff --git a/crates/harness/src/claude/settings.rs b/crates/harness/src/claude/settings.rs new file mode 100644 index 000000000..59489f9f1 --- /dev/null +++ b/crates/harness/src/claude/settings.rs @@ -0,0 +1,373 @@ +//! Claude Code user settings — specifically the `env` block that cc-switch and +//! Anthropic-compatible gateways rewrite to point the CLI at a third-party +//! provider (issue #305). +//! +//! The CLI resolves the `ANTHROPIC_DEFAULT_{FABLE,OPUS,SONNET,HAIKU}_MODEL` +//! mapping **only for family aliases** (`fable`, `opus`, `sonnet`, `haiku`). +//! A full model id passed via `--model` (`claude-opus-5`) goes to the provider +//! verbatim, so the mapping is bypassed. Zeron's curated catalog is full ids, +//! which is exactly that case — hence this reader: `build_command` resolves the +//! selected id through the configured family mapping, and `models()` surfaces +//! the provider models so the picker names what actually runs. +//! +//! Only `settings.json` under the Claude config dir is read. The CLI merges +//! more sources (project/local settings, process env), but the user-level file +//! is what cc-switch writes and the common case; process env is inherited by +//! the spawned CLI anyway. + +use std::path::PathBuf; + +use serde_json::Value; + +/// One of the four Claude model families the CLI maps through +/// `ANTHROPIC_DEFAULT__MODEL`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Family { + Fable, + Opus, + Sonnet, + Haiku, +} + +impl Family { + /// Every family, in flagship order — the picker's dedup order. + pub(crate) const ALL: [Family; 4] = + [Family::Fable, Family::Opus, Family::Sonnet, Family::Haiku]; + + /// The alias the CLI accepts on `--model` and resolves to the family's + /// configured provider model. + pub(crate) fn alias(self) -> &'static str { + match self { + Family::Fable => "fable", + Family::Opus => "opus", + Family::Sonnet => "sonnet", + Family::Haiku => "haiku", + } + } + + fn model_key(self) -> &'static str { + match self { + Family::Fable => "ANTHROPIC_DEFAULT_FABLE_MODEL", + Family::Opus => "ANTHROPIC_DEFAULT_OPUS_MODEL", + Family::Sonnet => "ANTHROPIC_DEFAULT_SONNET_MODEL", + Family::Haiku => "ANTHROPIC_DEFAULT_HAIKU_MODEL", + } + } + + fn name_key(self) -> &'static str { + match self { + Family::Fable => "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", + Family::Opus => "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", + Family::Sonnet => "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", + Family::Haiku => "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", + } + } +} + +/// The family a curated catalog id belongs to (`claude-opus-5` → `Opus`). +/// Substring match, mirroring [`super::catalog::supports_xhigh`]'s approach. +pub(crate) fn family_of(model: &str) -> Option { + let model = model.to_ascii_lowercase(); + ["fable", "opus", "sonnet", "haiku"] + .into_iter() + .find(|needle| model.contains(*needle)) + .and_then(|needle| { + Family::ALL + .into_iter() + .find(|family| family.alias() == needle) + }) +} + +/// A provider model configured for one family. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ProviderModel { + pub family: Family, + /// The value the CLI maps the family alias to (`ANTHROPIC_DEFAULT_*_MODEL`, + /// e.g. `deepseek-v4-flash[1M]`). Safe to pass as `--model` directly — the + /// CLI strips the trailing context suffix. + pub model: String, + /// Display name (`ANTHROPIC_DEFAULT_*_MODEL_NAME`), falling back to the + /// model value with any `[...]` suffix removed. + pub label: String, +} + +/// The provider-facing slice of Claude Code's user settings. +#[derive(Debug, Clone, Default)] +pub(crate) struct ClaudeSettings { + /// Per-family provider mappings. Empty when no third-party mapping is + /// configured (native Anthropic users), which leaves the curated catalog + /// and `--model` behavior untouched. + pub families: Vec, +} + +impl ClaudeSettings { + /// Read the user-level settings file, tolerating every failure (missing + /// file, bad JSON, no Claude install). A read error is indistinguishable + /// from "no provider mapping", which is the safe default. + pub(crate) fn load() -> Self { + Self::load_from(&settings_path()) + } + + fn load_from(path: &std::path::Path) -> Self { + let Ok(source) = std::fs::read_to_string(path) else { + return Self::default(); + }; + serde_json::from_str::(&source) + .map(|value| Self::from_json(&value)) + .unwrap_or_default() + } + + /// Parse the `env` block. Visible for tests. + pub(crate) fn from_json(value: &Value) -> Self { + let env = value.get("env").and_then(Value::as_object); + let string = |key: &str| { + env.and_then(|env| env.get(key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }; + let base_url = string("ANTHROPIC_BASE_URL"); + let default_model = string("ANTHROPIC_MODEL"); + let mut families = Vec::new(); + for family in Family::ALL { + let label = string(family.name_key()); + let mapping = match string(family.model_key()) { + Some(model) => Some((model, label.unwrap_or_default())), + // A gateway that only pins `ANTHROPIC_MODEL` (older cc-switch) + // still needs its model to reach every family. + None if base_url.is_some() => default_model + .clone() + .map(|model| (model, label.unwrap_or_default())), + None => None, + }; + if let Some((model, label)) = mapping { + let label = if label.is_empty() { + display_name(&model) + } else { + label + }; + families.push(ProviderModel { + family, + model, + label, + }); + } + } + Self { families } + } + + /// Whether a third-party family mapping is active at all. + pub(crate) fn has_provider_mapping(&self) -> bool { + !self.families.is_empty() + } + + pub(crate) fn family(&self, family: Family) -> Option<&ProviderModel> { + self.families.iter().find(|pm| pm.family == family) + } + + /// Resolve a picker/catalog model id to the value `--model` should carry. + /// + /// `None` means "no provider mapping applies — use the id as-is", which + /// keeps native Anthropic behavior (including the `[1m]` context suffix) + /// exactly as it was. + pub(crate) fn resolve_request_model(&self, model: &str) -> Option { + // A provider row already carries the provider model value; pass it on + // untouched (the CLI strips a `[...]` suffix). + if self.families.iter().any(|pm| pm.model == model) { + return Some(model.to_owned()); + } + family_of(model) + .and_then(|family| self.family(family)) + .map(|pm| pm.model.clone()) + } + + /// Picker rows for the configured provider models, deduped by display name + /// (cc-switch points every family at one model, which would otherwise show + /// as four identical rows). `None` when no mapping is configured. + pub(crate) fn provider_rows( + &self, + catalog: &[zeron_proto::Model], + ) -> Option> { + if !self.has_provider_mapping() { + return None; + } + let mut seen = std::collections::HashSet::new(); + let mut rows = Vec::new(); + for pm in &self.families { + if !seen.insert(dedup_key(&pm.label)) { + continue; + } + // Keep the family's curated effort ladder usable; curated options + // (context window / fast mode) are Claude-native and dropped. + let base = catalog + .iter() + .find(|model| family_of(&model.id) == Some(pm.family)); + rows.push(zeron_proto::Model { + id: pm.model.clone(), + label: pm.label.clone(), + description: None, + reasoning_levels: base + .map(|model| model.reasoning_levels.clone()) + .unwrap_or_default(), + options: Vec::new(), + }); + } + Some(rows) + } +} + +/// Claude config dir (`$CLAUDE_CONFIG_DIR` else `~/.claude`), matching +/// `zeron_engine`'s account resolution. +fn settings_path() -> PathBuf { + if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|dir| !dir.is_empty()) { + return PathBuf::from(dir).join("settings.json"); + } + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_default() + .join(".claude") + .join("settings.json") +} + +/// `deepseek-v4-flash[1M]` → `deepseek-v4-flash`. +fn display_name(model: &str) -> String { + model + .split_once('[') + .map(|(base, _)| base) + .unwrap_or(model) + .trim() + .to_owned() +} + +/// Dedup key for provider rows: the display name, case/suffix-insensitive. +fn dedup_key(label: &str) -> String { + display_name(label).to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn settings(env: Value) -> ClaudeSettings { + ClaudeSettings::from_json(&json!({ "env": env })) + } + + #[test] + fn family_matching_is_substring_like_the_xhigh_check() { + assert_eq!(family_of("claude-opus-5"), Some(Family::Opus)); + assert_eq!(family_of("claude-opus-4-7-20260101"), Some(Family::Opus)); + assert_eq!(family_of("claude-sonnet-5[1m]"), Some(Family::Sonnet)); + assert_eq!(family_of("claude-haiku-4-5"), Some(Family::Haiku)); + assert_eq!(family_of("claude-fable-5-1"), Some(Family::Fable)); + assert_eq!(family_of("gpt-5.2-codex"), None); + } + + #[test] + fn cc_switch_default_family_models_map_each_family() { + let settings = settings(json!({ + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", + "ANTHROPIC_MODEL": "deepseek-v4-flash", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-flash[1M]", + "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "deepseek-v4-flash", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-flash[1M]", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash", + "ANTHROPIC_DEFAULT_FABLE_MODEL": "deepseek-v4-flash[1M]", + })); + assert!(settings.has_provider_mapping()); + assert_eq!( + settings.resolve_request_model("claude-opus-5").as_deref(), + Some("deepseek-v4-flash[1M]") + ); + assert_eq!( + settings + .resolve_request_model("claude-haiku-4-5") + .as_deref(), + Some("deepseek-v4-flash") + ); + // Provider row ids pass through untouched. + assert_eq!( + settings + .resolve_request_model("deepseek-v4-flash[1M]") + .as_deref(), + Some("deepseek-v4-flash[1M]") + ); + } + + #[test] + fn non_claude_ids_are_never_remapped() { + let settings = settings(json!({ + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-flash[1M]", + })); + assert_eq!(settings.resolve_request_model("gpt-5.2-codex"), None); + } + + #[test] + fn native_settings_leave_everything_alone() { + let settings = settings(json!({ "ANTHROPIC_MODEL": "claude-opus-5" })); + assert!(!settings.has_provider_mapping()); + assert_eq!(settings.resolve_request_model("claude-opus-5"), None); + assert!( + settings + .provider_rows(&super::super::catalog::static_models()) + .is_none() + ); + } + + #[test] + fn a_model_only_gateway_falls_back_to_anthropic_model() { + let settings = settings(json!({ + "ANTHROPIC_BASE_URL": "https://gateway.example/anthropic", + "ANTHROPIC_MODEL": "glm-5.2", + })); + assert_eq!( + settings.resolve_request_model("claude-sonnet-5").as_deref(), + Some("glm-5.2") + ); + // All four families carry the same fallback, deduped to one row by name. + let rows = settings + .provider_rows(&super::super::catalog::static_models()) + .expect("provider rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "glm-5.2"); + assert_eq!(rows[0].label, "glm-5.2"); + } + + #[test] + fn provider_rows_dedup_by_display_name() { + let settings = settings(json!({ + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-flash[1M]", + "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "deepseek-v4-flash", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-flash[1M]", + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "deepseek-v4-flash", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash", + "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "deepseek-v4-flash", + "ANTHROPIC_DEFAULT_FABLE_MODEL": "deepseek-v4-flash[1M]", + "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME": "deepseek-v4-flash", + })); + let rows = settings + .provider_rows(&super::super::catalog::static_models()) + .expect("provider rows"); + assert_eq!(rows.len(), 1, "same provider model must collapse: {rows:?}"); + assert_eq!(rows[0].id, "deepseek-v4-flash[1M]"); + assert_eq!(rows[0].label, "deepseek-v4-flash"); + assert!(!rows[0].reasoning_levels.is_empty()); + } + + #[test] + fn distinct_provider_models_stay_separate() { + let settings = settings(json!({ + "ANTHROPIC_BASE_URL": "https://gateway.example/anthropic", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "big-model", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "small-model", + })); + let rows = settings + .provider_rows(&super::super::catalog::static_models()) + .expect("provider rows"); + let ids: Vec<&str> = rows.iter().map(|row| row.id.as_str()).collect(); + assert_eq!(ids, ["big-model", "small-model"]); + } +} diff --git a/crates/harness/tests/fixtures/fake-claude.sh b/crates/harness/tests/fixtures/fake-claude.sh index bb9daf7c6..85e60790e 100755 --- a/crates/harness/tests/fixtures/fake-claude.sh +++ b/crates/harness/tests/fixtures/fake-claude.sh @@ -17,16 +17,20 @@ case "$first" in tools_off=false system_set=false mcp_off=false + # `user` (not "") keeps the user settings.json env cc-switch writes; "" + # dropped third-party provider auth and broke every title run (issue #305). + settings_user=false while [ "$#" -gt 0 ]; do case "$1" in --tools) shift; [ "$1" = "" ] && tools_off=true ;; --system-prompt) shift; case "$1" in "You generate session titles."*) system_set=true ;; esac ;; --strict-mcp-config) mcp_off=true ;; + --setting-sources) shift; [ "$1" = "user" ] && settings_user=true ;; --dangerously-skip-permissions) exit 1 ;; esac shift done - [ "$tools_off" = true ] && [ "$system_set" = true ] && [ "$mcp_off" = true ] || exit 1 + [ "$tools_off" = true ] && [ "$system_set" = true ] && [ "$mcp_off" = true ] && [ "$settings_user" = true ] || exit 1 emit '{"type":"control_request","request_id":"title-tool","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"touch should-not-exist"}}}' read -r response || exit 1 case "$response" in *'"behavior":"deny"'*) ;; *) exit 1 ;; esac