fix(lite): round Codex's default level down and describe a team's shared-name model from its own deployments

Move the reasoning strength helper to litellm_core_utils so lite and the Codex catalog import it without the SDK or the router, and let make lint-format-check-changed diff the working tree so a staged rename passes.
This commit is contained in:
mateo-berri 2026-10-28 21:20:40 -07:00
parent 88f935b19e
commit 8b51849002
13 changed files with 146 additions and 73 deletions

View file

@ -153,7 +153,7 @@ lint-install:
# the same set CI's ':(glob)litellm/**/*.py' selects.
lint-format-check-changed: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
@base_ref=$$($(RESOLVE_BASE)) && \
changed=$$(git diff --name-only --diff-filter=ACMR "$$base_ref...HEAD" -- 'litellm/*.py') && \
changed=$$(git diff --name-only --diff-filter=ACMR --merge-base "$$base_ref" -- 'litellm/*.py') && \
files=$$(printf '%s\n' "$$changed" | grep -v '^litellm/enterprise/' || true) || exit $$?; \
if [ -z "$$files" ]; then \
echo "No changed litellm Python files to format-check."; \

View file

@ -31,7 +31,7 @@ from packaging.version import InvalidVersion, Version
from pydantic import ConfigDict, Field, StringConstraints, TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.router_utils.reasoning_effort_strength import nearest_declared_reasoning_effort
from litellm.litellm_core_utils.reasoning_effort_strength import nearest_declared_reasoning_effort
from litellm.types.llms.base_model import LiteLLMBaseModel
if TYPE_CHECKING:
@ -282,12 +282,13 @@ def codex_reasoning_levels(
a version that does not parse, only the efforts every release knows are offered. The default is
set whenever a level is offered, because Codex reads a missing one as `none`: `none` itself when the
group accepts it, so a user who picks no level sends what Codex sent before, else medium, else the
nearest stronger declared level, else the first one."""
nearest weaker declared level, so picking no level never costs more reasoning than Codex's usual medium,
else the weakest declared level, else the first one."""
parses_any: Final = _parses_custom_efforts(client_version)
offered: Final = tuple(effort for effort in efforts if parses_any or effort in _EFFORTS_EVERY_CODEX_PARSES)
if not offered:
return (), None
nearest: Final = nearest_declared_reasoning_effort(_PREFERRED_DEFAULT_EFFORT, offered)
nearest: Final = nearest_declared_reasoning_effort(_PREFERRED_DEFAULT_EFFORT, offered, "down")
levels: Final = tuple(
CodexReasoningLevel(effort=effort, description=f"Sends reasoning_effort={effort} upstream")
for effort in offered

View file

@ -17986,7 +17986,7 @@ def get_model_group_info(
**_model_group_info.model_dump(),
**model_group_custom_fields(
llm_router.get_model_list_of_routed_group(
resolve_model_group_alias(llm_router.model_group_alias, model) or model
resolve_model_group_alias(llm_router.model_group_alias, model) or model, team_id
)
),
}

View file

@ -12635,11 +12635,7 @@ class Router:
serves_team_deployments: Final = team_id is not None and bool(
self._team_deployments_named(team_id, model_group)
)
model_list: Final = (
self.get_all_deployments(model_name=model_group, team_id=team_id)
if serves_team_deployments
else self.get_model_list_of_routed_group(model_group)
)
model_list: Final = self.get_model_list_of_routed_group(model_group, team_id)
model_group_description: Final[str | None] = next(
(d for d in (_configured_model_info(m).get("description") for m in model_list) if isinstance(d, str)),
None,
@ -13997,17 +13993,20 @@ class Router:
lambda name: self.wildcard_route_deployments(name, team_id),
)
def get_model_list_of_routed_group(self, model_group: str) -> list[DeploymentTypedDict]:
def get_model_list_of_routed_group(self, model_group: str, team_id: str | None = None) -> list[DeploymentTypedDict]:
"""
The deployments a request the router has already resolved to model_group is
served from: the ones named model_group, the routing group of that name, or
the wildcard route matching it when neither exists.
served from: team_id's own deployments under that public name when the team has
any, else the ones named model_group, the routing group of that name, or the
wildcard route matching it when neither exists.
Unlike get_model_list, model_group's own model_group_alias entry is not
followed. The router resolves an alias exactly once, so a group reached as
an alias target is served by its own deployments, never by a second hop:
in the chain X -> T -> U a request to X is served from T's deployments.
"""
if team_id is not None and self._team_deployments_named(team_id, model_group):
return self.get_all_deployments(model_name=model_group, team_id=team_id)
named: Final = [
*self._get_all_deployments(model_name=model_group),
*self.get_model_list_from_routing_groups(model_name=model_group),

View file

@ -33,13 +33,13 @@ import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.litellm_core_utils.get_supported_openai_params import get_supported_openai_params
from litellm.litellm_core_utils.reasoning_effort_utils import reasoning_effort_from_thinking
from litellm.llms.anthropic.common_utils import AnthropicModelInfo, validated_object_mapping
from litellm.router_utils.reasoning_effort_strength import (
from litellm.litellm_core_utils.reasoning_effort_strength import (
REASONING_EFFORT_STRENGTH_RANK,
ReasoningEffortRounding,
nearest_declared_reasoning_effort,
)
from litellm.litellm_core_utils.reasoning_effort_utils import reasoning_effort_from_thinking
from litellm.llms.anthropic.common_utils import AnthropicModelInfo, validated_object_mapping
from litellm.types.llms.anthropic import AnthropicThinkingParam
from litellm.types.llms.openai import REASONING_EFFORT

View file

@ -868,7 +868,7 @@ class TestCodexModelSync:
"effort": "low",
"description": "Sends reasoning_effort=low upstream",
}
assert entry["default_reasoning_level"] == "high"
assert entry["default_reasoning_level"] == "low"
def test_known_models_and_unknown_or_empty_groups_keep_their_entries(self, tmp_path):
listing = self._listing(self._row("gpt-5.5"), self._row("unknown"), self._row("empty"))

View file

@ -139,7 +139,8 @@ _ABSENT = object()
@pytest.mark.parametrize(
("efforts", "client_version", "levels", "default"),
[
(("low", "high", "max"), "0.165.0", ["low", "high", "max"], "high"),
(("low", "high", "max"), "0.165.0", ["low", "high", "max"], "low"),
(("high", "xhigh", "max"), "0.165.0", ["high", "xhigh", "max"], "high"),
(
("none", "low", "medium", "high", "xhigh", "max"),
"0.165.0",
@ -150,9 +151,9 @@ _ABSENT = object()
(("none", "low", "max"), "0.137.0", ["none", "low"], "none"),
(("none",), "0.165.0", ["none"], "none"),
(("low", "turbo"), "0.165.0", ["low", "turbo"], "low"),
(("low", "high", "max"), "0.138.0", ["low", "high", "max"], "high"),
(("low", "high", "max"), "0.137.9", ["low", "high"], "high"),
(("low", "high", "max"), "0.138.0-alpha.2", ["low", "high"], "high"),
(("low", "high", "max"), "0.138.0", ["low", "high", "max"], "low"),
(("low", "high", "max"), "0.137.9", ["low", "high"], "low"),
(("low", "high", "max"), "0.138.0-alpha.2", ["low", "high"], "low"),
(("minimal", "max"), "not-a-version", ["minimal"], "minimal"),
(("low", "turbo"), None, ["low"], "low"),
(("max",), "0.137.0", [], _ABSENT),
@ -652,12 +653,12 @@ def test_model_list_body_offers_what_model_group_info_reports_for_each_listed_mo
assert router.get_model_group_info("declared").supported_reasoning_efforts == ("low", "high", "max")
assert offered("0.165.0") == {
"declared": (["low", "high", "max"], "high"),
"declared": (["low", "high", "max"], "low"),
"unknown": ([], None),
"non-reasoning": ([], None),
"hidden-declared": (["low", "high", "max"], "high"),
"hidden-declared": (["low", "high", "max"], "low"),
}
assert offered("0.137.0")["declared"] == (["low", "high"], "high")
assert offered("0.137.0")["declared"] == (["low", "high"], "low")
def test_catalog_rows_without_a_router_carry_only_the_listing():

View file

@ -221,57 +221,39 @@ def test_deleted_model_params_stay_absent_after_reload_and_another_save(
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo, updateDeployment, updateLiteLLMParams
configured: Final = {
"model": "openai/test-model",
"api_key": "synthetic-runtime-secret",
"use_litellm_proxy": True,
"use_in_pass_through": True,
"use_chat_completions_api": False,
"litellm_image_enabled": True,
"timeout": 30,
"input_cost_per_token": 0.003,
"team_id": "param-only-team",
"temperature": 0,
"metadata": {},
"model": "openai/test-model", "api_key": "synthetic-runtime-secret",
"use_litellm_proxy": True, "use_in_pass_through": True, "use_chat_completions_api": False,
"litellm_image_enabled": True, "timeout": 30, "input_cost_per_token": 0.003,
"team_id": "param-only-team", "temperature": 0, "metadata": {},
}
original: Final = Deployment(
model_name="editor-model",
litellm_params=LiteLLM_Params.model_validate(configured),
model_name="editor-model", litellm_params=LiteLLM_Params.model_validate(configured),
model_info=ModelInfo(id="editor-model", team_id="owner-team", access_groups=["retained-group"]),
)
removals: Final = ("litellm_image_enabled", "use_litellm_proxy", "input_cost_per_token", "team_id")
deleted: Final = update_db_model(
db_model=original,
updated_patch=PatchModelRequest(
litellm_params=updateLiteLLMParams(max_retries=0),
litellm_params_to_remove=removals,
litellm_params=updateLiteLLMParams(max_retries=0), litellm_params_to_remove=removals,
model_info=ModelInfo(id="editor-model", input_cost_per_token=0.003),
),
)
saved_params: Final = json.loads(deleted["litellm_params"])
saved_info: Final = json.loads(deleted["model_info"])
assert saved_params == {
**{key: value for key, value in configured.items() if key not in removals},
"max_retries": 0,
}
assert saved_params == {**{key: value for key, value in configured.items() if key not in removals}, "max_retries": 0}
assert "input_cost_per_token" not in saved_info
assert saved_info["team_id"] == "owner-team"
assert saved_info["access_groups"] == ["retained-group"]
reloaded: Final = Deployment.model_validate(
{"model_name": original.model_name, "litellm_params": saved_params, "model_info": saved_info}
)
router: Final = litellm.Router(
model_list=[
{
"model_name": "editor-model",
"litellm_params": saved_params,
"model_info": {"id": "editor-model", "db_model": True},
}
]
)
router: Final = litellm.Router(model_list=[{
"model_name": "editor-model", "litellm_params": saved_params,
"model_info": {"id": "editor-model", "db_model": True},
}])
stored_params: Final = {**saved_params, "api_key": "synthetic-stored-secret"}
mock_prisma.db.litellm_proxymodeltable.find_unique.return_value = LiteLLM_ProxyModelTable(
model_id="editor-model",
model_name="editor-model",
model_id="editor-model", model_name="editor-model",
litellm_params=json.dumps(stored_params) if encoded else stored_params,
model_info={"id": "editor-model"},
)
@ -1051,6 +1033,67 @@ def test_model_group_info_describes_a_team_model_under_the_public_name_its_team_
assert groups[_BYOK_PUBLIC_NAME]["supported_reasoning_efforts"] == efforts
@pytest.mark.parametrize(
("team_id", "providers", "efforts", "custom_tags"),
[
(_BYOK_TEAM_ID, ["fireworks_ai"], ["low", "high", "max"], ["team-tag"]),
("team-other", ["openai"], [], ["global-tag"]),
],
)
def test_model_group_info_describes_the_deployments_a_shared_name_routes_the_team_to(
client, auth_as, monkeypatch, team_id: str, providers: list[str], efforts: list[str], custom_tags: list[str]
):
"""A team model whose public name is also a global model's name routes the team's requests to the
team's deployment, so the team's row must describe that deployment in every field, while another
team still sees the global model."""
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.agent_endpoints import model_list_helpers
router: Final = litellm.Router(
model_list=[
{
"model_name": _BYOK_PUBLIC_NAME,
"litellm_params": {"model": "openai/gpt-4.1-mini"},
"model_info": {"supports_reasoning": False, "custom_tags": ["global-tag"]},
},
{
"model_name": _BYOK_INTERNAL_NAME,
"litellm_params": {"model": "fireworks_ai/accounts/fireworks/models/kimi-k3"},
"model_info": {
"team_id": _BYOK_TEAM_ID,
"team_public_model_name": _BYOK_PUBLIC_NAME,
"supports_reasoning": True,
"reasoning_effort_levels": ["low", "high", "max"],
"custom_tags": ["team-tag"],
},
},
]
)
async def append_agents_to_model_group(*, model_groups, user_api_key_dict):
return model_groups
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)
monkeypatch.setattr(
model_list_helpers, "append_agents_to_model_group", AsyncMock(side_effect=append_agents_to_model_group)
)
with auth_as(LitellmUserRoles.INTERNAL_USER, user_id=None, team_id=team_id, team_models=[_BYOK_PUBLIC_NAME]):
response: Final = client.get("/model_group/info")
assert response.status_code == 200
group: Final = {group["model_group"]: group for group in response.json()["data"]}[_BYOK_PUBLIC_NAME]
assert (group["providers"], group["supported_reasoning_efforts"], group["custom_tags"]) == (
providers,
efforts,
custom_tags,
)
# ---------------------------------------------------------------------------
# GET /v2/model/info?exclude_auto_routers
# ---------------------------------------------------------------------------

View file

@ -22,7 +22,9 @@ from litellm.types.router import DeploymentModelListingInfo
from .conftest import normalize # type: ignore[import-not-found]
def _stub_model_info_response(model_id: str = "gpt-4", provider: str = "openai") -> dict:
def _stub_model_info_response(
model_id: str = "gpt-4", provider: str = "openai"
) -> dict:
return {
"id": model_id,
"object": "model",
@ -64,7 +66,9 @@ def patched_models(monkeypatch):
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, "create_model_info_response", _fake_create_model_info_response
)
monkeypatch.setattr(proxy_utils, "validate_model_access", lambda **kwargs: None)
@ -103,7 +107,9 @@ def test_get_models_happy_path(client, auth_as, patched_models, path):
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
def test_get_models_anthropic_format_when_header_present(client, auth_as, patched_models, path):
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."""
@ -123,7 +129,9 @@ def test_get_models_anthropic_format_when_header_present(client, auth_as, patche
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
def test_anthropic_format_exposes_token_limits(client, auth_as, patched_models, monkeypatch, path):
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."""
@ -138,7 +146,9 @@ def test_anthropic_format_exposes_token_limits(client, auth_as, patched_models,
"max_output_tokens": 64000,
}
monkeypatch.setattr(proxy_utils, "create_model_info_response", _create_model_info_response)
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"})
@ -213,7 +223,9 @@ def test_anthropic_format_uses_configured_display_name(client, auth_as, patched_
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
def test_anthropic_display_name_resolved_via_internal_team_key(client, auth_as, patched_models, monkeypatch, params):
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."""
@ -246,10 +258,14 @@ def test_anthropic_display_name_resolved_via_internal_team_key(client, auth_as,
"get_available_models_for_user",
_fake_get_available_models_for_user,
)
monkeypatch.setattr(model_checks, "get_complete_model_list", lambda **kwargs: [internal_name])
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"})
response = client.get(
"/v1/models", params=params, headers={"anthropic-version": "2023-06-01"}
)
assert response.status_code == 200
(entry,) = response.json()["data"]
@ -290,7 +306,9 @@ def test_get_model_by_id_not_found(client, auth_as, patched_models, path):
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
def test_anthropic_format_returns_public_team_model_name(client, auth_as, patched_models, monkeypatch, params):
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."""
@ -320,10 +338,14 @@ def test_anthropic_format_returns_public_team_model_name(client, auth_as, patche
"get_available_models_for_user",
_fake_get_available_models_for_user,
)
monkeypatch.setattr(model_checks, "get_complete_model_list", lambda **kwargs: [internal_name])
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"})
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"]
@ -354,9 +376,7 @@ def test_anthropic_format_lists_claude_code_view_ids_for_claude_code(
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()
)
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():
@ -376,9 +396,7 @@ def test_anthropic_format_lists_claude_code_view_ids_for_claude_code(
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"}
)
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"]

View file

@ -29,7 +29,7 @@ class _FakeRouter:
def get_model_group_info(self, model_group: str, team_id: str | None = None) -> ModelGroupInfo | None:
return self.infos.get(model_group)
def get_model_list_of_routed_group(self, model_group: str) -> list[DeploymentTypedDict]:
def get_model_list_of_routed_group(self, model_group: str, team_id: str | None = None) -> list[DeploymentTypedDict]:
return []

View file

@ -16,7 +16,7 @@ from litellm.router_utils.reasoning_effort_capability import (
resolve_supported_reasoning_efforts,
thinking_budget_effort,
)
from litellm.router_utils.reasoning_effort_strength import REASONING_EFFORT_STRENGTH_ORDER
from litellm.litellm_core_utils.reasoning_effort_strength import REASONING_EFFORT_STRENGTH_ORDER
class TestDeploymentIsCatalogMapped:

View file

@ -119,6 +119,17 @@ def test_explicit_base_works_without_remote_access(
assert "No changed litellm Python files" in checked.stdout
def test_format_check_reads_staged_renames(remote_and_clone: tuple[Path, Path]) -> None:
_, repo = remote_and_clone
(repo / "litellm" / "before_move.py").write_text("value = 2\n")
_commit(repo, "add module")
_git(repo, "mv", "litellm/before_move.py", "litellm/after_move.py")
checked: Final = _make(repo, "lint-format-check-changed", "BASE_REF=origin/release_branch", "UV_RUN=echo")
assert checked.returncode == 0, checked.stdout + checked.stderr
assert "litellm/after_move.py" in checked.stdout
assert "before_move.py" not in checked.stdout
def _freshness(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[