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
46 changes: 36 additions & 10 deletions coworker/providers/anthropic_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -388,15 +394,22 @@ 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,
):
# Mirrors OpenAIProvider: the SDK client is built lazily so engines can be assembled
# 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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
*,
Expand All @@ -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],
Expand Down Expand Up @@ -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],
Expand Down
94 changes: 86 additions & 8 deletions coworker/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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 `<base>/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
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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).
],
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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",
Expand Down
49 changes: 49 additions & 0 deletions surfaces/gui/src/providers/ProviderSetup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): 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(<ProviderForm ps={makeAnthropicPs()} tp="t" />);
expect(screen.getByTestId("t-endpoint-link")).toBeTruthy();
expect(screen.queryByTestId("t-field-base_url")).toBeNull();
});

it("renders the endpoint input once expanded", () => {
render(<ProviderForm ps={makeAnthropicPs({ showEndpoint: true })} tp="t" />);
const input = screen.getByTestId("t-field-base_url") as HTMLInputElement;
expect(input.placeholder).toBe("https://…/anthropic");
expect(screen.queryByTestId("t-endpoint-link")).toBeNull();
});
});
9 changes: 8 additions & 1 deletion surfaces/gui/src/providers/ProviderSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
Loading