From 7ef04e576adbbbf81b7723c854e61fb2e0f82e87 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Thu, 11 Jun 2026 20:27:12 -0700 Subject: [PATCH] fix(providers): require base_url for anthropic-compatible Copilot review on #661: empty base_url let the SDK fall back to https://api.anthropic.com, sending compat-shaped requests to the commercial API. The lane is local-only by definition, and the /v1-strip edge case already established fail-loudly-over-silent-prod-retarget; apply the same principle to the empty case. create_client raises an actionable ValueError; the admin Detect path surfaces it as a clean error string via probe_model_endpoint's existing handler. --- docs/architecture.md | 8 +++++--- tests/test_provider_anthropic_compat.py | 10 ++++++++++ turnstone/core/providers/__init__.py | 10 +++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9a3e3a9c..569d2529 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -775,9 +775,11 @@ wire translation as the real Anthropic lane, but every model resolves to the `_ANTHROPIC_COMPAT_DEFAULT` capabilities (200K context, 64K output, `token_param=max_tokens`, `thinking_mode=none`, no native web_search/tool_search, no vision) — the static Claude table never -applies to local checkpoints. `base_url` is the server root WITHOUT -`/v1` (the Anthropic SDK appends `/v1/messages`); a trailing `/v1` -pasted out of openai-compatible habit is stripped automatically. Set a +applies to local checkpoints. `base_url` is required — the server root +WITHOUT `/v1` (the Anthropic SDK appends `/v1/messages`); a trailing +`/v1` pasted out of openai-compatible habit is stripped automatically, +and an empty value fails at client construction rather than falling +back to the commercial endpoint. Set a placeholder `api_key` (e.g. `"dummy"`) for unauthenticated servers. Tool calling needs the server started with `--enable-auto-tool-choice --tool-call-parser ` plus the matching reasoning parser. diff --git a/tests/test_provider_anthropic_compat.py b/tests/test_provider_anthropic_compat.py index 240ff30d..e0ce3bc7 100644 --- a/tests/test_provider_anthropic_compat.py +++ b/tests/test_provider_anthropic_compat.py @@ -219,6 +219,16 @@ class TestCompatFactory: create_client("anthropic-compatible", base_url="/v1", api_key="") mock_anthropic_cls.assert_called_once_with(base_url="/v1") + @patch("turnstone.core.providers._anthropic._ensure_anthropic") + def test_create_client_requires_base_url(self, mock_ensure: MagicMock) -> None: + """Empty base_url fails at construction — the local-only lane must + never fall back to the SDK's https://api.anthropic.com default.""" + from turnstone.core.providers import create_client + + with pytest.raises(ValueError, match="anthropic-compatible requires base_url"): + create_client("anthropic-compatible", base_url="", api_key="dummy") + mock_ensure.return_value.Anthropic.assert_not_called() + @patch("turnstone.core.providers._anthropic._ensure_anthropic") def test_create_client_real_lane_base_url_untouched(self, mock_ensure: MagicMock) -> None: """The real anthropic lane forwards base_url verbatim — the /v1 diff --git a/turnstone/core/providers/__init__.py b/turnstone/core/providers/__init__.py index d87cf90a..12aaf512 100644 --- a/turnstone/core/providers/__init__.py +++ b/turnstone/core/providers/__init__.py @@ -153,7 +153,15 @@ def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any: kwargs: dict[str, str] = {} if resolved_key is not None: kwargs["api_key"] = resolved_key - if provider_name == "anthropic-compatible" and base_url: + if provider_name == "anthropic-compatible": + # The lane targets local /v1/messages servers; without a + # base_url the SDK would default to https://api.anthropic.com + # and send compat-shaped requests to the commercial API. + if not base_url: + raise ValueError( + "anthropic-compatible requires base_url (the server root, " + "e.g. http://your-vllm-host:8000)" + ) # The Anthropic SDK appends /v1/... to base_url, so a # /v1-suffixed URL (the openai-compatible convention) would # request /v1/v1/messages and 404. Tolerate the suffix.