fix(proxy): send the whole Codex model catalog to Codex 0.166.0-alpha.1 and newer (#50736)

* fix(proxy): send the whole Codex model catalog to Codex 0.166.0-alpha.1 and newer

Codex removed its 1 MiB limit on model_catalog_url responses in openai/codex#56525,
first shipped in 0.166.0-alpha.1 (0.165.0 stable was cut without it). Older or unknown
Codex versions still get the catalog cut to 1 MiB, since they drop a larger one.

* fix(proxy): order patched Codex alphas like 0.166.0-alpha.5.1 when gating the catalog

Codex tags patch releases of an alpha as X.Y.Z-alpha.N.M, which PEP 440 rejects, so those
clients counted as too old for the uncut catalog, their custom reasoning levels and stock
entries. Map them to aN.postM so they sort between alpha.N and alpha.N+1 as semver does.

* test(proxy): type the Codex catalog byte-limit test

* refactor(proxy): gate the uncut Codex catalog on the 0.166 release line

Read only the major and minor numbers of client_version instead of a shared
semver comparison, and leave the reasoning level and stock entry version
checks as they are on main.

* refactor(proxy): read every Codex client_version with one helper

Codex sends semver tags such as 0.166.0-alpha.5.1 that the generic version
parser rejects. The catalog byte limit, the per-model minimum version and the
custom reasoning level checks now share one reader for Codex's format, which
also bounds digit counts so an absurd version can't fail the listing.
This commit is contained in:
ryan-crabbe-berri 2026-11-09 23:10:30 -08:00 • committed by GitHub
parent b3f75884a6
commit 87be092af7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 170 additions and 27 deletions

View file

@ -44,7 +44,6 @@ from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Literal, TypeAlias
from packaging.version import Version
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
@ -59,6 +58,7 @@ from litellm.proxy.common_utils.codex_model_translation import (
no_priced_deployment_lacks_tier,
tier_price_keys,
)
from litellm.proxy.common_utils.codex_version import codex_version_at_least
from litellm.types.llms.base_model import LiteLLMBaseModel
if TYPE_CHECKING:
@ -77,6 +77,7 @@ CODEX_BEDROCK_MODELS_PATHS: Final[Mapping[CodexBedrockEndpoint, Path]] = Mapping
}
)
CODEX_CATALOG_BYTE_LIMIT: Final = 1024 * 1024
_FIRST_CODEX_WITHOUT_CATALOG_BYTE_LIMIT: Final = "0.166.0-alpha.1"
CODEX_CHAT_MODES: Final = frozenset({"chat", "responses"})
_BODY_PREFIX: Final = '{"models":['
@ -179,9 +180,10 @@ def bundled_codex_models() -> Mapping[str, CodexStockModel]:
def _with_minimal_client_version(model: CodexStockModel, built_from: str | tuple[int, ...] | None) -> CodexStockModel:
source_minimum: Final = built_from if not isinstance(built_from, tuple) else ".".join(map(str, built_from))
minimum: Final = max(
(version for version in (CODEX_BEDROCK_MODELS_MINIMAL_CLIENT_VERSION, source_minimum) if version is not None),
key=Version,
minimum: Final = (
source_minimum
if codex_version_at_least(source_minimum, CODEX_BEDROCK_MODELS_MINIMAL_CLIENT_VERSION)
else CODEX_BEDROCK_MODELS_MINIMAL_CLIENT_VERSION
)
return model.model_copy(update={"minimal_client_version": minimum})
@ -396,10 +398,7 @@ def _readable_by(stock_model: CodexStockModel, client_version: str | None) -> bo
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
return codex_version_at_least(client_version, minimum if isinstance(minimum, str) else ".".join(map(str, minimum)))
def _tier_fields(stock_model: CodexStockModel, tiers: tuple[CodexServiceTier, ...]) -> Mapping[str, object]:
@ -601,6 +600,16 @@ class CodexCatalogBody:
left_out: tuple[str, ...]
def codex_catalog_byte_limit(client_version: str | None) -> int | None:
"""None, no limit, for a Codex that reads a catalog of any size since https://github.com/openai/codex/pull/56525.
An older Codex, or one sending no version, drops a catalog over 1 MiB. 0.165.0 still has the limit."""
return (
None
if codex_version_at_least(client_version, _FIRST_CODEX_WITHOUT_CATALOG_BYTE_LIMIT)
else CODEX_CATALOG_BYTE_LIMIT
)
def codex_models_response_json(
rows: Sequence[CodexCatalogRow],
*,
@ -659,7 +668,7 @@ def merged_codex_catalog(
*,
stock: Mapping[str, CodexStockModel] | None = None,
instructions: str | None = None,
byte_limit: int = CODEX_CATALOG_BYTE_LIMIT,
byte_limit: int | None = CODEX_CATALOG_BYTE_LIMIT,
client_version: str | None = None,
) -> str | None:
try:
@ -673,7 +682,7 @@ def merged_codex_catalog(
entries: Final = _entries(added, stock, instructions, first_priority, _NATIVE_ALIAS_DEFAULTS, client_version)
added_models: Final = tuple(_entry_json(entry) for entry in entries)
serialized: Final = tuple(_compact_json(model) for model in added_models)
budget: Final = byte_limit - len(_compact_json({**top, "models": upstream}))
budget: Final = None if byte_limit is None else byte_limit - len(_compact_json({**top, "models": upstream}))
kept: Final = tuple(sorted(_kept_positions(entries, serialized, budget)))
if len(kept) < len(entries):
verbose_proxy_logger.warning(
@ -819,17 +828,19 @@ def codex_model_list_body(
) -> str:
"""The `/v1/models?client_version=...` body, logging the models Codex's byte limit left out. `stock` and
`instructions` are the requesting Codex release's catalog and prompt, the bundled ones when None."""
byte_limit: Final = codex_catalog_byte_limit(client_version)
body: Final = codex_models_response_json(
codex_catalog_rows(rows, entries, llm_router, team_id, label_entries),
stock=stock,
instructions=instructions,
byte_limit=byte_limit,
client_version=client_version,
)
if body.left_out:
verbose_proxy_logger.warning(
"Codex model catalog cut at %d bytes, left out: %s. Models offering a service tier are kept first, "
"then model_list order; list the models Codex users need first in model_list",
CODEX_CATALOG_BYTE_LIMIT,
byte_limit,
", ".join(body.left_out),
)
return body.json

View file

@ -15,10 +15,10 @@ from functools import cache
from types import NoneType
from typing import Annotated, Final, Literal, TypeAlias
from packaging.version import Version
from pydantic import ConfigDict, Field, StringConstraints, field_serializer
from litellm.litellm_core_utils.reasoning_effort_strength import nearest_declared_reasoning_effort
from litellm.proxy.common_utils.codex_version import codex_version_at_least
from litellm.types.llms.base_model import LiteLLMBaseModel
TierId: TypeAlias = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
@ -27,7 +27,7 @@ CodexInputModality: TypeAlias = Literal["text", "image"]
_TEXT_ONLY: Final[tuple[CodexInputModality, ...]] = ("text",)
_TEXT_AND_IMAGE: Final[tuple[CodexInputModality, ...]] = ("text", "image")
TOKEN_PRICE_KEY: Final = "input_cost_per_token"
_FIRST_CODEX_WITH_CUSTOM_EFFORTS: Final = Version("0.138.0")
_FIRST_CODEX_WITH_CUSTOM_EFFORTS: Final = "0.138.0"
_EFFORTS_EVERY_CODEX_PARSES: Final = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
_PREFERRED_DEFAULT_EFFORT: Final = "medium"
_CODEX_UNSET_EFFORT: Final = "none"
@ -191,13 +191,6 @@ def combined_capabilities(
)
def _parses_custom_efforts(client_version: str | None) -> bool:
try:
return client_version is not None and Version(client_version) >= _FIRST_CODEX_WITH_CUSTOM_EFFORTS
except ValueError:
return False
def codex_reasoning_levels(
efforts: Sequence[str], client_version: str | None
) -> tuple[tuple[CodexFallbackReasoningLevel, ...], str | None]:
@ -209,7 +202,7 @@ def codex_reasoning_levels(
group accepts it, so a user who picks no level sends what Codex sent before, else medium, else the
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)
parses_any: Final = codex_version_at_least(client_version, _FIRST_CODEX_WITH_CUSTOM_EFFORTS)
offered: Final = tuple(effort for effort in efforts if parses_any or effort in _EFFORTS_EVERY_CODEX_PARSES)
if not offered:
return (), None

View file

@ -0,0 +1,24 @@
"""Compare Codex `client_version` strings, which are semver tags such as `0.166.0`, `0.166.0-alpha.5` and the
patch build `0.166.0-alpha.5.1`."""
from __future__ import annotations
import re
from typing import Final
_CODEX_VERSION: Final = re.compile(r"(\d{1,9})\.(\d{1,9})\.(\d{1,9})(?:-alpha\.(\d{1,9})(?:\.(\d{1,9}))?)?")
def _sort_key(version: str) -> tuple[int, int, int, bool, int, int] | None:
match: Final = _CODEX_VERSION.fullmatch(version)
if match is None:
return None
major, minor, patch, alpha, alpha_patch = match.groups()
return (int(major), int(minor), int(patch), alpha is None, int(alpha or 0), int(alpha_patch or 0))
def codex_version_at_least(client_version: str | None, first: str) -> bool:
"""Whether `client_version` is `first` or newer. A missing or unreadable version counts as older."""
current: Final = None if client_version is None else _sort_key(client_version)
threshold: Final = _sort_key(first)
return current is not None and threshold is not None and current >= threshold

View file

@ -32,6 +32,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.codex_model_catalog import (
CodexCatalogRow,
bundled_codex_models,
codex_catalog_byte_limit,
codex_catalog_etag,
codex_entry_names_default_reasoning_level,
merged_codex_catalog,
@ -685,8 +686,13 @@ async def _catalog(
etag: Final = None if upstream_etag is None else await _remembered_models_etag(caller, upstream_etag, rows, cache)
if not rows:
return upstream
client_version: Final = request.query_params.get("client_version")
merged: Final = await asyncio.to_thread(
merged_codex_catalog, upstream_body, rows, client_version=request.query_params.get("client_version")
merged_codex_catalog,
upstream_body,
rows,
byte_limit=codex_catalog_byte_limit(client_version),
client_version=client_version,
)
return _with_etag(upstream, merged if merged is not None else upstream_body, etag)

View file

@ -37,6 +37,7 @@ pytestmark: Final = pytest.mark.shares_worker_state
T = TypeVar("T")
CLIENT_VERSION: Final = "0.166.0"
BYTE_LIMITED_CODEX_PARAMS: Final = {"client_version": "0.165.0"}
CONSISTENT_READS: Final = 6
CONVERGENCE_SECONDS: Final = 60
RELOAD_MARGIN_SECONDS: Final = 5.0
@ -513,10 +514,10 @@ def test_wildcard_deployment_never_listed(gateway: Gateway) -> None:
def test_a_tiered_model_listed_last_survives_the_byte_cut(gateway: Gateway) -> None:
"""Codex 0.159.3 rejects a `model_catalog_url` body over `MAX_MODEL_CATALOG_BYTES` (1 MiB,
`codex-rs/model-provider/src/models_endpoint.rs`, read on 2026-10-03), so the proxy keeps the
"""Codex up to 0.165.0 rejects a `model_catalog_url` body over `MAX_MODEL_CATALOG_BYTES` (1 MiB,
`codex-rs/model-provider/src/models_endpoint.rs`, removed by openai/codex#56525), so for it the proxy keeps the
entries offering a tier first and then listing order; fallback entries carry Codex's base
prompt, so some fifty of them overrun the limit."""
prompt, so some fifty of them overrun the limit. A newer Codex gets every entry."""
with gateway.scenario() as scenario:
plain: Final = tuple(scenario.model() for _ in range(52))
tiered: Final = scenario.model(model_info={"service_tiers": ["ultrafast"]})
@ -526,7 +527,7 @@ def test_a_tiered_model_listed_last_survives_the_byte_cut(gateway: Gateway) -> N
)
assert listing.index(tiered) == len(plain), listing
response: Final = eventually(
lambda: _catalog_response(gateway, key=key),
lambda: _catalog_response(gateway, key=key, params=BYTE_LIMITED_CODEX_PARAMS),
lambda value: value.status_code == 200 and tiered in {entry["slug"] for entry in _entries(value)},
seconds=CONVERGENCE_SECONDS,
)
@ -538,6 +539,9 @@ def test_a_tiered_model_listed_last_survives_the_byte_cut(gateway: Gateway) -> N
assert slugs[-1] == tiered and _tier_ids(entries[-1]) == ("ultrafast",), entries[-1]
assert slugs[:-1] == list(plain[: len(slugs) - 1]), slugs
assert [entry["priority"] for entry in entries] == [listing.index(slug) for slug in slugs], slugs
whole: Final = _catalog_response(gateway, key=key)
assert len(whole.content) > 1024 * 1024, len(whole.content)
assert [string_value(entry["slug"]) for entry in _entries(whole)] == listing
def test_an_oversized_tiered_model_is_left_out_alone(gateway: Gateway) -> None:
@ -557,7 +561,7 @@ def test_an_oversized_tiered_model_is_left_out_alone(gateway: Gateway) -> None:
assert listing == [plain, oversized, tiered], listing
def kept() -> tuple[int, tuple[tuple[JsonValue, JsonValue, tuple[str, ...]], ...]]:
response: Final = _catalog_response(gateway, key=key)
response: Final = _catalog_response(gateway, key=key, params=BYTE_LIMITED_CODEX_PARAMS)
return len(response.content), tuple(
(entry["slug"], entry["priority"], _tier_ids(entry)) for entry in _entries(response)
)

View file

@ -31,6 +31,7 @@ from litellm.proxy.common_utils.codex_model_catalog import (
merged_codex_catalog,
)
from litellm.proxy.common_utils.codex_model_translation import CodexDeploymentCapabilities, CodexServiceTier
from litellm.types.proxy.model_listing import ModelInfoResponse
_PROMPT = "base prompt"
_FAST = {"id": "priority", "name": "Fast", "description": "2x speed, increased usage"}
@ -193,6 +194,7 @@ def test_a_model_codex_knows_keeps_its_own_reasoning_levels(levels):
[
("0.153.0", "GPT-6.1 Terra", ["low", "max"]),
("0.166.0", "GPT-6.1 Terra", ["low", "max"]),
("0.166.0-alpha.5.1", "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"]),
@ -782,6 +784,43 @@ def test_unlimited_body_keeps_every_entry():
assert CODEX_CATALOG_BYTE_LIMIT == 1024 * 1024
_LIMITED_CODEX: Final = "a codex that drops a catalog over 1 MiB gets one cut to fit"
_UNLIMITED_CODEX: Final = "a codex that reads any size gets every model"
@pytest.mark.parametrize(
("client_version", "cut"),
[
pytest.param("0.166.0-alpha.1", False, id=f"0.166.0-alpha.1, {_UNLIMITED_CODEX}"),
pytest.param("0.166.0", False, id=f"0.166.0, {_UNLIMITED_CODEX}"),
pytest.param("0.169.0-alpha.7", False, id=f"0.169.0-alpha.7, {_UNLIMITED_CODEX}"),
pytest.param("0.166.0-alpha.5.1", False, id=f"0.166.0-alpha.5.1, {_UNLIMITED_CODEX}"),
pytest.param("0.165.0", True, id=f"0.165.0, {_LIMITED_CODEX}"),
pytest.param("0.165.0-alpha.1.2", True, id=f"0.165.0-alpha.1.2, {_LIMITED_CODEX}"),
pytest.param("0.164.0", True, id=f"0.164.0, {_LIMITED_CODEX}"),
pytest.param(None, True, id=f"no version, {_LIMITED_CODEX}"),
pytest.param("not-a-version", True, id=f"unparseable version, {_LIMITED_CODEX}"),
pytest.param("9" * 5000 + ".0.0", True, id=f"absurdly long version, {_LIMITED_CODEX}"),
],
)
def test_model_list_body_is_cut_to_1_mib_only_for_a_codex_that_drops_a_larger_catalog(
client_version: str | None, cut: bool
) -> None:
names: Final = tuple(f"model-{index}" for index in range(60))
listing: Final[tuple[ModelInfoResponse, ...]] = tuple(
{"id": name, "object": "model", "created": 0, "owned_by": "openai", "mode": "chat"} for name in names
)
body: Final = codex_model_list_body(
listing, tuple((name, name) for name in names), None, client_version=client_version, instructions="x" * 20_000
)
slugs: Final[list[str]] = [entry["slug"] for entry in json.loads(body)["models"]]
assert slugs == list(names[: len(slugs)])
assert (len(slugs) < len(names)) is cut
assert (len(body.encode()) <= CODEX_CATALOG_BYTE_LIMIT) is cut
def test_vendored_codex_catalog_entries_decode_on_codex():
"""Every stock entry served keeps the `model_messages` Codex's `ModelsResponse` decoder
needs in place of `base_instructions`, and the catalog is read from the vendored file."""

View file

@ -0,0 +1,39 @@
from __future__ import annotations
import pytest
from litellm.proxy.common_utils.codex_version import codex_version_at_least
@pytest.mark.parametrize(
("client_version", "first", "at_least"),
[
("0.166.0", "0.166.0", True),
("0.166.1", "0.166.0", True),
("0.167.0", "0.166.9", True),
("1.0.0", "0.999.0", True),
("0.165.9", "0.166.0", False),
("0.166.0-alpha.1", "0.166.0", False),
("0.166.0", "0.166.0-alpha.9", True),
("0.166.0-alpha.2", "0.166.0-alpha.1", True),
("0.166.0-alpha.10", "0.166.0-alpha.9", True),
("0.166.0-alpha.5.1", "0.166.0-alpha.5", True),
("0.166.0-alpha.5.1", "0.166.0-alpha.6", False),
("0.166.0-alpha.5.2", "0.166.0-alpha.5.1", True),
("0.165.0", "0.165.0-alpha.2", True),
("0.165.0-alpha.1.2", "0.166.0-alpha.1", False),
],
)
def test_codex_versions_order_as_semver(client_version: str, first: str, at_least: bool) -> None:
assert codex_version_at_least(client_version, first) is at_least
@pytest.mark.parametrize(
"client_version", [None, "", "dev", "0.166", "v0.166.0", "0.166.0-beta.1", "9" * 5000 + ".0.0"]
)
def test_a_missing_or_unreadable_version_counts_as_older(client_version: str | None) -> None:
assert codex_version_at_least(client_version, "0.0.0") is False
def test_an_unreadable_threshold_admits_no_version() -> None:
assert codex_version_at_least("0.166.0", "latest") is False

View file

@ -324,6 +324,33 @@ def test_native_catalog_offers_the_declared_reasoning_efforts_the_requesting_cod
assert [level["effort"] for level in added["supported_reasoning_levels"]] == expected_efforts
@pytest.mark.parametrize(
("query", "cut"),
[
pytest.param("?client_version=0.166.0-alpha.1", False, id="first codex that reads any size gets every model"),
pytest.param("?client_version=0.166.0", False, id="newer codex gets every model"),
pytest.param("?client_version=0.165.0", True, id="last codex with the 1 MiB limit gets a cut catalog"),
pytest.param("", True, id="no version gets a cut catalog"),
],
)
def test_native_catalog_is_cut_to_1_mib_only_for_a_codex_that_drops_a_larger_catalog(
client: TestClient, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, query: str, cut: bool
) -> None:
names: Final = tuple(f"open-weights-{index}" for index in range(60))
monkeypatch.setattr(proxy_server, "llm_router", Router(model_list=[_open_weights(name) for name in names]))
respx_mock.get(f"{_BASE}/models").respond(json=_UPSTREAM_CATALOG)
response: Final = client.get(f"/backend-api/codex/models{query}", headers=_HEADERS)
models: Final = response.json()["models"]
added: Final = [model["slug"] for model in models[len(_UPSTREAM_CATALOG["models"]) :]]
assert response.status_code == 200
assert models[: len(_UPSTREAM_CATALOG["models"])] == _UPSTREAM_CATALOG["models"]
assert added == list(names[: len(added)])
assert (len(added) < len(names)) is cut
assert (len(response.content) <= 1024 * 1024) is cut
@pytest.mark.parametrize(
"upstream",
[