mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #23314 from Chesars/feat/model-cost-aliases-clean
feat: add model_cost aliases expansion support
This commit is contained in:
commit
0d9afb2200
3 changed files with 324 additions and 6 deletions
|
|
@ -13,6 +13,7 @@ Here's the full specification with all available fields:
|
|||
```json
|
||||
{
|
||||
"sample_spec": {
|
||||
"aliases": ["optional list of alternate names for this model, e.g. dated versions like sample_spec-20250101"],
|
||||
"code_interpreter_cost_per_session": 0.0,
|
||||
"computer_use_input_cost_per_1k_tokens": 0.0,
|
||||
"computer_use_output_cost_per_1k_tokens": 0.0,
|
||||
|
|
@ -121,4 +122,28 @@ Here's the full specification with all available fields:
|
|||
}
|
||||
```
|
||||
|
||||
That's it! Your PR will be reviewed and merged.
|
||||
### Using Aliases
|
||||
|
||||
Many providers release the same model under multiple names — for example, a `latest` tag and a dated version like `claude-sonnet-4-5-20250929`. Instead of duplicating the entire entry, you can use the `aliases` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"claude-sonnet-4-5": {
|
||||
"aliases": ["claude-sonnet-4-5-20250929"],
|
||||
"input_cost_per_token": 3e-06,
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"litellm_provider": "anthropic",
|
||||
"max_input_tokens": 200000,
|
||||
"max_output_tokens": 64000,
|
||||
"mode": "chat",
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
At load time, each alias is expanded into a top-level entry sharing the same data as the canonical entry. The example above makes both `claude-sonnet-4-5` and `claude-sonnet-4-5-20250929` resolve with the same pricing and capabilities.
|
||||
|
||||
:::info
|
||||
This is different from [`model_alias_map`](../completion/model_alias.md), which is a runtime SDK/proxy feature for mapping user-facing model names to LiteLLM model identifiers. The `aliases` field here is for the model cost JSON only — it avoids duplicate entries for models that share identical pricing and capabilities.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export LITELLM_LOCAL_MODEL_COST_MAP=True
|
|||
import json
|
||||
import os
|
||||
from importlib.resources import files
|
||||
from typing import Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -183,6 +183,61 @@ def get_model_cost_map_source_info() -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _expand_model_aliases(model_cost: dict) -> dict:
|
||||
"""
|
||||
Expand ``aliases`` lists in model cost entries into top-level entries.
|
||||
|
||||
Each alias gets a reference to the **same** dict object as the canonical
|
||||
entry (zero memory overhead). The ``aliases`` key is removed from the
|
||||
entry so downstream code never sees it.
|
||||
|
||||
If an alias collides with an existing canonical entry the alias is
|
||||
skipped and a warning is logged.
|
||||
"""
|
||||
aliases_to_add: Dict[str, dict] = {}
|
||||
keys_with_aliases: List[str] = []
|
||||
|
||||
for model_name, model_info in model_cost.items():
|
||||
aliases: Optional[list] = model_info.get("aliases")
|
||||
if aliases is None:
|
||||
continue
|
||||
keys_with_aliases.append(model_name)
|
||||
if not isinstance(aliases, list):
|
||||
verbose_logger.warning(
|
||||
"LiteLLM model alias field for '%s' is not a list (got %s) — skipping.",
|
||||
model_name,
|
||||
type(aliases).__name__,
|
||||
)
|
||||
continue
|
||||
if not aliases:
|
||||
continue
|
||||
for alias in aliases:
|
||||
if alias in model_cost:
|
||||
verbose_logger.warning(
|
||||
"LiteLLM model alias conflict: alias '%s' (from '%s') "
|
||||
"already exists as a canonical entry — skipping.",
|
||||
alias,
|
||||
model_name,
|
||||
)
|
||||
continue
|
||||
if alias in aliases_to_add:
|
||||
verbose_logger.warning(
|
||||
"LiteLLM model alias conflict: alias '%s' (from '%s') "
|
||||
"was already claimed by another entry — skipping.",
|
||||
alias,
|
||||
model_name,
|
||||
)
|
||||
continue
|
||||
aliases_to_add[alias] = model_info # same dict reference
|
||||
|
||||
# Remove the ``aliases`` key from entries so it doesn't pollute model info
|
||||
for key in keys_with_aliases:
|
||||
model_cost[key].pop("aliases", None)
|
||||
|
||||
model_cost.update(aliases_to_add)
|
||||
return model_cost
|
||||
|
||||
|
||||
def get_model_cost_map(url: str) -> dict:
|
||||
"""
|
||||
Public entry point — returns the model cost map dict.
|
||||
|
|
@ -202,7 +257,7 @@ def get_model_cost_map(url: str) -> dict:
|
|||
_cost_map_source_info.url = None
|
||||
_cost_map_source_info.is_env_forced = True
|
||||
_cost_map_source_info.fallback_reason = None
|
||||
return GetModelCostMap.load_local_model_cost_map()
|
||||
return _expand_model_aliases(GetModelCostMap.load_local_model_cost_map())
|
||||
|
||||
_cost_map_source_info.url = url
|
||||
_cost_map_source_info.is_env_forced = False
|
||||
|
|
@ -218,7 +273,7 @@ def get_model_cost_map(url: str) -> dict:
|
|||
)
|
||||
_cost_map_source_info.source = "local"
|
||||
_cost_map_source_info.fallback_reason = f"Remote fetch failed: {str(e)}"
|
||||
return GetModelCostMap.load_local_model_cost_map()
|
||||
return _expand_model_aliases(GetModelCostMap.load_local_model_cost_map())
|
||||
|
||||
# Validate using cached count (cheap int comparison, no file I/O)
|
||||
if not GetModelCostMap.validate_model_cost_map(
|
||||
|
|
@ -232,8 +287,8 @@ def get_model_cost_map(url: str) -> dict:
|
|||
)
|
||||
_cost_map_source_info.source = "local"
|
||||
_cost_map_source_info.fallback_reason = "Remote data failed integrity validation"
|
||||
return GetModelCostMap.load_local_model_cost_map()
|
||||
return _expand_model_aliases(GetModelCostMap.load_local_model_cost_map())
|
||||
|
||||
_cost_map_source_info.source = "remote"
|
||||
_cost_map_source_info.fallback_reason = None
|
||||
return content
|
||||
return _expand_model_aliases(content)
|
||||
|
|
|
|||
238
tests/test_litellm/test_model_cost_aliases.py
Normal file
238
tests/test_litellm/test_model_cost_aliases.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"""
|
||||
Tests for the ``aliases`` feature in the model cost map.
|
||||
|
||||
The ``_expand_model_aliases`` function processes ``aliases`` lists from model
|
||||
entries, creating shared dict references for alias entries at load time.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from litellm.litellm_core_utils.get_model_cost_map import _expand_model_aliases
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core expansion behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExpandModelAliases:
|
||||
"""Unit tests for _expand_model_aliases."""
|
||||
|
||||
def test_basic_expansion(self):
|
||||
"""Aliases are added as top-level entries in model_cost."""
|
||||
model_cost = {
|
||||
"my-model-latest": {
|
||||
"aliases": ["my-model-20250101"],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
assert "my-model-20250101" in result
|
||||
assert result["my-model-20250101"]["input_cost_per_token"] == 1e-06
|
||||
assert result["my-model-20250101"]["litellm_provider"] == "test"
|
||||
|
||||
def test_multiple_aliases(self):
|
||||
"""A single entry can declare multiple aliases."""
|
||||
model_cost = {
|
||||
"provider/model-latest": {
|
||||
"aliases": ["provider/model-v1", "provider/model-v2"],
|
||||
"input_cost_per_token": 5e-06,
|
||||
"litellm_provider": "provider",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
assert "provider/model-v1" in result
|
||||
assert "provider/model-v2" in result
|
||||
|
||||
def test_shared_dict_reference(self):
|
||||
"""Alias entries share the same dict object as the canonical entry (no copy)."""
|
||||
model_cost = {
|
||||
"canonical-model": {
|
||||
"aliases": ["alias-model"],
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
assert result["alias-model"] is result["canonical-model"]
|
||||
|
||||
def test_aliases_key_removed(self):
|
||||
"""The ``aliases`` key is removed from the entry after expansion."""
|
||||
model_cost = {
|
||||
"my-model": {
|
||||
"aliases": ["my-model-alias"],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
assert "aliases" not in result["my-model"]
|
||||
assert "aliases" not in result["my-model-alias"]
|
||||
|
||||
def test_entries_without_aliases_unchanged(self):
|
||||
"""Entries with no ``aliases`` key are left untouched."""
|
||||
model_cost = {
|
||||
"plain-model": {
|
||||
"input_cost_per_token": 3e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
assert "plain-model" in result
|
||||
assert result["plain-model"]["input_cost_per_token"] == 3e-06
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_aliases_list(self):
|
||||
"""An empty ``aliases`` list is treated the same as no aliases."""
|
||||
model_cost = {
|
||||
"model-a": {
|
||||
"aliases": [],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "model-a" in result
|
||||
assert "aliases" not in result["model-a"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conflict handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAliasConflicts:
|
||||
"""Tests for alias conflict detection and handling."""
|
||||
|
||||
def test_alias_conflicts_with_canonical_entry(self, caplog):
|
||||
"""Alias that matches an existing canonical entry is skipped with a warning."""
|
||||
model_cost = {
|
||||
"model-latest": {
|
||||
"aliases": ["model-dated"],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
"model-dated": {
|
||||
"input_cost_per_token": 1e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
# The canonical "model-dated" entry is preserved, not overwritten
|
||||
assert "model-dated" in result
|
||||
assert "alias conflict" in caplog.text.lower()
|
||||
|
||||
def test_duplicate_alias_across_entries(self, caplog):
|
||||
"""Same alias claimed by two different entries: second one is skipped."""
|
||||
model_cost = {
|
||||
"model-a": {
|
||||
"aliases": ["shared-alias"],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
"model-b": {
|
||||
"aliases": ["shared-alias"],
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
# "shared-alias" should point to model-a (first one wins)
|
||||
assert "shared-alias" in result
|
||||
assert result["shared-alias"]["input_cost_per_token"] == 1e-06
|
||||
assert "alias conflict" in caplog.text.lower()
|
||||
|
||||
def test_canonical_entry_not_overwritten_by_alias(self):
|
||||
"""An alias must never overwrite an existing canonical entry's data."""
|
||||
original_cost = 9.99e-06
|
||||
model_cost = {
|
||||
"existing-model": {
|
||||
"input_cost_per_token": original_cost,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
"other-model": {
|
||||
"aliases": ["existing-model"],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
# Original entry must be preserved
|
||||
assert result["existing-model"]["input_cost_per_token"] == original_cost
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration with model_cost dict mutation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAliasIntegration:
|
||||
"""Higher-level tests verifying aliases work with the model_cost dict."""
|
||||
|
||||
def test_mutation_through_alias_visible_on_canonical(self):
|
||||
"""Since alias is a shared reference, mutations are visible on both."""
|
||||
model_cost = {
|
||||
"canonical": {
|
||||
"aliases": ["alias"],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
# Mutate via alias
|
||||
result["alias"]["input_cost_per_token"] = 999
|
||||
assert result["canonical"]["input_cost_per_token"] == 999
|
||||
|
||||
def test_mixed_entries_with_and_without_aliases(self):
|
||||
"""A model_cost dict with a mix of aliased and plain entries."""
|
||||
model_cost = {
|
||||
"model-with-alias": {
|
||||
"aliases": ["alias-1", "alias-2"],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
"plain-model": {
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "test",
|
||||
"mode": "chat",
|
||||
},
|
||||
}
|
||||
result = _expand_model_aliases(model_cost)
|
||||
|
||||
assert len(result) == 4 # 2 canonical + 2 aliases
|
||||
assert "alias-1" in result
|
||||
assert "alias-2" in result
|
||||
assert "plain-model" in result
|
||||
assert "model-with-alias" in result
|
||||
|
||||
def test_expand_on_empty_dict(self):
|
||||
"""Expanding an empty dict returns an empty dict."""
|
||||
assert _expand_model_aliases({}) == {}
|
||||
Loading…
Add table
Reference in a new issue