mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-09 08:18:12 +00:00
* fix(openrouter): list OpenRouter's live catalog for openrouter/* when check_provider_endpoint is on * fix(openrouter): list models off the event loop and keep only chat-callable catalog ids The model-list builders are sync, and with check_provider_endpoint on they call OpenRouter's catalog with a 10 second timeout and no caching of failures. They ran on the event loop inside /v1/models, /model/info, /model_group/info and the per-user listing, so one slow catalog froze every request on the worker. Those calls now run in a worker thread with asyncio.to_thread File, batch and RAG credential lookups only need each name's deployment, and a wildcard that is itself a deployment sits in the list ahead of its expansions and resolves its own credentials. get_complete_model_list takes expand_deployed_wildcard_routes, which that lookup sets to False, so a deployed wildcard never fetches its provider catalog there. A key wildcard wider than any deployment, like anthropic/* over a deployed anthropic/claude-*, still expands so it finds the matching deployment's credentials. Every other caller keeps the default The pattern router's model lookup tolerates a pattern removed between its two reads of the pattern table, which a deployment delete on the event loop can now do while a listing runs in a thread Listing now reads only OpenRouter's models/user catalog. OpenRouter's own /models list carries no embedding models, the OpenAI-format /v1/models has no mode field so chat pickers cannot tell embeddings apart, /model_group/info labels them mode chat, and one failing catalog emptied the whole list. Ids ending in :batch are dropped because chat completions answers 404 for them, while :free and every other variant stay. An api_base that already ends in /chat/completions, which chat accepts as is, has that suffix stripped before the catalog route is joined * fix(proxy): keep model listing off the shared thread pool and out of credential lookups Listings run on their own thread pool so many slow catalogs cannot starve the default executor that chat and Responses calls share. File and batch credential lookups match deployed names against a key's wildcard grants instead of listing any provider catalog. The token counter keeps OpenRouter deployments on their previous path, and a catch-all "*" over provider/* lists bare ids that route back through it * test(proxy): give the off-loop listing tests a generous deadlock guard and typed ids * fix(proxy): keep routed models in wildcard model info, skip key-only OpenRouter catalogs, cache failed listings briefly * fix(utils): cache failed model listings under their own key so a late failure never hides a good listing * fix(proxy): remember only timed-out provider listings so a provider that failed fast is listed again * fix(proxy): ask each provider catalog once per worker, keep deployed names out of catch-all ids, and match file credentials by name Listings that start together for one deployment now wait for the first catalog request. A catch-all wildcard no longer lists a name a deployment serves by name. A key wildcard only finds file and batch credentials on deployments named under it, so a bare name keeps the default credentials as before. * fix(proxy): keep the listing failure log line the same for timed-out listings * refactor(proxy): fold the cached listing lookup back into the provider listing function * fix(proxy): list each model id once and keep a named deployment's row over a wildcard's * fix(proxy): drop a wildcard's model info row when named deployments serve every name it lists --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
817 lines
34 KiB
Python
817 lines
34 KiB
Python
"""Behavior pins for ``proxy_server.py`` model routes.
|
|
|
|
Pins (PR2):
|
|
- GET /v1/models
|
|
- GET /models
|
|
- GET /v1/models/{model_id}
|
|
- GET /models/{model_id}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import dataclass, field
|
|
from typing import Final
|
|
from unittest.mock import MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
import litellm
|
|
from litellm import Router
|
|
from litellm.integrations.custom_secret_manager import CustomSecretManager
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
|
from litellm.proxy import utils as proxy_utils
|
|
from litellm.proxy.utils import create_model_info_response
|
|
from litellm.types.router import DeploymentModelListingInfo
|
|
from litellm.types.secret_managers.main import KeyManagementSettings, KeyManagementSystem
|
|
|
|
from .conftest import normalize, route_entry_points_to_stubs # type: ignore[import-not-found]
|
|
|
|
|
|
def _stub_model_info_response(model_id: str = "gpt-4", provider: str = "openai") -> dict:
|
|
return {
|
|
"id": model_id,
|
|
"object": "model",
|
|
"created": 0,
|
|
"owned_by": provider,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def patched_models(monkeypatch):
|
|
"""Stub router + utility helpers used by the /models routes."""
|
|
from litellm.proxy import utils as proxy_utils
|
|
|
|
router = MagicMock()
|
|
router.get_fully_blocked_model_names = MagicMock(return_value=set())
|
|
router.get_model_names = MagicMock(return_value=["gpt-4", "claude-sonnet"])
|
|
router.get_model_access_groups = MagicMock(return_value={})
|
|
|
|
deployment = MagicMock()
|
|
deployment.litellm_params.model = "gpt-4"
|
|
router.get_deployment_by_model_group_name = MagicMock(return_value=deployment)
|
|
router.get_routable_upstream_model = MagicMock(return_value="gpt-4")
|
|
router.get_configured_display_name = MagicMock(return_value=None)
|
|
router.get_configured_service_tiers = MagicMock(return_value=())
|
|
router.get_model_group_info = MagicMock(return_value=None)
|
|
router.get_search_tool_support = MagicMock(return_value=())
|
|
route_entry_points_to_stubs(router)
|
|
|
|
monkeypatch.setattr(proxy_server, "llm_router", router)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
|
|
|
async def _fake_get_available_models_for_user(**kwargs):
|
|
return ["gpt-4", "claude-sonnet"]
|
|
|
|
monkeypatch.setattr(
|
|
proxy_utils,
|
|
"get_available_models_for_user",
|
|
_fake_get_available_models_for_user,
|
|
)
|
|
|
|
def _fake_create_model_info_response(model_id, provider="openai", **kwargs):
|
|
return _stub_model_info_response(model_id=model_id, provider=provider)
|
|
|
|
monkeypatch.setattr(proxy_utils, "create_model_info_response", _fake_create_model_info_response)
|
|
|
|
monkeypatch.setattr(proxy_utils, "validate_model_access", lambda **kwargs: None)
|
|
|
|
monkeypatch.setattr(
|
|
litellm,
|
|
"get_llm_provider",
|
|
lambda model: (model, "openai", None, None),
|
|
)
|
|
|
|
return router
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
def test_get_models_happy_path(client, auth_as, patched_models, path):
|
|
"""Pins: ``GET /v1/models``, ``GET /models``."""
|
|
with auth_as():
|
|
response = client.get(path)
|
|
assert response.status_code == 200
|
|
assert normalize(response.json()) == {
|
|
"data": [
|
|
{
|
|
"id": "<VOLATILE>",
|
|
"object": "model",
|
|
"created": "<VOLATILE>",
|
|
"owned_by": "openai",
|
|
},
|
|
{
|
|
"id": "<VOLATILE>",
|
|
"object": "model",
|
|
"created": "<VOLATILE>",
|
|
"owned_by": "openai",
|
|
},
|
|
],
|
|
"object": "list",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
def test_get_models_anthropic_format_when_header_present(client, auth_as, patched_models, path):
|
|
"""Pins: ``GET /v1/models`` returns the Anthropic-native models shape when
|
|
the caller sends an ``anthropic-version`` header (Claude Code gateway
|
|
discovery), while the default OpenAI shape is unchanged without it."""
|
|
with auth_as():
|
|
response = client.get(path, headers={"anthropic-version": "2023-06-01"})
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert "object" not in body
|
|
assert body["has_more"] is False
|
|
assert body["first_id"] == "gpt-4"
|
|
assert body["last_id"] == "claude-sonnet"
|
|
assert [m["id"] for m in body["data"]] == ["gpt-4", "claude-sonnet"]
|
|
for entry in body["data"]:
|
|
assert entry["type"] == "model"
|
|
assert entry["display_name"] == entry["id"]
|
|
assert entry["created_at"].endswith("Z")
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
def test_anthropic_format_exposes_token_limits(client, auth_as, patched_models, monkeypatch, path):
|
|
"""Claude Code sizes requests off the listing, so the Anthropic-native entries
|
|
carry the same token limits the OpenAI listing resolves, with the output budget
|
|
named max_tokens as the Messages API names it."""
|
|
from litellm.proxy import utils as proxy_utils
|
|
|
|
def _create_model_info_response(model_id, provider="openai", **kwargs):
|
|
if model_id != "claude-sonnet":
|
|
return _stub_model_info_response(model_id=model_id, provider=provider)
|
|
return {
|
|
**_stub_model_info_response(model_id=model_id, provider=provider),
|
|
"max_input_tokens": 200000,
|
|
"max_output_tokens": 64000,
|
|
}
|
|
|
|
monkeypatch.setattr(proxy_utils, "create_model_info_response", _create_model_info_response)
|
|
|
|
with auth_as():
|
|
response = client.get(path, headers={"anthropic-version": "2023-06-01"})
|
|
|
|
assert response.status_code == 200
|
|
gpt_4, claude = response.json()["data"]
|
|
assert claude["max_input_tokens"] == 200000
|
|
assert claude["max_tokens"] == 64000
|
|
assert "max_output_tokens" not in claude
|
|
assert gpt_4["max_input_tokens"] is None
|
|
assert gpt_4["max_tokens"] is None
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
def test_anthropic_format_carries_router_configured_token_limits(client, auth_as, patched_models, monkeypatch, path):
|
|
"""Pins the whole resolution chain, not just the formatter: a deployment's
|
|
configured limits beat the cost map, and the configured output budget is what
|
|
lands on the Anthropic ``max_tokens``. All eight limits differ, so an entry
|
|
built from another entry's lookup shows up as the wrong numbers."""
|
|
|
|
def _configured(model_name):
|
|
max_input, max_output = (300000, 32000) if model_name == "gpt-4" else (500000, 4096)
|
|
return DeploymentModelListingInfo(
|
|
cost_map_keys=(model_name,), max_input_tokens=max_input, max_output_tokens=max_output
|
|
)
|
|
|
|
def _cost_map_lookup(model_id):
|
|
max_input, max_output = (200000, 64000) if model_id == "gpt-4" else (100000, 8000)
|
|
return {"max_input_tokens": max_input, "max_output_tokens": max_output, "mode": "chat"}
|
|
|
|
patched_models.get_model_listing_info = MagicMock(side_effect=_configured)
|
|
|
|
def _resolved(**kwargs):
|
|
return create_model_info_response(**kwargs, get_model_info=_cost_map_lookup)
|
|
|
|
monkeypatch.setattr(proxy_utils, "create_model_info_response", _resolved)
|
|
|
|
with auth_as():
|
|
response = client.get(path, headers={"anthropic-version": "2023-06-01"})
|
|
|
|
assert response.status_code == 200
|
|
gpt_4, claude = response.json()["data"]
|
|
assert (gpt_4["max_input_tokens"], gpt_4["max_tokens"]) == (300000, 32000)
|
|
assert (claude["max_input_tokens"], claude["max_tokens"]) == (500000, 4096)
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
def test_anthropic_format_uses_configured_display_name(client, auth_as, patched_models, path):
|
|
"""A deployment's ``model_info.display_name`` becomes the Anthropic-native
|
|
``display_name`` so Claude Code's picker shows a clean name while the id keeps
|
|
routing; models without one keep the id fallback, and the OpenAI-shaped
|
|
listing carries no display_name either way."""
|
|
|
|
def _configured(model_name):
|
|
return "Kimi K3" if model_name == "gpt-4" else None
|
|
|
|
patched_models.get_configured_display_name = MagicMock(side_effect=_configured)
|
|
|
|
with auth_as():
|
|
anthropic_response = client.get(path, headers={"anthropic-version": "2023-06-01"})
|
|
openai_response = client.get(path)
|
|
|
|
assert anthropic_response.status_code == 200
|
|
gpt_4, claude = anthropic_response.json()["data"]
|
|
assert (gpt_4["id"], gpt_4["display_name"]) == ("gpt-4", "Kimi K3")
|
|
assert (claude["id"], claude["display_name"]) == ("claude-sonnet", "claude-sonnet")
|
|
|
|
assert openai_response.status_code == 200
|
|
openai_models = openai_response.json()["data"]
|
|
assert [m["id"] for m in openai_models] == ["gpt-4", "claude-sonnet"]
|
|
assert all("display_name" not in m for m in openai_models)
|
|
|
|
|
|
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
|
|
def test_anthropic_display_name_resolved_via_internal_team_key(client, auth_as, patched_models, monkeypatch, params):
|
|
"""For a team-scoped row the configured display name must be looked up by the
|
|
internal routing key while the entry itself is keyed by the public name, so
|
|
the clean name lands on the id the client actually sees."""
|
|
from litellm.proxy import utils as proxy_utils
|
|
from litellm.proxy.auth import model_checks
|
|
|
|
internal_name = "model_name_team-1_c0ffee"
|
|
|
|
patched_models.get_model_list = MagicMock(
|
|
return_value=[
|
|
{
|
|
"model_name": internal_name,
|
|
"litellm_params": {"model": "openai/gpt-4"},
|
|
"model_info": {
|
|
"team_id": "team-1",
|
|
"team_public_model_name": "gpt-4-team",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
patched_models.get_model_names = MagicMock(return_value=[internal_name])
|
|
patched_models.get_configured_display_name = MagicMock(
|
|
side_effect=lambda model_name: "Team GPT" if model_name == internal_name else None
|
|
)
|
|
|
|
async def _fake_get_available_models_for_user(**kwargs):
|
|
return [internal_name]
|
|
|
|
monkeypatch.setattr(
|
|
proxy_utils,
|
|
"get_available_models_for_user",
|
|
_fake_get_available_models_for_user,
|
|
)
|
|
monkeypatch.setattr(model_checks, "get_complete_model_list", lambda **kwargs: [internal_name])
|
|
|
|
with auth_as():
|
|
response = client.get("/v1/models", params=params, headers={"anthropic-version": "2023-06-01"})
|
|
|
|
assert response.status_code == 200
|
|
(entry,) = response.json()["data"]
|
|
assert (entry["id"], entry["display_name"]) == ("gpt-4-team", "Team GPT")
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
def test_get_models_invalid_scope_returns_400(client, auth_as, patched_models, path):
|
|
"""Pins: ``GET /v1/models``, ``GET /models`` (error path: invalid scope)."""
|
|
with auth_as():
|
|
response = client.get(path, params={"scope": "not-a-real-scope"})
|
|
assert response.status_code == 400
|
|
assert "Invalid scope parameter" in str(response.json())
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models/gpt-4", "/models/gpt-4"])
|
|
def test_get_model_by_id_happy_path(client, auth_as, patched_models, path):
|
|
"""Pins: ``GET /v1/models/{model_id}``, ``GET /models/{model_id}``."""
|
|
with auth_as():
|
|
response = client.get(path)
|
|
assert response.status_code == 200
|
|
assert normalize(response.json()) == {
|
|
"id": "<VOLATILE>",
|
|
"object": "model",
|
|
"created": "<VOLATILE>",
|
|
"owned_by": "openai",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models/missing", "/models/missing"])
|
|
def test_get_model_by_id_not_found(client, auth_as, patched_models, path):
|
|
"""Pins: ``GET /v1/models/{model_id}``, ``GET /models/{model_id}`` (error: 404)."""
|
|
patched_models.get_deployment_by_model_group_name = MagicMock(return_value=None)
|
|
with auth_as():
|
|
response = client.get(path)
|
|
assert response.status_code == 404
|
|
assert "not found" in response.text.lower()
|
|
|
|
|
|
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
|
|
def test_anthropic_format_returns_public_team_model_name(client, auth_as, patched_models, monkeypatch, params):
|
|
"""Regression: the Anthropic-native listing must go through the same team
|
|
name translation as the OpenAI listing, so a caller never sees the internal
|
|
``model_name_{team_id}_{uuid}`` routing key."""
|
|
from litellm.proxy import utils as proxy_utils
|
|
from litellm.proxy.auth import model_checks
|
|
|
|
internal_name = "model_name_team-1_c0ffee"
|
|
|
|
patched_models.get_model_list = MagicMock(
|
|
return_value=[
|
|
{
|
|
"model_name": internal_name,
|
|
"litellm_params": {"model": "openai/gpt-4"},
|
|
"model_info": {
|
|
"team_id": "team-1",
|
|
"team_public_model_name": "gpt-4-team",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
patched_models.get_model_names = MagicMock(return_value=[internal_name])
|
|
|
|
async def _fake_get_available_models_for_user(**kwargs):
|
|
return [internal_name]
|
|
|
|
monkeypatch.setattr(
|
|
proxy_utils,
|
|
"get_available_models_for_user",
|
|
_fake_get_available_models_for_user,
|
|
)
|
|
monkeypatch.setattr(model_checks, "get_complete_model_list", lambda **kwargs: [internal_name])
|
|
|
|
with auth_as():
|
|
response = client.get("/v1/models", params=params, headers={"anthropic-version": "2023-06-01"})
|
|
|
|
assert response.status_code == 200
|
|
assert [m["id"] for m in response.json()["data"]] == ["gpt-4-team"]
|
|
assert internal_name not in response.text
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
@pytest.mark.parametrize(
|
|
"caller_headers",
|
|
[
|
|
{"anthropic-version": "2023-06-01", "user-agent": "claude-code/2.1.267"},
|
|
{"anthropic-version": "2023-06-01", "user-agent": "claude-cli/2.1.267 (external, sdk-cli)"},
|
|
{"anthropic-version": "2023-06-01", "x-gateway-client": "claude-code"},
|
|
],
|
|
)
|
|
def test_anthropic_format_lists_claude_code_view_ids_for_claude_code(
|
|
client, auth_as, patched_models, monkeypatch, path, caller_headers
|
|
):
|
|
"""Claude Code drops every id without claude/anthropic in it and reads [1m] as its 1M marker, so for Claude
|
|
Code (its discovery fetch's own user agent, its SDK's, or the gateway-client header a launcher sends) every
|
|
group is listed under a Claude-shaped id with the marker where the window reaches 1M; the display name stays
|
|
the served name."""
|
|
|
|
def _create_model_info_response(model_id, provider="openai", **kwargs):
|
|
if model_id != "claude-sonnet":
|
|
return _stub_model_info_response(model_id=model_id, provider=provider)
|
|
return {**_stub_model_info_response(model_id=model_id, provider=provider), "max_input_tokens": 1000000}
|
|
|
|
patched_models.model_group_alias = {}
|
|
patched_models.has_model_id.return_value = False
|
|
patched_models.get_candidate_model_ids_for_route.side_effect = lambda name, team_id=None: (
|
|
frozenset({name}) if name in ("gpt-4", "claude-sonnet") else frozenset()
|
|
)
|
|
monkeypatch.setattr(proxy_utils, "create_model_info_response", _create_model_info_response)
|
|
|
|
with auth_as():
|
|
response = client.get(path, headers=caller_headers)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert [(m["id"], m["display_name"]) for m in body["data"]] == [
|
|
("claude-router-6770742d34", "gpt-4"),
|
|
("claude-sonnet[1m]", "claude-sonnet"),
|
|
]
|
|
assert (body["first_id"], body["last_id"]) == ("claude-router-6770742d34", "claude-sonnet[1m]")
|
|
assert [row["source_model"] for row in body["data"]] == ["gpt-4", "claude-sonnet"]
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
def test_anthropic_format_keeps_served_ids_for_other_anthropic_clients(client, auth_as, patched_models, path):
|
|
"""An Anthropic SDK asking for the vendor shape gets the served ids: the view is Claude Code's alone."""
|
|
with auth_as():
|
|
response = client.get(
|
|
path, headers={"anthropic-version": "2023-06-01", "user-agent": "anthropic-sdk-python/0.40"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert [m["id"] for m in response.json()["data"]] == ["gpt-4", "claude-sonnet"]
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
|
|
def test_codex_format_when_client_version_present(client, auth_as, patched_models, path, params):
|
|
"""Codex CLI fetches a provider's catalog as ``GET /v1/models?client_version=<its version>`` and
|
|
decodes Codex's own ``{"models": [...]}`` shape; the same request without the parameter keeps the
|
|
OpenAI shape byte for byte."""
|
|
with auth_as():
|
|
codex_response = client.get(path, params={**params, "client_version": "0.159.3"})
|
|
openai_response = client.get(path, params=params)
|
|
|
|
assert codex_response.status_code == 200
|
|
assert codex_response.headers["content-type"] == "application/json"
|
|
body = codex_response.json()
|
|
assert list(body) == ["models"]
|
|
assert [(m["slug"], m["display_name"], m["priority"]) for m in body["models"]] == [
|
|
("gpt-4", "gpt-4", 0),
|
|
("claude-sonnet", "claude-sonnet", 1),
|
|
]
|
|
assert all(m["base_instructions"] and m["visibility"] == "list" for m in body["models"])
|
|
|
|
assert openai_response.status_code == 200
|
|
assert normalize(openai_response.json()) == {
|
|
"data": [
|
|
{"id": "<VOLATILE>", "object": "model", "created": "<VOLATILE>", "owned_by": "openai"},
|
|
{"id": "<VOLATILE>", "object": "model", "created": "<VOLATILE>", "owned_by": "openai"},
|
|
],
|
|
"object": "list",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
def test_codex_format_wins_over_the_anthropic_header(client, auth_as, patched_models, path):
|
|
with auth_as():
|
|
response = client.get(path, params={"client_version": "0.159.3"}, headers={"anthropic-version": "2023-06-01"})
|
|
|
|
assert response.status_code == 200
|
|
assert list(response.json()) == ["models"]
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
|
def test_codex_format_carries_configured_service_tiers(client, auth_as, patched_models, path):
|
|
"""A deployment's ``model_info.service_tiers`` becomes the entry's ``service_tiers``, which Codex
|
|
offers as slash commands; a model without one offers none, and the OpenAI shape gains no field."""
|
|
patched_models.get_configured_service_tiers = MagicMock(
|
|
side_effect=lambda model_name, team_id=None: (["ultrafast"],) if model_name == "gpt-4" else (None,)
|
|
)
|
|
|
|
with auth_as():
|
|
codex_response = client.get(path, params={"client_version": "0.159.3"})
|
|
openai_response = client.get(path)
|
|
|
|
gpt_4, claude = codex_response.json()["models"]
|
|
assert gpt_4["service_tiers"] == [
|
|
{"id": "ultrafast", "name": "Ultrafast", "description": "Sends service_tier=ultrafast upstream"}
|
|
]
|
|
assert claude["service_tiers"] == []
|
|
assert all("service_tiers" not in m for m in openai_response.json()["data"])
|
|
|
|
|
|
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
|
|
def test_codex_reasoning_levels_are_the_ones_the_requesting_codex_parses(client, auth_as, monkeypatch, params):
|
|
"""Both listing paths hand the route's ``client_version`` to the catalog: Codex before 0.138.0 cannot
|
|
parse max, so it is offered there only to a newer release."""
|
|
declared = {"supports_reasoning": True, "reasoning_effort_levels": ["low", "max"]}
|
|
vllm = {"model": "hosted_vllm/qa-local-coder", "api_base": "https://vllm.example/v1"}
|
|
router = litellm.Router(
|
|
model_list=[
|
|
{"model_name": name, "litellm_params": vllm, "model_info": declared} for name in ("gpt-4", "claude-sonnet")
|
|
]
|
|
)
|
|
monkeypatch.setattr(proxy_server, "llm_router", router)
|
|
monkeypatch.setattr(proxy_server, "llm_model_list", router.model_list)
|
|
monkeypatch.setattr(proxy_server, "user_model", None)
|
|
monkeypatch.setattr(proxy_server, "general_settings", {})
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
|
|
with auth_as():
|
|
current = client.get("/v1/models", params={**params, "client_version": "0.165.0"})
|
|
older = client.get("/v1/models", params={**params, "client_version": "0.137.0"})
|
|
|
|
assert [[level["effort"] for level in m["supported_reasoning_levels"]] for m in current.json()["models"]] == [
|
|
["low", "max"]
|
|
] * 2
|
|
assert [[level["effort"] for level in m["supported_reasoning_levels"]] for m in older.json()["models"]] == [
|
|
["low"]
|
|
] * 2
|
|
|
|
|
|
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
|
|
def test_codex_service_tiers_are_read_for_the_key_team(client, auth_as, patched_models, params):
|
|
"""A tier and the upstream model that picks Codex's stock entry are read off the deployments the
|
|
key's team can route to, so both listing paths hand the router the key's team, and no team for a
|
|
key without one."""
|
|
patched_models.get_configured_service_tiers = MagicMock(
|
|
side_effect=lambda model_name, team_id=None: (["ultrafast"],) if team_id == "team-1" else (None,)
|
|
)
|
|
patched_models.get_routable_upstream_model = MagicMock(
|
|
side_effect=lambda model_name, team_id=None: "openai/gpt-5.5" if team_id == "team-1" else "gpt-4"
|
|
)
|
|
|
|
with auth_as(team_id="team-1"):
|
|
team_response = client.get("/v1/models", params={**params, "client_version": "0.159.3"})
|
|
with auth_as():
|
|
teamless_response = client.get("/v1/models", params={**params, "client_version": "0.159.3"})
|
|
|
|
assert [[t["id"] for t in m["service_tiers"]] for m in team_response.json()["models"]] == [["ultrafast"]] * 2
|
|
assert [m["service_tiers"] for m in teamless_response.json()["models"]] == [[], []]
|
|
assert all(m["supported_reasoning_levels"] for m in team_response.json()["models"])
|
|
assert [m["supported_reasoning_levels"] for m in teamless_response.json()["models"]] == [[], []]
|
|
|
|
|
|
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
|
|
def test_codex_service_tiers_resolved_via_internal_team_key(client, auth_as, patched_models, monkeypatch, params):
|
|
"""A team-scoped row's tiers are looked up by the internal routing key while the entry is keyed by
|
|
the public name Codex sends back as the model."""
|
|
from litellm.proxy import utils as proxy_utils
|
|
from litellm.proxy.auth import model_checks
|
|
|
|
internal_name = "model_name_team-1_c0ffee"
|
|
|
|
patched_models.get_model_list = MagicMock(
|
|
return_value=[
|
|
{
|
|
"model_name": internal_name,
|
|
"litellm_params": {"model": "openai/gpt-4"},
|
|
"model_info": {"team_id": "team-1", "team_public_model_name": "gpt-4-team"},
|
|
}
|
|
]
|
|
)
|
|
patched_models.get_model_names = MagicMock(return_value=[internal_name])
|
|
patched_models.get_configured_service_tiers = MagicMock(
|
|
side_effect=lambda model_name, team_id=None: (["ultrafast"],) if model_name == internal_name else ()
|
|
)
|
|
|
|
async def _fake_get_available_models_for_user(**kwargs):
|
|
return [internal_name]
|
|
|
|
monkeypatch.setattr(proxy_utils, "get_available_models_for_user", _fake_get_available_models_for_user)
|
|
monkeypatch.setattr(model_checks, "get_complete_model_list", lambda **kwargs: [internal_name])
|
|
|
|
with auth_as():
|
|
response = client.get("/v1/models", params={**params, "client_version": "0.159.3"})
|
|
|
|
assert response.status_code == 200
|
|
(entry,) = response.json()["models"]
|
|
assert (entry["slug"], [tier["id"] for tier in entry["service_tiers"]]) == ("gpt-4-team", ["ultrafast"])
|
|
|
|
|
|
OPENROUTER_BASE: Final = "https://openrouter.ai/api/v1"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _CatalogHeldUntilTheLoopRuns:
|
|
requested: threading.Event = field(default_factory=threading.Event)
|
|
released: threading.Event = field(default_factory=threading.Event)
|
|
|
|
def answer(self, _request: httpx.Request) -> httpx.Response:
|
|
self.requested.set()
|
|
if not self.released.wait(timeout=30):
|
|
return httpx.Response(504)
|
|
return httpx.Response(200, json={"data": [{"id": "openai/gpt-5.4-mini"}]})
|
|
|
|
async def release_from_the_loop(self) -> None:
|
|
assert await asyncio.to_thread(self.requested.wait, 30)
|
|
self.released.set()
|
|
|
|
|
|
@pytest.mark.parametrize("scope", [None, "expand"])
|
|
def test_v1_models_fetches_a_live_openrouter_catalog_without_holding_the_event_loop_or_its_default_thread_pool(
|
|
monkeypatch: pytest.MonkeyPatch, scope: str | None
|
|
):
|
|
monkeypatch.setattr(litellm, "check_provider_endpoint", True)
|
|
monkeypatch.setattr(proxy_server, "general_settings", {})
|
|
monkeypatch.setattr(proxy_server, "user_model", None)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
monkeypatch.setattr(
|
|
proxy_server,
|
|
"llm_router",
|
|
litellm.Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "openrouter/*",
|
|
"litellm_params": {
|
|
"model": "openrouter/*",
|
|
"api_key": f"sk-or-v1-models-off-loop-{scope}",
|
|
"api_base": OPENROUTER_BASE,
|
|
},
|
|
}
|
|
]
|
|
),
|
|
)
|
|
catalog: Final = _CatalogHeldUntilTheLoopRuns()
|
|
|
|
async def listed_ids_on_a_one_thread_default_pool() -> list[str]:
|
|
asyncio.get_running_loop().set_default_executor(ThreadPoolExecutor(max_workers=1))
|
|
listing: Final = asyncio.ensure_future(
|
|
proxy_server.model_list(
|
|
user_api_key_dict=UserAPIKeyAuth(
|
|
api_key="sk-test-key", user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
|
|
),
|
|
scope=scope,
|
|
)
|
|
)
|
|
await catalog.release_from_the_loop()
|
|
response: Final = await listing
|
|
return [str(model["id"]) for model in response["data"]]
|
|
|
|
with respx.mock() as respx_mock:
|
|
catalog_route: Final = respx_mock.get(f"{OPENROUTER_BASE}/models/user").mock(side_effect=catalog.answer)
|
|
listed_ids: Final = asyncio.run(listed_ids_on_a_one_thread_default_pool())
|
|
|
|
assert catalog_route.calls.last.response.status_code == 200
|
|
assert listed_ids == ["openrouter/openai/gpt-5.4-mini"]
|
|
|
|
|
|
def _priced_jev_models() -> frozenset[str]:
|
|
return frozenset(name.removeprefix("typesafe/") for name in litellm.model_cost if name.startswith("typesafe/"))
|
|
|
|
|
|
async def _admin_picker_models() -> set[str]:
|
|
admin: Final = UserAPIKeyAuth(api_key="sk-test", user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN)
|
|
response: Final = await proxy_server.model_list(
|
|
user_api_key_dict=admin, return_wildcard_routes=True, include_model_access_groups=True, scope="expand"
|
|
)
|
|
return {model["id"] for model in response["data"]}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scope_expand_offers_typesafe_models_when_typesafe_is_configured(
|
|
openai_only_router: Router, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "sk-typesafe-fake")
|
|
jev_models: Final = _priced_jev_models()
|
|
|
|
picker_models: Final = await _admin_picker_models()
|
|
|
|
assert jev_models
|
|
assert {"gpt-4", "typesafe/*", *jev_models} <= picker_models
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scope_expand_still_lists_models_when_the_typesafe_key_reference_cannot_resolve(
|
|
openai_only_router: Router, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("LITELLM_QA_UNSET_TYPESAFE_TOKEN", raising=False)
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "oidc/env/LITELLM_QA_UNSET_TYPESAFE_TOKEN")
|
|
|
|
picker_models: Final = await _admin_picker_models()
|
|
|
|
assert {"gpt-4", "typesafe/*"} <= picker_models
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scope_expand_offers_no_typesafe_models_without_typesafe_credentials(
|
|
openai_only_router: Router, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("TYPESAFE_API_KEY", raising=False)
|
|
|
|
picker_models: Final = await _admin_picker_models()
|
|
|
|
assert "gpt-4" in picker_models
|
|
assert picker_models.isdisjoint({"typesafe/*", *_priced_jev_models()})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scope_expand_does_not_read_the_secret_manager_for_typesafe_credentials(
|
|
openai_only_router: Router, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
secret_manager: Final = MagicMock(spec=CustomSecretManager)
|
|
secret_manager.sync_read_secret.return_value = None
|
|
monkeypatch.delenv("TYPESAFE_API_KEY", raising=False)
|
|
monkeypatch.setattr(litellm, "secret_manager_client", secret_manager)
|
|
monkeypatch.setattr(litellm, "_key_management_system", KeyManagementSystem.CUSTOM)
|
|
monkeypatch.setattr(litellm, "_key_management_settings", KeyManagementSettings(access_mode="read_only"))
|
|
|
|
picker_models: Final = await _admin_picker_models()
|
|
|
|
secrets_read: Final = {
|
|
call.kwargs["secret_name"]
|
|
for call in (*secret_manager.sync_read_secret.call_args_list, *secret_manager.async_read_secret.call_args_list)
|
|
}
|
|
assert "gpt-4" in picker_models
|
|
assert "TYPESAFE_API_KEY" not in secrets_read
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_model_list_does_not_offer_typesafe_models(
|
|
openai_only_router: Router, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "sk-typesafe-fake")
|
|
admin: Final = UserAPIKeyAuth(api_key="sk-test", user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN)
|
|
|
|
response: Final = await proxy_server.model_list(user_api_key_dict=admin)
|
|
|
|
assert {model["id"] for model in response["data"]}.isdisjoint({"typesafe/*", *_priced_jev_models()})
|
|
|
|
|
|
async def _key_dialog_models(user_role: LitellmUserRoles, team_id: str | None) -> set[str]:
|
|
caller: Final = UserAPIKeyAuth(api_key="sk-test", user_id="u", user_role=user_role)
|
|
response: Final = await proxy_server.model_list(
|
|
user_api_key_dict=caller, return_wildcard_routes=True, include_model_access_groups=True, team_id=team_id
|
|
)
|
|
return {model["id"] for model in response["data"]}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_key_dialog_without_a_team_offers_typesafe_models_to_an_admin(
|
|
openai_only_router: Router, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "sk-typesafe-fake")
|
|
|
|
dialog_models: Final = await _key_dialog_models(LitellmUserRoles.PROXY_ADMIN, team_id=None)
|
|
|
|
assert {"gpt-4", "typesafe/*", *_priced_jev_models()} <= dialog_models
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("scope", ["expand", None])
|
|
async def test_pickers_offer_typesafe_names_once_and_no_bare_name_for_registered_typesafe_deployments(
|
|
monkeypatch: pytest.MonkeyPatch, scope: str | None
|
|
) -> None:
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "sk-typesafe-fake")
|
|
jev_models: Final = _priced_jev_models()
|
|
monkeypatch.setattr(litellm, "model_cost", {**litellm.model_cost})
|
|
router: Final = Router(
|
|
model_list=[
|
|
{"model_name": "typesafe/*", "litellm_params": {"model": "typesafe/*", "api_key": "sk-typesafe-fake"}},
|
|
{
|
|
"model_name": "ticket-router",
|
|
"litellm_params": {"model": "typesafe/my-classifier", "api_key": "sk-typesafe-fake"},
|
|
},
|
|
]
|
|
)
|
|
monkeypatch.setattr(proxy_server, "llm_router", router)
|
|
monkeypatch.setattr(proxy_server, "llm_model_list", router.model_list)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
monkeypatch.setattr(proxy_server, "general_settings", {})
|
|
monkeypatch.setattr(proxy_server, "user_model", None)
|
|
admin: Final = UserAPIKeyAuth(api_key="sk-test", user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN)
|
|
|
|
response: Final = await proxy_server.model_list(
|
|
user_api_key_dict=admin, return_wildcard_routes=True, include_model_access_groups=True, scope=scope
|
|
)
|
|
|
|
listed: Final = [model["id"] for model in response["data"]]
|
|
assert {"ticket-router", *jev_models} <= set(listed)
|
|
assert listed.count("typesafe/*") == 1
|
|
assert set(listed).isdisjoint({"*", "my-classifier"})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_key_dialog_offers_no_typesafe_models_to_an_internal_user(
|
|
openai_only_router: Router, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "sk-typesafe-fake")
|
|
|
|
dialog_models: Final = await _key_dialog_models(LitellmUserRoles.INTERNAL_USER, team_id=None)
|
|
|
|
assert "gpt-4" in dialog_models
|
|
assert dialog_models.isdisjoint({"typesafe/*", *_priced_jev_models()})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_key_dialog_for_a_team_offers_no_typesafe_models_the_team_lacks(
|
|
openai_only_router: Router, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "sk-typesafe-fake")
|
|
|
|
dialog_models: Final = await _key_dialog_models(LitellmUserRoles.PROXY_ADMIN, team_id="team-without-typesafe")
|
|
|
|
assert dialog_models.isdisjoint({"typesafe/*", *_priced_jev_models()})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scope_expand_lists_no_typesafe_models_to_a_client_not_asking_for_allowlist_names(
|
|
openai_only_router: Router, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "sk-typesafe-fake")
|
|
admin: Final = UserAPIKeyAuth(api_key="sk-test", user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN)
|
|
|
|
response: Final = await proxy_server.model_list(user_api_key_dict=admin, scope="expand")
|
|
|
|
listed: Final = {model["id"] for model in response["data"]}
|
|
assert "gpt-4" in listed
|
|
assert listed.isdisjoint({"typesafe/*", *_priced_jev_models()})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("scope", "return_wildcard_routes", "only_model_access_groups"),
|
|
[
|
|
pytest.param(None, False, True, id="access-group-dropdown"),
|
|
pytest.param(None, None, None, id="dashboard-list-without-wildcard-routes"),
|
|
pytest.param("expand", None, None, id="expanded-list-without-wildcard-routes"),
|
|
pytest.param(None, True, True, id="access-groups-only-with-wildcard-routes"),
|
|
],
|
|
)
|
|
async def test_dashboard_lists_not_asking_for_allowlist_names_offer_no_typesafe_models(
|
|
openai_only_router: Router,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
scope: str | None,
|
|
return_wildcard_routes: bool | None,
|
|
only_model_access_groups: bool | None,
|
|
) -> None:
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "sk-typesafe-fake")
|
|
admin: Final = UserAPIKeyAuth(api_key="sk-test", user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN)
|
|
|
|
response: Final = await proxy_server.model_list(
|
|
user_api_key_dict=admin,
|
|
include_model_access_groups=True,
|
|
return_wildcard_routes=return_wildcard_routes,
|
|
only_model_access_groups=only_model_access_groups,
|
|
scope=scope,
|
|
)
|
|
|
|
assert {model["id"] for model in response["data"]}.isdisjoint({"typesafe/*", *_priced_jev_models()})
|