From 5bd47442fb1f1d237b1483dfcc9cd919ec1414c8 Mon Sep 17 00:00:00 2001 From: tessellark <349031000+tessellark@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:23:55 -0700 Subject: [PATCH] fix(cli): give lite codex aliases of a Codex-known model that model's catalog entry (#48894) * fix(cli): give lite codex aliases of a Codex-known model that model's catalog entry lite codex built its Codex model catalog from /v1/models ids only, so a proxy alias of a model Codex knows (api-gpt-6-luna for openai/gpt-6-luna) got the fallback entry. That entry has no supports_search_tool or tool_mode, so Codex sent every MCP and app tool inline and each turn cost far more input tokens. lite now reads each listed model's upstream from the proxy's /v1/model/info (team_public_model_name when set, else model_name, first deployment wins) and passes it to the shared catalog builder, the same way the proxy's own Codex route does. If that request fails, the catalog is built as before. * fix(cli): give a lite codex alias an upstream only when all its deployments route to one OpenAI model * fix(cli): cap a lite codex alias's context window at its listed max_input_tokens and count only OpenAI-hosted deployments * fix(cli): keep Codex's tier, upgrade and reasoning defaults off lite codex aliases * fix(cli): keep Codex's own effort, verbosity and promo off lite codex aliases and skip credential-hosted deployments --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/proxy/client/cli/commands/agents.py | 156 ++++++++++++- .../proxy/common_utils/codex_model_catalog.py | 62 +++++- tests/unit/proxy/client/cli/test_agents.py | 206 +++++++++++++++++- .../common_utils/test_codex_model_catalog.py | 78 +++++++ 4 files changed, 483 insertions(+), 19 deletions(-) diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index 4d09e4d1e0f..f19fb508c7d 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -5,16 +5,18 @@ import shutil import subprocess import sys import tempfile -from collections.abc import Callable, Mapping, Sequence +from collections import Counter +from collections.abc import Callable, Hashable, Iterable, Mapping, Sequence from dataclasses import dataclass from itertools import chain from pathlib import Path from types import MappingProxyType from typing import Final, TypeAlias, TypeVar +from urllib.parse import urlsplit import click import requests -from pydantic import JsonValue, TypeAdapter, ValidationError +from pydantic import ConfigDict, JsonValue, TypeAdapter, ValidationError from litellm.proxy.common_utils.codex_model_catalog import ( CODEX_BASE_INSTRUCTIONS_PATH, @@ -497,6 +499,113 @@ def _fetch_model_listing( return listing.data +class _DeploymentParams(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + model: str + custom_llm_provider: str | None = None + api_base: str | None = None + litellm_credential_name: str | None = None + reasoning_effort: JsonValue = None + + +class _DeploymentInfo(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + team_public_model_name: str | None = None + blocked: bool | None = None + + +class _Deployment(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + model_name: str + litellm_params: _DeploymentParams + model_info: _DeploymentInfo | None = None + + +class _DeploymentListing(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + data: tuple[_Deployment, ...] + + +@dataclass(frozen=True, slots=True) +class ModelUpstream: + """The OpenAI model every deployment behind a public name routes to, and the reasoning effort they all pin.""" + + model: str + reasoning_effort: str | None = None + + +_NO_UPSTREAMS: Final[Mapping[str, ModelUpstream]] = MappingProxyType({}) +_Pinned: Final = TypeVar("_Pinned", bound=Hashable) + + +def _public_name(deployment: _Deployment) -> str: + team_name: Final = deployment.model_info.team_public_model_name if deployment.model_info is not None else None + return team_name or deployment.model_name + + +def _is_paused(deployment: _Deployment) -> bool: + return deployment.model_info is not None and deployment.model_info.blocked is True + + +def _is_openai_host(params: _DeploymentParams) -> bool: + if params.api_base is None: + return not params.litellm_credential_name + host: Final = urlsplit(params.api_base).hostname or "" + return host == "api.openai.com" or host.endswith(".api.openai.com") + + +def _openai_model(params: _DeploymentParams) -> str | None: + if params.custom_llm_provider not in (None, "openai") or not _is_openai_host(params): + return None + provider, slash, name = params.model.partition("/") + if not slash: + return params.model + return name if provider == "openai" else None + + +def _pinned_effort(params: _DeploymentParams) -> str | None: + return params.reasoning_effort if isinstance(params.reasoning_effort, str) else None + + +def _shared_by_every_deployment(values: Iterable[tuple[str, _Pinned]]) -> Mapping[str, _Pinned]: + distinct: Final = frozenset(values) + values_per_name: Final = Counter(name for name, _ in distinct) + return MappingProxyType({name: value for name, value in distinct if values_per_name[name] == 1}) + + +def _upstreams_by_public_name(deployments: Sequence[_Deployment]) -> Mapping[str, ModelUpstream]: + live: Final = tuple(d for d in deployments if not _is_paused(d)) + models: Final = _shared_by_every_deployment((_public_name(d), _openai_model(d.litellm_params)) for d in live) + efforts: Final = _shared_by_every_deployment((_public_name(d), _pinned_effort(d.litellm_params)) for d in live) + return MappingProxyType( + {name: ModelUpstream(model, efforts.get(name)) for name, model in models.items() if model is not None} + ) + + +def _fetch_model_upstreams( + base_url: str, + api_key: str, + *, + get: Callable[..., requests.Response], +) -> Mapping[str, ModelUpstream]: + url: Final = base_url.rstrip("/") + "/v1/model/info" + try: + resp: Final = get(url, headers=MappingProxyType({"Authorization": f"Bearer {api_key}"}), timeout=10) + except requests.RequestException: + return _NO_UPSTREAMS + if resp.status_code != 200: + return _NO_UPSTREAMS + try: + listing: Final = _DeploymentListing.model_validate_json(resp.content) + except ValidationError: + return _NO_UPSTREAMS + return _upstreams_by_public_name(listing.data) + + class _OpenCodeLimit(LiteLLMBaseModel): context: int output: int @@ -576,8 +685,21 @@ def opencode_model_sync_env( return MappingProxyType({OPENCODE_CONFIG_CONTENT_ENV: opencode_provider_config(base_url, listing)}) +def _codex_row(model: ListedModel, upstream: ModelUpstream | None) -> CodexCatalogRow: + return CodexCatalogRow( + id=model.id, + mode=model.mode, + max_input_tokens=model.max_input_tokens, + upstream_model=upstream.model if upstream is not None else None, + reasoning_effort=upstream.reasoning_effort if upstream is not None else None, + ) + + def codex_model_catalog( - models: Sequence[ListedModel], stock: Sequence[CodexStockModel], instructions: str + models: Sequence[ListedModel], + stock: Sequence[CodexStockModel], + instructions: str, + upstreams: Mapping[str, ModelUpstream], ) -> str | None: """The `model_catalog_json` body listing the proxy's chat models, or None if there are none. @@ -586,14 +708,23 @@ def codex_model_catalog( installed Codex knows keeps that Codex's own entry and the proxy only decides its place in the picker: the listing orders it, lists it even when Codex hides it or keeps it off the API, and keeps Codex's upgrade nudge only - when the model it points at is served too. A model Codex does not know gets the fallback + when the model it points at is served too. An alias of such a model, per + `upstreams`, takes that entry too, minus the defaults Codex picks for the + model: no service tier or upgrade nudge, the reasoning effort its + deployments pin, and a window no larger than its listed max_input_tokens. + A model Codex does not know gets the fallback entry, with the same base instructions Codex itself uses so the agent never - runs without a system prompt. The catalog is the one `/v1/models?client_version=...` - serves, built from the installed Codex's own entries instead of the proxy's vendored ones. + runs without a system prompt. Apart from aliases, the catalog is the one + `/v1/models?client_version=...` serves, built from the installed Codex's own + entries instead of the proxy's vendored ones. """ - rows: Final = tuple(CodexCatalogRow(id=m.id, mode=m.mode, max_input_tokens=m.max_input_tokens) for m in models) + rows: Final = tuple(_codex_row(m, upstreams.get(m.id)) for m in models) body: Final = codex_models_response_json( - rows, stock=MappingProxyType({m.slug: m for m in stock}), instructions=instructions, byte_limit=None + rows, + stock=MappingProxyType({m.slug: m for m in stock}), + instructions=instructions, + byte_limit=None, + alias_defaults="deployment", ) return body.json if body.listed else None @@ -678,12 +809,14 @@ def codex_model_sync_args( Codex has no env or inline equivalent of OPENCODE_CONFIG_CONTENT: the catalog must be a file, so it is written under $CODEX_HOME (default ~/.codex) and atomically replaced on every launch. The Codex at `binary` first lists its - own models, so the ones the proxy serves keep that Codex's entries, and then + own models, so the ones the proxy serves keep that Codex's entries, an alias + included when every unpaused deployment /v1/model/info lists for it routes + to the same OpenAI model and Codex knows that model, and then reads the file back once before it is handed over. The key never lands in the file. A failed fetch, read, listing, write or read-back is reported rather than raised: Codex still launches with its built-in catalog and takes a proxy model by name via -m, and a rejected file stays on disk to be looked - at. + at. An unavailable /v1/model/info only leaves every alias on the fallback entry. """ listing: Final = _fetch_model_listing(base_url, api_key, get=get) if isinstance(listing, ModelSyncSkipped): @@ -700,7 +833,8 @@ def codex_model_sync_args( stock: Final = _stock_codex_models(binary, base_env, run=run) if isinstance(stock, ModelSyncSkipped): return stock - catalog: Final = codex_model_catalog(listing, stock, instructions) + upstreams: Final = _fetch_model_upstreams(base_url, api_key, get=get) + catalog: Final = codex_model_catalog(listing, stock, instructions, upstreams) if catalog is None: return ModelSyncSkipped(f"{base_url.rstrip('/')}/v1/models lists no chat models") try: diff --git a/litellm/proxy/common_utils/codex_model_catalog.py b/litellm/proxy/common_utils/codex_model_catalog.py index 9b42f99305e..397523288cf 100644 --- a/litellm/proxy/common_utils/codex_model_catalog.py +++ b/litellm/proxy/common_utils/codex_model_catalog.py @@ -24,7 +24,7 @@ from functools import cache from itertools import accumulate, pairwise from pathlib import Path from types import MappingProxyType, NoneType -from typing import TYPE_CHECKING, Annotated, Final, Literal +from typing import TYPE_CHECKING, Annotated, Final, Literal, TypeAlias from pydantic import ConfigDict, StringConstraints, TypeAdapter, ValidationError @@ -110,6 +110,12 @@ class CodexStockUpgrade(LiteLLMBaseModel): model: str +class CodexReasoningLevel(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True, extra="allow") + + effort: str + + class CodexStockModel(LiteLLMBaseModel): """One `ModelInfo` entry as Codex ships it or prints it from `codex debug models`. @@ -128,6 +134,8 @@ class CodexStockModel(LiteLLMBaseModel): upgrade: CodexStockUpgrade | None = None service_tiers: tuple[CodexServiceTier, ...] = () default_service_tier: str | None = None + supported_reasoning_levels: tuple[CodexReasoningLevel, ...] = () + context_window: int | None = None class CodexStockCatalog(LiteLLMBaseModel): @@ -135,19 +143,21 @@ class CodexStockCatalog(LiteLLMBaseModel): CodexCatalogEntry = CodexFallbackModel | CodexStockModel +CodexAliasDefaults: TypeAlias = Literal["codex", "deployment"] @dataclass(frozen=True, slots=True) class CodexCatalogRow: """What the catalog needs to know about one listed model: its public id, the listing's mode and - input limit, the upstream model behind it, and the `model_info` values the operator set; - `service_tiers` holds one raw `model_info.service_tiers` value per deployment behind the id, None - where a deployment sets none.""" + input limit, the upstream model behind it, the reasoning effort its deployments pin, and the + `model_info` values the operator set; `service_tiers` holds one raw `model_info.service_tiers` + value per deployment behind the id, None where a deployment sets none.""" id: str mode: str | None = None max_input_tokens: int | None = None upstream_model: str | None = None + reasoning_effort: str | None = None display_name: str | None = None service_tiers: tuple[object, ...] = () @@ -254,12 +264,42 @@ def _stock_for(row: CodexCatalogRow, stock: Mapping[str, CodexStockModel]) -> Co return upstream if upstream is not None else stock.get(row.upstream_model.partition("/")[2]) +def _alias_context_window(row: CodexCatalogRow, stock_model: CodexStockModel) -> Mapping[str, int]: + if row.max_input_tokens is None or stock_model.context_window is None: + return MappingProxyType({}) + return MappingProxyType({"context_window": min(stock_model.context_window, row.max_input_tokens)}) + + +def _alias_reasoning_level(row: CodexCatalogRow, stock_model: CodexStockModel) -> str | None: + efforts: Final = frozenset(level.effort for level in stock_model.supported_reasoning_levels) + return row.reasoning_effort if row.reasoning_effort in efforts else None + + +def _deployment_defaults(row: CodexCatalogRow, stock_model: CodexStockModel) -> Mapping[str, object]: + """What an alias's deployments decide in place of the defaults Codex picks for the model they route to: + no service tier, verbosity, upgrade nudge or startup promo of Codex's own, so what the deployment + pins (or OpenAI's API default) stays in force, the pinned reasoning effort when Codex offers it and + none otherwise, and a window no larger than the alias's own limit.""" + return MappingProxyType( + { + "service_tiers": (), + "default_service_tier": None, + "default_verbosity": None, + "upgrade": None, + "availability_nux": None, + "default_reasoning_level": _alias_reasoning_level(row, stock_model), + **_alias_context_window(row, stock_model), + } + ) + + def _entry( index: int, row: CodexCatalogRow, stock: Mapping[str, CodexStockModel], served: frozenset[str], instructions: str, + alias_defaults: CodexAliasDefaults, ) -> CodexCatalogEntry: stock_model: Final = _stock_for(row, stock) stock_tiers: Final = MappingProxyType( @@ -278,7 +318,8 @@ def _entry( upgrade: Final = ( stock_model.upgrade if stock_model.upgrade is not None and stock_model.upgrade.model in served else None ) - stock_name: Final = stock_model.display_name if row.id == stock_model.slug else row.id + is_alias: Final = row.id != stock_model.slug + stock_name: Final = row.id if is_alias else stock_model.display_name listing: Final = { "slug": row.id, "display_name": row.display_name if row.display_name is not None else stock_name, @@ -286,6 +327,7 @@ def _entry( "visibility": "list", "supported_in_api": True, "upgrade": upgrade, + **(_deployment_defaults(row, stock_model) if is_alias and alias_defaults == "deployment" else {}), } if configured is None: return stock_model.model_copy(update=listing) @@ -351,6 +393,7 @@ def codex_models_response_json( stock: Mapping[str, CodexStockModel] | None = None, instructions: str | None = None, byte_limit: int | None = CODEX_CATALOG_BYTE_LIMIT, + alias_defaults: CodexAliasDefaults = "codex", ) -> CodexCatalogBody: """Codex's `ModelsResponse` body for `rows`, in listing order, cut to Codex's byte limit. @@ -359,13 +402,18 @@ def codex_models_response_json( byte limit silently and keeps its bundled one, so the body holds only the entries that fit, the ones offering a service tier taken first and the rest in listing order, an entry too large for the bytes left passed over, every kept entry at its listing position; `byte_limit=None` - keeps every entry. + keeps every entry. A row listed under another name than the Codex model its `upstream_model` + names (an alias) takes that model's entry; `alias_defaults="deployment"` drops the defaults + Codex picks for that model in favor of what the alias's deployments pin, while `"codex"` keeps + them, as Codex would use them for the model's own name. """ known: Final = bundled_codex_models() if stock is None else stock prompt: Final = codex_base_instructions() if instructions is None else instructions drivable: Final = tuple(row for row in rows if _codex_can_drive(row)) served: Final = frozenset(row.id for row in drivable) - entries: Final = tuple(_entry(index, row, known, served, prompt) for index, row in enumerate(drivable)) + entries: Final = tuple( + _entry(index, row, known, served, prompt, alias_defaults) for index, row in enumerate(drivable) + ) serialized: Final = tuple(_serialized(entry) for entry in entries) kept: Final = _kept_positions(entries, serialized, byte_limit) return CodexCatalogBody( diff --git a/tests/unit/proxy/client/cli/test_agents.py b/tests/unit/proxy/client/cli/test_agents.py index 3c79b916202..3716162692d 100644 --- a/tests/unit/proxy/client/cli/test_agents.py +++ b/tests/unit/proxy/client/cli/test_agents.py @@ -92,6 +92,8 @@ _STOCK_MODELS = { "base_instructions": "You are Codex, a coding agent based on GPT-5.6.", "apply_patch_tool_type": "freeform", "supports_parallel_tool_calls": True, + "supports_search_tool": True, + "tool_mode": "code_mode_only", "context_window": 272000, "comp_hash": "terra-hash", }, @@ -669,10 +671,16 @@ class TestCodexModelSync: def _row(model_id, **extra): return {"id": model_id, "object": "model", "created": 1, "owned_by": "openai", **extra} - def _sync(self, listing, codex_home, base_url="http://localhost:4000/", run=None): + def _sync(self, listing, codex_home, base_url="http://localhost:4000/", run=None, model_info=None): captured = {} def fake_get(url, headers, timeout): + if headers.get("Authorization") != "Bearer sk-key": + return _FakeResponse(401) + if url.endswith("/v1/model/info"): + if isinstance(model_info, Exception): + raise model_info + return model_info if model_info is not None else _FakeResponse(200, {"data": []}) captured["url"] = url captured["headers"] = headers return _FakeResponse(200, listing) @@ -737,6 +745,202 @@ class TestCodexModelSync: assert entries["gpt-5.4"]["upgrade"] == _STOCK_MODELS["gpt-5.4"]["upgrade"] assert [entries["gpt-5.4"]["priority"], entries["gpt-5.6-terra"]["priority"]] == [0, 1] + @pytest.mark.parametrize( + "deployment", + [ + {"model_name": "api-terra", "litellm_params": {"model": "openai/gpt-5.6-terra"}}, + {"model_name": "api-terra", "litellm_params": {"model": "gpt-5.6-terra"}, "model_info": None}, + { + "model_name": "api-terra", + "litellm_params": {"model": "openai/gpt-5.6-terra"}, + "model_info": {"id": "d1", "team_public_model_name": ""}, + }, + { + "model_name": "model_name_team-1_d1", + "litellm_params": {"model": "openai/gpt-5.6-terra", "api_base": "https://api.openai.com/v1"}, + "model_info": {"id": "d1", "team_public_model_name": "api-terra"}, + }, + ], + ids=["provider_prefixed", "bare_without_model_info", "empty_team_public_name", "team_public_model_name"], + ) + def test_alias_of_a_codex_model_gets_that_models_entry_under_its_own_name(self, tmp_path, deployment): + listing = self._listing(self._row("api-terra"), self._row("m")) + _, result = self._sync(listing, tmp_path, model_info=_FakeResponse(200, {"data": [deployment]})) + + assert isinstance(result, ModelSyncArgs) + entries = self._entries(tmp_path) + assert entries["api-terra"] == { + **_STOCK_MODELS["gpt-5.6-terra"], + "slug": "api-terra", + "display_name": "api-terra", + "priority": 0, + "service_tiers": [], + "default_service_tier": None, + "default_verbosity": None, + "availability_nux": None, + "default_reasoning_level": None, + } + assert "supports_search_tool" not in entries["m"] + + def test_alias_gets_no_upgrade_nudge_away_from_its_own_name(self, tmp_path): + listing = self._listing(self._row("gpt-5.6-terra"), self._row("api-54")) + deployment = {"model_name": "api-54", "litellm_params": {"model": "openai/gpt-5.4"}} + self._sync(listing, tmp_path, model_info=_FakeResponse(200, {"data": [deployment]})) + + entry = self._entries(tmp_path)["api-54"] + assert (entry["comp_hash"], entry["upgrade"]) == (_STOCK_MODELS["gpt-5.4"]["comp_hash"], None) + + @pytest.mark.parametrize( + ("pins", "expected"), + [ + (("high",), "high"), + (("high", "high"), "high"), + (("high", None), None), + (("high", "low"), None), + (({"effort": "high"},), None), + ], + ids=["one_deployment", "every_deployment_agrees", "one_unpinned", "pins_differ", "non_string_pin"], + ) + def test_alias_starts_at_the_reasoning_effort_every_deployment_pins(self, tmp_path, pins, expected): + deployments = [ + { + "model_name": "api-terra", + "litellm_params": {"model": "openai/gpt-5.6-terra", **({"reasoning_effort": p} if p else {})}, + } + for p in pins + ] + self._sync( + self._listing(self._row("api-terra")), tmp_path, model_info=_FakeResponse(200, {"data": deployments}) + ) + + entry = self._entries(tmp_path)["api-terra"] + assert (entry["comp_hash"], entry["default_reasoning_level"]) == ("terra-hash", expected) + + @pytest.mark.parametrize( + "deployments", + [ + [ + {"model_name": "api-terra", "litellm_params": {"model": "openai/gpt-5.6-terra"}}, + {"model_name": "api-terra", "litellm_params": {"model": "gpt-5.6-terra"}}, + ], + [ + { + "model_name": "api-terra", + "litellm_params": {"model": "gpt-5.6-terra", "custom_llm_provider": "openai"}, + } + ], + [ + { + "model_name": "api-terra", + "litellm_params": {"model": "openai/gpt-5.5"}, + "model_info": {"id": "paused", "blocked": True}, + }, + {"model_name": "api-terra", "litellm_params": {"model": "openai/gpt-5.6-terra"}}, + ], + [ + { + "model_name": "api-terra", + "litellm_params": {"model": "openai/gpt-5.6-terra", "api_base": "https://eu.api.openai.com/v1"}, + } + ], + [ + { + "model_name": "api-terra", + "litellm_params": { + "model": "openai/gpt-5.6-terra", + "api_base": "https://api.openai.com/v1", + "litellm_credential_name": "openai-key", + }, + } + ], + ], + ids=[ + "same_model_with_and_without_prefix", + "openai_custom_llm_provider", + "paused_deployment_ignored", + "openai_regional_host", + "credential_under_an_openai_host", + ], + ) + def test_alias_whose_deployments_all_route_to_one_openai_model_gets_its_entry(self, tmp_path, deployments): + self._sync( + self._listing(self._row("api-terra")), tmp_path, model_info=_FakeResponse(200, {"data": deployments}) + ) + + assert self._entries(tmp_path)["api-terra"]["comp_hash"] == _STOCK_MODELS["gpt-5.6-terra"]["comp_hash"] + + @pytest.mark.parametrize( + "deployments", + [ + [ + {"model_name": "api-terra", "litellm_params": {"model": "openai/gpt-5.5"}}, + { + "model_name": "model_name_team-1_d1", + "litellm_params": {"model": "openai/gpt-5.6-terra"}, + "model_info": {"id": "d1", "team_public_model_name": "api-terra"}, + }, + ], + [ + {"model_name": "api-terra", "litellm_params": {"model": "openai/gpt-5.6-terra"}}, + {"model_name": "api-terra", "litellm_params": {"model": "anthropic/claude-opus-4-7"}}, + ], + [{"model_name": "api-terra", "litellm_params": {"model": "azure/gpt-5.6-terra"}}], + [{"model_name": "api-terra", "litellm_params": {"model": "gpt-5.6-terra", "custom_llm_provider": "azure"}}], + [ + { + "model_name": "api-terra", + "litellm_params": {"model": "openai/gpt-5.6-terra", "api_base": "http://vllm.example.invalid/v1"}, + } + ], + [ + { + "model_name": "api-terra", + "litellm_params": {"model": "gpt-5.6-terra", "api_base": "https://api.openai.com.example.invalid"}, + } + ], + [ + { + "model_name": "api-terra", + "litellm_params": {"model": "openai/gpt-5.6-terra", "litellm_credential_name": "gateway"}, + } + ], + ], + ids=[ + "team_row_and_global_row_differ", + "load_balanced_across_providers", + "azure_prefix", + "azure_provider", + "openai_prefix_on_another_host", + "openai_lookalike_host", + "host_only_a_credential_knows", + ], + ) + def test_alias_without_one_shared_openai_upstream_keeps_the_fallback_entry(self, tmp_path, deployments): + listing = self._listing(self._row("api-terra")) + self._sync(listing, tmp_path / "with", model_info=_FakeResponse(200, {"data": deployments})) + self._sync(listing, tmp_path / "without") + + assert self._entries(tmp_path / "with")["api-terra"] == self._entries(tmp_path / "without")["api-terra"] + + @pytest.mark.parametrize( + "model_info", + [ + requests.ConnectionError("refused"), + _FakeResponse(500, {"data": [{"model_name": "api-terra", "litellm_params": {"model": "gpt-5.6-terra"}}]}), + _FakeResponse(200, {"data": "nope"}), + _FakeResponse(200, {"data": [{"model_name": "api-terra"}]}), + ], + ids=["unreachable", "http_500", "unexpected_body", "deployment_without_litellm_params"], + ) + def test_unavailable_model_info_keeps_the_alias_on_the_fallback_entry(self, tmp_path, model_info): + _, result = self._sync(self._listing(self._row("api-terra")), tmp_path, model_info=model_info) + + catalog = tmp_path / "litellm-models.json" + assert result == ModelSyncArgs(("-c", f"model_catalog_json={json.dumps(str(catalog))}")) + entry = self._entries(tmp_path)["api-terra"] + assert entry["slug"] == "api-terra" + assert "supports_search_tool" not in entry + def test_stock_catalog_is_decoded_as_utf8_regardless_of_locale(self, tmp_path): description = "Modelo equilibrado para el trabajo diario, con acentos y ñ." catalog = {"models": [{**_STOCK_MODELS["gpt-5.5"], "description": description}]} 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 c9d4127c6d4..b43e43df7c9 100644 --- a/tests/unit/proxy/common_utils/test_codex_model_catalog.py +++ b/tests/unit/proxy/common_utils/test_codex_model_catalog.py @@ -135,6 +135,84 @@ def test_alias_of_a_known_upstream_model_takes_stock_metadata_under_its_own_name assert (entry["slug"], entry["display_name"]) == ("team-55", "team-55") +@pytest.mark.parametrize(("max_input_tokens", "expected"), [(100000, 100000), (922000, 272000), (None, 272000)]) +def test_alias_with_deployment_defaults_caps_codexs_window_at_its_listed_max_input_tokens(max_input_tokens, expected): + stock = MappingProxyType({"gpt-5.5": _stock("gpt-5.5").model_copy(update={"context_window": 272000})}) + alias = _row("team-55", upstream_model="openai/gpt-5.5", max_input_tokens=max_input_tokens) + + body = codex_models_response_json((alias,), stock=stock, instructions=_PROMPT, alias_defaults="deployment") + + assert json.loads(body.json)["models"][0]["context_window"] == expected + + +def test_alias_with_deployment_defaults_drops_codexs_tier_and_upgrade_while_the_native_name_keeps_them(): + rows = (_row("gpt-6-sol"), _row("team-sol", upstream_model="gpt-6-sol"), _row("team-55", upstream_model="gpt-5.5")) + codex = {entry["slug"]: entry for entry in _models(*rows)} + deployment = {entry["slug"]: entry for entry in _models(*rows, alias_defaults="deployment")} + + assert deployment["gpt-6-sol"] == codex["gpt-6-sol"] + assert (codex["team-sol"]["default_service_tier"], codex["team-55"]["upgrade"]) == ( + "priority", + {"model": "gpt-6-sol"}, + ) + assert _tiers(codex["team-sol"]) == _tiers(codex["gpt-6-sol"]) != [] + assert [ + (deployment[slug]["service_tiers"], deployment[slug]["default_service_tier"]) + for slug in ("team-sol", "team-55") + ] == [ + ([], None), + ([], None), + ] + assert deployment["team-55"]["upgrade"] is None + assert deployment["team-sol"]["model_messages"] == codex["team-sol"]["model_messages"] + + +@pytest.mark.parametrize(("pinned", "expected"), [("high", "high"), ("turbo", None), (None, None)]) +def test_alias_with_deployment_defaults_starts_only_at_a_reasoning_effort_its_deployments_pin(pinned, expected): + stock = CodexStockModel.model_validate( + { + "slug": "gpt-5.5", + "display_name": "GPT-5.5", + "priority": 1, + "visibility": "list", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [{"effort": e, "description": e} for e in ("low", "medium", "high")], + } + ) + alias = _row("team-55", upstream_model="gpt-5.5", reasoning_effort=pinned) + + body = codex_models_response_json( + (alias,), stock=MappingProxyType({"gpt-5.5": stock}), instructions=_PROMPT, alias_defaults="deployment" + ) + + assert json.loads(body.json)["models"][0]["default_reasoning_level"] == expected + + +def test_alias_with_deployment_defaults_sends_no_verbosity_or_promo_of_codexs_while_the_native_name_keeps_them(): + stock = CodexStockModel.model_validate( + { + "slug": "gpt-6-sol", + "display_name": "GPT-6 Sol", + "priority": 1, + "visibility": "list", + "support_verbosity": True, + "default_verbosity": "low", + "availability_nux": {"message": "GPT-6 Sol is here"}, + } + ) + rows = (_row("gpt-6-sol"), _row("team-sol", upstream_model="gpt-6-sol")) + + body = codex_models_response_json( + rows, stock=MappingProxyType({"gpt-6-sol": stock}), instructions=_PROMPT, alias_defaults="deployment" + ) + native, alias = json.loads(body.json)["models"] + + assert [(e["support_verbosity"], e["default_verbosity"], e["availability_nux"]) for e in (native, alias)] == [ + (True, "low", {"message": "GPT-6 Sol is here"}), + (True, None, None), + ] + + @pytest.mark.parametrize( ("row", "expected"), [