From 037c56709653d8211d3a0f15877bec6894a695ce Mon Sep 17 00:00:00 2001 From: Oleg Saprykin Date: Fri, 17 Apr 2026 21:29:28 +0300 Subject: [PATCH] fix(openrouter): resolve os.environ api_key + defensive transform strip Follow-up to #24282 / GH#24234 which landed the native-model guard approach for get_llm_provider. Two small remaining gaps: 1. The OpenRouter early return in get_llm_provider() now fires for both native and non-native IDs, but it runs *before* the generic `api_key = "os.environ/..."` secret resolution. Callers passing `api_key="os.environ/OPENROUTER_API_KEY"` through the OpenRouter path therefore get dynamic_api_key=None. Move the resolution above the early return so the secret is populated in both branches. 2. Add a defensive "openrouter/" strip inside OpenrouterConfig.transform_request for code paths that reach the transform without going through get_llm_provider (e.g. adapter/bridge invocations). Same native-model guard ("/" in remainder) as the merged fix, so openrouter/auto / openrouter/free stay intact. Tests cover both: new TestOpenRouterApiKeyResolution verifies os.environ resolution for native + non-native models, and TestOpenRouterTransformRequestDefensiveStrip covers the transform-layer strip with the native/no-prefix/non-native matrix. --- .../get_llm_provider_logic.py | 7 ++ .../llms/openrouter/chat/transformation.py | 10 +++ .../test_openrouter_provider_routing.py | 71 +++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 95bcd4d7186..ea8ff64477c 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -158,6 +158,13 @@ def get_llm_provider( # noqa: PLR0915 ): # handle scenario where model="azure/*" and custom_llm_provider="azure" model = custom_llm_provider + "/" + model + # Resolve `os.environ/` api_key placeholders before the OpenRouter + # early return below, so callers passing + # `api_key="os.environ/OPENROUTER_API_KEY"` still get the secret + # resolved when the model routes via the `openrouter/` prefix. + if api_key and api_key.startswith("os.environ/"): + dynamic_api_key = get_secret_str(api_key) + # OpenRouter: when the router/proxy already set custom_llm_provider, # the model may still carry LiteLLM's "openrouter/" routing prefix. # Native IDs like "openrouter/auto" must stay intact for the API; IDs diff --git a/litellm/llms/openrouter/chat/transformation.py b/litellm/llms/openrouter/chat/transformation.py index 86e63fd0c41..458e1576a52 100644 --- a/litellm/llms/openrouter/chat/transformation.py +++ b/litellm/llms/openrouter/chat/transformation.py @@ -159,6 +159,16 @@ class OpenrouterConfig(OpenAIGPTConfig): Returns: dict: The transformed request. Sent as the body of the API call. """ + # Defensive strip of the "openrouter/" prefix for code paths that + # reach this transform without going through get_llm_provider (e.g. + # some adapter/bridge invocations). Native IDs like + # "openrouter/auto" / "openrouter/free" have no "/" after the prefix + # and must be kept intact. + if model.startswith("openrouter/"): + remainder = model[len("openrouter/") :] + if "/" in remainder: + model = remainder + if self._supports_cache_control_in_content(model): messages = self._move_cache_control_to_content(messages) diff --git a/tests/test_litellm/llms/openrouter/test_openrouter_provider_routing.py b/tests/test_litellm/llms/openrouter/test_openrouter_provider_routing.py index 0815b15c873..928e2a1e0bf 100644 --- a/tests/test_litellm/llms/openrouter/test_openrouter_provider_routing.py +++ b/tests/test_litellm/llms/openrouter/test_openrouter_provider_routing.py @@ -100,3 +100,74 @@ class TestOpenRouterNativeModelRouting: ) assert provider == "openrouter" assert result_model == "anthropic/claude-3.5-sonnet" + + +class TestOpenRouterApiKeyResolution: + """api_key="os.environ/..." must be resolved even when the model takes the + OpenRouter early-return path (native or pre-resolved custom_llm_provider). + Regression introduced when the early return was added above the generic + os.environ resolution block. + """ + + def test_os_environ_api_key_resolved_for_native_model(self, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY_TEST", "sk-test-native-123") + + _, provider, dynamic_api_key, _ = litellm.get_llm_provider( + model="openrouter/auto", + custom_llm_provider="openrouter", + api_key="os.environ/OPENROUTER_API_KEY_TEST", + ) + assert provider == "openrouter" + assert dynamic_api_key == "sk-test-native-123" + + def test_os_environ_api_key_resolved_for_non_native_model(self, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY_TEST", "sk-test-nonnative-456") + + _, provider, dynamic_api_key, _ = litellm.get_llm_provider( + model="openrouter/anthropic/claude-3.5-sonnet", + custom_llm_provider="openrouter", + api_key="os.environ/OPENROUTER_API_KEY_TEST", + ) + assert provider == "openrouter" + assert dynamic_api_key == "sk-test-nonnative-456" + + +class TestOpenRouterTransformRequestDefensiveStrip: + """OpenrouterConfig.transform_request must strip a doubled "openrouter/" + prefix as a safety net for code paths that bypass get_llm_provider, while + preserving native single-segment IDs like "openrouter/auto". + """ + + @pytest.mark.parametrize( + "input_model,expected_model", + [ + # Non-native: prefix must be stripped + ( + "openrouter/anthropic/claude-3.5-sonnet", + "anthropic/claude-3.5-sonnet", + ), + ( + "openrouter/meta-llama/llama-3-70b-instruct", + "meta-llama/llama-3-70b-instruct", + ), + # Native: prefix must be preserved + ("openrouter/auto", "openrouter/auto"), + ("openrouter/free", "openrouter/free"), + # No prefix: unchanged + ("anthropic/claude-3.5-sonnet", "anthropic/claude-3.5-sonnet"), + ], + ) + def test_transform_request_strips_non_native_prefix( + self, input_model, expected_model + ): + from litellm.llms.openrouter.chat.transformation import OpenrouterConfig + + config = OpenrouterConfig() + result = config.transform_request( + model=input_model, + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + headers={}, + ) + assert result["model"] == expected_model