aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-09 16:57:48 +0200
committerDanilo M. <danix@danix.xyz>2026-08-09 16:57:48 +0200
commit4a22fe71f8414328d8cc1071619182468c3443e4 (patch)
treedc60e2a5df48c5d0fd0ff4742a54be3ad7b12768
parentfb8bec8aafb8d050fca4dc02af4fe2a04fdb690d (diff)
downloadllamachat-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.py13
-rwxr-xr-xtest_llamachat.py100
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()