fix(proxy): drop echoed cost-map pricing on a row's next save and build /model/info pricing stamps without mutation

This commit is contained in:
mateo-berri 2026-09-18 11:42:07 -07:00
parent b03957ba9c
commit 42541a9233
5 changed files with 162 additions and 14 deletions

View file

@ -137,7 +137,7 @@ from litellm.types.router import (
updateDeployment,
updateLiteLLMParams,
)
from litellm.types.utils import without_server_derived_pricing
from litellm.types.utils import echoed_cost_map_pricing_fields, without_server_derived_pricing
from litellm.utils import get_utc_datetime
if TYPE_CHECKING:
@ -876,7 +876,11 @@ def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> Pr
_raise_if_ptu_cost_attribution_disabled(updated_patch.model_info.model_dump(exclude_none=True))
merged_model_name: Final = updated_patch.model_name or db_model.model_name
merged_litellm_params: Final = db_model.litellm_params.model_dump(exclude_none=True)
merged_model_info: Final[dict[str, object]] = db_model.model_info.model_dump(exclude_none=True)
stored_model_info: Final = db_model.model_info.model_dump(exclude_none=True)
echoed_pricing: Final = echoed_cost_map_pricing_fields(stored_model_info)
merged_model_info: Final[dict[str, object]] = {
k: v for k, v in stored_model_info.items() if k not in echoed_pricing
}
# update litellm params
if updated_patch.litellm_params:

View file

@ -13630,12 +13630,17 @@ def _enrich_model_info_with_litellm_data(
discovered_model_info: Final = (
llm_router.get_discovered_model_info(model_info.get("id")) if llm_router is not None else MappingProxyType({})
)
for k, v in _pricing_override_stamps(model_info, model.get("litellm_params") or MappingProxyType({})).items():
model_info[k] = v
for k, v in MappingProxyType({**litellm_model_info, **discovered_model_info}).items():
if k not in model_info or (model_info[k] is None and k in discovered_model_info):
model_info[k] = v
model["model_info"] = model_info
stamped_model_info: Final = MappingProxyType(
{**model_info, **_pricing_override_stamps(model_info, model.get("litellm_params") or MappingProxyType({}))}
)
model["model_info"] = {
**stamped_model_info,
**{
k: v
for k, v in MappingProxyType({**litellm_model_info, **discovered_model_info}).items()
if k not in stamped_model_info or (stamped_model_info[k] is None and k in discovered_model_info)
},
}
# don't return the api key / vertex credentials
# don't return the llm credentials
model = remove_sensitive_info_from_deployment(model, excluded_keys={"litellm_credential_name"})
@ -15101,12 +15106,17 @@ def _get_proxy_model_info(model: dict) -> dict:
discovered_model_info: Final = (
llm_router.get_discovered_model_info(model_info.get("id")) if llm_router is not None else MappingProxyType({})
)
for k, v in _pricing_override_stamps(model_info, model.get("litellm_params") or MappingProxyType({})).items():
model_info[k] = v
for k, v in MappingProxyType({**litellm_model_info, **discovered_model_info}).items():
if k not in model_info or (model_info[k] is None and k in discovered_model_info):
model_info[k] = v
model["model_info"] = model_info
stamped_model_info: Final = MappingProxyType(
{**model_info, **_pricing_override_stamps(model_info, model.get("litellm_params") or MappingProxyType({}))}
)
model["model_info"] = {
**stamped_model_info,
**{
k: v
for k, v in MappingProxyType({**litellm_model_info, **discovered_model_info}).items()
if k not in stamped_model_info or (stamped_model_info[k] is None and k in discovered_model_info)
},
}
# don't return the llm credentials
model = remove_sensitive_info_from_deployment(deployment_dict=model, excluded_keys={"litellm_credential_name"})

View file

@ -3734,6 +3734,66 @@ class TestModelInfoServerDerivedPricingFilter:
assert info["access_groups"] == ["prod"]
assert "pricing_overrides" not in info
def test_a_row_pinned_before_1_102_drops_its_cost_map_copy_on_its_next_save(self, monkeypatch: pytest.MonkeyPatch):
"""LIT-8064. A stored ``model_info`` carrying ``key`` is a ``/model/info`` response an old
UI wrote back, so its pricing is the cost map of that day. The next edit of the row, here
only its reasoning level, leaves that copy behind and keeps everything the operator set."""
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.management_endpoints.model_management_endpoints import (
update_db_model,
)
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-lit8064-heal-on-save")
db_model = Deployment(
model_name="gpt-5.6",
litellm_params=LiteLLM_Params(model="openai/gpt-5.6", reasoning_effort="medium"),
model_info=ModelInfo(
id="dep-pinned-0",
key="gpt-5.6",
mode="chat",
access_groups=["prod"],
input_cost_per_token=4e-06,
output_cost_per_token=2e-05,
cache_read_input_token_cost_above_272k_tokens=8e-07,
),
)
result = update_db_model(
db_model=db_model,
updated_patch=updateDeployment(litellm_params=updateLiteLLMParams(reasoning_effort="low")),
)
info = json.loads(result["model_info"])
params = json.loads(result["litellm_params"])
assert decrypt_value_helper(value=params["reasoning_effort"], key="reasoning_effort") == "low"
assert (info["key"], info["mode"], info["access_groups"]) == ("gpt-5.6", "chat", ["prod"])
for field in ("input_cost_per_token", "output_cost_per_token", "cache_read_input_token_cost_above_272k_tokens"):
assert field not in info, f"{field} still pins the row to the cost map of the day it was saved"
assert field not in params
def test_a_litellm_params_price_survives_the_cost_map_copy_being_dropped(self):
"""The price an operator typed on ``litellm_params`` is the override the customer asked
for, so dropping the echoed ``model_info`` copy must leave it in place."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
update_db_model,
)
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
db_model = Deployment(
model_name="gpt-5.6",
litellm_params=LiteLLM_Params(model="openai/gpt-5.6", input_cost_per_token=3e-06),
model_info=ModelInfo(id="dep-typed-0", key="gpt-5.6", input_cost_per_token=3e-06),
)
result = update_db_model(
db_model=db_model,
updated_patch=updateDeployment(model_info=ModelInfo(id="dep-typed-0", access_groups=["prod"])),
)
assert json.loads(result["litellm_params"])["input_cost_per_token"] == 3e-06
assert json.loads(result["model_info"])["access_groups"] == ["prod"]
def test_tiered_above_threshold_pricing_is_dropped(self):
"""Tiered rates ride `get_model_info` on a pattern match and are declared on no
model, so a filter built only from the declared pricing fields would miss them."""

View file

@ -2709,6 +2709,37 @@ def test_ProxyConfig__add_deployment_pinned_row_follows_the_cost_map_across_relo
assert litellm.model_cost["typed-row"]["input_cost_per_token"] == 3e-06
def test_ProxyConfig__add_deployment_ptu_row_with_a_cost_map_copy_still_bills_zero(monkeypatch, local_model_cost_map):
"""A PTU deployment bills nothing per token: the proxy writes zeros to both blobs. When such
a row also carries the echoed cost map, dropping the ``model_info`` copy must not send it
back to the per-token price, because the ``litellm_params`` zeros are the operator's."""
monkeypatch.setattr(
"litellm.proxy.proxy_server.decrypt_value_helper",
lambda value, key, return_original_value: value,
)
router = litellm.Router(model_list=[])
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router)
ptu = SimpleNamespace(
model_id="ptu-row",
model_name="gpt-5.6-ptu",
model_info={**PINNED_MODEL_INFO, "id": "ptu-row", "input_cost_per_token": 0.0, "output_cost_per_token": 0.0},
litellm_params={
"model": "openai/gpt-5.6",
"api_key": "sk-test",
"input_cost_per_token": 0.0,
"output_cost_per_token": 0.0,
},
blocked=False,
)
assert ProxyConfig()._add_deployment(db_models=[ptu]) == 1
router._replay_model_cost_registrations()
assert litellm.model_cost["ptu-row"]["input_cost_per_token"] == 0.0
assert litellm.model_cost["ptu-row"]["output_cost_per_token"] == 0.0
assert router.get_deployment(model_id="ptu-row").model_info.input_cost_per_token == 0.0
def test_ProxyConfig_get_model_info_with_id_missing_model_id_raises(monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False)
pc = ProxyConfig()

