mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
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>
This commit is contained in:
parent
dbaae521f9
commit
5bd47442fb
4 changed files with 483 additions and 19 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}]}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue