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
64 changes: 56 additions & 8 deletions src/watchmen/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
import json
from dataclasses import dataclass

from watchmen import config


PROVIDER_NAMES = ("openrouter", "openai", "anthropic", "claude-pro", "chatgpt")

Expand Down Expand Up @@ -291,20 +293,58 @@ 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
# treats it as optional. We pick a generous default; callers that need
# 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 <token>` 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]
Expand Down Expand Up @@ -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:
Expand Down
78 changes: 76 additions & 2 deletions tests/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,17 +170,91 @@ 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"
assert h["anthropic-version"] == "2023-06-01"
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
Expand Down