diff --git a/coworker/providers/anthropic_provider.py b/coworker/providers/anthropic_provider.py index 0cf0e08e7..c02f0b6b6 100644 --- a/coworker/providers/anthropic_provider.py +++ b/coworker/providers/anthropic_provider.py @@ -141,20 +141,26 @@ def _raise_on_refusal(stop_reason: Any, raw: Any) -> None: ) -def resolve_api_key(secrets: Any = None) -> Optional[str]: +def resolve_api_key(secrets: Any = None, *, prefer_profile: bool = False) -> Optional[str]: """Resolve the Anthropic API key: env `ANTHROPIC_API_KEY` first, else the SecretStore `provider:anthropic` profile (`{api_key}`). Same contract as the OpenAI resolver: the Tauri-launched sidecar does not inherit the shell env, so Settings-entered keys must work. + + `prefer_profile` flips that order, and is set whenever a custom endpoint is configured: + the key then leaves the machine for a host that is NOT Anthropic, so the one the user + typed alongside that endpoint must win over an ambient `ANTHROPIC_API_KEY` meant for + api.anthropic.com (same reasoning as `_openai_compat` in registry.py). """ import os - key = os.environ.get("ANTHROPIC_API_KEY") - if key: - return key + env_key = os.environ.get("ANTHROPIC_API_KEY") + profile_key = None if secrets is not None: profile = secrets.get("provider:anthropic") or {} - return profile.get("api_key") or None - return None + profile_key = profile.get("api_key") or None + if prefer_profile: + return profile_key or env_key or None + return env_key or profile_key or None def _parse_args(raw: Any) -> dict[str, Any]: @@ -388,6 +394,7 @@ def __init__( *, default_model: str = "claude-sonnet-4-6", api_key: Optional[str] = None, + base_url: Optional[str] = None, secrets: Any = None, thinking_budget: Optional[int] = None, ): @@ -395,8 +402,14 @@ def __init__( # before any key exists; the key resolves at call time (explicit → env → SecretStore). # Tests inject a `client` directly. `thinking_budget` (tokens, from the provider # profile's optional field) opts every request into extended thinking. + # + # `base_url` points the same Anthropic SDK at any Messages-API-compatible endpoint + # (LiteLLM, a corporate gateway). It is the ROOT — the SDK appends `/v1/messages` + # itself — and the registry normalizes it. When None the SDK falls back to its own + # default, which still honours `ANTHROPIC_BASE_URL` from the environment. self._client = client self._api_key = api_key + self._base_url = base_url or None self._secrets = secrets self.default_model = default_model self.thinking_budget = thinking_budget or 0 @@ -406,13 +419,18 @@ def _ensure_client(self) -> Any: # Lazy import so the SDK is only required when actually talking to Anthropic. from anthropic import Anthropic - key = self._api_key or resolve_api_key(self._secrets) + key = self._api_key or resolve_api_key( + self._secrets, prefer_profile=bool(self._base_url) + ) if not key: raise RuntimeError( "No Anthropic API key configured. Set ANTHROPIC_API_KEY in the environment, " "or add your key in Manage → Configure Models." ) - self._client = Anthropic(api_key=key) + kwargs: dict[str, Any] = {"api_key": key} + if self._base_url: + kwargs["base_url"] = self._base_url + self._client = Anthropic(**kwargs) return self._client def _request_kwargs( @@ -458,6 +476,14 @@ def _request_kwargs( _add_cache_breakpoints(kwargs) return kwargs + def _use_refusal_fallback(self, model: str) -> bool: + """Whether this call takes the beta server-side-fallback path. Never on a custom + endpoint: the beta header, the `fallbacks` param and the beta messages route are + Anthropic-only, and a compatible gateway that doesn't know them 400s the whole + turn. Losing the fallback there costs a refusal retry, not a working request. + """ + return self._base_url is None and _needs_refusal_fallback(model) + def complete( self, *, @@ -477,7 +503,7 @@ def complete( # for every Anthropic model (found by the 2026-08-31 eval run; fail-closed, # so verdicts fell back to asking a human). get_final_message() returns the # same Message shape create() would. - if _needs_refusal_fallback(model): + if self._use_refusal_fallback(model): with client.beta.messages.stream( **kwargs, betas=[_FALLBACK_BETA], @@ -546,7 +572,7 @@ def stream( ) kwargs["stream"] = True client = self._ensure_client() - if _needs_refusal_fallback(model): + if self._use_refusal_fallback(model): events = client.beta.messages.create( **kwargs, betas=[_FALLBACK_BETA], diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 397efee10..e008edbda 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -8,8 +8,10 @@ Today: `openai` (the default — native models via the Responses API; an optional custom endpoint covering Azure OpenAI's `/openai/v1` and any OpenAI-compliant gateway keeps the -Chat Completions path), `anthropic` (native Messages API via -`AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), `bedrock` +Chat Completions path), `anthropic` (native Messages API via `AnthropicProvider`, with an +optional custom endpoint for LiteLLM and other Anthropic-compatible gateways — one wire +either way, so the endpoint only swaps the host), `gemini` (native Google GenAI API via +`GeminiProvider`), `bedrock` (models in the user's own AWS account — Claude natively, everything else via Converse), `vertex` (the user's own GCP project — Gemini and Claude natively, open-weight via the MaaS endpoint), and `ollama` (local, OpenAI-compatible `/v1`). @@ -117,6 +119,23 @@ def _normalize_ollama_url(url: Optional[str]) -> str: return base +def _normalize_anthropic_base(url: Optional[str]) -> Optional[str]: + """A pasted Anthropic-compatible endpoint → the ROOT the SDK expects, or None. + + Unlike the OpenAI field (where the user pastes a `/v1` path and the SDK appends + `/chat/completions`), the Anthropic SDK builds `/v1/messages` itself — so a + user who copies the `/v1` habit over from the OpenAI box would get `/v1/v1/messages`. + The trailing `/v1` is stripped for them. A gateway whose real root genuinely ends in + `/v1` is the price; the SDK's own convention makes root-without-`/v1` the norm. + """ + base = (url or "").strip().rstrip("/") + if not base: + return None + if base.endswith("/v1"): + base = base[: -len("/v1")].rstrip("/") + return base or None + + def _build_openai(profile: dict[str, Any], secrets: Any) -> ProviderClient: # Key resolution stays in resolve_api_key (explicit → env → SecretStore), so we just # hand over the SecretStore. Stock OpenAI (no custom endpoint) speaks the Responses @@ -149,8 +168,14 @@ def _build_anthropic(profile: dict[str, Any], secrets: Any) -> ProviderClient: thinking_budget = int(str((profile or {}).get("thinking_budget") or "").strip()) except ValueError: thinking_budget = DEFAULT_THINKING_BUDGET + # A custom endpoint (LiteLLM, a corporate gateway) points the same SDK elsewhere; blank + # keeps api.anthropic.com — and leaves the SDK's own ANTHROPIC_BASE_URL env support intact. + base_url = _normalize_anthropic_base((profile or {}).get("base_url")) return AnthropicProvider( - api_key=api_key, secrets=secrets, thinking_budget=thinking_budget + api_key=api_key, + base_url=base_url, + secrets=secrets, + thinking_budget=thinking_budget, ) @@ -382,6 +407,15 @@ def _responses_compat( secret=True, placeholder="sk-ant-…", ), + ProviderField( + "base_url", + "Custom endpoint (optional)", + secret=False, + required=False, + placeholder="https://…/anthropic", + help="For LiteLLM or any Anthropic-compatible gateway. Give the root — " + "/v1 is added automatically. Leave blank for api.anthropic.com.", + ), # No thinking_budget field (owner call 2026-07-23): extended thinking is # on by default; the profile key stays a hidden override (0 = off). ], @@ -927,6 +961,31 @@ def _verify_vertex(fields: dict[str, Any], timeout: float) -> dict[str, Any]: return {"ok": False, "error": f"Vertex AI returned HTTP {resp.status_code}."} +# Verify fallback (custom Anthropic endpoints only): a one-token Messages call, on the +# most widely proxied current model. Its own availability is not what's being tested — +# see `_anthropic_error_shaped`. +_ANTHROPIC_PROBE_MODEL = "claude-sonnet-4-6" + + +def _anthropic_error_shaped(resp: Any) -> bool: + """Whether a 4xx body is an Anthropic API error object (`{"type":"error","error":{…}}`) + rather than a generic gateway/proxy 404 page — i.e. whether something on the other end + actually speaks the Messages API. + + The top-level `type` discriminator is what's checked, NOT just a nested `error` object: + `{"error": {"message": …}}` alone is the OpenAI/LiteLLM error shape, and passing it + would green-light an OpenAI-compatible URL pasted into the Anthropic endpoint box — + Test would say "saved" and every turn would then fail at runtime. + """ + try: + body = resp.json() + except Exception: + return False + if not isinstance(body, dict) or not isinstance(body.get("error"), dict): + return False + return body.get("type") == "error" + + def verify_provider_key( name: str, *, @@ -956,11 +1015,30 @@ def verify_provider_key( return _verify_vertex(fields or {}, timeout) try: if name == "anthropic": - resp = httpx.get( - "https://api.anthropic.com/v1/models", - headers={"x-api-key": key, "anthropic-version": "2023-06-01"}, - timeout=timeout, - ) + base = _normalize_anthropic_base(base_url) or "https://api.anthropic.com" + headers = {"x-api-key": key, "anthropic-version": "2023-06-01"} + resp = httpx.get(base + "/v1/models", timeout=timeout, headers=headers) + if resp.status_code == 404 and base != "https://api.anthropic.com": + # Gateways commonly proxy /v1/messages only, so a missing model list says + # nothing about the endpoint. Fall back to the one call every + # Anthropic-compatible endpoint must serve, capped at a single output + # token so a Test button never costs anything meaningful. + resp = httpx.post( + base + "/v1/messages", + headers=headers, + json={ + "model": _ANTHROPIC_PROBE_MODEL, + "max_tokens": 1, + "messages": [{"role": "user", "content": "hi"}], + }, + timeout=timeout, + ) + # 4xx that came back in Anthropic's error shape (an unknown probe model on + # a gateway that only serves its own aliases, a param it dislikes) still + # proves what Test is for: an Anthropic-speaking endpoint that accepted + # the key. Auth failures are 401/403 and fall through to the mapping below. + if resp.status_code not in (401, 403) and _anthropic_error_shaped(resp): + return {"ok": True} elif name == "gemini": resp = httpx.get( "https://generativelanguage.googleapis.com/v1beta/models", diff --git a/surfaces/gui/src/providers/ProviderSetup.test.tsx b/surfaces/gui/src/providers/ProviderSetup.test.tsx index 89d0e8d2d..dc4af654b 100644 --- a/surfaces/gui/src/providers/ProviderSetup.test.tsx +++ b/surfaces/gui/src/providers/ProviderSetup.test.tsx @@ -120,3 +120,52 @@ describe("Ark provider presentation", () => { ); }); }); + +// The custom-endpoint row is descriptor-driven, not per-provider: any keyed provider that +// declares a base_url field gets it. Anthropic (Messages-API gateways) stands in here. +const ANTHROPIC: ProviderInfo = { + name: "anthropic", + title: "Claude (Anthropic)", + needs_key: true, + configured: false, + values: {}, + suggested_models: [], + recommended_model: null, + fields: [ + { key: "api_key", label: "Anthropic API key", secret: true, required: true, help: "", placeholder: "sk-ant-…" }, + { + key: "base_url", + label: "Custom endpoint (optional)", + secret: false, + required: false, + help: "For LiteLLM or any Anthropic-compatible gateway.", + placeholder: "https://…/anthropic", + }, + ], +}; + +function makeAnthropicPs(over: Partial = {}): ProviderSetupState { + return { + ...makePs({}), + providers: [ANTHROPIC], + ordered: [ANTHROPIC], + sel: "anthropic", + info: ANTHROPIC, + ...over, + }; +} + +describe("ProviderForm custom endpoint", () => { + it("offers the disclosure and keeps the field hidden until it opens", () => { + render(); + expect(screen.getByTestId("t-endpoint-link")).toBeTruthy(); + expect(screen.queryByTestId("t-field-base_url")).toBeNull(); + }); + + it("renders the endpoint input once expanded", () => { + render(); + const input = screen.getByTestId("t-field-base_url") as HTMLInputElement; + expect(input.placeholder).toBe("https://…/anthropic"); + expect(screen.queryByTestId("t-endpoint-link")).toBeNull(); + }); +}); diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx index 1f7559c95..d12adbdd5 100644 --- a/surfaces/gui/src/providers/ProviderSetup.tsx +++ b/surfaces/gui/src/providers/ProviderSetup.tsx @@ -145,7 +145,14 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup setFields(next); setDirty(!!draft && Object.values(draft).some(Boolean)); setVerify({ state: "idle" }); - setShowEndpoint(false); + // Collapsed by default, but a stored CUSTOM endpoint opens the row: a configured + // gateway is the one thing about a provider a collapsed disclosure would actively + // misrepresent. Compared against the field's default, not just emptiness — the compat + // vendors (Z AI, DeepSeek, Ark, …) prefill their own official endpoint, and those + // stay quiet because nothing has been customised. + const endpoint = p?.fields?.find((f) => f.key === "base_url"); + const storedEndpoint = p?.values?.base_url || ""; + setShowEndpoint(!!storedEndpoint && storedEndpoint !== (endpoint?.default || "")); }; const backToGallery = () => { diff --git a/tests/test_anthropic_provider.py b/tests/test_anthropic_provider.py index cfcee3df6..0011f3edd 100644 --- a/tests/test_anthropic_provider.py +++ b/tests/test_anthropic_provider.py @@ -363,6 +363,24 @@ def test_ensure_client_without_key_raises(monkeypatch): AnthropicProvider()._ensure_client() +def test_ensure_client_passes_base_url_only_when_set(monkeypatch): + """A custom endpoint reaches the SDK; a blank one is left off entirely so the SDK's + own default (which still honours ANTHROPIC_BASE_URL) applies.""" + import anthropic + + seen: list[dict] = [] + monkeypatch.setattr( + anthropic, "Anthropic", lambda **kwargs: seen.append(kwargs) or object() + ) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env") + + AnthropicProvider(base_url="https://gw.example/anthropic")._ensure_client() + assert seen[-1]["base_url"] == "https://gw.example/anthropic" + + AnthropicProvider()._ensure_client() + assert "base_url" not in seen[-1] + + # -- stream() ------------------------------------------------------------------------ @@ -491,6 +509,23 @@ def get(self, name): assert resolve_api_key(None) is None +def test_custom_endpoint_prefers_the_profile_key_over_env(monkeypatch): + """With a gateway configured, the key leaves the machine for a host that is NOT + Anthropic — the one typed alongside that endpoint must win over an ambient + ANTHROPIC_API_KEY meant for api.anthropic.com.""" + from coworker.providers.anthropic_provider import resolve_api_key + + class _Secrets: + def get(self, name): + return {"api_key": "sk-gw"} if name == "provider:anthropic" else None + + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env") + assert resolve_api_key(_Secrets(), prefer_profile=True) == "sk-gw" + assert resolve_api_key(_Secrets()) == "sk-ant-env" # stock: env still wins + # No profile key stored → the env key is still better than no key at all. + assert resolve_api_key(None, prefer_profile=True) == "sk-ant-env" + + def test_anthropic_capabilities_parallel_tool_calls(): caps = capabilities_for("anthropic:claude-sonnet-4-6") assert caps.tools and caps.vision and caps.streaming @@ -664,6 +699,23 @@ def test_thinking_defaults_on_with_hidden_profile_override(): assert build_provider_client("anthropic", {"thinking_budget": "0"}, None).thinking_budget == 0 +def test_build_normalizes_the_custom_endpoint(): + """The SDK appends /v1/messages itself, so the stored endpoint is the ROOT — a `/v1` + carried over from the OpenAI box (where it belongs) is trimmed rather than doubled.""" + from coworker.providers.registry import build_provider_client + + def base_for(value): + return build_provider_client("anthropic", {"base_url": value}, None)._base_url + + assert base_for("https://gw.example/anthropic") == "https://gw.example/anthropic" + assert base_for("https://gw.example/anthropic/") == "https://gw.example/anthropic" + assert base_for("https://gw.example/anthropic/v1") == "https://gw.example/anthropic" + assert base_for("https://gw.example/anthropic/v1/") == "https://gw.example/anthropic" + # Blank stays None — stock api.anthropic.com, and the SDK's ANTHROPIC_BASE_URL still applies. + assert base_for(" ") is None + assert build_provider_client("anthropic", {}, None)._base_url is None + + def test_fable_requests_carry_server_side_fallback(): """Fable/Mythos classifiers can decline benign-adjacent requests — every call opts into the server-side fallback so Opus 4.8 re-serves declines in the same call.""" @@ -682,6 +734,17 @@ def test_fable_requests_carry_server_side_fallback(): assert "betas" not in client2.kwargs and "fallbacks" not in client2.kwargs +def test_custom_endpoint_skips_the_beta_fallback_path(): + """The beta header, the `fallbacks` param and the beta route are Anthropic-only — a + compatible gateway that doesn't know them 400s the whole turn, so a custom endpoint + takes the plain path even on Fable.""" + client = _FakeClient(response=_text_response()) + AnthropicProvider(client=client, base_url="https://gw.example/anthropic").complete( + model="claude-fable-5", messages=[{"role": "user", "content": "x"}] + ) + assert "betas" not in client.kwargs and "fallbacks" not in client.kwargs + + def test_whole_chain_refusal_raises_friendly_error(): """A refusal that survives the fallback chain must surface as an error (notice + Retry in the GUI) — not a silent blank reply (owner-hit 2026-07-23).""" diff --git a/tests/test_provider_verify.py b/tests/test_provider_verify.py index a6e553afa..4bc5e128f 100644 --- a/tests/test_provider_verify.py +++ b/tests/test_provider_verify.py @@ -41,14 +41,17 @@ def fake_get(url, **kwargs): monkeypatch.setattr("httpx.get", fake_get) -def _patch_post(monkeypatch, status=200, capture=None, raise_exc=None): +def _patch_post(monkeypatch, status=200, body=None, capture=None, raise_exc=None): def fake_post(url, **kwargs): if capture is not None: capture["url"] = url capture.update(kwargs) if raise_exc is not None: raise raise_exc - return SimpleNamespace(status_code=status) + return SimpleNamespace( + status_code=status, + json=lambda: body if body is not None else {}, + ) monkeypatch.setattr("httpx.post", fake_post) @@ -88,6 +91,76 @@ def test_verify_anthropic_headers(monkeypatch): assert "anthropic-version" in cap["headers"] +def test_verify_anthropic_custom_endpoint(monkeypatch): + cap: dict = {} + _patch_get(monkeypatch, status=200, capture=cap) + # The stored endpoint is the ROOT (the SDK adds /v1) — a trailing /v1 is trimmed, not doubled. + verify_provider_key("anthropic", api_key="sk-x", base_url="https://gw.example/anthropic/v1/") + assert cap["url"] == "https://gw.example/anthropic/v1/models" + assert cap["headers"]["x-api-key"] == "sk-x" + + +def test_verify_anthropic_gateway_without_model_list_falls_back_to_messages(monkeypatch): + """Gateways commonly proxy /v1/messages only — a missing model list says nothing about + the endpoint, so Test falls back to a one-token Messages call. An Anthropic-shaped 4xx + (unknown probe model on a gateway serving its own aliases) still proves the endpoint + speaks the API and took the key.""" + cap: dict = {} + _patch_get(monkeypatch, status=404) + _patch_post( + monkeypatch, + status=404, + body={"type": "error", "error": {"type": "not_found_error"}}, + capture=cap, + ) + assert verify_provider_key( + "anthropic", api_key="sk-x", base_url="https://gw.example/anthropic" + ) == {"ok": True} + assert cap["url"] == "https://gw.example/anthropic/v1/messages" + assert cap["json"]["max_tokens"] == 1 # a Test button never costs anything meaningful + + +def test_verify_anthropic_gateway_rejects_key_on_the_fallback(monkeypatch): + _patch_get(monkeypatch, status=404) + _patch_post(monkeypatch, status=401, body={"type": "error", "error": {}}) + assert verify_provider_key( + "anthropic", api_key="sk-bad", base_url="https://gw.example/anthropic" + ) == {"ok": False, "error": "Invalid API key."} + + +@pytest.mark.parametrize( + "body", + [ + {"message": "Not Found"}, # a plain proxy 404 page + # The OpenAI/LiteLLM error shape: nested `error`, no top-level "type". Pasting an + # OpenAI-compatible URL into the Anthropic endpoint box is the likely mistake, and + # a Test that passed there would only fail later, mid-conversation. + {"error": {"message": "Not Found", "type": "invalid_request_error"}}, + ], +) +def test_verify_anthropic_non_anthropic_host_stays_a_failure(monkeypatch, body): + """Only Anthropic's own error shape (top-level "type": "error") counts as proof the + endpoint speaks the Messages API.""" + _patch_get(monkeypatch, status=404) + _patch_post(monkeypatch, status=404, body=body) + res = verify_provider_key( + "anthropic", api_key="sk-x", base_url="https://gw.example/wrong" + ) + assert res["ok"] is False and "404" in res["error"] + + +def test_verify_anthropic_stock_404_does_not_probe_messages(monkeypatch): + """No custom endpoint, no fallback: api.anthropic.com always serves /v1/models, so a + 404 there is a real failure — and nobody's Anthropic account gets a stray call.""" + _patch_get(monkeypatch, status=404) + + def boom(*a, **k): # pragma: no cover - asserts the path is never taken + raise AssertionError("stock anthropic must not POST /v1/messages") + + monkeypatch.setattr("httpx.post", boom) + assert verify_provider_key("anthropic", api_key="sk-x")["ok"] is False + + def test_verify_gemini_key_param(monkeypatch): cap: dict = {} _patch_get(monkeypatch, status=200, capture=cap)