From e4d6b1ef29572eac2c2595fa7da02cf5d35877d9 Mon Sep 17 00:00:00 2001 From: kirillkom Date: Mon, 20 Jul 2026 22:37:55 +0300 Subject: [PATCH] feat(providers): honor ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN in AnthropicProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the Messages API endpoint and auth scheme env-overridable so watchmen works against any Anthropic-compatible proxy (z.ai, AWS Bedrock reverse-proxy, Azure Anthropic, internal gateways) without code changes. Changes to AnthropicProvider: - endpoint: class attr → property that reads ANTHROPIC_BASE_URL (default https://api.anthropic.com). Trailing slash tolerated. - headers(): when ANTHROPIC_AUTH_TOKEN is set, switch from x-api-key to Authorization: Bearer — matches Claude Code's env semantics so the same env that drives claude / pi.dev drives watchmen. - probe(): same env override for URL + auth, otherwise `watchmen settings api-key --check` would always hit api.anthropic.com and report proxy tokens as invalid. Env vars are read via config.read_env_var(), so they resolve from process env first, then ~/.config/watchmen/.env — same lookup path as every other watchmen env var. ClaudePro (subclass) keeps its hardcoded endpoint + OAuth headers: that flow always targets api.anthropic.com, env override would break it. Tests: - test_anthropic_headers_use_x_api_key: hardened — explicitly clears ANTHROPIC_AUTH_TOKEN so the test no longer flips auth schemes under env contamination (pre-existing latent bug). - test_anthropic_endpoint_defaults_to_api_anthropic_com: backward-compat assertion — when env unset, endpoint is unchanged. - test_anthropic_endpoint_honors_base_url_env: env override + trailing slash tolerance. - test_anthropic_headers_use_bearer_when_auth_token_set: Bearer path. - test_anthropic_probe_hits_env_base_url: probe honors env override for URL + auth. Patches httpx.get via monkeypatch (no module-swap leak). All 568 existing tests + 4 new ones pass. Verified end-to-end against z.ai's Anthropic-compatible endpoint: ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic \ watchmen settings api-key --provider anthropic --set → ✓ valid · 8 models accessible --- src/watchmen/providers.py | 64 ++++++++++++++++++++++++++++---- tests/test_providers.py | 78 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 10 deletions(-) diff --git a/src/watchmen/providers.py b/src/watchmen/providers.py index 3bd8282..d3b7469 100644 --- a/src/watchmen/providers.py +++ b/src/watchmen/providers.py @@ -30,6 +30,8 @@ import json from dataclasses import dataclass +from watchmen import config + PROVIDER_NAMES = ("openrouter", "openai", "anthropic", "claude-pro", "chatgpt") @@ -291,7 +293,14 @@ def probe(self, api_key: str, *, timeout: float = 10.0) -> ProbeResult: class AnthropicProvider(Provider): name = "anthropic" - endpoint = "https://api.anthropic.com/v1/messages" + # Base URL for the Messages API. Override at runtime with + # `ANTHROPIC_BASE_URL` to point watchmen at an Anthropic-compatible + # proxy (z.ai, AWS Bedrock reverse-proxy, Azure Anthropic, internal + # gateways). Mirrors the official anthropic SDK's env-var contract so + # the same env that drives `claude` / Claude Code drives watchmen. + # Format: scheme + host (+ optional path), no trailing slash. We append + # `/v1/messages` (and `/v1/models` for probe) on top. + DEFAULT_BASE_URL = "https://api.anthropic.com" default_model = "claude-haiku-4-5-20251001" quota_label = "Anthropic API credits" # Cap on output tokens per turn — Anthropic requires this field, OpenAI @@ -299,12 +308,43 @@ class AnthropicProvider(Provider): # less can rely on the model stopping early. _max_tokens_per_turn = 8192 + @property + def endpoint(self) -> str: + """Messages API URL — honors `ANTHROPIC_BASE_URL` env override. + + Falls back to the official Anthropic endpoint when unset, so + existing installs see no behavior change. + """ + base = self._effective_base_url() + return f"{base}/v1/messages" + + @classmethod + def _effective_base_url(cls) -> str: + raw = config.read_env_var("ANTHROPIC_BASE_URL") or cls.DEFAULT_BASE_URL + return raw.rstrip("/") + + @staticmethod + def _auth_token() -> str | None: + """Optional bearer token. When set, overrides `x-api-key` auth. + + Some Anthropic-compatible proxies (and Claude Code's own OAuth flow) + authenticate via `Authorization: Bearer ` instead of the + SDK's `x-api-key`. Setting `ANTHROPIC_AUTH_TOKEN` switches us to + the bearer scheme, matching Claude Code's env handling. + """ + return config.read_env_var("ANTHROPIC_AUTH_TOKEN") + def headers(self, api_key: str, *, agent_name: str = "") -> dict[str, str]: - return { - "x-api-key": api_key, + h = { "anthropic-version": "2023-06-01", "content-type": "application/json", } + token = self._auth_token() + if token: + h["Authorization"] = f"Bearer {token}" + else: + h["x-api-key"] = api_key + return h def translate_request( self, *, model: str, messages: list[dict], tools: list[dict] @@ -436,12 +476,20 @@ def translate_response(self, raw: dict) -> dict: def probe(self, api_key: str, *, timeout: float = 10.0) -> ProbeResult: import httpx + base = self._effective_base_url() + token = self._auth_token() + if token: + headers = { + "Authorization": f"Bearer {token}", + "anthropic-version": "2023-06-01", + } + else: + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + } try: - r = httpx.get( - "https://api.anthropic.com/v1/models", - headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"}, - timeout=timeout, - ) + r = httpx.get(f"{base}/v1/models", headers=headers, timeout=timeout) except httpx.RequestError as e: return ProbeResult(False, f"connection error: {type(e).__name__}") if r.status_code == 200: diff --git a/tests/test_providers.py b/tests/test_providers.py index 55f88f4..4633f44 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -170,10 +170,15 @@ def test_openai_headers_minimal(): # ─── Anthropic: Messages API translation ─────────────────────────────────── -def test_anthropic_headers_use_x_api_key(): +def test_anthropic_headers_use_x_api_key(monkeypatch): """Anthropic uses `x-api-key`, not `Authorization: Bearer`. Getting this wrong is the single most common source of 401s when migrating between - providers — explicit test so a regression here can't slip through.""" + providers — explicit test so a regression here can't slip through. + + Must explicitly clear ANTHROPIC_AUTH_TOKEN because the env-contaminated + path (Claude Code / proxies that use bearer auth) would otherwise flip + the header scheme under the test's nose.""" + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) prov = providers.get_provider("anthropic") h = prov.headers("sk-ant-test") assert h["x-api-key"] == "sk-ant-test" @@ -181,6 +186,75 @@ def test_anthropic_headers_use_x_api_key(): assert "Authorization" not in h +def test_anthropic_endpoint_defaults_to_api_anthropic_com(monkeypatch): + """Without ANTHROPIC_BASE_URL, the endpoint is the official Messages API. + Backward compat for existing installs — no behavior change unless the + user opts in via env.""" + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + prov = providers.get_provider("anthropic") + assert prov.endpoint == "https://api.anthropic.com/v1/messages" + + +def test_anthropic_endpoint_honors_base_url_env(monkeypatch): + """ANTHROPIC_BASE_URL redirects the Messages API to an Anthropic- + compatible proxy (z.ai, Bedrock reverse-proxy, internal gateway). + Trailing slashes are tolerated so users can paste a base either way. + Mirrors the official anthropic SDK's env-var contract — same env that + drives Claude Code drives watchmen.""" + prov = providers.get_provider("anthropic") + + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.z.ai/api/anthropic") + assert prov.endpoint == "https://api.z.ai/api/anthropic/v1/messages" + + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://proxy.example.com/anthropic/") + assert prov.endpoint == "https://proxy.example.com/anthropic/v1/messages" + + +def test_anthropic_headers_use_bearer_when_auth_token_set(monkeypatch): + """ANTHROPIC_AUTH_TOKEN flips the auth scheme to Bearer, matching Claude + Code's env semantics. Required for proxies that mint their own bearer + tokens (z.ai, OAuth bridges) and reject x-api-key.""" + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "tok_xxx") + prov = providers.get_provider("anthropic") + h = prov.headers("ignored-sk-ant-key") + assert h["Authorization"] == "Bearer tok_xxx" + assert "x-api-key" not in h + assert h["anthropic-version"] == "2023-06-01" + + +def test_anthropic_probe_hits_env_base_url(monkeypatch): + """probe() must honor the same env override as endpoint(), otherwise + `watchmen settings api-key --check` would always hit api.anthropic.com + and report the proxy token as invalid.""" + import httpx + + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://proxy.example.com") + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + + captured: dict = {} + + class _FakeResponse: + status_code = 200 + + def json(self): + return {"data": [{"id": "stub-model"}]} + + def fake_get(url, headers=None, timeout=None): + captured["url"] = url + captured["headers"] = headers + return _FakeResponse() + + # Patch the get() attribute on the real httpx module — probe() does + # `import httpx` inline, so this is the attribute it will read. + monkeypatch.setattr(httpx, "get", fake_get) + + prov = providers.get_provider("anthropic") + result = prov.probe("sk-ant-test") + assert result.ok is True + assert captured["url"] == "https://proxy.example.com/v1/models" + assert captured["headers"]["x-api-key"] == "sk-ant-test" + + def test_anthropic_request_lifts_system_to_top_level(): """Anthropic's Messages API rejects `system` messages inside the `messages` list — `system` must be a top-level field. The translator