Skip to content

Commit e9cebcb

Browse files
feat: add BlankAPIKeyIsUnsetHook to handle blank API keys and improve authorization flow
1 parent cb401a8 commit e9cebcb

2 files changed

Lines changed: 118 additions & 2 deletions

File tree

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,43 @@
1-
from .types import Hooks
1+
from openrouter import components
2+
from openrouter.sdkconfiguration import SDKConfiguration
3+
4+
from .types import Hooks, SDKInitHook
25

36

47
# This file is only ever generated once on the first generation and then is free to be modified.
58
# Any hooks you wish to add should be registered in the init_hooks function. Feel free to define them
69
# in this file or in separate files in the hooks folder.
710

811

12+
class BlankAPIKeyIsUnsetHook(SDKInitHook):
13+
"""Treat a blank `api_key` as "not supplied".
14+
15+
`get_security_from_env` only falls back to `OPENROUTER_API_KEY` when no
16+
security was supplied at all, so `OpenRouter(api_key="")` short-circuits the
17+
fallback. The documented pattern `api_key=os.getenv("OPENROUTER_API_KEY", "")`
18+
hits that path whenever the variable is unset, and the resulting empty bearer
19+
token fails inside httpx with `LocalProtocolError: Illegal header value
20+
b'Bearer '` — before any request is sent, and with nothing to suggest the
21+
problem is a missing credential.
22+
23+
Normalising the blank value to `None` here restores the fallback, and leaves
24+
a client with genuinely no credentials sending no `Authorization` header at
25+
all, which is the clearer failure.
26+
27+
A callable `api_key` is left alone: it is resolved per-request, and calling
28+
it here to inspect the result would defeat that.
29+
"""
30+
31+
def sdk_init(self, config: SDKConfiguration) -> SDKConfiguration:
32+
security = config.security
33+
if isinstance(security, components.Security):
34+
if security.api_key is None or not security.api_key.strip():
35+
config.security = None
36+
return config
37+
38+
939
def init_hooks(hooks: Hooks):
10-
# pylint: disable=unused-argument
1140
"""Add hooks by calling hooks.register{sdk_init/before_request/after_success/after_error}Hook
1241
with an instance of a hook that implements that specific Hook interface
1342
Hooks are registered per SDK instance, and are valid for the lifetime of the SDK instance"""
43+
hooks.register_sdk_init_hook(BlankAPIKeyIsUnsetHook())

‎tests/test_api_key_resolution.py‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import threading
2+
from http.server import BaseHTTPRequestHandler, HTTPServer
3+
4+
import pytest
5+
6+
from openrouter import OpenRouter
7+
8+
9+
@pytest.fixture(name="server")
10+
def _server():
11+
"""A stub API that records the Authorization header it was sent."""
12+
received = []
13+
14+
class Handler(BaseHTTPRequestHandler):
15+
def do_GET(self): # noqa: N802 - name fixed by BaseHTTPRequestHandler
16+
received.append(self.headers.get("authorization"))
17+
self.send_response(200)
18+
self.send_header("Content-Type", "application/json")
19+
self.end_headers()
20+
self.wfile.write(b'{"data": 0}')
21+
22+
def log_message(self, *args):
23+
pass
24+
25+
httpd = HTTPServer(("127.0.0.1", 0), Handler)
26+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
27+
try:
28+
yield f"http://127.0.0.1:{httpd.server_address[1]}", received
29+
finally:
30+
httpd.shutdown()
31+
32+
33+
def _authorization(url, received, **kwargs):
34+
try:
35+
OpenRouter(server_url=url, **kwargs).models.count()
36+
except Exception: # the stub body does not satisfy the response schema
37+
pass
38+
assert received, "no request reached the server"
39+
return received.pop()
40+
41+
42+
def test_env_var_is_used_when_no_api_key_is_passed(server, monkeypatch):
43+
url, received = server
44+
monkeypatch.setenv("OPENROUTER_API_KEY", "from-env")
45+
46+
assert _authorization(url, received) == "Bearer from-env"
47+
48+
49+
def test_explicit_api_key_wins_over_the_env_var(server, monkeypatch):
50+
url, received = server
51+
monkeypatch.setenv("OPENROUTER_API_KEY", "from-env")
52+
53+
assert _authorization(url, received, api_key="explicit") == "Bearer explicit"
54+
55+
56+
def test_blank_api_key_falls_back_to_the_env_var(server, monkeypatch):
57+
# The documented pattern api_key=os.getenv("OPENROUTER_API_KEY", "") produces
58+
# "" when the variable is unset. It used to short-circuit the fallback and
59+
# fail in httpx with LocalProtocolError: Illegal header value b'Bearer '.
60+
url, received = server
61+
monkeypatch.setenv("OPENROUTER_API_KEY", "from-env")
62+
63+
assert _authorization(url, received, api_key="") == "Bearer from-env"
64+
assert _authorization(url, received, api_key=" ") == "Bearer from-env"
65+
66+
67+
def test_no_credentials_anywhere_sends_no_authorization_header(server, monkeypatch):
68+
url, received = server
69+
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
70+
71+
assert _authorization(url, received) is None
72+
assert _authorization(url, received, api_key="") is None
73+
74+
75+
def test_callable_api_key_is_still_resolved_per_request(server, monkeypatch):
76+
url, received = server
77+
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
78+
keys = iter(["first", "second"])
79+
80+
client = OpenRouter(server_url=url, api_key=lambda: next(keys))
81+
for expected in ["Bearer first", "Bearer second"]:
82+
try:
83+
client.models.count()
84+
except Exception:
85+
pass
86+
assert received.pop() == expected

0 commit comments

Comments
 (0)