mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
* feat(guardrails): run each mode only on streaming, non-streaming, or both
Add stream_scope so a rail can target streaming inference, non-streaming inference, or both per pre, during, and post mode
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(guardrails): honor stream_scope in pipelines and dashboard types
Pipeline steps skipped the stream_scope filter, direct construction ignored mixed-case maps, and schema.d.ts was stale.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(guardrails): skip unmatched stream_scope steps instead of allowing
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ui): keep stored stream_scope keys for modes not on screen
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ci): format stream_scope helpers and update fork MCP unit tests
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(mcp): keep hang cancellation tests from timing out during setup
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(guardrails): honor path-defined streaming for stream_scope
Passthrough routes like Gemini streamGenerateContent decide streaming from the URL, so stamp that onto hook data before guardrails run.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(rust): copy AnthropicModelCapabilities instead of cloning
Clippy treats clone-on-Copy as an error, which failed rust-lint on the messages request tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(guardrails): trust only server stream classification
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(guardrails): keep streaming marker through deepcopy
scan_raw_request snapshots copy each field, so a plain object() marker would lose identity and skip streaming-only rails.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cost-map): drop duplicate perceptron-mk1.5 row
Two main cost-map PRs both added the OpenRouter model, so the merge left a second key that CI rejects.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(rust): expect native transcription 429 as RustUpstreamError
HTTP status errors from native routes map through route_error_to_pyerr, so the wheel SIGINT child was dying on an outdated RuntimeError check and never reached the hang probe.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(tests): follow Google Interactions OpenAPI without hardcoded names
The live spec dropped CreateModelInteractionParams and renamed the item path to {interactionsId}. Misc CI failed because the compliance tests still looked those names up as literals.
* test(guardrails): reproduce stream_scope bedrock and passthrough field gaps
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): cover invalid stored stream_scope reads
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(guardrails): classify bedrock stream actions and keep caller is_streaming_request
Co-authored-by: Shivi Jain <mobile.350017@gmail.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(guardrails): make stream_scope_allows public and drop mutable builds
Co-authored-by: Shivi Jain <mobile.350017@gmail.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(guardrails): classify pass-through and Bedrock stream scopes
Co-authored-by: Shivi Jain <mobile.350017@gmail.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(guardrails): harden stream classification and validation
Co-authored-by: Shivi Jain <mobile.350017@gmail.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): add stream scope integration audit
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(proxy): avoid mutating passthrough custom body
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): run stream scope audit without enterprise license
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): keep stored scope restart cell on one worker
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): scope stream scope audit sink assertions to the rail under test
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): assert logging_only scope absence behind an ordered barrier rail
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): assert one logging_only scan per phase after the barrier rail
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): set request scope on pass-through stream fixtures
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(guardrails): tolerate invalid YAML stream_scope in v1 guardrails list
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: merge main into litellm_guardrail_stream_scope_fixes
Update pass-through pre-call test callbacks for main's endpoint_type argument
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): add request paths to pass-through fixtures
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): cover websocket pass-through stream scope and LIT-9050 outage spend rows
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore(lint): remove unused type discipline suppressions
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(guardrails): script the vertex live upstream in the websocket stream scope test
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(guardrails): keep stream marker json-serializable and restore pass-through helper names
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(lint): allow required Bedrock action re-export
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(guardrails): strip the stream marker from pass-through payloads
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(guardrails): drop stream marker by value in scans and snapshots, plain-tuple stream scope state
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(ui): render tag-scoped guardrail modes read-only in the custom code editor
A guardrail whose litellm_params.mode is the tag-scoped dict {tags, default}
crashed the Custom Code editor on open: normalizeMode wrapped the dict into
the mode array and StreamScopeFields rendered it as a React child (error #31,
whole dashboard unmounted). Treat a non-string non-array mode as no editable
modes, show formatGuardrailMode(mode) in a disabled input (read-only, matching
the guardrail info view), and keep mode/stream_scope out of the update payload
for such guardrails.
* fix(ui): resolve merge fallout in guardrails components
Deduplicate toModeArray import after the merge, and move the read-only
guardrail details block back into GuardrailReadOnlyDetails (now rendering
the shared mode/logging-only rows plus the stream-scope detail) so
guardrail_info.tsx stays under the 800-line lint budget. Guardrails UI
suite: 298 passed.
* fix(ui): drop duplicate toModeArray import reintroduced by merge
* chore(pass-through): document the deliberate in-place marker strip as mutable-ok
The clear/update on _parsed_body is load-bearing: rebinding to a fresh
mapping instead breaks 75 pass-through tests because the marker-free body
must propagate through the caller's request dict so downstream guardrail
scans and snapshots never observe the server streaming marker.
* fix(guardrails): move stream_scope after timeout in CustomGuardrail init
Inserting stream_scope before the existing timeout parameter shifted the
positional slot of timeout, so positional callers constructed with their
timeout bound to stream_scope (ValueError) and timeout silently None.
Restores the base parameter order; keyword callers are unaffected.
* fix(guardrails): typing pass for the lint gates
stream_scope leaves the declared constructor parameters (restoring the
base positional surface; keyword construction unchanged), unknown config
values crossing the new stream-scope code paths get typed locals or
cast-ok boundaries, and the passthrough payload literals are annotated.
All three lint gates pass against current main; the guardrail suites are
unchanged (236+5019 passing; one known anyio-driver failure pre-existing
on base).
* style: sort cast imports for the ruff gate
* chore(pass-through): drop unused BEDROCK_STREAMING_ACTIONS re-export
The streaming check now uses is_bedrock_streaming_endpoint; nothing in
the repo imports the name from this module.
---------
Co-authored-by: Shivi Jain <mobile.350017@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: yucheng <yucheng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: gabriele <gabriele@berri.ai>
375 lines
13 KiB
Python
375 lines
13 KiB
Python
"""Proxy strips client-supplied pricing parameters from request bodies.
|
|
|
|
`litellm.completion` accepts pricing fields (`input_cost_per_token`,
|
|
`output_cost_per_token`, the rest of `CustomPricingLiteLLMParams`,
|
|
`metadata.model_info`) as part of its kwarg surface. On direct SDK use that
|
|
is intentional. On the proxy, those same fields would let any caller rewrite
|
|
their own per-request cost and — via `litellm.register_model` — mutate
|
|
`litellm.model_cost` for every subsequent caller in the worker. The proxy
|
|
strips them at the boundary; an opt-in key/team flag preserves the override
|
|
for operators who actually want it.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from fastapi import Request
|
|
|
|
import litellm
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.litellm_pre_call_utils import (
|
|
_CLIENT_PRICING_CONTROL_FIELDS,
|
|
_CLIENT_PRICING_METADATA_FIELDS,
|
|
_strip_client_pricing_overrides,
|
|
add_litellm_data_to_request,
|
|
)
|
|
from litellm.types.utils import CustomPricingLiteLLMParams
|
|
|
|
|
|
def _make_request_mock() -> Request:
|
|
request_mock = MagicMock(spec=Request)
|
|
request_mock.url.path = "/v1/chat/completions"
|
|
request_mock.url = MagicMock()
|
|
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
|
request_mock.method = "POST"
|
|
request_mock.query_params = {}
|
|
request_mock.headers = {"Content-Type": "application/json"}
|
|
request_mock.client = MagicMock()
|
|
request_mock.client.host = "127.0.0.1"
|
|
return request_mock
|
|
|
|
|
|
def _user_api_key_auth(metadata=None, team_metadata=None) -> UserAPIKeyAuth:
|
|
return UserAPIKeyAuth(
|
|
api_key="hashed-key",
|
|
metadata=metadata or {},
|
|
team_metadata=team_metadata or {},
|
|
spend=0.0,
|
|
max_budget=100.0,
|
|
model_max_budget={},
|
|
team_spend=0.0,
|
|
team_max_budget=200.0,
|
|
)
|
|
|
|
|
|
class TestStripClientPricingOverrides:
|
|
def test_pricing_field_set_tracks_pydantic_model(self):
|
|
# The strip set is built from the model so additions are picked up
|
|
# automatically — this test guards against the model and the strip
|
|
# set drifting apart if someone replaces the auto-derivation later.
|
|
assert _CLIENT_PRICING_CONTROL_FIELDS == frozenset(CustomPricingLiteLLMParams.model_fields.keys())
|
|
# Sanity: the obvious top-level pricing fields are in the set.
|
|
for field in (
|
|
"input_cost_per_token",
|
|
"output_cost_per_token",
|
|
"cost_per_second",
|
|
"input_cost_per_second",
|
|
"cache_creation_input_token_cost",
|
|
):
|
|
assert field in _CLIENT_PRICING_CONTROL_FIELDS
|
|
|
|
def test_root_pricing_fields_dropped(self):
|
|
data = {
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"input_cost_per_token": 0.0,
|
|
"output_cost_per_token": 0.0,
|
|
"cache_creation_input_token_cost": 0.0,
|
|
}
|
|
_strip_client_pricing_overrides(data)
|
|
assert data == {
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
}
|
|
|
|
def test_metadata_model_info_dropped(self):
|
|
data = {
|
|
"model": "gpt-4",
|
|
"metadata": {
|
|
"user_session": "keep-me",
|
|
"model_info": {"input_cost_per_token": 0.0},
|
|
},
|
|
"litellm_metadata": {
|
|
"model_info": {"output_cost_per_token": 0.0},
|
|
},
|
|
}
|
|
_strip_client_pricing_overrides(data)
|
|
assert data["metadata"] == {"user_session": "keep-me"}
|
|
assert data["litellm_metadata"] == {}
|
|
|
|
def test_metadata_guardrail_information_dropped(self):
|
|
# Client-seeded guardrail entries would otherwise be summed into
|
|
# response_cost and spend, letting a caller forge (even negative)
|
|
# guardrail cost against their own budget.
|
|
data = {
|
|
"model": "gpt-4",
|
|
"metadata": {
|
|
"user_session": "keep-me",
|
|
"standard_logging_guardrail_information": [
|
|
{
|
|
"guardrail_name": "forged",
|
|
"guardrail_status": "success",
|
|
"guardrail_cost": -0.005,
|
|
}
|
|
],
|
|
},
|
|
"litellm_metadata": {
|
|
"standard_logging_guardrail_information": [{"guardrail_cost": 5.0}],
|
|
},
|
|
}
|
|
_strip_client_pricing_overrides(data)
|
|
assert data["metadata"] == {"user_session": "keep-me"}
|
|
assert data["litellm_metadata"] == {}
|
|
|
|
def test_non_pricing_fields_untouched(self):
|
|
data = {
|
|
"model": "gpt-4",
|
|
"temperature": 0.7,
|
|
"max_tokens": 100,
|
|
"tools": [{"type": "function"}],
|
|
"metadata": {"trace_id": "abc"},
|
|
}
|
|
snapshot = {
|
|
"model": "gpt-4",
|
|
"temperature": 0.7,
|
|
"max_tokens": 100,
|
|
"tools": [{"type": "function"}],
|
|
"metadata": {"trace_id": "abc"},
|
|
}
|
|
_strip_client_pricing_overrides(data)
|
|
assert data == snapshot
|
|
|
|
def test_metadata_strip_handles_non_dict_metadata(self):
|
|
# Defensive — Pydantic validation would normally reject non-dict
|
|
# metadata, but the strip mustn't crash if a malformed body sneaks in.
|
|
_strip_client_pricing_overrides({"metadata": "not-a-dict"})
|
|
_strip_client_pricing_overrides({"metadata": None})
|
|
_strip_client_pricing_overrides({"litellm_metadata": ["a", "b"]})
|
|
|
|
def test_metadata_field_set_contains_model_info(self):
|
|
assert "model_info" in _CLIENT_PRICING_METADATA_FIELDS
|
|
assert "standard_logging_guardrail_information" in _CLIENT_PRICING_METADATA_FIELDS
|
|
|
|
def test_strip_emits_debug_log_listing_dropped_fields(self, caplog):
|
|
# Operators need a paper trail so they can diagnose why a previously
|
|
# working override stopped applying after the strip landed.
|
|
import logging
|
|
|
|
from litellm._logging import verbose_proxy_logger
|
|
|
|
verbose_proxy_logger.setLevel(logging.DEBUG)
|
|
with caplog.at_level(logging.DEBUG, logger=verbose_proxy_logger.name):
|
|
_strip_client_pricing_overrides(
|
|
{
|
|
"model": "gpt-4",
|
|
"input_cost_per_token": 0.0,
|
|
"metadata": {"model_info": {"output_cost_per_token": 0.0}},
|
|
}
|
|
)
|
|
log_text = " ".join(record.getMessage() for record in caplog.records)
|
|
assert "input_cost_per_token" in log_text
|
|
assert "metadata.model_info" in log_text
|
|
assert "allow_client_pricing_override" in log_text
|
|
|
|
def test_strip_does_not_log_when_no_fields_present(self, caplog):
|
|
# No-op strips must stay silent so the log isn't filled with noise on
|
|
# every legitimate request.
|
|
import logging
|
|
|
|
from litellm._logging import verbose_proxy_logger
|
|
|
|
verbose_proxy_logger.setLevel(logging.DEBUG)
|
|
with caplog.at_level(logging.DEBUG, logger=verbose_proxy_logger.name):
|
|
_strip_client_pricing_overrides({"model": "gpt-4", "temperature": 0.7})
|
|
assert not any("pricing" in record.getMessage().lower() for record in caplog.records)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_litellm_data_to_request_strips_root_pricing_fields():
|
|
data = {
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"input_cost_per_token": 0.0,
|
|
"output_cost_per_token": 0.0,
|
|
}
|
|
|
|
updated = await add_litellm_data_to_request(
|
|
data=data,
|
|
request=_make_request_mock(),
|
|
user_api_key_dict=_user_api_key_auth(),
|
|
proxy_config=MagicMock(),
|
|
general_settings={},
|
|
version="test-version",
|
|
)
|
|
|
|
assert "input_cost_per_token" not in updated
|
|
assert "output_cost_per_token" not in updated
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_litellm_data_to_request_preserves_caller_streaming_request():
|
|
data = {
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"is_streaming_request": True,
|
|
}
|
|
|
|
updated = await add_litellm_data_to_request(
|
|
data=data,
|
|
request=_make_request_mock(),
|
|
user_api_key_dict=_user_api_key_auth(),
|
|
proxy_config=MagicMock(),
|
|
general_settings={},
|
|
version="test-version",
|
|
)
|
|
|
|
assert updated["is_streaming_request"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_litellm_data_to_request_strips_client_disconnect_metadata():
|
|
data = {
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"metadata": {
|
|
"client_disconnected": True,
|
|
"error_information": {
|
|
"error_code": "499",
|
|
"error_message": "Client disconnected the request",
|
|
"error_class": "ClientDisconnected",
|
|
},
|
|
},
|
|
}
|
|
|
|
updated = await add_litellm_data_to_request(
|
|
data=data,
|
|
request=_make_request_mock(),
|
|
user_api_key_dict=_user_api_key_auth(),
|
|
proxy_config=MagicMock(),
|
|
general_settings={},
|
|
version="test-version",
|
|
)
|
|
|
|
assert "client_disconnected" not in updated.get("metadata", {})
|
|
assert "error_information" not in updated.get("metadata", {})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_litellm_data_to_request_strips_metadata_model_info():
|
|
data = {
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"metadata": {"model_info": {"input_cost_per_token": 0.0}},
|
|
}
|
|
|
|
updated = await add_litellm_data_to_request(
|
|
data=data,
|
|
request=_make_request_mock(),
|
|
user_api_key_dict=_user_api_key_auth(),
|
|
proxy_config=MagicMock(),
|
|
general_settings={},
|
|
version="test-version",
|
|
)
|
|
|
|
assert "model_info" not in updated.get("metadata", {})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_litellm_data_to_request_skips_strip_with_key_opt_in():
|
|
data = {
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"input_cost_per_token": 0.0001,
|
|
"metadata": {"model_info": {"output_cost_per_token": 0.0002}},
|
|
}
|
|
|
|
user_auth = _user_api_key_auth(metadata={"allow_client_pricing_override": True})
|
|
updated = await add_litellm_data_to_request(
|
|
data=data,
|
|
request=_make_request_mock(),
|
|
user_api_key_dict=user_auth,
|
|
proxy_config=MagicMock(),
|
|
general_settings={},
|
|
version="test-version",
|
|
)
|
|
|
|
assert updated["input_cost_per_token"] == 0.0001
|
|
assert updated["metadata"]["model_info"] == {"output_cost_per_token": 0.0002}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_litellm_data_to_request_strips_json_string_litellm_metadata():
|
|
"""``litellm_metadata`` may arrive as a JSON-encoded string (multipart/
|
|
form-data or ``extra_body``). The strip has to run after the proxy parses
|
|
it into a dict but before the chat-route fold into ``metadata``; otherwise
|
|
``model_info`` survives via the string path and lands in the folded bucket.
|
|
"""
|
|
import json
|
|
|
|
data = {
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"litellm_metadata": json.dumps({"model_info": {"input_cost_per_token": 0.0}}),
|
|
}
|
|
|
|
updated = await add_litellm_data_to_request(
|
|
data=data,
|
|
request=_make_request_mock(),
|
|
user_api_key_dict=_user_api_key_auth(),
|
|
proxy_config=MagicMock(),
|
|
general_settings={},
|
|
version="test-version",
|
|
)
|
|
|
|
assert "litellm_metadata" not in updated
|
|
assert "model_info" not in updated["metadata"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_litellm_data_to_request_skips_strip_with_team_opt_in():
|
|
data = {
|
|
"model": "gpt-4",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"input_cost_per_token": 0.0001,
|
|
}
|
|
|
|
user_auth = _user_api_key_auth(team_metadata={"allow_client_pricing_override": True})
|
|
updated = await add_litellm_data_to_request(
|
|
data=data,
|
|
request=_make_request_mock(),
|
|
user_api_key_dict=user_auth,
|
|
proxy_config=MagicMock(),
|
|
general_settings={},
|
|
version="test-version",
|
|
)
|
|
|
|
assert updated["input_cost_per_token"] == 0.0001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_global_model_cost_unmutated_after_stripped_request(monkeypatch):
|
|
"""After a stripped request, ``litellm.model_cost`` must not carry the
|
|
caller's submitted pricing for the model. The mutation only happens when
|
|
the pricing fields reach ``litellm.completion``; the strip prevents that."""
|
|
snapshot = dict(litellm.model_cost)
|
|
data = {
|
|
"model": "test-pricing-canary-model",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"input_cost_per_token": 0.0,
|
|
"output_cost_per_token": 0.0,
|
|
}
|
|
|
|
await add_litellm_data_to_request(
|
|
data=data,
|
|
request=_make_request_mock(),
|
|
user_api_key_dict=_user_api_key_auth(),
|
|
proxy_config=MagicMock(),
|
|
general_settings={},
|
|
version="test-version",
|
|
)
|
|
|
|
# The strip prevents the pricing fields from ever reaching the path that
|
|
# would mutate the global model_cost map.
|
|
assert "test-pricing-canary-model" not in litellm.model_cost
|
|
# And no other entries were mutated as a side effect.
|
|
assert litellm.model_cost == snapshot
|