Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -353,19 +391,19 @@ 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:
project_id = sdk_env.get("ANTHROPIC_VERTEX_PROJECT_ID", "unknown")
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):
Expand Down
50 changes: 50 additions & 0 deletions test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
EXTRA_READ_PATHS_VAR,
convert_model_for_vertex,
get_extra_read_paths,
resolve_model_for_endpoint,
)


Expand Down Expand Up @@ -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()."""

Expand Down