mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge pull request #39000 from BerriAI/litellm_fix_supported_openai_params_router_alias
fix(proxy): resolve router model aliases in /utils/supported_openai_params
This commit is contained in:
commit
981c5924a3
7 changed files with 203 additions and 19 deletions
|
|
@ -127,7 +127,7 @@ def handle_anthropic_text_model_custom_llm_provider(
|
|||
return model, custom_llm_provider
|
||||
|
||||
|
||||
def declared_authenticating_provider(model: str, custom_llm_provider: str | None = None) -> str | None:
|
||||
def declared_authenticating_provider(model: str | None, custom_llm_provider: str | None = None) -> str | None:
|
||||
"""The authenticating provider this pair already names, or None.
|
||||
|
||||
get_llm_provider runs the OAuth device flow for github_copilot and chatgpt, because their
|
||||
|
|
@ -135,7 +135,7 @@ def declared_authenticating_provider(model: str, custom_llm_provider: str | None
|
|||
and for a declared pair the resolver's answer is the declaration itself, so metadata callers
|
||||
adopt the declaration instead of resolving.
|
||||
"""
|
||||
declared: Final = custom_llm_provider or model.split("/", 1)[0]
|
||||
declared: Final = custom_llm_provider or (model.split("/", 1)[0] if model and "/" in model else None)
|
||||
return declared if declared in PROVIDERS_THAT_AUTHENTICATE_ON_PROVIDER_INFO else None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12481,11 +12481,21 @@ async def supported_openai_params(model: str):
|
|||
--header 'Authorization: Bearer sk-1234'
|
||||
```
|
||||
"""
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider
|
||||
|
||||
global llm_router
|
||||
try:
|
||||
model, custom_llm_provider, _, _ = litellm.get_llm_provider(model=model)
|
||||
resolved_models: Final = llm_router.resolved_litellm_models(model) if llm_router is not None else ()
|
||||
target_model: Final = resolved_models[0] if resolved_models else model
|
||||
declared_provider: Final = declared_authenticating_provider(target_model)
|
||||
litellm_model, custom_llm_provider = (
|
||||
(target_model.removeprefix(f"{declared_provider}/"), declared_provider)
|
||||
if declared_provider is not None
|
||||
else litellm.get_llm_provider(model=target_model)[:2]
|
||||
)
|
||||
return {
|
||||
"supported_openai_params": litellm.get_supported_openai_params(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
model=litellm_model, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
}
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from re import Match
|
|||
from typing import Final
|
||||
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider, get_llm_provider
|
||||
|
||||
|
||||
class PatternUtils:
|
||||
|
|
@ -204,7 +204,7 @@ class PatternMatchRouter:
|
|||
|
||||
return litellm_deployment_litellm_model
|
||||
|
||||
def get_pattern(self, model: str, custom_llm_provider: str | None = None) -> list[dict] | None:
|
||||
def get_pattern(self, model: str | None, custom_llm_provider: str | None = None) -> list[dict] | None:
|
||||
"""
|
||||
Check if a pattern exists for the given model and custom llm provider
|
||||
|
||||
|
|
@ -215,18 +215,17 @@ class PatternMatchRouter:
|
|||
Returns:
|
||||
bool: True if pattern exists, False otherwise
|
||||
"""
|
||||
if custom_llm_provider is None:
|
||||
try:
|
||||
(
|
||||
_,
|
||||
custom_llm_provider,
|
||||
_,
|
||||
_,
|
||||
) = get_llm_provider(model=model)
|
||||
except Exception:
|
||||
# get_llm_provider raises exception when provider is unknown
|
||||
pass
|
||||
return self.route(model) or self.route(f"{custom_llm_provider}/{model}")
|
||||
provider: Final = (
|
||||
custom_llm_provider or declared_authenticating_provider(model) or self._resolved_provider(model)
|
||||
)
|
||||
return self.route(model) or self.route(f"{provider}/{model}")
|
||||
|
||||
@staticmethod
|
||||
def _resolved_provider(model: str | None) -> str | None:
|
||||
try:
|
||||
return get_llm_provider(model=model)[1] if model else None
|
||||
except Exception: # noqa: BLE001 # get_llm_provider raises when the provider is unknown; the name then routes as-is
|
||||
return None
|
||||
|
||||
def get_deployments_by_pattern(self, model: str, custom_llm_provider: str | None = None) -> list[dict]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ ignored_function_names = [
|
|||
"_merge_tools_from_deployment", # Tested indirectly via _update_kwargs_with_deployment (test files lack "router" in name)
|
||||
"_invalidate_access_groups_cache", # Tested indirectly via set_model_list, upsert_model etc. (test files lack "router" in name)
|
||||
"has_buffered_provider_output", # Property, so its reads in test_router.py are never an ast.Call
|
||||
"_resolved_provider", # Tested via get_pattern in test_pattern_match_deployments.py (file lacks "router" in name)
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@ class TestDeclaredAuthenticatingProvider:
|
|||
("gpt-4o", "github_copilot", "github_copilot"),
|
||||
("openai/gpt-4o", None, None),
|
||||
("gpt-4o", "openai", None),
|
||||
("github_copilot", None, None),
|
||||
("chatgpt", None, None),
|
||||
],
|
||||
)
|
||||
def test_names_only_the_providers_whose_resolution_authenticates(self, model, provider, expected):
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ Pins (PR2):
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.router_utils import pattern_match_deployments
|
||||
|
||||
from .conftest import normalize # type: ignore[import-not-found]
|
||||
|
||||
|
|
@ -99,6 +100,7 @@ def test_token_counter_missing_input_returns_400(
|
|||
|
||||
@pytest.fixture
|
||||
def patched_supported_params(monkeypatch):
|
||||
monkeypatch.setattr(proxy_server, "llm_router", None)
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"get_llm_provider",
|
||||
|
|
@ -124,12 +126,104 @@ def test_supported_openai_params_happy_path(client, auth_as, patched_supported_p
|
|||
}
|
||||
|
||||
|
||||
def test_supported_openai_params_resolves_router_alias(client, auth_as, monkeypatch):
|
||||
"""A router alias absent from the cost map resolves through the deployment's underlying model."""
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "claude-opus-4-6-cached",
|
||||
"litellm_params": {"model": "anthropic/claude-opus-4-6", "api_key": "sk-test"},
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
|
||||
with auth_as():
|
||||
response = client.get("/utils/supported_openai_params", params={"model": "claude-opus-4-6-cached"})
|
||||
|
||||
assert response.status_code == 200
|
||||
expected = litellm.get_supported_openai_params(model="claude-opus-4-6", custom_llm_provider="anthropic")
|
||||
assert response.json() == {"supported_openai_params": expected}
|
||||
assert "max_tokens" in response.json()["supported_openai_params"]
|
||||
|
||||
|
||||
def test_supported_openai_params_declared_prefix_alias_resolves_through_router(client, auth_as, monkeypatch):
|
||||
"""Regression: an alias whose name starts with an authenticating provider's prefix skipped
|
||||
router resolution and answered with that provider's params instead of the deployment's."""
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "github_copilot/gpt-4o",
|
||||
"litellm_params": {"model": "anthropic/claude-opus-4-6", "api_key": "sk-test"},
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
|
||||
with auth_as():
|
||||
response = client.get("/utils/supported_openai_params", params={"model": "github_copilot/gpt-4o"})
|
||||
|
||||
assert response.status_code == 200
|
||||
expected = litellm.get_supported_openai_params(model="claude-opus-4-6", custom_llm_provider="anthropic")
|
||||
assert response.json() == {"supported_openai_params": expected}
|
||||
|
||||
|
||||
def test_supported_openai_params_never_runs_oauth_for_authenticating_providers(client, auth_as, monkeypatch, tmp_path):
|
||||
"""Regression: github_copilot/chatgpt names answer from their declaration; resolving them
|
||||
through ``get_llm_provider`` would run the provider's OAuth device flow and block the event loop."""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "access-token").write_text("fake-access-token")
|
||||
(tmp_path / "api-key.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"token": "fake-api-key",
|
||||
"expires_at": 4102444800,
|
||||
"endpoints": {"api": "https://api.githubcopilot.com"},
|
||||
}
|
||||
)
|
||||
)
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "copilot-alias",
|
||||
"litellm_params": {"model": "github_copilot/gpt-4o"},
|
||||
},
|
||||
{
|
||||
"model_name": "openai/*",
|
||||
"litellm_params": {"model": "openai/*"},
|
||||
},
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
|
||||
resolution_attempts: list[str] = []
|
||||
|
||||
def _oauth_tripwire(model, *args, **kwargs):
|
||||
resolution_attempts.append(model)
|
||||
raise AssertionError("get_llm_provider would run the OAuth device flow")
|
||||
|
||||
monkeypatch.setattr(litellm, "get_llm_provider", _oauth_tripwire)
|
||||
monkeypatch.setattr(pattern_match_deployments, "get_llm_provider", _oauth_tripwire)
|
||||
expected = litellm.get_supported_openai_params(model="gpt-4o", custom_llm_provider="github_copilot")
|
||||
|
||||
with auth_as():
|
||||
via_alias = client.get("/utils/supported_openai_params", params={"model": "copilot-alias"})
|
||||
via_direct_name = client.get("/utils/supported_openai_params", params={"model": "github_copilot/gpt-4o"})
|
||||
|
||||
assert via_alias.status_code == 200
|
||||
assert via_alias.json() == {"supported_openai_params": expected}
|
||||
assert via_direct_name.status_code == 200
|
||||
assert via_direct_name.json() == {"supported_openai_params": expected}
|
||||
assert resolution_attempts == []
|
||||
|
||||
|
||||
def test_supported_openai_params_invalid_model(client, auth_as, monkeypatch):
|
||||
"""Pins ``GET /utils/supported_openai_params`` (error: unknown model)."""
|
||||
|
||||
def _raise(model):
|
||||
raise Exception("unknown")
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", None)
|
||||
monkeypatch.setattr(litellm, "get_llm_provider", _raise)
|
||||
with auth_as():
|
||||
response = client.get("/utils/supported_openai_params", params={"model": "??"})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
"""Behavior pins for ``litellm/router_utils/pattern_match_deployments.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from litellm.router_utils import pattern_match_deployments
|
||||
from litellm.router_utils.pattern_match_deployments import PatternMatchRouter
|
||||
|
||||
|
||||
def _wildcard_deployment(model_name: str) -> dict:
|
||||
return {"model_name": model_name, "litellm_params": {"model": model_name}}
|
||||
|
||||
|
||||
def _matched_models(matches: list[dict] | None) -> list[str]:
|
||||
return [deployment["litellm_params"]["model"] for deployment in matches or []]
|
||||
|
||||
|
||||
def test_get_pattern_never_resolves_declared_authenticating_providers(monkeypatch):
|
||||
"""Regression: resolving a github_copilot/chatgpt name through ``get_llm_provider`` runs the
|
||||
provider's OAuth device flow; the auth layer walks every wildcard router on every request, so
|
||||
a single metadata lookup for an unserved name would block the proxy's event loop."""
|
||||
resolution_attempts: list[str] = []
|
||||
|
||||
def _oauth_tripwire(model, *args, **kwargs):
|
||||
resolution_attempts.append(model)
|
||||
raise AssertionError("get_llm_provider would run the OAuth device flow")
|
||||
|
||||
monkeypatch.setattr(pattern_match_deployments, "get_llm_provider", _oauth_tripwire)
|
||||
|
||||
unmatched_router = PatternMatchRouter()
|
||||
unmatched_router.add_pattern("anthropic/*", _wildcard_deployment("anthropic/*"))
|
||||
assert unmatched_router.get_pattern("github_copilot/gpt-4o") is None
|
||||
|
||||
matched_router = PatternMatchRouter()
|
||||
matched_router.add_pattern("github_copilot/*", _wildcard_deployment("github_copilot/*"))
|
||||
assert _matched_models(matched_router.get_pattern("github_copilot/gpt-4o")) == ["github_copilot/gpt-4o"]
|
||||
assert _matched_models(matched_router.get_pattern("gpt-4o", custom_llm_provider="github_copilot")) == [
|
||||
"github_copilot/gpt-4o"
|
||||
]
|
||||
|
||||
assert resolution_attempts == []
|
||||
|
||||
|
||||
def test_get_pattern_bare_provider_name_never_matches_that_providers_wildcard(monkeypatch):
|
||||
"""Regression: a bare ``github_copilot`` adopted itself as its provider and retried as
|
||||
``github_copilot/github_copilot``, false-matching the wildcard for a name no deployment serves."""
|
||||
|
||||
def _unknown_provider(model, *args, **kwargs):
|
||||
raise ValueError(f"unknown provider for {model}")
|
||||
|
||||
monkeypatch.setattr(pattern_match_deployments, "get_llm_provider", _unknown_provider)
|
||||
router = PatternMatchRouter()
|
||||
router.add_pattern("github_copilot/*", _wildcard_deployment("github_copilot/*"))
|
||||
assert router.get_pattern("github_copilot") is None
|
||||
|
||||
|
||||
def test_get_pattern_missing_model_returns_none(monkeypatch):
|
||||
"""Regression: a request without a model reaches the auth layer's pattern walk as ``None``; the
|
||||
declared-provider guard raised ``TypeError`` where the old inline resolve swallowed every
|
||||
resolver error, so the proxy's missing-model 400 became a crash."""
|
||||
|
||||
def _unknown_provider(model, *args, **kwargs):
|
||||
raise ValueError(f"unknown provider for {model}")
|
||||
|
||||
monkeypatch.setattr(pattern_match_deployments, "get_llm_provider", _unknown_provider)
|
||||
router = PatternMatchRouter()
|
||||
router.add_pattern("openai/*", _wildcard_deployment("openai/*"))
|
||||
assert router.get_pattern(None) is None
|
||||
|
||||
|
||||
def test_get_pattern_still_resolves_unqualified_names(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
pattern_match_deployments,
|
||||
"get_llm_provider",
|
||||
lambda model, **kwargs: (model, "openai", None, None),
|
||||
)
|
||||
router = PatternMatchRouter()
|
||||
router.add_pattern("openai/*", _wildcard_deployment("openai/*"))
|
||||
assert _matched_models(router.get_pattern("gpt-4o")) == ["openai/gpt-4o"]
|
||||
Loading…
Add table
Reference in a new issue