diff --git a/litellm/proxy/common_utils/codex_model_catalog.py b/litellm/proxy/common_utils/codex_model_catalog.py index 12db7fabb76..462e372f3d8 100644 --- a/litellm/proxy/common_utils/codex_model_catalog.py +++ b/litellm/proxy/common_utils/codex_model_catalog.py @@ -32,6 +32,7 @@ from pathlib import Path from types import MappingProxyType from typing import TYPE_CHECKING, Final, Literal, TypeAlias +from packaging.version import Version from pydantic import ConfigDict, TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger @@ -109,6 +110,7 @@ class CodexStockModel(LiteLLMBaseModel): supported_reasoning_levels: tuple[CodexReasoningLevel, ...] = () context_window: int | None = None max_context_window: int | None = None + minimal_client_version: str | tuple[int, ...] | None = None class CodexStockCatalog(LiteLLMBaseModel): @@ -278,6 +280,19 @@ def _stock_for(row: CodexCatalogRow, stock: Mapping[str, CodexStockModel]) -> Co return next((stock[name] for name in names if name in stock), None) +def _readable_by(stock_model: CodexStockModel, client_version: str | None) -> bool: + """Whether Codex's own server would hand this entry to the requesting release: one at or past the entry's + `minimal_client_version`. An older release can fail to decode it, and one entry it cannot decode (a reasoning + effort it has no name for) fails the whole catalog, so it gets a translated entry. No version keeps the entry.""" + minimum: Final = stock_model.minimal_client_version + if minimum is None or client_version is None: + return True + try: + return Version(client_version) >= Version(minimum if isinstance(minimum, str) else ".".join(map(str, minimum))) + except ValueError: + return False + + def _tier_fields(stock_model: CodexStockModel, tiers: tuple[CodexServiceTier, ...]) -> Mapping[str, object]: """The tier fields of an entry offering `tiers`. Codex turns `/fast` on off a speed tier as much as off a service tier, so a speed tier stays only while a service tier billed the same way is still offered, and the @@ -370,7 +385,8 @@ def _entry( alias_defaults: CodexAliasDefaults, client_version: str | None, ) -> CodexCatalogEntry: - stock_model: Final = _stock_for(row, stock) + matched: Final = _stock_for(row, stock) + stock_model: Final = matched if matched is not None and _readable_by(matched, client_version) else None stock_tiers: Final = MappingProxyType( {tier.id: tier for tier in (stock_model.service_tiers if stock_model is not None else ())} ) diff --git a/tests/unit/proxy/common_utils/test_codex_model_catalog.py b/tests/unit/proxy/common_utils/test_codex_model_catalog.py index 69509bed060..760f79f4ec2 100644 --- a/tests/unit/proxy/common_utils/test_codex_model_catalog.py +++ b/tests/unit/proxy/common_utils/test_codex_model_catalog.py @@ -180,6 +180,51 @@ def test_a_model_codex_knows_keeps_its_own_reasoning_levels(levels): assert (entry["default_reasoning_level"], entry["supported_reasoning_levels"]) == ("medium", levels) +@pytest.mark.parametrize("minimum", ["0.153.0", [0, 153, 0]], ids=["string", "parts"]) +@pytest.mark.parametrize( + ("client_version", "display_name", "levels"), + [ + ("0.153.0", "GPT-6.1 Terra", ["low", "max"]), + ("0.166.0", "GPT-6.1 Terra", ["low", "max"]), + (None, "GPT-6.1 Terra", ["low", "max"]), + ("0.152.9", "gpt-6.1-terra", ["low", "max"]), + ("0.137.0", "gpt-6.1-terra", ["low"]), + ("not-a-version", "gpt-6.1-terra", ["low"]), + ], +) +def test_a_codex_older_than_an_entrys_minimal_version_gets_an_entry_it_can_decode( + minimum, client_version, display_name, levels +): + """Codex 0.137 rejects the whole catalog on an effort it has no name for, such as the `max` Codex's own + gpt-6.1-terra entry offers, so a release older than the entry's `minimal_client_version` gets the translated + entry, whose levels are trimmed to that release.""" + stock = CodexStockModel.model_validate( + { + "slug": "gpt-6.1-terra", + "display_name": "GPT-6.1 Terra", + "priority": 3, + "visibility": "list", + "minimal_client_version": minimum, + "default_reasoning_level": "low", + "supported_reasoning_levels": [{"effort": "low"}, {"effort": "max"}], + } + ) + + (entry,) = json.loads( + codex_models_response_json( + (_row("gpt-6.1-terra", supported_reasoning_efforts=("low", "max")),), + stock={"gpt-6.1-terra": stock}, + instructions=_PROMPT, + client_version=client_version, + ).json + )["models"] + + assert (entry["display_name"], [level["effort"] for level in entry["supported_reasoning_levels"]]) == ( + display_name, + levels, + ) + + def test_known_model_keeps_codex_stock_entry_listed_under_the_served_id(): (entry,) = _models(_row("gpt-daybreak"))