From 9108f1bad9fa8b8bf7864f1f096bc7c584c7c324 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:49:48 +0000 Subject: [PATCH 1/4] fix(auth): treat a blank api_key as unset so OPENROUTER_API_KEY fallback applies The documented `api_key=os.getenv("OPENROUTER_API_KEY", "")` pattern yields Security(api_key="") when the env var is unset, which short-circuits get_security_from_env and produces an empty `Bearer ` header. Add an sdk_init hook (user-owned, survives regeneration) that normalizes a blank api_key to None, and fix the hand-maintained docs/overview.mdx snippet. Fixes #591 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/overview.mdx | 2 +- src/openrouter/_hooks/registration.py | 24 ++++++++++++++++-- tests/test_api_key_resolution.py | 36 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 tests/test_api_key_resolution.py 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..db0cabe67 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,25 @@ # in this file or in separate files in the hooks folder. +class BlankAPIKeyIsUnsetHook(SDKInitHook): + """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. + + Callable `api_key`s are resolved per request and are left untouched. + """ + + 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 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 000000000..0aaef4066 --- /dev/null +++ b/tests/test_api_key_resolution.py @@ -0,0 +1,36 @@ +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 + + +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" From 234be22607a452c83d1d98bcf357698beaa34130 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:23:11 +0000 Subject: [PATCH 2/4] fix(auth): drop blank bearer Authorization header before sending The sdk_init hook clears a blank api_key so the OPENROUTER_API_KEY fallback applies, but the generated env fallback reads the variable per request without stripping it, so a whitespace-only env value (or a callable api_key returning blank) still produced a "Bearer " header. Register the same hook as a BeforeRequest hook that removes an Authorization header whose bearer token is blank, keeping "blank credential = absent" end to end without touching generated code. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/openrouter/_hooks/registration.py | 25 +++++++++++++++++++++---- tests/test_api_key_resolution.py | 8 ++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/openrouter/_hooks/registration.py b/src/openrouter/_hooks/registration.py index db0cabe67..de357174e 100644 --- a/src/openrouter/_hooks/registration.py +++ b/src/openrouter/_hooks/registration.py @@ -1,7 +1,9 @@ +import httpx + from openrouter import components from openrouter.sdkconfiguration import SDKConfiguration -from .types import Hooks, SDKInitHook +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. @@ -9,12 +11,15 @@ # in this file or in separate files in the hooks folder. -class BlankAPIKeyIsUnsetHook(SDKInitHook): +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. - Callable `api_key`s are resolved per request and are left untouched. + 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; any + `Authorization` header whose bearer token is blank is dropped before the + request is sent. """ def sdk_init(self, config: SDKConfiguration) -> SDKConfiguration: @@ -25,9 +30,21 @@ def sdk_init(self, config: SDKConfiguration) -> SDKConfiguration: 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()): + del request.headers["authorization"] + return request + def init_hooks(hooks: Hooks): """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()) + 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 index 0aaef4066..46dab1da1 100644 --- a/tests/test_api_key_resolution.py +++ b/tests/test_api_key_resolution.py @@ -31,6 +31,14 @@ def test_blank_api_key_without_env_var_sends_no_authorization(monkeypatch): 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 + + 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" From 85c4ead21cdefb4d4c69964d57cdf2c35f869e4b Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:42:23 +0000 Subject: [PATCH 3/4] fix: fall back to OPENROUTER_API_KEY when a callable api_key returns blank A callable api_key is resolved per request, after get_security_from_env has already skipped the environment, so a blank result left the request unauthenticated even when OPENROUTER_API_KEY was set. Replace a blank bearer token with the environment key when one is available, and drop the header only when it is not. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/openrouter/_hooks/registration.py | 14 ++++++++++---- tests/test_api_key_resolution.py | 5 +++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/openrouter/_hooks/registration.py b/src/openrouter/_hooks/registration.py index de357174e..32242e9ab 100644 --- a/src/openrouter/_hooks/registration.py +++ b/src/openrouter/_hooks/registration.py @@ -1,3 +1,5 @@ +import os + import httpx from openrouter import components @@ -17,9 +19,9 @@ class BlankAPIKeyIsUnsetHook(SDKInitHook, BeforeRequestHook): 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; any - `Authorization` header whose bearer token is blank is dropped before the - request is sent. + 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: @@ -37,7 +39,11 @@ def before_request( if auth is not None: scheme, _, token = auth.strip().partition(" ") if not auth.strip() or (scheme.lower() == "bearer" and not token.strip()): - del request.headers["authorization"] + env_key = (os.getenv("OPENROUTER_API_KEY") or "").strip() + if env_key: + request.headers["authorization"] = f"Bearer {env_key}" + else: + del request.headers["authorization"] return request diff --git a/tests/test_api_key_resolution.py b/tests/test_api_key_resolution.py index 46dab1da1..f7ee457d0 100644 --- a/tests/test_api_key_resolution.py +++ b/tests/test_api_key_resolution.py @@ -39,6 +39,11 @@ def test_blank_api_key_and_blank_env_var_sends_no_authorization( assert _authorization_header(api_key="") is None +def test_blank_callable_api_key_falls_back_to_env_var(monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") + 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" From 2f9ed612cff9bb8dc3c9023aec4ad96af6e75ba5 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:59:51 +0000 Subject: [PATCH 4/4] fix: accept a Bearer-prefixed OPENROUTER_API_KEY in the blank-key fallback The generated security code leaves an already-prefixed bearer token as is; match that when substituting the environment key so a value of "Bearer " is not sent as "Bearer Bearer ". Co-Authored-By: Claude Opus 5.5 (1M context) --- src/openrouter/_hooks/registration.py | 3 +++ tests/test_api_key_resolution.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/openrouter/_hooks/registration.py b/src/openrouter/_hooks/registration.py index 32242e9ab..1f133f1a6 100644 --- a/src/openrouter/_hooks/registration.py +++ b/src/openrouter/_hooks/registration.py @@ -40,6 +40,9 @@ def before_request( 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: diff --git a/tests/test_api_key_resolution.py b/tests/test_api_key_resolution.py index f7ee457d0..d368dcbc4 100644 --- a/tests/test_api_key_resolution.py +++ b/tests/test_api_key_resolution.py @@ -39,8 +39,9 @@ def test_blank_api_key_and_blank_env_var_sends_no_authorization( assert _authorization_header(api_key="") is None -def test_blank_callable_api_key_falls_back_to_env_var(monkeypatch): - monkeypatch.setenv("OPENROUTER_API_KEY", "from-env") +@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"