mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
feat(proxy): give Codex accurate model entries for every model in /v1/models
This commit is contained in:
parent
0d697017b5
commit
2e0939d2f2
16 changed files with 1030 additions and 166 deletions
|
|
@ -13,7 +13,7 @@ import warnings
|
|||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, get_args
|
||||
|
||||
from typing_extensions import (
|
||||
ReadOnly,
|
||||
|
|
@ -32,6 +32,7 @@ from pydantic import ConfigDict, TypeAdapter, ValidationError
|
|||
|
||||
import litellm
|
||||
from litellm import verbose_logger
|
||||
from litellm.constants import BEDROCK_INVOKE_PROVIDERS_LITERAL
|
||||
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
|
||||
from litellm.litellm_core_utils.optional_imports import ensure_optional_import
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import schema_property_count
|
||||
|
|
@ -1490,6 +1491,14 @@ def get_bedrock_base_model(model: str) -> str:
|
|||
return model
|
||||
|
||||
|
||||
def bedrock_vendor_model_name(model: str) -> str:
|
||||
"""The name the model's vendor publishes it under, without Bedrock's routing, region and vendor
|
||||
prefixes: ``bedrock/us.openai.gpt-6.1-sol`` and ``us-east-1/openai.gpt-6.1-sol`` are ``gpt-6.1-sol``."""
|
||||
base: Final = get_bedrock_base_model(model)
|
||||
vendor, separator, name = base.partition(".")
|
||||
return name if separator and vendor in get_args(BEDROCK_INVOKE_PROVIDERS_LITERAL) else base
|
||||
|
||||
|
||||
def bedrock_converse_accepts_redacted_thinking(model: str, base_model: str | None) -> bool:
|
||||
"""
|
||||
Whether ``reasoningContent.redactedContent`` may be replayed to this Converse target.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,37 +3,49 @@
|
|||
Codex CLI discovers a provider's models with ``GET <model_catalog_url>?client_version=<its version>``
|
||||
(the provider's ``model_catalog_url`` pointed at ``/v1/models``, with its ``api_key_model_discovery``
|
||||
feature on) and decodes the body as its own ``ModelsResponse``, ``{"models": [ModelInfo, ...]}``,
|
||||
never the OpenAI list shape. This module builds that body from the rows ``/v1/models`` already
|
||||
lists: a model Codex knows keeps Codex's own entry for it, any other model gets the fallback entry
|
||||
Codex itself uses for an unknown slug, and ``model_info.service_tiers`` becomes the entry's
|
||||
never the OpenAI list shape. Codex then ignores its own bundled catalog, so this body is all it knows
|
||||
about the provider's models. It is built from the rows ``/v1/models`` already lists: a model Codex
|
||||
knows, by its id or by the upstream model its deployments call once the provider's own prefixes are
|
||||
stripped, keeps Codex's own entry for it, minus any service tier some deployment behind it has no
|
||||
price for; any other model gets an entry translated from the capabilities its deployments' model info
|
||||
declares (see ``codex_model_translation``). ``model_info.service_tiers`` becomes the entry's
|
||||
``service_tiers``, which Codex turns into slash commands (``/ultrafast``) that send ``service_tier``
|
||||
upstream. A ``model_info.max_input_tokens`` the operator sets replaces a known model's
|
||||
``context_window``, up to the most Codex lets its own ``model_context_window`` raise that model to.
|
||||
A fallback entry offers the model group's ``supported_reasoning_efforts`` as its reasoning levels,
|
||||
A translated entry offers the model group's ``supported_reasoning_efforts`` as its reasoning levels,
|
||||
trimmed to the ones the requesting Codex release can parse.
|
||||
|
||||
The stock entries are Codex 0.159.3's bundled catalog, vendored unchanged from
|
||||
https://github.com/openai/codex/blob/rust-v0.159.3/codex-rs/models-manager/models.json (Apache-2.0)
|
||||
as ``codex_bundled_models_0.159.3.json``, and ``codex_base_instructions.md`` is
|
||||
The stock entries are Codex 0.166.0's bundled catalog, vendored unchanged from
|
||||
https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/models-manager/models.json (Apache-2.0)
|
||||
as ``codex_bundled_models_0.166.0.json``, and ``codex_base_instructions.md`` is
|
||||
``codex-rs/models-manager/prompt.md`` at the same tag. Refresh both by downloading those two paths at
|
||||
a newer tag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
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, TypeAlias
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Literal, TypeAlias
|
||||
|
||||
from packaging.version import Version
|
||||
from pydantic import ConfigDict, Field, StringConstraints, TypeAdapter, ValidationError
|
||||
from pydantic import ConfigDict, TypeAdapter, ValidationError
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.litellm_core_utils.reasoning_effort_strength import nearest_declared_reasoning_effort
|
||||
from litellm.llms.bedrock.common_utils import bedrock_vendor_model_name
|
||||
from litellm.proxy.common_utils.codex_model_translation import (
|
||||
TIER_PRICE_KEYS,
|
||||
CodexDeploymentCapabilities,
|
||||
CodexFallbackModel,
|
||||
CodexServiceTier,
|
||||
TierId,
|
||||
codex_fallback_entry,
|
||||
combined_capabilities,
|
||||
every_deployment_prices,
|
||||
)
|
||||
from litellm.types.llms.base_model import LiteLLMBaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -42,89 +54,24 @@ if TYPE_CHECKING:
|
|||
from litellm.types.proxy.model_listing import ModelInfoResponse
|
||||
|
||||
CODEX_BASE_INSTRUCTIONS_PATH: Final = Path(__file__).with_name("codex_base_instructions.md")
|
||||
CODEX_BUNDLED_MODELS_PATH: Final = Path(__file__).with_name("codex_bundled_models_0.159.3.json")
|
||||
CODEX_BUNDLED_MODELS_PATH: Final = Path(__file__).with_name("codex_bundled_models_0.166.0.json")
|
||||
CODEX_CATALOG_BYTE_LIMIT: Final = 1024 * 1024
|
||||
CODEX_CHAT_MODES: Final = frozenset({"chat", "responses"})
|
||||
|
||||
_TierId = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
_BODY_PREFIX: Final = '{"models":['
|
||||
_BODY_SUFFIX: Final = "]}"
|
||||
_FIRST_CODEX_WITH_CUSTOM_EFFORTS: Final = Version("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"
|
||||
|
||||
|
||||
class CodexServiceTier(LiteLLMBaseModel):
|
||||
"""A `ModelServiceTier` as Codex reads it: the slash command is `name` lowercased, and toggling
|
||||
it sends `id` as the request's `service_tier`."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: _TierId
|
||||
name: str
|
||||
description: str
|
||||
_NO_UPDATE: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
|
||||
|
||||
class _ConfiguredTier(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
id: _TierId
|
||||
id: TierId
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
_CONFIGURED_TIERS: Final = TypeAdapter(tuple[_TierId | _ConfiguredTier, ...])
|
||||
|
||||
|
||||
class CodexFallbackReasoningLevel(LiteLLMBaseModel):
|
||||
"""A `ReasoningEffortPreset` as Codex reads it: picking it sends `effort` as the request's reasoning effort."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
effort: str
|
||||
description: str
|
||||
|
||||
|
||||
class CodexTruncationPolicy(LiteLLMBaseModel):
|
||||
mode: Literal["bytes"] = "bytes"
|
||||
limit: int = 10_000
|
||||
|
||||
|
||||
class CodexFallbackModel(LiteLLMBaseModel):
|
||||
"""One `ModelInfo` entry of a Codex model catalog for a model Codex does not know.
|
||||
|
||||
Every field that some Codex release since `model_catalog_json` appeared
|
||||
(0.105.0) deserializes without a default is spelled out here, so one catalog
|
||||
parses on all of them; the values match the fallback metadata Codex uses for
|
||||
a model slug it does not know, so picking such a proxy model behaves the
|
||||
same as `codex -m` did.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
description: NoneType = None
|
||||
default_reasoning_level: str | None = Field(default=None, exclude_if=lambda level: level is None)
|
||||
supported_reasoning_levels: tuple[CodexFallbackReasoningLevel, ...] = ()
|
||||
shell_type: Literal["unified_exec"] = "unified_exec"
|
||||
visibility: Literal["list"] = "list"
|
||||
supported_in_api: Literal[True] = True
|
||||
priority: int
|
||||
service_tiers: tuple[CodexServiceTier, ...] = ()
|
||||
availability_nux: NoneType = None
|
||||
upgrade: NoneType = None
|
||||
support_verbosity: Literal[False] = False
|
||||
supports_reasoning_summaries: Literal[False] = False
|
||||
supports_parallel_tool_calls: Literal[False] = False
|
||||
default_verbosity: NoneType = None
|
||||
apply_patch_tool_type: NoneType = None
|
||||
truncation_policy: CodexTruncationPolicy = CodexTruncationPolicy()
|
||||
experimental_supported_tools: tuple[()] = ()
|
||||
context_window: int | None
|
||||
base_instructions: str
|
||||
supports_search_tool: bool = False
|
||||
_CONFIGURED_TIERS: Final = TypeAdapter(tuple[TierId | _ConfiguredTier, ...])
|
||||
|
||||
|
||||
class CodexStockUpgrade(LiteLLMBaseModel):
|
||||
|
|
@ -156,6 +103,7 @@ class CodexStockModel(LiteLLMBaseModel):
|
|||
supported_in_api: bool = True
|
||||
upgrade: CodexStockUpgrade | None = None
|
||||
service_tiers: tuple[CodexServiceTier, ...] = ()
|
||||
additional_speed_tiers: tuple[str, ...] = ()
|
||||
default_service_tier: str | None = None
|
||||
supports_search_tool: bool = False
|
||||
supported_reasoning_levels: tuple[CodexReasoningLevel, ...] = ()
|
||||
|
|
@ -178,7 +126,8 @@ class CodexCatalogRow:
|
|||
`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, and
|
||||
`configured_max_input_tokens` one `model_info.max_input_tokens` per deployment the same way.
|
||||
`supported_reasoning_efforts` is the model group's, None when unknown."""
|
||||
`deployment_capabilities` holds what each deployment's model info, merged with the cost map,
|
||||
declares. `supported_reasoning_efforts` is the model group's, None when unknown."""
|
||||
|
||||
id: str
|
||||
mode: str | None = None
|
||||
|
|
@ -190,6 +139,7 @@ class CodexCatalogRow:
|
|||
supported_reasoning_efforts: tuple[str, ...] | None = None
|
||||
search_tool_support: tuple[bool | None, ...] = ()
|
||||
configured_max_input_tokens: tuple[int | None, ...] = ()
|
||||
deployment_capabilities: tuple[CodexDeploymentCapabilities, ...] = ()
|
||||
|
||||
|
||||
@cache
|
||||
|
|
@ -292,44 +242,49 @@ def _codex_can_drive(model_id: str, mode: str | None) -> bool:
|
|||
return "*" not in model_id and (mode is None or mode in CODEX_CHAT_MODES)
|
||||
|
||||
|
||||
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
|
||||
_VENDOR_MODEL_NAMES: Final[Mapping[str, Callable[[str], str]]] = MappingProxyType(
|
||||
{"bedrock": bedrock_vendor_model_name, "bedrock_mantle": bedrock_vendor_model_name}
|
||||
)
|
||||
|
||||
|
||||
def codex_reasoning_levels(
|
||||
efforts: Sequence[str], client_version: str | None
|
||||
) -> tuple[tuple[CodexFallbackReasoningLevel, ...], str | None]:
|
||||
"""The reasoning levels a fallback entry offers for a model group's efforts, and its default level.
|
||||
|
||||
Codex before 0.138.0 fails the whole catalog on an effort it has no name for, so for it, and for
|
||||
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 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, "down")
|
||||
levels: Final = tuple(
|
||||
CodexFallbackReasoningLevel(effort=effort, description=f"Sends reasoning_effort={effort} upstream")
|
||||
for effort in offered
|
||||
)
|
||||
if _CODEX_UNSET_EFFORT in offered:
|
||||
return levels, _CODEX_UNSET_EFFORT
|
||||
return levels, nearest if nearest in offered else offered[0]
|
||||
def _vendor_model_name(upstream_model: str) -> str:
|
||||
provider, _, model = upstream_model.partition("/")
|
||||
name_for: Final = _VENDOR_MODEL_NAMES.get(provider)
|
||||
return model if name_for is None else name_for(model)
|
||||
|
||||
|
||||
def _stock_for(row: CodexCatalogRow, stock: Mapping[str, CodexStockModel]) -> CodexStockModel | None:
|
||||
listed: Final = stock.get(row.id)
|
||||
if listed is not None or row.upstream_model is None:
|
||||
return listed
|
||||
upstream: Final = stock.get(row.upstream_model)
|
||||
return upstream if upstream is not None else stock.get(row.upstream_model.partition("/")[2])
|
||||
"""Codex's entry for the row's id, else for its upstream model as written, else for that model's name
|
||||
once the provider's own prefixes are stripped (`bedrock/us.openai.gpt-6.1-sol` is `gpt-6.1-sol`)."""
|
||||
names: Final = (
|
||||
(row.id,)
|
||||
if row.upstream_model is None
|
||||
else (row.id, row.upstream_model, _vendor_model_name(row.upstream_model))
|
||||
)
|
||||
return next((stock[name] for name in names if name in stock), None)
|
||||
|
||||
|
||||
def _priced_tiers(row: CodexCatalogRow, stock_model: CodexStockModel) -> Mapping[str, object]:
|
||||
"""Codex's own tiers for the model without the ones some deployment behind the row has no price for,
|
||||
or nothing when every deployment prices them all. Sending a tier the upstream does not sell fails the
|
||||
request, and the cost map prices a tier for exactly the models that sell it."""
|
||||
deployments: Final = row.deployment_capabilities
|
||||
tiers: Final = tuple(tier for tier in stock_model.service_tiers if every_deployment_prices(tier.id, deployments))
|
||||
speed_tiers: Final = tuple(
|
||||
tier for tier in stock_model.additional_speed_tiers if every_deployment_prices(tier, deployments)
|
||||
)
|
||||
if tiers == stock_model.service_tiers and speed_tiers == stock_model.additional_speed_tiers:
|
||||
return _NO_UPDATE
|
||||
offered: Final = frozenset(tier.id for tier in tiers)
|
||||
return MappingProxyType(
|
||||
{
|
||||
"service_tiers": tiers,
|
||||
"additional_speed_tiers": speed_tiers,
|
||||
"default_service_tier": (
|
||||
stock_model.default_service_tier if stock_model.default_service_tier in offered else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _alias_context_window(row: CodexCatalogRow, stock_model: CodexStockModel) -> Mapping[str, int]:
|
||||
|
|
@ -396,17 +351,16 @@ def _entry(
|
|||
configured: Final = configured_service_tiers(row.service_tiers, row.id, stock_tiers)
|
||||
search_tool: Final = offered_search_tool(row.search_tool_support)
|
||||
if stock_model is None:
|
||||
levels, default_level = codex_reasoning_levels(row.supported_reasoning_efforts or (), client_version)
|
||||
return CodexFallbackModel(
|
||||
return codex_fallback_entry(
|
||||
slug=row.id,
|
||||
display_name=row.display_name if row.display_name is not None else row.id,
|
||||
default_reasoning_level=default_level,
|
||||
supported_reasoning_levels=levels,
|
||||
priority=index,
|
||||
capabilities=combined_capabilities(row.deployment_capabilities, row.max_input_tokens),
|
||||
instructions=instructions,
|
||||
service_tiers=configured if configured is not None else (),
|
||||
context_window=row.max_input_tokens,
|
||||
base_instructions=instructions,
|
||||
supports_search_tool=search_tool is True,
|
||||
reasoning_efforts=row.supported_reasoning_efforts or (),
|
||||
client_version=client_version,
|
||||
)
|
||||
upgrade: Final = (
|
||||
stock_model.upgrade if stock_model.upgrade is not None and stock_model.upgrade.model in served else None
|
||||
|
|
@ -425,7 +379,7 @@ def _entry(
|
|||
**_configured_window(row, stock_model),
|
||||
}
|
||||
if configured is None:
|
||||
return stock_model.model_copy(update=listing)
|
||||
return stock_model.model_copy(update={**_priced_tiers(row, stock_model), **listing})
|
||||
configured_ids: Final = frozenset(tier.id for tier in configured)
|
||||
return stock_model.model_copy(
|
||||
update={
|
||||
|
|
@ -503,8 +457,10 @@ def codex_models_response_json(
|
|||
`max_context_window`. 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. `client_version` is the requesting Codex's version, which decides the reasoning
|
||||
levels a fallback entry may offer; None counts as a version too old for custom levels.
|
||||
model's own name. Codex's own service tiers stay on a known model's entry only when every deployment
|
||||
behind the row prices them, and only while the operator configures none. `client_version` is the
|
||||
requesting Codex's version, which decides the reasoning levels a fallback entry may offer; None counts
|
||||
as a version too old for custom levels.
|
||||
"""
|
||||
known: Final = bundled_codex_models() if stock is None else stock
|
||||
prompt: Final = codex_base_instructions() if instructions is None else instructions
|
||||
|
|
@ -531,6 +487,32 @@ def _group_reasoning_efforts(
|
|||
return group.supported_reasoning_efforts if group is not None else None
|
||||
|
||||
|
||||
def deployment_capabilities(model_info: Mapping[str, object] | None, model_id: str) -> CodexDeploymentCapabilities:
|
||||
"""One deployment's model info, merged with the cost map, as the capabilities a Codex entry describes.
|
||||
Model info that does not validate declares nothing, logged, so one bad value never fails the listing."""
|
||||
if model_info is None:
|
||||
return CodexDeploymentCapabilities()
|
||||
priced: Final = frozenset(key for key in TIER_PRICE_KEYS if model_info.get(key) is not None)
|
||||
try:
|
||||
return CodexDeploymentCapabilities.model_validate({**model_info, "priced_service_tier_keys": priced})
|
||||
except ValidationError as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"model info of a deployment of %s declares no capability to Codex: %s", model_id, e.errors()[0]["msg"]
|
||||
)
|
||||
return CodexDeploymentCapabilities(priced_service_tier_keys=priced)
|
||||
|
||||
|
||||
def _deployment_capabilities(
|
||||
lookup_id: str, registry: RouterRegistry | None, team_id: str | None
|
||||
) -> tuple[CodexDeploymentCapabilities, ...]:
|
||||
if registry is None:
|
||||
return ()
|
||||
return tuple(
|
||||
deployment_capabilities(model_info, lookup_id)
|
||||
for model_info in registry.group(lookup_id, field="deployment_model_info", team_id=team_id)
|
||||
)
|
||||
|
||||
|
||||
def _catalog_row(
|
||||
row: ModelInfoResponse, lookup_id: str, label_id: str, llm_router: Router | None, team_id: str | None
|
||||
) -> CodexCatalogRow:
|
||||
|
|
@ -549,6 +531,7 @@ def _catalog_row(
|
|||
configured_max_input_tokens=(
|
||||
registry.group(lookup_id, field="max_input_tokens", team_id=team_id) if registry is not None else ()
|
||||
),
|
||||
deployment_capabilities=_deployment_capabilities(lookup_id, registry, team_id),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -562,8 +545,9 @@ def codex_catalog_rows(
|
|||
"""`rows` joined with the router's configured metadata, looked up by each entry's internal id so
|
||||
team-scoped rows resolve the way the Anthropic listing's display names do, a `model_group_alias`
|
||||
reading its target's deployments under its own name; `team_id` is the requesting key's team, so the
|
||||
upstream model that picks Codex's stock entry, the tiers, and the configured input limits are read
|
||||
only off the deployments its requests can route to, and the reasoning efforts are the ones
|
||||
upstream model that picks Codex's stock entry, the tiers, the configured input limits and each
|
||||
deployment's capabilities are read only off the deployments its requests can route to, and the
|
||||
reasoning efforts are the ones
|
||||
`/model_group/info` reports for the entry. Display names are read under `label_entries`' ids when given."""
|
||||
lookup_ids: Final = MappingProxyType(dict(entries))
|
||||
label_ids: Final = lookup_ids if label_entries is None else MappingProxyType(dict(label_entries))
|
||||
|
|
|
|||
229
litellm/proxy/common_utils/codex_model_translation.py
Normal file
229
litellm/proxy/common_utils/codex_model_translation.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Codex's `ModelInfo` entry for a model Codex does not know, translated from what LiteLLM knows about the
|
||||
deployments behind it.
|
||||
|
||||
Field names, types and enum values follow `codex-rs/protocol/src/openai_models.rs` at rust-v0.166.0, and every
|
||||
field a release back to 0.140.0 decodes without a default is spelled out, so one body parses on all of them.
|
||||
Codex rejects the whole catalog when one entry fails to decode, so only enum values every one of those
|
||||
releases knows are sent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import NoneType
|
||||
from typing import Annotated, Final, Literal, TypeAlias
|
||||
|
||||
from packaging.version import Version
|
||||
from pydantic import ConfigDict, Field, StringConstraints
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import SERVICE_TIER_COST_KEY_SUFFIXES, get_service_tier_cost_key
|
||||
from litellm.litellm_core_utils.reasoning_effort_strength import nearest_declared_reasoning_effort
|
||||
from litellm.types.llms.base_model import LiteLLMBaseModel
|
||||
|
||||
TierId: TypeAlias = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
CodexInputModality: TypeAlias = Literal["text", "image"]
|
||||
|
||||
_TEXT_ONLY: Final[tuple[CodexInputModality, ...]] = ("text",)
|
||||
_TEXT_AND_IMAGE: Final[tuple[CodexInputModality, ...]] = ("text", "image")
|
||||
_TIER_PRICE_BASE_KEY: Final = "input_cost_per_token"
|
||||
TIER_PRICE_KEYS: Final = tuple(f"{_TIER_PRICE_BASE_KEY}{suffix}" for suffix in SERVICE_TIER_COST_KEY_SUFFIXES)
|
||||
_FIRST_CODEX_WITH_CUSTOM_EFFORTS: Final = Version("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"
|
||||
|
||||
|
||||
class CodexServiceTier(LiteLLMBaseModel):
|
||||
"""A `ModelServiceTier` as Codex reads it: the slash command is `name` lowercased, and toggling
|
||||
it sends `id` as the request's `service_tier`."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: TierId
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class CodexFallbackReasoningLevel(LiteLLMBaseModel):
|
||||
"""A `ReasoningEffortPreset` as Codex reads it: picking it sends `effort` as the request's reasoning effort."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
effort: str
|
||||
description: str
|
||||
|
||||
|
||||
class CodexTruncationPolicy(LiteLLMBaseModel):
|
||||
"""How Codex truncates a long tool output before sending it back, the policy its own entries use."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
mode: Literal["tokens"] = "tokens"
|
||||
limit: int = 10_000
|
||||
|
||||
|
||||
class CodexFallbackModel(LiteLLMBaseModel):
|
||||
"""One `ModelInfo` entry of a Codex model catalog for a model Codex does not know.
|
||||
|
||||
`supports_reasoning_summaries` and `supports_parallel_tool_calls` are read only by Codex before 0.150.0,
|
||||
which requires them, and `base_instructions` is the prompt field every release still decodes.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
description: NoneType = None
|
||||
default_reasoning_level: str | None = Field(default=None, exclude_if=lambda level: level is None)
|
||||
supported_reasoning_levels: tuple[CodexFallbackReasoningLevel, ...] = ()
|
||||
shell_type: Literal["unified_exec"] = "unified_exec"
|
||||
visibility: Literal["list"] = "list"
|
||||
supported_in_api: Literal[True] = True
|
||||
priority: int
|
||||
service_tiers: tuple[CodexServiceTier, ...] = ()
|
||||
availability_nux: NoneType = None
|
||||
upgrade: NoneType = None
|
||||
support_verbosity: Literal[False] = False
|
||||
supports_reasoning_summary_parameter: bool = False
|
||||
supports_reasoning_summaries: bool = False
|
||||
supports_parallel_tool_calls: bool = False
|
||||
default_verbosity: NoneType = None
|
||||
apply_patch_tool_type: Literal["freeform"] | None = None
|
||||
truncation_policy: CodexTruncationPolicy = CodexTruncationPolicy()
|
||||
experimental_supported_tools: tuple[()] = ()
|
||||
input_modalities: tuple[CodexInputModality, ...] = _TEXT_AND_IMAGE
|
||||
context_window: int | None
|
||||
base_instructions: str
|
||||
supports_search_tool: bool = False
|
||||
use_responses_lite: Literal[False] = False
|
||||
|
||||
|
||||
class CodexDeploymentCapabilities(LiteLLMBaseModel):
|
||||
"""What one deployment's model info says about the capabilities a Codex entry describes, None where
|
||||
it says nothing, and the input price keys of the service tiers it has a price for."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
|
||||
max_input_tokens: int | None = None
|
||||
supports_vision: bool | None = None
|
||||
supports_function_calling: bool | None = None
|
||||
supports_parallel_function_calling: bool | None = None
|
||||
supports_reasoning: bool | None = None
|
||||
priced_service_tier_keys: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def every_deployment_prices(tier_id: str, deployments: Sequence[CodexDeploymentCapabilities]) -> bool:
|
||||
"""Whether every deployment has a price for `tier_id`, the way cost tracking prices a request sent at
|
||||
that tier; a tier cost tracking has no price key for is priced by none."""
|
||||
price_key: Final = get_service_tier_cost_key(_TIER_PRICE_BASE_KEY, tier_id)
|
||||
return all(price_key in deployment.priced_service_tier_keys for deployment in deployments)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CodexCapabilities:
|
||||
"""What a request to a listed name can count on whichever deployment serves it: a capability is False
|
||||
when one deployment lacks it, True when one has it and none lacks it, None when none says, and the
|
||||
context window is the smallest one any deployment declares."""
|
||||
|
||||
context_window: int | None = None
|
||||
supports_vision: bool | None = None
|
||||
supports_function_calling: bool | None = None
|
||||
supports_parallel_function_calling: bool | None = None
|
||||
supports_reasoning: bool | None = None
|
||||
|
||||
|
||||
def _combined(flags: Iterable[bool | None]) -> bool | None:
|
||||
said: Final = frozenset(flag for flag in flags if flag is not None)
|
||||
return None if not said else False not in said
|
||||
|
||||
|
||||
def combined_capabilities(
|
||||
deployments: Sequence[CodexDeploymentCapabilities], listed_context_window: int | None = None
|
||||
) -> CodexCapabilities:
|
||||
"""The capabilities every one of `deployments` offers, the window falling back to
|
||||
`listed_context_window` when no deployment declares one."""
|
||||
windows: Final = tuple(
|
||||
deployment.max_input_tokens
|
||||
for deployment in deployments
|
||||
if deployment.max_input_tokens is not None and deployment.max_input_tokens > 0
|
||||
)
|
||||
return CodexCapabilities(
|
||||
context_window=min(windows) if windows else listed_context_window,
|
||||
supports_vision=_combined(deployment.supports_vision for deployment in deployments),
|
||||
supports_function_calling=_combined(deployment.supports_function_calling for deployment in deployments),
|
||||
supports_parallel_function_calling=_combined(
|
||||
deployment.supports_parallel_function_calling for deployment in deployments
|
||||
),
|
||||
supports_reasoning=_combined(deployment.supports_reasoning for deployment in deployments),
|
||||
)
|
||||
|
||||
|
||||
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]:
|
||||
"""The reasoning levels a fallback entry offers for a model group's efforts, and its default level.
|
||||
|
||||
Codex before 0.138.0 fails the whole catalog on an effort it has no name for, so for it, and for
|
||||
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 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, "down")
|
||||
levels: Final = tuple(
|
||||
CodexFallbackReasoningLevel(effort=effort, description=f"Sends reasoning_effort={effort} upstream")
|
||||
for effort in offered
|
||||
)
|
||||
if _CODEX_UNSET_EFFORT in offered:
|
||||
return levels, _CODEX_UNSET_EFFORT
|
||||
return levels, nearest if nearest in offered else offered[0]
|
||||
|
||||
|
||||
def codex_fallback_entry(
|
||||
*,
|
||||
slug: str,
|
||||
display_name: str,
|
||||
priority: int,
|
||||
capabilities: CodexCapabilities,
|
||||
instructions: str,
|
||||
service_tiers: tuple[CodexServiceTier, ...] = (),
|
||||
supports_search_tool: bool = False,
|
||||
reasoning_efforts: Sequence[str] = (),
|
||||
client_version: str | None = None,
|
||||
) -> CodexFallbackModel:
|
||||
"""Codex's entry for a model it does not know, from the capabilities its deployments share.
|
||||
|
||||
A capability no deployment declares keeps Codex's own default for an unknown model, except the two
|
||||
that send something a model may reject: the `reasoning.summary` parameter goes out only to a model
|
||||
that reasons, and the freeform `apply_patch` tool only to one that calls tools.
|
||||
"""
|
||||
levels, default_level = codex_reasoning_levels(reasoning_efforts, client_version)
|
||||
reasons: Final = capabilities.supports_reasoning is True
|
||||
return CodexFallbackModel(
|
||||
slug=slug,
|
||||
display_name=display_name,
|
||||
default_reasoning_level=default_level,
|
||||
supported_reasoning_levels=levels,
|
||||
priority=priority,
|
||||
service_tiers=service_tiers,
|
||||
supports_reasoning_summary_parameter=reasons,
|
||||
supports_reasoning_summaries=reasons,
|
||||
supports_parallel_tool_calls=capabilities.supports_parallel_function_calling is True,
|
||||
apply_patch_tool_type="freeform" if capabilities.supports_function_calling is True else None,
|
||||
input_modalities=_TEXT_ONLY if capabilities.supports_vision is False else _TEXT_AND_IMAGE,
|
||||
context_window=capabilities.context_window,
|
||||
base_instructions=instructions,
|
||||
supports_search_tool=supports_search_tool,
|
||||
)
|
||||
|
|
@ -156,6 +156,7 @@ GroupField: TypeAlias = Literal[
|
|||
"mode",
|
||||
"service_tiers",
|
||||
"max_input_tokens",
|
||||
"deployment_model_info",
|
||||
"token_limits",
|
||||
"listing_info",
|
||||
]
|
||||
|
|
@ -866,6 +867,16 @@ class RouterRegistry:
|
|||
team_id: str | None = None,
|
||||
) -> tuple[int | None, ...]: ...
|
||||
|
||||
@overload
|
||||
def group(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
field: Literal["deployment_model_info"],
|
||||
include_hidden: bool = False,
|
||||
team_id: str | None = None,
|
||||
) -> "tuple[ModelMapInfo | None, ...]": ...
|
||||
|
||||
@overload
|
||||
def group(
|
||||
self,
|
||||
|
|
@ -904,6 +915,7 @@ class RouterRegistry:
|
|||
``get_configured_token_limits``, ``get_model_listing_info``)
|
||||
- ``service_tiers``: (was ``get_configured_service_tiers``)
|
||||
- ``max_input_tokens``: each routable deployment's own ``model_info.max_input_tokens``
|
||||
- ``deployment_model_info``: each routable deployment's model info, merged with the pricing map
|
||||
"""
|
||||
r: Final = self._router
|
||||
if field == "info":
|
||||
|
|
@ -918,6 +930,8 @@ class RouterRegistry:
|
|||
return r._core_get_configured_service_tiers(name, team_id=team_id)
|
||||
if field == "max_input_tokens":
|
||||
return r._core_get_configured_max_input_tokens(name, team_id=team_id)
|
||||
if field == "deployment_model_info":
|
||||
return r._core_get_routable_deployment_model_info(name, team_id=team_id)
|
||||
if field == "token_limits":
|
||||
return r._core_get_configured_token_limits(name)
|
||||
if field == "listing_info":
|
||||
|
|
|
|||
|
|
@ -12740,6 +12740,26 @@ class PythonCore(DeprecatedRouterMembers):
|
|||
"""
|
||||
return tuple(_search_tool_support(deployment) for deployment in self._routable_deployments(model_name, team_id))
|
||||
|
||||
def _core_get_routable_deployment_model_info(
|
||||
self, model_name: str, team_id: str | None = None
|
||||
) -> tuple[ModelMapInfo | None, ...]:
|
||||
"""
|
||||
The model info of each deployment a request from team_id to model_name
|
||||
can route to (see _routable_deployments), read one deployment at a time
|
||||
the way cost tracking reads it: the cost map entry of its upstream model
|
||||
merged with its own model_info, None where neither is known. Unlike
|
||||
get_model_group_info nothing is merged across deployments, so a caller
|
||||
can tell a deployment that lacks a capability from one that says nothing.
|
||||
|
||||
Returns an empty tuple for wildcard-expanded or unknown names.
|
||||
"""
|
||||
return tuple(
|
||||
self._core_cached_deployment_model_info(deployment_id, deployment["litellm_params"]["model"])
|
||||
if isinstance(deployment_id := _configured_model_info(deployment).get("id"), str)
|
||||
else None
|
||||
for deployment in self._routable_deployments(model_name, team_id)
|
||||
)
|
||||
|
||||
def _core_get_routable_upstream_model(self, model_name: str, team_id: str | None = None) -> str | None:
|
||||
"""
|
||||
Return the `litellm_params.model` of the first deployment a request
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ include = [
|
|||
"litellm/router_strategy/complexity_router/fuse_presets.json",
|
||||
"litellm/proxy/model_insights_tasks.json",
|
||||
"litellm/proxy/common_utils/codex_base_instructions.md",
|
||||
"litellm/proxy/common_utils/codex_bundled_models_0.159.3.json",
|
||||
"litellm/proxy/common_utils/codex_bundled_models_0.166.0.json",
|
||||
]
|
||||
exclude = [
|
||||
"litellm/proxy/_experimental/out",
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ include = [
|
|||
"litellm/proxy/model_insights_tasks.json",
|
||||
"litellm/proxy/public_endpoints/whats_new.json",
|
||||
"litellm/proxy/common_utils/codex_base_instructions.md",
|
||||
"litellm/proxy/common_utils/codex_bundled_models_0.159.3.json",
|
||||
"litellm/proxy/common_utils/codex_bundled_models_0.166.0.json",
|
||||
{ path = "model_prices_and_context_window.json", format = "sdist" },
|
||||
]
|
||||
exclude = [
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ RELOAD_MARGIN_SECONDS: Final = 5.0
|
|||
PROXY_WORKERS: Final = int(os.environ.get("INTEGRATION_PROXY_WORKERS", "1"))
|
||||
WORKER_SYNC_SECONDS: Final = 0.0 if PROXY_WORKERS == 1 else PROXY_CONFIG_RELOAD_INTERVAL_SECONDS + RELOAD_MARGIN_SECONDS
|
||||
ROOT: Final = Path(os.environ.get("INTEGRATION_PROXY_ROOT") or Path(__file__).resolve().parents[3])
|
||||
BUNDLED_STOCK_PATH: Final = ROOT / "litellm" / "proxy" / "common_utils" / "codex_bundled_models_0.159.3.json"
|
||||
BUNDLED_STOCK_PATH: Final = ROOT / "litellm" / "proxy" / "common_utils" / "codex_bundled_models_0.166.0.json"
|
||||
BASE_INSTRUCTIONS_PATH: Final = ROOT / "litellm" / "proxy" / "common_utils" / "codex_base_instructions.md"
|
||||
FRESH_CONNECTION: Final = {"Connection": "close"}
|
||||
ANTHROPIC_HEADERS: Final = {"anthropic-version": "2023-06-01"}
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ def test_yaml_tiers_and_alias_in_owned_proxy(gateway: Gateway, tmp_path: Path) -
|
|||
|
||||
|
||||
ROOT: Final = Path(os.environ.get("INTEGRATION_PROXY_ROOT") or Path(__file__).resolve().parents[3])
|
||||
BUNDLED_STOCK_PATH: Final = ROOT / "litellm" / "proxy" / "common_utils" / "codex_bundled_models_0.159.3.json"
|
||||
BUNDLED_STOCK_PATH: Final = ROOT / "litellm" / "proxy" / "common_utils" / "codex_bundled_models_0.166.0.json"
|
||||
STOCK_UPSTREAM: Final = "gpt-5.5"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from litellm.llms.bedrock.common_utils import(
|
|||
NativeOutputFallback,
|
||||
bedrock_native_output_is_opt_in,
|
||||
bedrock_strict_tools_fallback,
|
||||
bedrock_vendor_model_name,
|
||||
deployment_model_info,
|
||||
extract_model_name_from_bedrock_arn,
|
||||
get_bedrock_base_model,
|
||||
|
|
@ -302,6 +303,23 @@ def test_legacy_mantle_route_prefix_stripped_for_cost_lookup():
|
|||
assert BedrockModelInfo.get_bedrock_route("bedrock/mantle/anthropic.claude-sonnet-5") == "mantle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "expected"),
|
||||
[
|
||||
("bedrock/us.openai.gpt-6.1-sol", "gpt-6.1-sol"),
|
||||
("global.openai.gpt-6.1-sol", "gpt-6.1-sol"),
|
||||
("bedrock/converse/eu.anthropic.claude-sonnet-5-5", "claude-sonnet-5-5"),
|
||||
("us-east-1/openai.gpt-6.1-sol", "gpt-6.1-sol"),
|
||||
("openai.gpt-5.5", "gpt-5.5"),
|
||||
("arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.meta.llama4-scout-17b", "llama4-scout-17b"),
|
||||
("gpt-5.5", "gpt-5.5"),
|
||||
("unknownvendor.some-model", "unknownvendor.some-model"),
|
||||
],
|
||||
)
|
||||
def test_bedrock_vendor_model_name_strips_routing_region_and_vendor_prefixes_only(model, expected):
|
||||
assert bedrock_vendor_model_name(model) == expected
|
||||
|
||||
|
||||
def test_output_config_effort_normalization_uses_model_info_ceiling(monkeypatch):
|
||||
import litellm.llms.bedrock.common_utils as mod
|
||||
|
||||
|
|
|
|||
|
|
@ -1037,7 +1037,7 @@ class TestCodexModelSync:
|
|||
assert entry["supported_in_api"] is True
|
||||
assert entry["shell_type"] == "unified_exec"
|
||||
assert entry["supported_reasoning_levels"] == []
|
||||
assert entry["truncation_policy"] == {"mode": "bytes", "limit": 10000}
|
||||
assert entry["truncation_policy"] == {"mode": "tokens", "limit": 10000}
|
||||
assert entry["experimental_supported_tools"] == []
|
||||
assert entry["support_verbosity"] is False
|
||||
assert entry["supports_reasoning_summaries"] is False
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import replace
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal
|
||||
|
||||
|
|
@ -7,10 +8,10 @@ import pytest
|
|||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import get_service_tier_cost_key
|
||||
from litellm.proxy.common_utils.codex_model_catalog import (
|
||||
CODEX_CATALOG_BYTE_LIMIT,
|
||||
CodexCatalogRow,
|
||||
CodexServiceTier,
|
||||
CodexStockModel,
|
||||
CodexStockUpgrade,
|
||||
bundled_codex_models,
|
||||
|
|
@ -18,7 +19,9 @@ from litellm.proxy.common_utils.codex_model_catalog import (
|
|||
codex_model_list_body,
|
||||
codex_models_response_json,
|
||||
configured_service_tiers,
|
||||
deployment_capabilities,
|
||||
)
|
||||
from litellm.proxy.common_utils.codex_model_translation import CodexDeploymentCapabilities, CodexServiceTier
|
||||
|
||||
_PROMPT = "base prompt"
|
||||
_FAST = {"id": "priority", "name": "Fast", "description": "2x speed, increased usage"}
|
||||
|
|
@ -85,8 +88,8 @@ def test_body_is_exactly_codex_models_response():
|
|||
|
||||
|
||||
def test_unknown_model_entry_carries_every_field_codex_deserializes_without_a_default():
|
||||
"""Codex 0.159.3 `codex-rs/protocol/src/openai_models.rs` ModelInfo, the fields with no
|
||||
`#[serde(default)]`, read on 2026-10-01; `base_instructions` because its `ModelsResponse`
|
||||
"""Codex 0.166.0 `codex-rs/protocol/src/openai_models.rs` ModelInfo, the fields with no
|
||||
`#[serde(default)]`, read on 2026-11-02; `base_instructions` because its `ModelsResponse`
|
||||
decoder rejects an entry with neither it nor `model_messages.instructions_template`."""
|
||||
(entry,) = _models(_row("gpt-6-astra"))
|
||||
|
||||
|
|
@ -116,25 +119,14 @@ def test_unknown_model_entry_carries_every_field_codex_deserializes_without_a_de
|
|||
assert (entry["context_window"], entry["base_instructions"], entry["service_tiers"]) == (272000, _PROMPT, [])
|
||||
|
||||
|
||||
_NO_REASONING_BODY = (
|
||||
'{"models":[{"slug":"gpt-6-astra","display_name":"gpt-6-astra","description":null,'
|
||||
'"supported_reasoning_levels":[],"shell_type":"unified_exec","visibility":"list","supported_in_api":true,'
|
||||
'"priority":0,"service_tiers":[],"availability_nux":null,"upgrade":null,"support_verbosity":false,'
|
||||
'"supports_reasoning_summaries":false,"supports_parallel_tool_calls":false,"default_verbosity":null,'
|
||||
'"apply_patch_tool_type":null,"truncation_policy":{"mode":"bytes","limit":10000},'
|
||||
'"experimental_supported_tools":[],"context_window":272000,"base_instructions":"base prompt",'
|
||||
'"supports_search_tool":false}]}'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("efforts", [None, ()], ids=["unknown", "non-reasoning"])
|
||||
@pytest.mark.parametrize("client_version", ["0.165.0", "0.120.0", None])
|
||||
def test_unknown_or_no_reasoning_efforts_keep_the_fallback_entry_byte_for_byte(efforts, client_version):
|
||||
"""The body an unknown model got before reasoning efforts reached the catalog."""
|
||||
assert (
|
||||
_body(_row("gpt-6-astra", supported_reasoning_efforts=efforts), client_version=client_version).json
|
||||
== _NO_REASONING_BODY
|
||||
)
|
||||
def test_unknown_or_no_reasoning_efforts_offer_no_reasoning_level(efforts, client_version):
|
||||
"""Codex reads a missing default level as `none`, so an entry offering no level sends no effort."""
|
||||
(entry,) = _models(_row("mystery", supported_reasoning_efforts=efforts), client_version=client_version)
|
||||
|
||||
assert entry["supported_reasoning_levels"] == []
|
||||
assert "default_reasoning_level" not in entry
|
||||
|
||||
|
||||
_ABSENT = object()
|
||||
|
|
@ -724,7 +716,7 @@ def test_catalog_rows_read_the_router_by_each_entry_lookup_id():
|
|||
|
||||
rows = codex_catalog_rows(listing, (("gpt-5.5-team", "model_name_team-1_c0ffee"), ("plain", "plain")), router)
|
||||
|
||||
assert rows == (
|
||||
assert tuple(replace(row, deployment_capabilities=()) for row in rows) == (
|
||||
CodexCatalogRow(
|
||||
id="gpt-5.5-team",
|
||||
mode="chat",
|
||||
|
|
@ -746,6 +738,9 @@ def test_catalog_rows_read_the_router_by_each_entry_lookup_id():
|
|||
configured_max_input_tokens=(None,),
|
||||
),
|
||||
)
|
||||
catalog_window = GetModelCostMap.loaded_model_cost_map()["gpt-5.5"]["max_input_tokens"]
|
||||
assert [deployment.max_input_tokens for deployment in rows[0].deployment_capabilities] == [catalog_window] * 2
|
||||
assert rows[1].deployment_capabilities == (CodexDeploymentCapabilities(),)
|
||||
|
||||
|
||||
def test_catalog_rows_read_an_alias_off_its_target_under_the_alias_name():
|
||||
|
|
@ -765,7 +760,8 @@ def test_catalog_rows_read_an_alias_off_its_target_under_the_alias_name():
|
|||
|
||||
(row,) = codex_catalog_rows(listing, (("gpt-6", "gpt-6"),), router)
|
||||
|
||||
assert row == CodexCatalogRow(
|
||||
assert len(row.deployment_capabilities) == 1
|
||||
assert replace(row, deployment_capabilities=()) == CodexCatalogRow(
|
||||
id="gpt-6",
|
||||
mode="chat",
|
||||
max_input_tokens=9,
|
||||
|
|
@ -944,3 +940,211 @@ def test_codex_tool_search_is_offered_off_the_route_and_model_info_and_only_when
|
|||
"mixed": False,
|
||||
}
|
||||
assert models[0]["context_window"] == 1000000
|
||||
|
||||
|
||||
_SOL_SLUG: Final = next(
|
||||
slug for slug, model in bundled_codex_models().items() if model.default_service_tier is not None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"upstream",
|
||||
[
|
||||
"bedrock/us.openai.{slug}",
|
||||
"bedrock/global.openai.{slug}",
|
||||
"bedrock/converse/us.openai.{slug}",
|
||||
"bedrock/openai.{slug}",
|
||||
"bedrock_mantle/openai.{slug}",
|
||||
"bedrock_mantle/us-east-1/openai.{slug}",
|
||||
"openai/{slug}",
|
||||
"{slug}",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("slug", sorted(bundled_codex_models()))
|
||||
def test_a_model_codex_knows_keeps_codexs_entry_under_any_provider_prefix(upstream: str, slug: str) -> None:
|
||||
stock: Final = bundled_codex_models()[slug].model_dump(mode="json")
|
||||
|
||||
(entry,) = json.loads(
|
||||
codex_models_response_json(
|
||||
(_row("team-model", upstream_model=upstream.format(slug=slug)),), byte_limit=None
|
||||
).json
|
||||
)["models"]
|
||||
|
||||
assert entry["model_messages"] == stock["model_messages"]
|
||||
assert entry["supported_reasoning_levels"] == stock["supported_reasoning_levels"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"upstream",
|
||||
["bedrock/us.openai.{slug}-mini", "bedrock/us.meta.{slug}x", "bedrock_mantle/openai.{slug}.1", "{slug}-0"],
|
||||
)
|
||||
def test_a_model_whose_name_only_starts_like_one_codex_knows_gets_a_translated_entry(upstream: str) -> None:
|
||||
(entry,) = json.loads(
|
||||
codex_models_response_json(
|
||||
(_row("team-model", upstream_model=upstream.format(slug=_SOL_SLUG)),), instructions=_PROMPT
|
||||
).json
|
||||
)["models"]
|
||||
|
||||
assert "model_messages" not in entry and entry["base_instructions"] == _PROMPT
|
||||
|
||||
|
||||
def _listing(*names: str, max_input_tokens: int | None = None) -> tuple[dict[str, object], ...]:
|
||||
return tuple(
|
||||
{
|
||||
"id": name,
|
||||
"object": "model",
|
||||
"created": 0,
|
||||
"owned_by": "x",
|
||||
"mode": "chat",
|
||||
"max_input_tokens": max_input_tokens,
|
||||
}
|
||||
for name in names
|
||||
)
|
||||
|
||||
|
||||
def _served(router: litellm.Router, *names: str, team_id: str | None = None, **listing: int) -> dict[str, dict]:
|
||||
body: Final = codex_model_list_body(
|
||||
_listing(*names, **listing), tuple((name, name) for name in names), router, team_id, client_version="0.166.0"
|
||||
)
|
||||
return {entry["slug"]: entry for entry in json.loads(body)["models"]}
|
||||
|
||||
|
||||
def _vllm(name: str, host: str, **model_info: object) -> dict[str, object]:
|
||||
return {
|
||||
"model_name": name,
|
||||
"litellm_params": {"model": f"hosted_vllm/coder-{host}", "api_base": f"https://{host}.example/v1"},
|
||||
"model_info": model_info,
|
||||
}
|
||||
|
||||
|
||||
def test_a_text_only_deployment_takes_images_off_the_name_and_a_silent_one_does_not() -> None:
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
_vllm("mixed", "h0", supports_vision=True),
|
||||
_vllm("mixed", "h1", supports_vision=False),
|
||||
_vllm("vision-and-silent", "h2", supports_vision=True),
|
||||
_vllm("vision-and-silent", "h3"),
|
||||
]
|
||||
)
|
||||
|
||||
served: Final = _served(router, "mixed", "vision-and-silent")
|
||||
|
||||
assert served["mixed"]["input_modalities"] == ["text"]
|
||||
assert served["vision-and-silent"]["input_modalities"] == ["text", "image"]
|
||||
|
||||
|
||||
def test_a_silent_deployment_does_not_vote_against_tool_calls_but_one_without_them_does() -> None:
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
_vllm("tools-and-silent", "h4", supports_function_calling=True),
|
||||
_vllm("tools-and-silent", "h5"),
|
||||
_vllm("tools-and-none", "h6", supports_function_calling=True),
|
||||
_vllm("tools-and-none", "h7", supports_function_calling=False),
|
||||
]
|
||||
)
|
||||
|
||||
served: Final = _served(router, "tools-and-silent", "tools-and-none")
|
||||
|
||||
assert served["tools-and-silent"]["apply_patch_tool_type"] == "freeform"
|
||||
assert served["tools-and-none"]["apply_patch_tool_type"] is None
|
||||
|
||||
|
||||
def test_a_mixed_group_takes_the_smallest_window_any_deployment_declares() -> None:
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
_vllm("mixed", "h8", max_input_tokens=200_000),
|
||||
_vllm("mixed", "h9", max_input_tokens=128_000, supports_vision=True),
|
||||
_vllm("mixed", "h10"),
|
||||
_vllm("undeclared", "h11"),
|
||||
]
|
||||
)
|
||||
|
||||
served: Final = _served(router, "mixed", "undeclared", max_input_tokens=200_000)
|
||||
|
||||
assert served["mixed"]["context_window"] == 128_000
|
||||
assert served["undeclared"]["context_window"] == 200_000
|
||||
|
||||
|
||||
def test_a_claude_like_deployment_gets_codexs_coding_setup() -> None:
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
_vllm(
|
||||
"claude-like",
|
||||
"h-claude",
|
||||
max_input_tokens=1_000_000,
|
||||
supports_vision=True,
|
||||
supports_function_calling=True,
|
||||
supports_reasoning=True,
|
||||
),
|
||||
_vllm("plain", "h12"),
|
||||
]
|
||||
)
|
||||
|
||||
served: Final = _served(router, "claude-like", "plain")
|
||||
|
||||
claude_like: Final = served["claude-like"]
|
||||
assert claude_like["apply_patch_tool_type"] == "freeform"
|
||||
assert claude_like["truncation_policy"] == {"mode": "tokens", "limit": 10000}
|
||||
assert claude_like["supports_reasoning_summary_parameter"] is True
|
||||
assert claude_like["context_window"] == 1_000_000
|
||||
assert (served["plain"]["apply_patch_tool_type"], served["plain"]["supports_reasoning_summary_parameter"]) == (
|
||||
None,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def test_capabilities_are_read_only_off_the_deployments_the_key_team_routes_to() -> None:
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
_vllm("coder", "h13", supports_vision=True),
|
||||
_vllm("coder", "h14", supports_vision=False, team_id="team-2"),
|
||||
]
|
||||
)
|
||||
|
||||
assert _served(router, "coder", team_id="team-1")["coder"]["input_modalities"] == ["text", "image"]
|
||||
assert _served(router, "coder", team_id="team-2")["coder"]["input_modalities"] == ["text"]
|
||||
|
||||
|
||||
def _tier_fields(entry: Mapping[str, object]) -> tuple[object, object, object]:
|
||||
return (entry["service_tiers"], entry.get("additional_speed_tiers"), entry.get("default_service_tier"))
|
||||
|
||||
|
||||
def test_codexs_own_tiers_stay_only_when_every_deployment_behind_the_name_prices_them() -> None:
|
||||
stock: Final = bundled_codex_models()[_SOL_SLUG].model_dump(mode="json")
|
||||
assert stock["service_tiers"] and stock["additional_speed_tiers"] and stock["default_service_tier"]
|
||||
priority_price: Final = {"input_cost_per_token_priority": 1e-05}
|
||||
|
||||
def upstream(host: str) -> dict[str, str]:
|
||||
return {"model": f"hosted_vllm/{_SOL_SLUG}", "api_base": f"https://{host}.example/v1"}
|
||||
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{"model_name": "all-priced", "litellm_params": upstream("a"), "model_info": priority_price},
|
||||
{"model_name": "all-priced", "litellm_params": upstream("b"), "model_info": priority_price},
|
||||
{"model_name": "half-priced", "litellm_params": upstream("c"), "model_info": priority_price},
|
||||
{"model_name": "half-priced", "litellm_params": upstream("d")},
|
||||
{
|
||||
"model_name": "configured",
|
||||
"litellm_params": upstream("e"),
|
||||
"model_info": {"service_tiers": ["priority"]},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
served: Final = _served(router, "all-priced", "half-priced", "configured")
|
||||
|
||||
assert _tier_fields(served["all-priced"]) == _tier_fields(stock)
|
||||
assert _tier_fields(served["half-priced"]) == ([], [], None)
|
||||
assert served["half-priced"]["model_messages"] == stock["model_messages"]
|
||||
assert [tier["id"] for tier in served["configured"]["service_tiers"]] == ["priority"]
|
||||
|
||||
|
||||
|
||||
def test_model_info_that_does_not_validate_declares_no_capability_but_keeps_its_prices() -> None:
|
||||
priced: Final = get_service_tier_cost_key("input_cost_per_token", "priority")
|
||||
|
||||
capabilities: Final = deployment_capabilities(
|
||||
{"supports_vision": "sometimes", "supports_function_calling": False, priced: 1e-05}, "odd"
|
||||
)
|
||||
|
||||
assert capabilities == CodexDeploymentCapabilities(priced_service_tier_keys=frozenset({priced}))
|
||||
|
|
|
|||
218
tests/unit/proxy/common_utils/test_codex_model_translation.py
Normal file
218
tests/unit/proxy/common_utils/test_codex_model_translation.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import json
|
||||
from itertools import product
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import get_service_tier_cost_key
|
||||
from litellm.proxy.common_utils.codex_model_translation import (
|
||||
CodexCapabilities,
|
||||
CodexDeploymentCapabilities,
|
||||
CodexServiceTier,
|
||||
codex_fallback_entry,
|
||||
combined_capabilities,
|
||||
every_deployment_prices,
|
||||
)
|
||||
|
||||
_PROMPT: Final = "base prompt"
|
||||
_SILENT: Final = CodexDeploymentCapabilities()
|
||||
|
||||
|
||||
def _entry(capabilities: CodexCapabilities, **kwargs: object) -> dict[str, object]:
|
||||
entry: Final = codex_fallback_entry(
|
||||
slug="m", display_name="M", priority=3, capabilities=capabilities, instructions=_PROMPT, **kwargs
|
||||
)
|
||||
return json.loads(entry.model_dump_json())
|
||||
|
||||
|
||||
_CLAUDE_LIKE: Final = CodexCapabilities(
|
||||
context_window=1_000_000, supports_vision=True, supports_function_calling=True, supports_reasoning=True
|
||||
)
|
||||
|
||||
|
||||
def test_a_model_that_calls_tools_and_reasons_gets_codexs_own_coding_setup():
|
||||
entry: Final = _entry(_CLAUDE_LIKE)
|
||||
|
||||
assert entry["apply_patch_tool_type"] == "freeform"
|
||||
assert entry["truncation_policy"] == {"mode": "tokens", "limit": 10000}
|
||||
assert entry["supports_reasoning_summary_parameter"] is True
|
||||
assert entry["supports_reasoning_summaries"] is True
|
||||
assert entry["input_modalities"] == ["text", "image"]
|
||||
assert entry["context_window"] == 1_000_000
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flag", "field", "when_true", "otherwise"),
|
||||
[
|
||||
("supports_function_calling", "apply_patch_tool_type", "freeform", None),
|
||||
("supports_reasoning", "supports_reasoning_summary_parameter", True, False),
|
||||
("supports_reasoning", "supports_reasoning_summaries", True, False),
|
||||
("supports_parallel_function_calling", "supports_parallel_tool_calls", True, False),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("value", [True, False, None])
|
||||
def test_a_feature_that_sends_something_upstream_is_on_only_when_every_deployment_declares_it(
|
||||
flag, field, when_true, otherwise, value
|
||||
):
|
||||
entry: Final = _entry(CodexCapabilities(**{flag: value}))
|
||||
|
||||
assert entry[field] == (when_true if value is True else otherwise)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("vision", "modalities"), [(False, ["text"]), (True, ["text", "image"]), (None, ["text", "image"])]
|
||||
)
|
||||
def test_images_are_offered_unless_a_deployment_cannot_read_them(vision, modalities):
|
||||
assert _entry(CodexCapabilities(supports_vision=vision))["input_modalities"] == modalities
|
||||
|
||||
|
||||
def test_the_rest_of_the_entry_is_what_the_listing_decides():
|
||||
tier: Final = CodexServiceTier(id="flex", name="Flex", description="cheaper")
|
||||
|
||||
entry: Final = _entry(
|
||||
CodexCapabilities(),
|
||||
service_tiers=(tier,),
|
||||
supports_search_tool=True,
|
||||
reasoning_efforts=("low", "high"),
|
||||
client_version="0.166.0",
|
||||
)
|
||||
|
||||
assert (entry["slug"], entry["display_name"], entry["priority"], entry["base_instructions"]) == (
|
||||
"m",
|
||||
"M",
|
||||
3,
|
||||
_PROMPT,
|
||||
)
|
||||
assert entry["service_tiers"] == [{"id": "flex", "name": "Flex", "description": "cheaper"}]
|
||||
assert entry["supports_search_tool"] is True
|
||||
assert [level["effort"] for level in entry["supported_reasoning_levels"]] == ["low", "high"]
|
||||
assert entry["default_reasoning_level"] == "low"
|
||||
assert entry["context_window"] is None
|
||||
assert (entry["shell_type"], entry["visibility"], entry["supported_in_api"]) == ("unified_exec", "list", True)
|
||||
assert (entry["support_verbosity"], entry["use_responses_lite"]) == (False, False)
|
||||
assert "tool_mode" not in entry
|
||||
|
||||
|
||||
_REQUIRED_SINCE_CODEX_0_140: Final = frozenset(
|
||||
{
|
||||
"slug",
|
||||
"display_name",
|
||||
"description",
|
||||
"supported_reasoning_levels",
|
||||
"shell_type",
|
||||
"visibility",
|
||||
"supported_in_api",
|
||||
"priority",
|
||||
"availability_nux",
|
||||
"upgrade",
|
||||
"base_instructions",
|
||||
"supports_reasoning_summaries",
|
||||
"support_verbosity",
|
||||
"default_verbosity",
|
||||
"apply_patch_tool_type",
|
||||
"truncation_policy",
|
||||
"supports_parallel_tool_calls",
|
||||
"experimental_supported_tools",
|
||||
}
|
||||
)
|
||||
_CODEX_ENUMS: Final = {
|
||||
"shell_type": {"unified_exec", "disabled"},
|
||||
"visibility": {"list", "hide", "none"},
|
||||
"apply_patch_tool_type": {"freeform", None},
|
||||
}
|
||||
_CODEX_MODALITIES: Final = {"text", "image"}
|
||||
_CODEX_TRUNCATION_MODES: Final = {"bytes", "tokens"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flags", list(product((True, False, None), repeat=4)), ids=lambda flags: "-".join(map(str, flags))
|
||||
)
|
||||
def test_every_entry_carries_the_fields_and_enum_values_every_supported_codex_decodes(flags):
|
||||
"""`codex-rs/protocol/src/openai_models.rs` ModelInfo at rust-v0.140.0 and rust-v0.166.0, the fields with
|
||||
no `#[serde(default)]` and the variants of InputModality, ConfigShellToolType, ModelVisibility,
|
||||
ApplyPatchToolType and TruncationMode in both, read on 2026-11-02. Codex rejects the whole catalog when
|
||||
one entry does not decode."""
|
||||
vision, tools, parallel, reasoning = flags
|
||||
entry: Final = _entry(
|
||||
CodexCapabilities(
|
||||
context_window=128_000,
|
||||
supports_vision=vision,
|
||||
supports_function_calling=tools,
|
||||
supports_parallel_function_calling=parallel,
|
||||
supports_reasoning=reasoning,
|
||||
)
|
||||
)
|
||||
|
||||
assert entry.keys() >= _REQUIRED_SINCE_CODEX_0_140
|
||||
assert all(entry[field] in values for field, values in _CODEX_ENUMS.items())
|
||||
assert set(entry["input_modalities"]) <= _CODEX_MODALITIES and "text" in entry["input_modalities"]
|
||||
assert entry["truncation_policy"]["mode"] in _CODEX_TRUNCATION_MODES
|
||||
|
||||
|
||||
def test_a_deployment_that_says_nothing_does_not_vote():
|
||||
declares: Final = CodexDeploymentCapabilities(
|
||||
supports_vision=True,
|
||||
supports_function_calling=True,
|
||||
supports_parallel_function_calling=True,
|
||||
supports_reasoning=True,
|
||||
)
|
||||
|
||||
assert combined_capabilities((declares, _SILENT)) == CodexCapabilities(
|
||||
supports_vision=True,
|
||||
supports_function_calling=True,
|
||||
supports_parallel_function_calling=True,
|
||||
supports_reasoning=True,
|
||||
)
|
||||
assert combined_capabilities((_SILENT, _SILENT)) == CodexCapabilities()
|
||||
assert combined_capabilities(()) == CodexCapabilities()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flag",
|
||||
["supports_vision", "supports_function_calling", "supports_parallel_function_calling", "supports_reasoning"],
|
||||
)
|
||||
def test_one_deployment_lacking_a_capability_takes_it_off_the_whole_name(flag):
|
||||
has: Final = CodexDeploymentCapabilities(**{flag: True})
|
||||
lacks: Final = CodexDeploymentCapabilities(**{flag: False})
|
||||
|
||||
for order in ((has, lacks, _SILENT), (_SILENT, lacks, has)):
|
||||
assert getattr(combined_capabilities(order), flag) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("windows", "listed", "expected"),
|
||||
[
|
||||
((200_000, 128_000, None), 400_000, 128_000),
|
||||
((None, 64_000), 400_000, 64_000),
|
||||
((None, None), 400_000, 400_000),
|
||||
((), 400_000, 400_000),
|
||||
((0, -1, 32_000), None, 32_000),
|
||||
((0,), None, None),
|
||||
],
|
||||
)
|
||||
def test_the_window_is_the_smallest_any_deployment_declares(windows, listed, expected):
|
||||
deployments: Final = tuple(CodexDeploymentCapabilities(max_input_tokens=window) for window in windows)
|
||||
|
||||
assert combined_capabilities(deployments, listed).context_window == expected
|
||||
|
||||
|
||||
def _priced(*tier_ids: str) -> CodexDeploymentCapabilities:
|
||||
return CodexDeploymentCapabilities(
|
||||
priced_service_tier_keys=frozenset(get_service_tier_cost_key("input_cost_per_token", tier) for tier in tier_ids)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("deployments", "tier", "priced"),
|
||||
[
|
||||
((_priced("priority"), _priced("priority", "flex")), "priority", True),
|
||||
((_priced("priority"), _priced("flex")), "priority", False),
|
||||
((_priced("priority"), _SILENT), "priority", False),
|
||||
((_priced("priority"),), "fast", True),
|
||||
((_priced("flex"),), "fast", False),
|
||||
((_priced("priority", "flex"),), "made-up-tier", False),
|
||||
((), "priority", True),
|
||||
],
|
||||
)
|
||||
def test_a_tier_is_priced_only_when_every_deployment_prices_it_the_way_cost_tracking_does(deployments, tier, priced):
|
||||
assert every_deployment_prices(tier, deployments) is priced
|
||||
|
|
@ -59,6 +59,7 @@ def patched_models(monkeypatch):
|
|||
router.get_configured_display_name = MagicMock(return_value=None)
|
||||
router.get_configured_service_tiers = MagicMock(return_value=())
|
||||
router.get_configured_max_input_tokens = MagicMock(return_value=())
|
||||
router.get_routable_deployment_model_info = MagicMock(return_value=())
|
||||
router.get_model_group_info = MagicMock(return_value=None)
|
||||
router.get_search_tool_support = MagicMock(return_value=())
|
||||
route_entry_points_to_stubs(router)
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ def route_entry_points_to_stubs(router: _RouterDouble) -> _RouterDouble:
|
|||
return router.get_configured_service_tiers(name, **_given(team_id=team_id))
|
||||
if field == "max_input_tokens":
|
||||
return router.get_configured_max_input_tokens(name, **_given(team_id=team_id))
|
||||
if field == "deployment_model_info":
|
||||
return router.get_routable_deployment_model_info(name, **_given(team_id=team_id))
|
||||
if field == "listing_info":
|
||||
return router.get_model_listing_info(name)
|
||||
if field == "mode":
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue