mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
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:
parent
b03957ba9c
commit
42541a9233
5 changed files with 162 additions and 14 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue