From a9b48b6eed1cdec1c98cff8efd5dc6c9366503f8 Mon Sep 17 00:00:00 2001 From: mohamed zwanski Date: Thu, 22 Oct 2026 21:26:23 +0100 Subject: [PATCH] Fix #107: Map Claude model IDs to tier overrides on custom endpoints The UI/stored model (e.g. claude-opus-4-7) was passed verbatim via --model. The Claude CLI only applies ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL to tier aliases, so Ollama and other custom endpoints received a Claude model ID they don't serve (404 model not found). When ANTHROPIC_BASE_URL is set (non-Vertex), resolve a claude-* model to the matching tier override. Co-Authored-By: Claude Opus 5.5 --- client.py | 44 +++++++++++++++++++++++++++++++++++++++++--- test_client.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/client.py b/client.py index 26a710f50..e92bcc157 100644 --- a/client.py +++ b/client.py @@ -63,6 +63,44 @@ def convert_model_for_vertex(model: str) -> str: return model +# Claude model family -> env var the Claude CLI uses to override that tier +_TIER_OVERRIDE_VARS = ( + ("opus", "ANTHROPIC_DEFAULT_OPUS_MODEL"), + ("sonnet", "ANTHROPIC_DEFAULT_SONNET_MODEL"), + ("haiku", "ANTHROPIC_DEFAULT_HAIKU_MODEL"), +) + + +def resolve_model_for_endpoint(model: str, sdk_env: dict[str, str]) -> str: + """ + Map a full Claude model ID to the configured tier override on custom endpoints. + + The Claude CLI only applies ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL to the + tier aliases ("opus", "sonnet", "haiku"), not to explicit model IDs. When a + custom endpoint (Ollama, GLM, a proxy, ...) is configured via ANTHROPIC_BASE_URL + and the selected model is still a Claude ID like "claude-opus-4-7" (e.g. the + UI's stored model), the endpoint would receive a model it doesn't serve. + In that case use the matching tier override instead. + + Args: + model: The selected model ID + sdk_env: Environment overrides passed to the Claude CLI + + Returns: + The tier override model if one applies, otherwise the model unchanged. + """ + if not sdk_env.get("ANTHROPIC_BASE_URL") or sdk_env.get("CLAUDE_CODE_USE_VERTEX") == "1": + return model + if not model.startswith("claude-"): + return model + + for tier, env_var in _TIER_OVERRIDE_VARS: + if tier in model: + override = (sdk_env.get(env_var) or "").strip() + return override or model + return model + + def get_extra_read_paths() -> list[Path]: """ Get extra read-only paths from EXTRA_READ_PATHS environment variable. @@ -353,7 +391,7 @@ def create_client( is_alternative_api = bool(base_url) or is_vertex is_ollama = "localhost:11434" in base_url or "127.0.0.1:11434" in base_url is_azure = "services.ai.azure.com" in base_url - model = convert_model_for_vertex(model) + model = convert_model_for_vertex(resolve_model_for_endpoint(model, sdk_env)) if sdk_env: print(f" - API overrides: {', '.join(sdk_env.keys())}") if is_vertex: @@ -361,11 +399,11 @@ def create_client( region = sdk_env.get("CLOUD_ML_REGION", "unknown") print(f" - Vertex AI Mode: Using GCP project '{project_id}' with model '{model}' in region '{region}'") elif is_ollama: - print(" - Ollama Mode: Using local models") + print(f" - Ollama Mode: Using local model '{model}'") elif is_azure: print(f" - Azure Mode: Using {base_url}") elif "ANTHROPIC_BASE_URL" in sdk_env: - print(f" - Alternative API: Using {sdk_env['ANTHROPIC_BASE_URL']}") + print(f" - Alternative API: Using {sdk_env['ANTHROPIC_BASE_URL']} with model '{model}'") # Create a wrapper for bash_security_hook that passes project_dir via context async def bash_hook_with_context(input_data, tool_use_id=None, context=None): diff --git a/test_client.py b/test_client.py index 3fb30cbc1..04ff2e7f8 100644 --- a/test_client.py +++ b/test_client.py @@ -18,6 +18,7 @@ EXTRA_READ_PATHS_VAR, convert_model_for_vertex, get_extra_read_paths, + resolve_model_for_endpoint, ) @@ -116,6 +117,55 @@ def test_empty_string_unchanged(self): self.assertEqual(convert_model_for_vertex(""), "") +class TestResolveModelForEndpoint(unittest.TestCase): + """Tests for resolve_model_for_endpoint (issue #107).""" + + OLLAMA_ENV = { + "ANTHROPIC_BASE_URL": "http://localhost:11434", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen2.5-coder", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "qwen2.5-coder:7b", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen2.5-coder:1.5b", + } + + def test_opus_id_mapped_to_opus_override(self): + self.assertEqual(resolve_model_for_endpoint("claude-opus-4-7", self.OLLAMA_ENV), "qwen2.5-coder") + + def test_legacy_opus_id_mapped(self): + self.assertEqual( + resolve_model_for_endpoint("claude-opus-4-5-20251101", self.OLLAMA_ENV), "qwen2.5-coder" + ) + + def test_sonnet_id_mapped_to_sonnet_override(self): + self.assertEqual(resolve_model_for_endpoint("claude-sonnet-4-6", self.OLLAMA_ENV), "qwen2.5-coder:7b") + + def test_haiku_id_mapped_to_haiku_override(self): + self.assertEqual(resolve_model_for_endpoint("claude-haiku-4-5", self.OLLAMA_ENV), "qwen2.5-coder:1.5b") + + def test_non_claude_model_unchanged(self): + self.assertEqual(resolve_model_for_endpoint("deepseek-coder-v2", self.OLLAMA_ENV), "deepseek-coder-v2") + + def test_no_base_url_unchanged(self): + env = {"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-7"} + self.assertEqual(resolve_model_for_endpoint("claude-opus-4-7", env), "claude-opus-4-7") + self.assertEqual(resolve_model_for_endpoint("claude-sonnet-4-6", {}), "claude-sonnet-4-6") + + def test_missing_tier_override_unchanged(self): + env = {"ANTHROPIC_BASE_URL": "http://localhost:11434", "ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-coder"} + self.assertEqual(resolve_model_for_endpoint("claude-sonnet-4-6", env), "claude-sonnet-4-6") + + def test_blank_override_unchanged(self): + env = {"ANTHROPIC_BASE_URL": "http://localhost:11434", "ANTHROPIC_DEFAULT_OPUS_MODEL": " "} + self.assertEqual(resolve_model_for_endpoint("claude-opus-4-7", env), "claude-opus-4-7") + + def test_vertex_unchanged(self): + env = { + "ANTHROPIC_BASE_URL": "https://example.com", + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-7@20260101", + } + self.assertEqual(resolve_model_for_endpoint("claude-opus-4-7", env), "claude-opus-4-7") + + class TestExtraReadPathsBlocklist(unittest.TestCase): """Tests for EXTRA_READ_PATHS sensitive directory blocking in get_extra_read_paths()."""