From c14f14fe8977c05fa0846f0340d348dd713b447a Mon Sep 17 00:00:00 2001 From: Cedric Karungu Date: Mon, 28 Sep 2026 13:13:42 +0200 Subject: [PATCH 1/2] feat(local): connect to Anthropic directly with just an API key Anthropic's SDK has its URL built in, so models.dev states none and the desktop app asked the user to type one. Add Anthropic's OpenAI SDK compatibility endpoint to the reviewed table so the provider is ready with a key alone, and update the committed manifest to match. Closes #1898 --- docs/proposals/model-catalog.md | 4 +- .../llm/catalog/remote/manifest/models.json | 2 +- .../scripts/remote_manifest/endpoints.py | 3 + .../tests/fixtures/models_dev_sample.json | 61 +++++++++++++++++++ .../unit/scripts/test_remote_manifest.py | 13 ++++ 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/proposals/model-catalog.md b/docs/proposals/model-catalog.md index 37f31c8195..b5cb69f97c 100644 --- a/docs/proposals/model-catalog.md +++ b/docs/proposals/model-catalog.md @@ -268,9 +268,9 @@ The script is tested against recorded Hugging Face responses, so a change in how | URL stated, OpenAI protocol | `api` with an OpenAI-style package, 184 of 223 providers | `ready`, `base_url_origin: models.dev` | | URL stated, another protocol | `api` with `@ai-sdk/anthropic` or a vendor package: MiniMax, Subconscious and six more | `unreachable`: "Speaks Anthropic's API, which SurfSense does not" | | URL is a template | `${VAR}` in `api` (Databricks, Cloudflare Workers AI, Infomaniak and two more) | `needs_account_details`, `account_fields` from the variables | -| No URL, one fixed endpoint | own SDK, OpenAI-compatible endpoint verified (OpenAI, Groq, Mistral, xAI, Together, Cerebras, Gemini) | `ready` from the reviewed table, `base_url_origin: reviewed`: 7 providers | +| No URL, one fixed endpoint | own SDK, OpenAI-compatible endpoint verified (OpenAI, Groq, Mistral, xAI, Together, Cerebras, Gemini, Anthropic) | `ready` from the reviewed table, `base_url_origin: reviewed`: 8 providers | | Not a bearer key | Amazon Bedrock (AWS signing), Google Vertex (Cloud sign-in) | `unreachable`, with the reason, from the reviewed table | -| No URL and no entry | Azure, SAP AI Core, watsonx, and endpoints not yet verified such as Anthropic, DeepInfra and Perplexity: 16 providers | `needs_url`: the user enters it | +| No URL and no entry | Azure, SAP AI Core, watsonx, and endpoints not yet verified such as DeepInfra and Perplexity: 15 providers | `needs_url`: the user enters it | | Loopback URL | LM Studio and three more | `key: none`, `local: true` | `npm` is not a reachability test on its own: Groq, Mistral, xAI and Together have their own package and serve the OpenAI API. The reviewed table is where a person decides it, once per provider, in a pull request. diff --git a/surfsense_local/backend/modules/llm/catalog/remote/manifest/models.json b/surfsense_local/backend/modules/llm/catalog/remote/manifest/models.json index 6363931496..c0b4414fe5 100644 --- a/surfsense_local/backend/modules/llm/catalog/remote/manifest/models.json +++ b/surfsense_local/backend/modules/llm/catalog/remote/manifest/models.json @@ -948,7 +948,7 @@ "anthropic": { "name": "Anthropic", "doc": "https://docs.anthropic.com/en/docs/about-claude/models", - "connect": {"status": "needs_url", "base_url": null, "base_url_origin": null, "account_fields": [], "key": "required", "local": false, "reason": null}, + "connect": {"status": "ready", "base_url": "https://api.anthropic.com/v1", "base_url_origin": "reviewed", "account_fields": [], "key": "required", "local": false, "reason": null}, "models": { "claude-fable-5": {"name": "Claude Fable 5", "family": "claude-fable", "description": "Claude model for creative writing, analysis, and controlled agent workflows", "release_date": "2026-06-07", "status": null, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}, "context": 1000000, "output_limit": 128000, "tool_call": true, "reasoning": true, "reasoning_options": [{"type": "effort", "values": ["low", "medium", "high", "xhigh", "max"]}], "structured_output": true, "temperature": false, "call": null}, "claude-fable-5-1": {"name": "Claude Fable 5.1", "family": "claude-fable", "description": "Claude model for demanding reasoning and long-horizon agentic work", "release_date": "2026-09-01", "status": null, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}, "context": 1000000, "output_limit": 128000, "tool_call": true, "reasoning": true, "reasoning_options": [{"type": "effort", "values": ["low", "medium", "high", "xhigh", "max"]}], "structured_output": true, "temperature": false, "call": null}, diff --git a/surfsense_local/backend/scripts/remote_manifest/endpoints.py b/surfsense_local/backend/scripts/remote_manifest/endpoints.py index c55e1c62f3..1bc8ee6c3a 100644 --- a/surfsense_local/backend/scripts/remote_manifest/endpoints.py +++ b/surfsense_local/backend/scripts/remote_manifest/endpoints.py @@ -20,6 +20,7 @@ # Where the first seven were verified: the connection form's presets, in use # since 2.0 against each provider's live /models. _PRESET = "SurfSense 2.0 connection preset" +_ANTHROPIC_OPENAI_SDK = "https://docs.claude.com/en/api/openai-sdk" @dataclass(frozen=True) @@ -45,6 +46,8 @@ class Unreachable: "xai": Fixed("https://api.x.ai/v1", _PRESET), "togetherai": Fixed("https://api.together.xyz/v1", _PRESET), "cerebras": Fixed("https://api.cerebras.ai/v1", _PRESET), + # Anthropic's SDK has its URL built in; this is its OpenAI compatibility layer. + "anthropic": Fixed("https://api.anthropic.com/v1", _ANTHROPIC_OPENAI_SDK), "amazon-bedrock": Unreachable("Signs requests with AWS credentials, not an API key"), "google-vertex": Unreachable("Needs a Google Cloud sign-in, not an API key"), "google-vertex-anthropic": Unreachable( diff --git a/surfsense_local/backend/tests/fixtures/models_dev_sample.json b/surfsense_local/backend/tests/fixtures/models_dev_sample.json index 1e98845a7e..50318891f8 100644 --- a/surfsense_local/backend/tests/fixtures/models_dev_sample.json +++ b/surfsense_local/backend/tests/fixtures/models_dev_sample.json @@ -889,5 +889,66 @@ } } } + }, + "anthropic": { + "id": "anthropic", + "env": [ + "ANTHROPIC_API_KEY" + ], + "npm": "@ai-sdk/anthropic", + "name": "Anthropic", + "doc": "https://docs.anthropic.com/en/docs/about-claude/models", + "models": { + "claude-sonnet-5": { + "id": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "Everyday Claude agent model for coding, planning, browsing, and general work", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "reasoning_options": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "tool_call": true, + "structured_output": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-29", + "last_updated": "2026-06-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 1000000, + "output": 128000 + }, + "cost": { + "input": 2, + "output": 10, + "cache_read": 0.2, + "cache_write": 2.5 + } + } + } } } diff --git a/surfsense_local/backend/tests/unit/scripts/test_remote_manifest.py b/surfsense_local/backend/tests/unit/scripts/test_remote_manifest.py index 3c427283fc..fbaf8217e1 100644 --- a/surfsense_local/backend/tests/unit/scripts/test_remote_manifest.py +++ b/surfsense_local/backend/tests/unit/scripts/test_remote_manifest.py @@ -153,6 +153,19 @@ def test_a_hosted_provider_models_dev_gives_no_url_takes_the_reviewed_one( } +def test_anthropic_is_reached_directly_through_its_openai_compatibility_layer( + api: dict, +) -> None: + """Anthropic's own SDK hides its URL; a key alone should still connect.""" + anthropic = _connect(api, "anthropic") + + assert (anthropic["status"], anthropic["base_url"], anthropic["base_url_origin"]) == ( + "ready", + "https://api.anthropic.com/v1", + "reviewed", + ) + + def test_a_url_models_dev_states_with_the_openai_protocol_is_used_as_is( api: dict, ) -> None: From 6c44eb464bc9a24cf438d99d0ebd8df75b2a9e25 Mon Sep 17 00:00:00 2001 From: Cedric Karungu Date: Mon, 28 Sep 2026 16:37:57 +0200 Subject: [PATCH 2/2] fix(local): send Anthropic keys in its native headers Anthropic's GET /models reads x-api-key and anthropic-version, not a bearer token, so discovery could reject a valid key. A key for api.anthropic.com now goes in those headers for discovery, chat and images; every other server keeps the bearer token. --- docs/architecture/connections.md | 2 +- .../modules/llm/connections/key_headers.py | 15 +++++ .../modules/llm/connections/service.py | 7 +- .../llm/providers/openai_compatible/chat.py | 3 +- .../llm/providers/openai_compatible/image.py | 5 +- .../tests/unit/llm/test_key_headers.py | 65 +++++++++++++++++++ 6 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 surfsense_local/backend/modules/llm/connections/key_headers.py create mode 100644 surfsense_local/backend/tests/unit/llm/test_key_headers.py diff --git a/docs/architecture/connections.md b/docs/architecture/connections.md index 8c6502dff9..addbecdfbd 100644 --- a/docs/architecture/connections.md +++ b/docs/architecture/connections.md @@ -112,7 +112,7 @@ Each listed model also carries `selectable_for`, the slots it can fill, decided Chat goes through `OpenAICompatibleChatProvider(base_url, api_key)`: - health and model listing through `GET /models`; -- a streaming `POST /chat/completions`, with an `Authorization: Bearer` header when there is a key and no provider-specific headers or fields; +- a streaming `POST /chat/completions`, with an `Authorization: Bearer` header when there is a key and no provider-specific fields. The one provider-specific header: a key for `api.anthropic.com` goes as `x-api-key` with `anthropic-version`, because Anthropic's `GET /models` does not read a bearer token. Discovery and images send the key the same way ([`key_headers.py`](../../surfsense_local/backend/modules/llm/connections/key_headers.py)); - each chunk read as answer (`content`) or reasoning (`reasoning_content`, or `reasoning` as vLLM, Ollama and OpenRouter name it). `chat_deltas()` yields both, marked; `chat()` yields the answer alone, for titles, Studio and the connection check; - 300 seconds to the first token, answer or reasoning, since a cold model may still be loading, then 30 seconds between tokens. A long think keeps the stream alive rather than counting as a model that never started. Model listings wait at most 120 seconds and connection discovery 10; - no context window and no token count, so chat falls back to its fixed history budget ([`chat.md`](chat.md)). diff --git a/surfsense_local/backend/modules/llm/connections/key_headers.py b/surfsense_local/backend/modules/llm/connections/key_headers.py new file mode 100644 index 0000000000..f04872b3ab --- /dev/null +++ b/surfsense_local/backend/modules/llm/connections/key_headers.py @@ -0,0 +1,15 @@ +from urllib.parse import urlsplit + +# Anthropic's OpenAI layer accepts a bearer key for chat, but its model listing +# reads only its native headers, which both routes accept. +_ANTHROPIC_HOST = "api.anthropic.com" +_ANTHROPIC_VERSION = "2023-06-01" + + +def key_headers(base_url: str, api_key: str | None) -> dict[str, str]: + """The headers that carry a connection's key to its server.""" + if not api_key: + return {} + if urlsplit(base_url).hostname == _ANTHROPIC_HOST: + return {"x-api-key": api_key, "anthropic-version": _ANTHROPIC_VERSION} + return {"Authorization": f"Bearer {api_key}"} diff --git a/surfsense_local/backend/modules/llm/connections/service.py b/surfsense_local/backend/modules/llm/connections/service.py index e923b49805..5357518929 100644 --- a/surfsense_local/backend/modules/llm/connections/service.py +++ b/surfsense_local/backend/modules/llm/connections/service.py @@ -7,6 +7,7 @@ from modules.llm.catalog.remote.manifest.loader import remote_lookup from modules.llm.catalog.remote.rows import CUSTOM +from modules.llm.connections.key_headers import key_headers from modules.llm.model_type import ModelType from modules.llm.models import ProviderConnection @@ -61,10 +62,6 @@ def normalize_base_url(value: str) -> str: return urlunsplit((parsed.scheme, netloc, path, "", "")) -def _headers(api_key: str | None) -> dict[str, str]: - return {"Authorization": f"Bearer {api_key}"} if api_key else {} - - def _modalities(entry: dict) -> set[str]: raw = entry.get("output_modalities") if raw is None and isinstance(entry.get("architecture"), dict): @@ -140,7 +137,7 @@ async def _request_entries( params = {"output_modalities": "image"} if image_only else None async with httpx.AsyncClient( timeout=DISCOVERY_TIMEOUT, - headers=_headers(api_key), + headers=key_headers(base_url, api_key), ) as client: reply = await client.get(f"{base_url}/models", params=params) reply.raise_for_status() diff --git a/surfsense_local/backend/modules/llm/providers/openai_compatible/chat.py b/surfsense_local/backend/modules/llm/providers/openai_compatible/chat.py index 7397b07899..6f649f983e 100644 --- a/surfsense_local/backend/modules/llm/providers/openai_compatible/chat.py +++ b/surfsense_local/backend/modules/llm/providers/openai_compatible/chat.py @@ -3,6 +3,7 @@ import httpx +from modules.llm.connections.key_headers import key_headers from modules.llm.connections.service import parse_models from modules.llm.profile import Fingerprint, from_remote from modules.llm.providers.stream_deadline import with_deadlines @@ -51,7 +52,7 @@ def __init__( self._thinking_off = thinking_off def _client(self, timeout: httpx.Timeout = LISTING_TIMEOUT) -> httpx.AsyncClient: - headers = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {} + headers = key_headers(self._base_url, self._api_key) return httpx.AsyncClient( timeout=timeout, headers=headers, transport=self._transport ) diff --git a/surfsense_local/backend/modules/llm/providers/openai_compatible/image.py b/surfsense_local/backend/modules/llm/providers/openai_compatible/image.py index 2bfa252e8a..caeca0346f 100644 --- a/surfsense_local/backend/modules/llm/providers/openai_compatible/image.py +++ b/surfsense_local/backend/modules/llm/providers/openai_compatible/image.py @@ -5,6 +5,7 @@ import httpx +from modules.llm.connections.key_headers import key_headers from modules.llm.providers.protocols import GeneratedImage TIMEOUT = httpx.Timeout(180.0, connect=5.0) @@ -32,9 +33,7 @@ def __init__( self._api_key = api_key def _headers(self) -> dict[str, str]: - return ( - {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {} - ) + return key_headers(self._base_url, self._api_key) async def generate(self, model: str, prompt: str) -> GeneratedImage: key = (self._connection_id, self._base_url) diff --git a/surfsense_local/backend/tests/unit/llm/test_key_headers.py b/surfsense_local/backend/tests/unit/llm/test_key_headers.py new file mode 100644 index 0000000000..ebce7688f8 --- /dev/null +++ b/surfsense_local/backend/tests/unit/llm/test_key_headers.py @@ -0,0 +1,65 @@ +import httpx +import pytest + +from modules.llm.connections import service +from modules.llm.connections.service import probe_connection +from modules.llm.providers.openai_compatible.chat import OpenAICompatibleChatProvider + +pytestmark = pytest.mark.unit +LISTING = {"data": [{"id": "claude-sonnet-5", "type": "model"}]} + + +def _recording(seen: list[httpx.Request]) -> httpx.MockTransport: + def answer(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json=LISTING) + + return httpx.MockTransport(answer) + + +async def test_anthropic_is_listed_with_its_own_key_headers() -> None: + """Anthropic's model listing reads x-api-key, not a bearer token.""" + seen: list[httpx.Request] = [] + provider = OpenAICompatibleChatProvider( + "https://api.anthropic.com/v1", "sk-ant-test", transport=_recording(seen) + ) + + models = await provider.models() + + assert [model.name for model in models] == ["claude-sonnet-5"] + assert seen[0].url == "https://api.anthropic.com/v1/models" + assert seen[0].headers["x-api-key"] == "sk-ant-test" + assert seen[0].headers["anthropic-version"] == "2023-06-01" + assert "authorization" not in seen[0].headers + + +async def test_another_server_keeps_the_bearer_token() -> None: + """Every other OpenAI-compatible server is reached as before.""" + seen: list[httpx.Request] = [] + provider = OpenAICompatibleChatProvider( + "https://api.openai.com/v1", "sk-test", transport=_recording(seen) + ) + + await provider.models() + + assert seen[0].headers["authorization"] == "Bearer sk-test" + assert "x-api-key" not in seen[0].headers + + +async def test_discovering_anthropic_sends_its_own_key_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Connecting runs discovery before any provider exists.""" + seen: list[httpx.Request] = [] + real_client = httpx.AsyncClient + + def client(**kwargs: object) -> httpx.AsyncClient: + return real_client(**kwargs, transport=_recording(seen)) + + monkeypatch.setattr(service.httpx, "AsyncClient", client) + + discovered = await probe_connection("https://api.anthropic.com/v1", "sk-ant-test") + + assert [model.name for model in discovered] == ["claude-sonnet-5"] + assert seen[0].headers["x-api-key"] == "sk-ant-test" + assert "authorization" not in seen[0].headers