diff --git a/src/openrouter/_hooks/registration.py b/src/openrouter/_hooks/registration.py index cab47787..45256a41 100644 --- a/src/openrouter/_hooks/registration.py +++ b/src/openrouter/_hooks/registration.py @@ -1,4 +1,7 @@ -from .types import Hooks +from openrouter import components +from openrouter.sdkconfiguration import SDKConfiguration + +from .types import Hooks, SDKInitHook # This file is only ever generated once on the first generation and then is free to be modified. @@ -6,8 +9,35 @@ # in this file or in separate files in the hooks folder. +class BlankAPIKeyIsUnsetHook(SDKInitHook): + """Treat a blank `api_key` as "not supplied". + + `get_security_from_env` only falls back to `OPENROUTER_API_KEY` when no + security was supplied at all, so `OpenRouter(api_key="")` short-circuits the + fallback. The documented pattern `api_key=os.getenv("OPENROUTER_API_KEY", "")` + hits that path whenever the variable is unset, and the resulting empty bearer + token fails inside httpx with `LocalProtocolError: Illegal header value + b'Bearer '` — before any request is sent, and with nothing to suggest the + problem is a missing credential. + + Normalising the blank value to `None` here restores the fallback, and leaves + a client with genuinely no credentials sending no `Authorization` header at + all, which is the clearer failure. + + A callable `api_key` is left alone: it is resolved per-request, and calling + it here to inspect the result would defeat that. + """ + + def sdk_init(self, config: SDKConfiguration) -> SDKConfiguration: + security = config.security + if isinstance(security, components.Security): + if security.api_key is None or not security.api_key.strip(): + config.security = None + return config + + def init_hooks(hooks: Hooks): - # pylint: disable=unused-argument """Add hooks by calling hooks.register{sdk_init/before_request/after_success/after_error}Hook with an instance of a hook that implements that specific Hook interface Hooks are registered per SDK instance, and are valid for the lifetime of the SDK instance""" + hooks.register_sdk_init_hook(BlankAPIKeyIsUnsetHook()) diff --git a/tests/test_api_key_resolution.py b/tests/test_api_key_resolution.py new file mode 100644 index 00000000..734c5558 --- /dev/null +++ b/tests/test_api_key_resolution.py @@ -0,0 +1,86 @@ +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from openrouter import OpenRouter + + +@pytest.fixture(name="server") +def _server(): + """A stub API that records the Authorization header it was sent.""" + received = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - name fixed by BaseHTTPRequestHandler + received.append(self.headers.get("authorization")) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"data": 0}') + + def log_message(self, *args): + pass + + httpd = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + try: + yield f"http://127.0.0.1:{httpd.server_address[1]}", received + finally: + httpd.shutdown() + + +def _authorization(url, received, **kwargs): + try: + OpenRouter(server_url=url, **kwargs).models.count() + except Exception: # the stub body does not satisfy the response schema + pass + assert received, "no request reached the server" + return received.pop() + + +def test_env_var_is_used_when_no_api_key_is_passed(server, monkeypatch): + url, received = server + monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") + + assert _authorization(url, received) == "Bearer from-env" + + +def test_explicit_api_key_wins_over_the_env_var(server, monkeypatch): + url, received = server + monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") + + assert _authorization(url, received, api_key="explicit") == "Bearer explicit" + + +def test_blank_api_key_falls_back_to_the_env_var(server, monkeypatch): + # The documented pattern api_key=os.getenv("OPENROUTER_API_KEY", "") produces + # "" when the variable is unset. It used to short-circuit the fallback and + # fail in httpx with LocalProtocolError: Illegal header value b'Bearer '. + url, received = server + monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") + + assert _authorization(url, received, api_key="") == "Bearer from-env" + assert _authorization(url, received, api_key=" ") == "Bearer from-env" + + +def test_no_credentials_anywhere_sends_no_authorization_header(server, monkeypatch): + url, received = server + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + assert _authorization(url, received) is None + assert _authorization(url, received, api_key="") is None + + +def test_callable_api_key_is_still_resolved_per_request(server, monkeypatch): + url, received = server + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + keys = iter(["first", "second"]) + + client = OpenRouter(server_url=url, api_key=lambda: next(keys)) + for expected in ["Bearer first", "Bearer second"]: + try: + client.models.count() + except Exception: + pass + assert received.pop() == expected