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.
This commit is contained in:
Oleg Saprykin 2026-04-17 21:29:28 +03:00
parent b8f7d61400
commit 037c567096
3 changed files with 88 additions and 0 deletions

View file

@ -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

View file

@ -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)

View file

@ -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