fix(cost-map): give the bare gpt-4o, gpt-4o-mini, gpt-4.1 and gpt-4.1-mini aliases their 2027-01-14 shutdown date (#49707)

* fix(cost-map): give the bare gpt-4o, gpt-4o-mini, gpt-4.1 and gpt-4.1-mini aliases their 2027-01-14 shutdown date

* fix(proxy): report a deployment's own provider retirement date before the bare model's

An Azure deployment declared as model gpt-4o with custom_llm_provider azure, or as
azure/<deployment> with base_model gpt-4o, now resolves azure/gpt-4o's date instead of
the bare OpenAI row's. The GPT-4 series alias test now reads both cost maps with typed rows

* fix(proxy): read Azure's retirement schedule for azure_ai deployments

An azure_ai deployment with no azure_ai row of its own now reports the azure/<model> date, and a cost-map row wins over the provider-less copy the router registers under the deployment's key, which carries the bare model's date.

* fix(proxy): let custom and registered base_model rows keep their deprecation date

Only the copy the router writes at startup, which has no provider and inherits the bare row's date, gives way to a later candidate

* fix(proxy): report a pinned snapshot's own earlier deprecation date over its base_model's

* fix(proxy): recognize the router's copied cost-map rows by the loaded catalog

* fix(proxy): read Azure's schedule for a provider-prefixed azure_ai base_model

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
zoveki 2026-11-02 18:58:39 -08:00 • committed by GitHub
parent 28c80048ed
commit 704e73d6fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 263 additions and 66 deletions

View file

@ -34809,6 +34809,7 @@
"gpt-4.1": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_priority": 8.75e-07,
"deprecation_date": "2027-01-14",
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
"input_cost_per_token_priority": 3.5e-06,
@ -34894,6 +34895,7 @@
"gpt-4.1-mini": {
"cache_read_input_token_cost": 1e-07,
"cache_read_input_token_cost_priority": 1.75e-07,
"deprecation_date": "2027-01-14",
"input_cost_per_token": 4e-07,
"input_cost_per_token_batches": 2e-07,
"input_cost_per_token_priority": 7e-07,
@ -35053,6 +35055,7 @@
"gpt-4o": {
"cache_read_input_token_cost": 1.25e-06,
"cache_read_input_token_cost_priority": 2.125e-06,
"deprecation_date": "2027-01-14",
"input_cost_per_token": 2.5e-06,
"input_cost_per_token_batches": 1.25e-06,
"input_cost_per_token_priority": 4.25e-06,
@ -35397,6 +35400,7 @@
"gpt-4o-mini": {
"cache_read_input_token_cost": 7.5e-08,
"cache_read_input_token_cost_priority": 1.25e-07,
"deprecation_date": "2027-01-14",
"input_cost_per_token": 1.5e-07,
"input_cost_per_token_batches": 7.5e-08,
"input_cost_per_token_priority": 2.5e-07,

View file

@ -1,14 +1,15 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date, datetime, timezone
from itertools import groupby
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, Final, TypeAlias
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.get_model_cost_map import AliasedCostEntry, GetModelCostMap
from litellm.types.proxy.model_deprecation import (
DEFAULT_DEPRECATION_WARN_DAYS,
DeprecationStatus,
@ -20,6 +21,9 @@ if TYPE_CHECKING:
from litellm.router import Router
_NO_MODEL_METADATA: Final[Mapping[str, object]] = MappingProxyType({})
_SHARED_SCHEDULE_PROVIDER: Final[Mapping[str, str]] = MappingProxyType({"azure_ai": "azure"})
LoadedCatalog: TypeAlias = Callable[[], Mapping[str, Mapping[str, object]]]
@dataclass(frozen=True, slots=True)
@ -27,6 +31,7 @@ class _ResolvedDeprecation:
deprecation_date: date
litellm_model: str | None
litellm_provider: str | None
copied_from_another_row: bool = False
def _parse_deprecation_date(raw_value: object) -> date | None:
@ -47,8 +52,8 @@ def _successor_model(raw_value: object) -> str | None:
return raw_value.strip() or None if isinstance(raw_value, str) else None
def _cost_map_lookup(model_key: object) -> _ResolvedDeprecation | None:
if not isinstance(model_key, str) or not model_key:
def _cost_map_lookup(model_key: str | None, catalog: Mapping[str, object]) -> _ResolvedDeprecation | None:
if model_key is None:
return None
entry: Final = litellm.model_cost.get(model_key)
if not isinstance(entry, Mapping):
@ -61,6 +66,51 @@ def _cost_map_lookup(model_key: object) -> _ResolvedDeprecation | None:
deprecation_date=parsed,
litellm_model=model_key,
litellm_provider=provider if isinstance(provider, str) else None,
copied_from_another_row=isinstance(entry, AliasedCostEntry)
and "deprecation_date" not in entry.registered_fields
and model_key not in catalog,
)
def _first_own_row(model_keys: Sequence[str | None], catalog: Mapping[str, object]) -> _ResolvedDeprecation | None:
found: Final = tuple(
resolved for resolved in (_cost_map_lookup(key, catalog) for key in model_keys) if resolved is not None
)
return next((resolved for resolved in found if not resolved.copied_from_another_row), next(iter(found), None))
def _deployment_provider(litellm_params: Mapping[str, object]) -> str | None:
declared: Final = litellm_params.get("custom_llm_provider")
if isinstance(declared, str) and declared:
return declared
model: Final = litellm_params.get("model")
prefix: Final = model.split("/", 1)[0] if isinstance(model, str) and "/" in model else None
return prefix if prefix in litellm.provider_list else None
def _provider_qualified(provider: str | None, model_key: str | None) -> str | None:
if provider is None or model_key is None or model_key.startswith(f"{provider}/"):
return None
return f"{provider}/{model_key}"
def _as_model_key(raw_value: object) -> str | None:
return raw_value if isinstance(raw_value, str) and raw_value else None
def _without_provider(provider: str | None, model_name: str | None) -> str | None:
if provider is None or model_name is None:
return model_name
return model_name.removeprefix(f"{provider}/")
def _schedule_keys(provider: str | None, model_name: str | None) -> tuple[str | None, ...]:
shared_provider: Final = _SHARED_SCHEDULE_PROVIDER.get(provider) if provider is not None else None
unprefixed: Final = _without_provider(provider, model_name)
return (
_provider_qualified(provider, unprefixed),
_provider_qualified(shared_provider, unprefixed),
model_name,
)
@ -70,34 +120,37 @@ def _mapping_field(deployment: Mapping[str, object], key: str) -> Mapping[str, o
def _resolve_deployment_deprecation(
deployment: Mapping[str, object],
deployment: Mapping[str, object], catalog: Mapping[str, object]
) -> _ResolvedDeprecation | None:
"""Resolve a deployment's deprecation date, preferring its explicit override"""
model_info: Final = _mapping_field(deployment, "model_info")
raw_model: Final = _mapping_field(deployment, "litellm_params").get("model")
litellm_params: Final = _mapping_field(deployment, "litellm_params")
model_key: Final = _as_model_key(litellm_params.get("model"))
override: Final = _parse_deprecation_date(model_info.get("deprecation_date"))
if override is not None:
provider: Final = model_info.get("litellm_provider")
override_provider: Final = model_info.get("litellm_provider")
return _ResolvedDeprecation(
deprecation_date=override,
litellm_model=raw_model if isinstance(raw_model, str) else None,
litellm_provider=provider if isinstance(provider, str) else None,
litellm_model=model_key,
litellm_provider=override_provider if isinstance(override_provider, str) else None,
)
unprefixed: Final = raw_model.split("/", 1)[1] if isinstance(raw_model, str) and "/" in raw_model else None
return next(
(
resolved
for resolved in (
_cost_map_lookup(model_info.get("base_model")),
_cost_map_lookup(raw_model),
_cost_map_lookup(unprefixed),
)
if resolved is not None
),
None,
provider: Final = _deployment_provider(litellm_params)
base_model: Final = _as_model_key(model_info.get("base_model"))
model_name: Final = model_key.split("/", 1)[1] if model_key is not None and "/" in model_key else model_key
picks: Final = tuple(
pick
for pick in (
_first_own_row(_schedule_keys(provider, base_model), catalog),
_first_own_row(
(_provider_qualified(provider, model_key), model_key, *_schedule_keys(provider, model_name)), catalog
),
)
if pick is not None
)
own_rows: Final = tuple(pick for pick in picks if not pick.copied_from_another_row)
return min(own_rows or picks, key=lambda pick: pick.deprecation_date, default=None)
def _classify(days_until: int, warn_within_days: int) -> DeprecationStatus:
@ -124,13 +177,17 @@ def _checked_successor(model_name: str, model_info: Mapping[str, object], llm_ro
def _build_info(
deployment: Mapping[str, object], llm_router: Router, today: date, warn_within_days: int
deployment: Mapping[str, object],
llm_router: Router,
today: date,
warn_within_days: int,
catalog: Mapping[str, object],
) -> ModelDeprecationInfo | None:
model_name: Final = deployment.get("model_name")
if not isinstance(model_name, str) or not model_name:
return None
resolved: Final = _resolve_deployment_deprecation(deployment)
resolved: Final = _resolve_deployment_deprecation(deployment, catalog)
if resolved is None:
return None
@ -182,15 +239,12 @@ def _bucket(models: Sequence[ModelDeprecationInfo], status: DeprecationStatus) -
)
def _collect_from_router(llm_router: Router, today: date, warn_within_days: int) -> tuple[ModelDeprecationInfo, ...]:
def _collect_from_router(
llm_router: Router, today: date, warn_within_days: int, catalog: Mapping[str, object]
) -> tuple[ModelDeprecationInfo, ...]:
deployments: Final = llm_router.registry().deployments() or ()
deduped: Final = _dedupe(
tuple(
info
for info in (_build_info(deployment, llm_router, today, warn_within_days) for deployment in deployments)
if info is not None
)
)
infos: Final = (_build_info(deployment, llm_router, today, warn_within_days, catalog) for deployment in deployments)
deduped: Final = _dedupe(tuple(info for info in infos if info is not None))
verbose_logger.debug(
"model_deprecation: %d/%d deployments carry a deprecation date",
@ -204,12 +258,15 @@ def collect_model_deprecations(
llm_router: Router | None,
warn_within_days: int = DEFAULT_DEPRECATION_WARN_DAYS,
today: date | None = None,
loaded_catalog: LoadedCatalog = GetModelCostMap.loaded_model_cost_map,
) -> ModelDeprecationResponse:
"""Bucket every deployment carrying a deprecation date by how urgent it is"""
snapshot_time: Final = datetime.now(timezone.utc)
effective_today: Final = today or snapshot_time.date()
deduped: Final = (
_collect_from_router(llm_router, effective_today, warn_within_days) if llm_router is not None else ()
_collect_from_router(llm_router, effective_today, warn_within_days, loaded_catalog())
if llm_router is not None
else ()
)
return ModelDeprecationResponse(

View file

@ -34809,6 +34809,7 @@
"gpt-4.1": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_priority": 8.75e-07,
"deprecation_date": "2027-01-14",
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
"input_cost_per_token_priority": 3.5e-06,
@ -34894,6 +34895,7 @@
"gpt-4.1-mini": {
"cache_read_input_token_cost": 1e-07,
"cache_read_input_token_cost_priority": 1.75e-07,
"deprecation_date": "2027-01-14",
"input_cost_per_token": 4e-07,
"input_cost_per_token_batches": 2e-07,
"input_cost_per_token_priority": 7e-07,
@ -35053,6 +35055,7 @@
"gpt-4o": {
"cache_read_input_token_cost": 1.25e-06,
"cache_read_input_token_cost_priority": 2.125e-06,
"deprecation_date": "2027-01-14",
"input_cost_per_token": 2.5e-06,
"input_cost_per_token_batches": 1.25e-06,
"input_cost_per_token_priority": 4.25e-06,
@ -35397,6 +35400,7 @@
"gpt-4o-mini": {
"cache_read_input_token_cost": 7.5e-08,
"cache_read_input_token_cost_priority": 1.25e-07,
"deprecation_date": "2027-01-14",
"input_cost_per_token": 1.5e-07,
"input_cost_per_token_batches": 7.5e-08,
"input_cost_per_token_priority": 2.5e-07,

View file

@ -5,14 +5,17 @@ Slack integration — so they can run without the full proxy stack.
"""
import logging
from collections.abc import Mapping
from datetime import date, datetime, timezone
from typing import Final
from unittest.mock import MagicMock
import pytest
import litellm
from litellm import Router
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.get_model_cost_map import AliasedCostEntry
from litellm.proxy.common_utils.model_deprecation import (
_classify,
_parse_deprecation_date,
@ -37,12 +40,7 @@ class TestParseDeprecationDate:
def test_should_return_none_for_documentation_sentinel(self):
# The JSON map ships a sentinel string under the "sample_spec" key.
assert (
_parse_deprecation_date(
"date when the model becomes deprecated in the format YYYY-MM-DD"
)
is None
)
assert _parse_deprecation_date("date when the model becomes deprecated in the format YYYY-MM-DD") is None
def test_should_return_none_for_none(self):
assert _parse_deprecation_date(None) is None
@ -51,9 +49,7 @@ class TestParseDeprecationDate:
assert _parse_deprecation_date(12345) is None
def test_should_narrow_datetime_to_date(self):
assert _parse_deprecation_date(
datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc)
) == date(2026, 12, 31)
assert _parse_deprecation_date(datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc)) == date(2026, 12, 31)
class TestClassify:
@ -134,9 +130,7 @@ class TestCollectModelDeprecations:
]
)
snapshot = collect_model_deprecations(
llm_router=router, warn_within_days=30, today=today
)
snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today)
assert [m.model_name for m in snapshot.deprecated] == ["deprecated-alias"]
assert [m.model_name for m in snapshot.imminent] == ["imminent-alias"]
@ -166,9 +160,7 @@ class TestCollectModelDeprecations:
]
)
snapshot = collect_model_deprecations(
llm_router=router, warn_within_days=30, today=today
)
snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today)
assert len(snapshot.imminent) == 1
assert snapshot.imminent[0].deprecation_date == date(2026, 6, 10)
@ -195,9 +187,7 @@ class TestCollectModelDeprecations:
]
)
snapshot = collect_model_deprecations(
llm_router=router, warn_within_days=30, today=today
)
snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today)
assert len(snapshot.imminent) == 1
@ -217,9 +207,7 @@ class TestCollectModelDeprecations:
]
)
snapshot = collect_model_deprecations(
llm_router=router, warn_within_days=30, today=date(2026, 6, 1)
)
snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1))
assert [m.litellm_model for m in snapshot.imminent] == ["gpt-4o"]
@ -244,9 +232,7 @@ class TestCollectModelDeprecations:
]
)
snapshot = collect_model_deprecations(
llm_router=router, warn_within_days=30, today=date(2026, 6, 1)
)
snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=date(2026, 6, 1))
assert len(snapshot.imminent) == 1
assert len(snapshot.upcoming) == 1
@ -268,14 +254,136 @@ class TestCollectModelDeprecations:
]
)
snapshot = collect_model_deprecations(
llm_router=router, warn_within_days=30, today=today
)
snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today)
assert len(snapshot.imminent) == 1
assert snapshot.imminent[0].litellm_model == "base-thing"
class TestProviderScopedResolution:
CATALOG = {
"gpt-4o": {"deprecation_date": "2027-01-14", "litellm_provider": "openai"},
"azure/gpt-4o": {"deprecation_date": "2027-04-14", "litellm_provider": "azure"},
"github_copilot/gpt-4o": {"litellm_provider": "github_copilot"},
"azure_ai/gpt-4.1": AliasedCostEntry(
{"deprecation_date": "2027-05-01", "litellm_provider": "azure_ai", "mode": "chat"}, ()
),
"custom-base": {"deprecation_date": "2026-12-01"},
"gpt-4.1": {"deprecation_date": "2027-01-14", "litellm_provider": "openai"},
"azure/gpt-4.1": {"deprecation_date": "2027-04-14", "litellm_provider": "azure"},
"gpt-4o-2024-05-13": {"deprecation_date": "2026-10-23", "litellm_provider": "openai"},
"azure/gpt-4o-2024-05-13": {"deprecation_date": "2026-12-09", "litellm_provider": "azure"},
"azure/aliased-base": AliasedCostEntry(
{"deprecation_date": "2026-11-20"}, ("azure/aliased-base", "azure/aliased-base-v1")
),
"aliased-base": {"deprecation_date": "2027-02-01"},
}
COST_MAP = {
**CATALOG,
"azure_ai/gpt-4o": AliasedCostEntry(
{"deprecation_date": "2027-01-14", "litellm_provider": "azure", "mode": "chat"}, ()
),
"registered-base": AliasedCostEntry({"deprecation_date": "2026-12-15"}, (), frozenset({"deprecation_date"})),
}
@pytest.mark.parametrize(
("litellm_params", "model_info", "expected"),
[
({"model": "gpt-4o"}, {}, ("gpt-4o", date(2027, 1, 14), "openai")),
({"model": "openai/gpt-4o"}, {}, ("gpt-4o", date(2027, 1, 14), "openai")),
(
{"model": "gpt-4o", "custom_llm_provider": "azure"},
{},
("azure/gpt-4o", date(2027, 4, 14), "azure"),
),
({"model": "azure/gpt-4o"}, {}, ("azure/gpt-4o", date(2027, 4, 14), "azure")),
(
{"model": "azure/prod-chat"},
{"base_model": "gpt-4o"},
("azure/gpt-4o", date(2027, 4, 14), "azure"),
),
(
{"model": "gpt-4o", "custom_llm_provider": "github_copilot"},
{},
("gpt-4o", date(2027, 1, 14), "openai"),
),
({"model": "azure_ai/gpt-4o"}, {}, ("azure/gpt-4o", date(2027, 4, 14), "azure")),
(
{"model": "azure_ai/prod-chat"},
{"base_model": "gpt-4o"},
("azure/gpt-4o", date(2027, 4, 14), "azure"),
),
(
{"model": "azure_ai/gpt-4o"},
{"base_model": "azure_ai/gpt-4o"},
("azure/gpt-4o", date(2027, 4, 14), "azure"),
),
(
{"model": "azure_ai/prod-chat"},
{"base_model": "azure_ai/gpt-4o"},
("azure/gpt-4o", date(2027, 4, 14), "azure"),
),
({"model": "azure_ai/gpt-4.1"}, {}, ("azure_ai/gpt-4.1", date(2027, 5, 1), "azure_ai")),
({"model": "azure/gpt-4o"}, {"base_model": "custom-base"}, ("custom-base", date(2026, 12, 1), None)),
(
{"model": "azure/gpt-4o"},
{"base_model": "registered-base"},
("registered-base", date(2026, 12, 15), None),
),
(
{"model": "azure/prod-chat"},
{"base_model": "aliased-base"},
("azure/aliased-base", date(2026, 11, 20), None),
),
(
{"model": "openai/gpt-4o-2024-05-13"},
{"base_model": "gpt-4o"},
("gpt-4o-2024-05-13", date(2026, 10, 23), "openai"),
),
(
{"model": "azure/gpt-4o-2024-05-13"},
{"base_model": "gpt-4o"},
("azure/gpt-4o-2024-05-13", date(2026, 12, 9), "azure"),
),
],
ids=[
"bare",
"openai-prefix",
"declared-azure",
"azure-prefix",
"azure-base-model",
"undated-own-row",
"azure-ai-reads-azure",
"azure-ai-base-model-reads-azure",
"azure-ai-copied-base-model-yields-to-azure",
"azure-ai-prefixed-base-model-on-custom-deployment-reads-azure",
"azure-ai-own-row-wins",
"custom-base-model-row-wins",
"registered-base-model-row-wins",
"aliased-base-model-row-wins",
"openai-snapshot-shuts-down-before-its-base-model",
"azure-snapshot-shuts-down-before-its-base-model",
],
)
def test_should_prefer_the_deployments_own_provider_date(
self,
monkeypatch: pytest.MonkeyPatch,
litellm_params: Mapping[str, str],
model_info: Mapping[str, str],
expected: tuple[str, date, str | None],
) -> None:
monkeypatch.setattr(litellm, "model_cost", self.COST_MAP)
router: Final = _make_router(
[{"model_name": "alias", "litellm_params": litellm_params, "model_info": {"id": "1", **model_info}}]
)
snapshot: Final = collect_model_deprecations(
llm_router=router, warn_within_days=30, today=date(2026, 6, 1), loaded_catalog=lambda: self.CATALOG
)
assert [(m.litellm_model, m.deprecation_date, m.litellm_provider) for m in snapshot.upcoming] == [expected]
class TestFormatDeprecationAlertMessage:
def test_should_return_none_when_nothing_to_alert(self):
snapshot = collect_model_deprecations(llm_router=None)
@ -321,9 +429,7 @@ class TestFormatDeprecationAlertMessage:
]
)
snapshot = collect_model_deprecations(
llm_router=router, warn_within_days=30, today=today
)
snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today)
message = format_deprecation_alert_message(snapshot)
assert message is not None
@ -350,9 +456,7 @@ class TestFormatDeprecationAlertMessage:
]
)
snapshot = collect_model_deprecations(
llm_router=router, warn_within_days=30, today=today
)
snapshot = collect_model_deprecations(llm_router=router, warn_within_days=30, today=today)
message = format_deprecation_alert_message(snapshot)
assert message is not None

View file

@ -388,6 +388,34 @@ def test_vertex_gemini_flash_rows_carry_the_documented_retirement_date(prices: d
assert prices[f"{prefix}{model}"]["deprecation_date"] == VERTEX_GEMINI_FLASH_RETIREMENT_DATES[model]
# https://developers.openai.com/api/docs/deprecations, the 2026-10-14 GPT-4 series notice
OPENAI_GPT_4_SERIES_SNAPSHOTS: Final = MappingProxyType(
{
"gpt-4o": ("gpt-4o-2024-08-06", "gpt-4o-2024-11-20"),
"gpt-4o-mini": ("gpt-4o-mini-2024-07-18",),
"gpt-4.1": ("gpt-4.1-2025-04-14",),
"gpt-4.1-mini": ("gpt-4.1-mini-2025-04-14",),
}
)
class _ShutdownRow(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
deprecation_date: str | None = None
@pytest.mark.parametrize("path", (PRICES_PATH, BACKUP_PRICES_PATH), ids=("main", "backup"))
@pytest.mark.parametrize("alias", sorted(OPENAI_GPT_4_SERIES_SNAPSHOTS))
def test_openai_gpt_4_series_aliases_retire_with_their_snapshots(path: Path, alias: str) -> None:
prices: Final[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue]).validate_json(path.read_bytes())
snapshot_dates: Final = frozenset(
_ShutdownRow.model_validate(prices[snapshot]).deprecation_date
for snapshot in OPENAI_GPT_4_SERIES_SNAPSHOTS[alias]
)
assert snapshot_dates == {_ShutdownRow.model_validate(prices[alias]).deprecation_date}
def _sampling_flags_across_provider_twins(prices: Mapping[str, Mapping[str, object]], key: str) -> frozenset[object]:
bare: Final = key.split("/")[-1]
twins: Final = tuple(twin for twin in (bare, f"vertex_ai/{bare}", f"gemini/{bare}") if twin in prices)