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/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/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/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/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 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: