Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions src/openrouter/_hooks/registration.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,43 @@
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.
# Any hooks you wish to add should be registered in the init_hooks function. Feel free to define them
# 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())
86 changes: 86 additions & 0 deletions tests/test_api_key_resolution.py
Original file line number Diff line number Diff line change
@@ -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