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)