diff --git a/docs/overview.mdx b/docs/overview.mdx index 3980c0bec..e91085d5e 100644 --- a/docs/overview.mdx +++ b/docs/overview.mdx @@ -20,7 +20,7 @@ from openrouter import OpenRouter import os with OpenRouter( - api_key=os.getenv("OPENROUTER_API_KEY", ""), + api_key=os.getenv("OPENROUTER_API_KEY"), ) as open_router: res = open_router.chat.send( messages=[ diff --git a/src/openrouter/_hooks/registration.py b/src/openrouter/_hooks/registration.py index cab477877..1f133f1a6 100644 --- a/src/openrouter/_hooks/registration.py +++ b/src/openrouter/_hooks/registration.py @@ -1,4 +1,11 @@ -from .types import Hooks +import os + +import httpx + +from openrouter import components +from openrouter.sdkconfiguration import SDKConfiguration + +from .types import BeforeRequestContext, BeforeRequestHook, Hooks, SDKInitHook # This file is only ever generated once on the first generation and then is free to be modified. @@ -6,8 +13,47 @@ # in this file or in separate files in the hooks folder. +class BlankAPIKeyIsUnsetHook(SDKInitHook, BeforeRequestHook): + """Treat a blank `api_key` (e.g. from `os.getenv("OPENROUTER_API_KEY", "")`) + as not supplied, so the `OPENROUTER_API_KEY` env-var fallback still applies + instead of sending an empty `Bearer ` token. + + The env var is read per request and a callable `api_key` is resolved per + request, so either can still yield a blank token after init; a blank + bearer token is replaced with `OPENROUTER_API_KEY` when that is set, and + otherwise the `Authorization` header is dropped. + """ + + def sdk_init(self, config: SDKConfiguration) -> SDKConfiguration: + security = config.security + if isinstance(security, components.Security) and not ( + security.api_key or "" + ).strip(): + config.security = None + return config + + def before_request( + self, hook_ctx: BeforeRequestContext, request: httpx.Request + ) -> httpx.Request: + auth = request.headers.get("authorization") + if auth is not None: + scheme, _, token = auth.strip().partition(" ") + if not auth.strip() or (scheme.lower() == "bearer" and not token.strip()): + env_key = (os.getenv("OPENROUTER_API_KEY") or "").strip() + env_scheme, _, env_token = env_key.partition(" ") + if env_scheme.lower() == "bearer": + env_key = env_token.strip() + if env_key: + request.headers["authorization"] = f"Bearer {env_key}" + else: + del request.headers["authorization"] + return request + + 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""" + blank_api_key_hook = BlankAPIKeyIsUnsetHook() + hooks.register_sdk_init_hook(blank_api_key_hook) + hooks.register_before_request_hook(blank_api_key_hook) diff --git a/tests/test_api_key_resolution.py b/tests/test_api_key_resolution.py new file mode 100644 index 000000000..d368dcbc4 --- /dev/null +++ b/tests/test_api_key_resolution.py @@ -0,0 +1,50 @@ +import httpx +import pytest + +from openrouter import OpenRouter + + +def _authorization_header(**kwargs): + seen = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers.get("authorization")) + return httpx.Response(200, json={"data": {"count": 0}}) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + OpenRouter(client=client, **kwargs).models.count() + except Exception: # pylint: disable=broad-except + pass # response shape is irrelevant; only the request header matters + assert seen, "no request was sent" + return seen[0] + + +@pytest.mark.parametrize("api_key", ["", " "]) +def test_blank_api_key_falls_back_to_env_var(monkeypatch, api_key): + monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") + assert _authorization_header(api_key=api_key) == "Bearer from-env" + + +def test_blank_api_key_without_env_var_sends_no_authorization(monkeypatch): + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + assert _authorization_header(api_key="") is None + + +@pytest.mark.parametrize("env_value", ["", " "]) +def test_blank_api_key_and_blank_env_var_sends_no_authorization( + monkeypatch, env_value +): + monkeypatch.setenv("OPENROUTER_API_KEY", env_value) + assert _authorization_header(api_key="") is None + + +@pytest.mark.parametrize("env_value", ["from-env", "Bearer from-env"]) +def test_blank_callable_api_key_falls_back_to_env_var(monkeypatch, env_value): + monkeypatch.setenv("OPENROUTER_API_KEY", env_value) + assert _authorization_header(api_key=lambda: "") == "Bearer from-env" + + +def test_explicit_api_key_wins_over_env_var(monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") + assert _authorization_header(api_key="explicit") == "Bearer explicit"