View file

@ -328,6 +328,49 @@ def test_get_proxy_model_info_names_config_model_info_pricing_as_an_override(mon
assert info["output_cost_per_token"] == 7e-06
def test_v2_model_info_reports_pricing_overrides_to_the_admin_ui(client, auth_as, monkeypatch, local_model_cost_map):
"""LIT-8064. The Admin UI model page reads ``GET /v2/model/info``, so the override report
has to ride that route too, not only ``/model/info``."""
model_list: Final = [
{
"model_name": "gpt-5.6",
"litellm_params": {"model": "openai/gpt-5.6", "input_cost_per_token": 3e-06},
"model_info": {"id": "dep-typed", "db_model": True},
},
{
"model_name": "gpt-5.6",
"litellm_params": {"model": "openai/gpt-5.6"},
"model_info": {"id": "dep-synced", "db_model": True},
},
]
router: Final = MagicMock()
router.model_list = model_list
router.get_discovered_model_info = MagicMock(return_value={})
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "llm_model_list", model_list)
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server, "user_model", None)
monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={}))
monkeypatch.setattr(
proxy_server,
"_apply_search_filter_to_models",
AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))),
)
import litellm.proxy.agent_endpoints.model_list_helpers as mlh
monkeypatch.setattr(mlh, "append_agents_to_model_info", AsyncMock(side_effect=lambda models, **kw: models))
with auth_as():
response = client.get("/v2/model/info")
assert response.status_code == 200, response.text
by_id: Final = {m["model_info"]["id"]: m["model_info"] for m in response.json()["data"]}
assert by_id["dep-typed"]["pricing_overrides"] == ["input_cost_per_token"]
assert by_id["dep-typed"]["input_cost_per_token"] == 3e-06
assert by_id["dep-synced"]["pricing_overrides"] == []
assert by_id["dep-synced"]["input_cost_per_token"] == litellm.model_cost["gpt-5.6"]["input_cost_per_token"]
def test_v1_model_info_star_wildcard_filter_keeps_provider_expansion(monkeypatch):
from litellm.proxy._types import SpecialModelNames, UserAPIKeyAuth
from litellm.proxy.auth import model_checks