diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-09 16:57:48 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-09 16:57:48 +0200 |
| commit | 4a22fe71f8414328d8cc1071619182468c3443e4 (patch) | |
| tree | dc60e2a5df48c5d0fd0ff4742a54be3ad7b12768 | |
| parent | fb8bec8aafb8d050fca4dc02af4fe2a04fdb690d (diff) | |
| download | llamachat-4a22fe71f8414328d8cc1071619182468c3443e4.tar.gz llamachat-4a22fe71f8414328d8cc1071619182468c3443e4.zip | |
feat: send bearer auth when a provider needs a key
The local router needs none, so the header is omitted entirely rather
than sent empty.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | llamachat/backend.py | 13 | ||||
| -rwxr-xr-x | test_llamachat.py | 100 |
2 files changed, 109 insertions, 4 deletions
diff --git a/llamachat/backend.py b/llamachat/backend.py index 3a32ff7..fea2de0 100644 --- a/llamachat/backend.py +++ b/llamachat/backend.py @@ -169,14 +169,21 @@ def build_user_content(text: str, attachments: list[Attachment]): class Client: """Minimal OpenAI-compatible client for the local router.""" - def __init__(self, base_url: str, timeout: int = 300): + def __init__(self, base_url: str, timeout: int = 300, api_key: str = ""): self.base_url = base_url.rstrip("/") self.timeout = timeout + self.api_key = api_key + + def _headers(self) -> dict: + """Bearer auth when the provider needs it, nothing when it does not.""" + return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} def models(self) -> list[str]: """Model ids the router currently offers.""" try: - resp = httpx.get(f"{self.base_url}/v1/models", timeout=10) + resp = httpx.get( + f"{self.base_url}/v1/models", timeout=10, headers=self._headers() + ) resp.raise_for_status() payload = resp.json() except httpx.HTTPError as exc: @@ -207,6 +214,7 @@ class Client: "chat_template_kwargs": {"enable_thinking": False}, }, timeout=httpx.Timeout(self.timeout, connect=10), + headers=self._headers(), ) resp.raise_for_status() payload = resp.json() @@ -349,6 +357,7 @@ class Client: f"{self.base_url}/v1/chat/completions", json=body, timeout=httpx.Timeout(self.timeout, connect=10), + headers=self._headers(), ) as resp: if resp.status_code != 200: resp.read() diff --git a/test_llamachat.py b/test_llamachat.py index 4d68cc6..0251c78 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -1820,6 +1820,88 @@ def test_token_column_migration(): print("ok token column migration") +def test_client_auth_header(): + """A client with a key sends Bearer auth; one without sends no header.""" + sent = {} + + class _HttpxResponse: + """Enough of an httpx response for Client.models(). + + The existing _FakeResponse in this file wraps bytes for urlopen and + has neither .json() nor .raise_for_status(), so it cannot stand in + for an httpx call. + """ + + status_code = 200 + + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + def _recorder(url, timeout=None, headers=None): + sent["url"] = url + sent["headers"] = headers or {} + return _HttpxResponse({"data": [{"id": "m1"}]}) + + import httpx + original = httpx.get + try: + httpx.get = _recorder + + assert backend.Client("http://x.example.org").models() == ["m1"] + assert "Authorization" not in sent["headers"] + + backend.Client( + "http://x.example.org", api_key="sk-test-not-a-real-key" + ).models() + assert sent["headers"]["Authorization"] == "Bearer sk-test-not-a-real-key" + finally: + httpx.get = original + + # The streaming path is the one that carries every real turn, and no + # other test reaches it: the search tests all stub _stream_once out. + class _Stream: + status_code = 200 + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def iter_lines(self): + return iter(['data: {"choices":[{"delta":{"content":"hi"}}]}']) + + def _stream_recorder(method, url, json=None, timeout=None, headers=None): + sent["headers"] = headers or {} + return _Stream() + + original_stream = httpx.stream + try: + httpx.stream = _stream_recorder + + client = backend.Client("http://x.example.org") + assert list(client._stream_once("m", [], tools=None)) == [("content", "hi")] + assert "Authorization" not in sent["headers"] + + keyed = backend.Client( + "http://x.example.org", api_key="sk-test-not-a-real-key" + ) + list(keyed._stream_once("m", [], tools=None)) + assert sent["headers"]["Authorization"] == "Bearer sk-test-not-a-real-key" + finally: + httpx.stream = original_stream + + # The key is not on the client's repr, which reaches logs and tracebacks. + assert "sk-test-not-a-real-key" not in repr(keyed) + print("ok client authorization header") + + class _FakeResponse: """Enough of an http.client response for urlopen's context manager.""" @@ -2455,9 +2537,10 @@ def test_title_request(): def json(self): return {"choices": [{"message": {"content": " A Title "}}]} - def fake_post(url, json=None, timeout=None): + def fake_post(url, json=None, timeout=None, headers=None): seen["url"] = url seen["body"] = json + seen["headers"] = headers or {} return FakeResponse() import httpx @@ -2479,13 +2562,25 @@ def test_title_request(): assert "tools" not in seen["body"] # Thinking off, or a reasoning model spends the budget and returns "". assert seen["body"]["chat_template_kwargs"] == {"enable_thinking": False} + # The local router needs no key, so the side errand carries no header. + assert "Authorization" not in seen["headers"] + + # A keyed provider authenticates on this path too, not only on models(). + # It runs after the body assertions above because it overwrites `seen`. + httpx.post = fake_post + try: + keyed = backend.Client("http://router:8181", api_key="sk-test-not-a-real-key") + assert keyed.complete("m", []) == " A Title " + finally: + httpx.post = original + assert seen["headers"]["Authorization"] == "Bearer sk-test-not-a-real-key" # A reply with no choices is empty rather than an exception. class Empty(FakeResponse): def json(self): return {} - httpx.post = lambda url, json=None, timeout=None: Empty() + httpx.post = lambda url, json=None, timeout=None, headers=None: Empty() try: assert backend.Client("http://x").complete("m", []) == "" finally: @@ -2566,6 +2661,7 @@ if __name__ == "__main__": test_models_store() test_metadata_and_cost() test_token_column_migration() + test_client_auth_header() test_search_tool_schema() test_search_results_sanitising() test_tool_call_accumulation() |
