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