fix(proxy): raise live budget counters for failure spend rows (#47741)

* fix(proxy): raise live budget counters for failure spend rows

A stream that breaks mid-flight writes its recovered cost to the spend log, but the failure hook
had already released the reservation and never touched the live counters, so an over-budget key
kept passing until the counter was next repaired from the database

Every charged failure row with a cost now raises the same counters the success path raises. The
error waits at most a second for them, so a hung Redis cannot hold it back

Fixes #35568

* test(proxy): cover failure spend counters across budgets, endpoints and outages

Integration cells for team, user, end-user, tag and window budgets,
concurrent streams, billed retries, native /v1/messages, Bedrock guardrail
cost, a Redis stall on two workers and disable_spend_updates

* test(proxy): check stalled failure counters and use the real spend writer

- The Redis stall cell now reads the burst key's live counter and requires at least the full failure spend
- Unit tests drop the charging writer double, bare dicts, list mutation and wall-clock timestamps
- Partial-stream test models are frozen

* test(proxy): move the stalled counter bound reason into the assert message

---------

Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
This commit is contained in:
yucheng-berri 2026-10-30 13:05:12 -07:00 • committed by GitHub
parent 8f968b6c8e
commit f4d1f45d87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1103 additions and 10 deletions

View file

@ -12,6 +12,7 @@ from pydantic import TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.batches.batch_utils import batch_cost_is_final
from litellm.caching.redis_batch import flush_post_call_redis_batches
from litellm.constants import BACKGROUND_INTERACTION_COST_POLLING_ENABLED, BILLED_RETRY_ATTEMPT_METADATA_KEY
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import (
@ -134,6 +135,8 @@ _CAPTURED_IDENTITY_CALL_TYPES: Final[frozenset[str]] = frozenset(
)
)
FAILURE_SPEND_COUNTER_WAIT_SECONDS: Final = 1.0
_OBJECT_MAPPING: Final = TypeAdapter(ObjectMapping)
@ -235,6 +238,12 @@ def _proxy_spend_writer() -> DBSpendUpdateWriter:
return proxy_logging_obj.db_spend_update_writer
def _proxy_spend_counters() -> "_IncrementSpendCounters":
from litellm.proxy.proxy_server import increment_spend_counters
return increment_spend_counters
DeploymentLookup: TypeAlias = Callable[[str], Deployment | None]
@ -361,11 +370,16 @@ class _ProxyDBLogger(CustomLogger):
message_logging: bool = True,
spend_writer: Callable[[], DBSpendUpdateWriter] = _proxy_spend_writer,
deployment_lookup: DeploymentLookup = _proxy_deployment,
spend_counters: Callable[[], "_IncrementSpendCounters"] = _proxy_spend_counters,
failure_counter_wait_seconds: float = FAILURE_SPEND_COUNTER_WAIT_SECONDS,
) -> None:
super().__init__(turn_off_message_logging=turn_off_message_logging, message_logging=message_logging)
self.spend_event_producer = spend_event_producer
self._spend_writer: Final = spend_writer
self._deployment_lookup: Final = deployment_lookup
self._spend_counters: Final = spend_counters
self._failure_counter_wait_seconds: Final = failure_counter_wait_seconds
self._pending_increments: Final[set[asyncio.Task[None]]] = set() # mutable-ok: tasks leave when done
async def async_log_success_event(
self, kwargs: ObjectMapping, response_obj: object, start_time: datetime, end_time: datetime
@ -590,9 +604,11 @@ class _ProxyDBLogger(CustomLogger):
completed_cost: bool = False,
request_tags: list[str] | None = None,
) -> None:
"""One spend row (and one key/team/user spend deduction) per ``(response_cost, kwargs)`` pair."""
for row_cost, row_kwargs in spend_rows:
if completed_cost:
"""One spend row (and one key/team/user spend deduction) per ``(response_cost, kwargs)`` pair, then the
live spend counters raised by every charged row that cost something. A completed cost settles through the
success path's write, which raises the counters against the kept reservation."""
if completed_cost:
for row_cost, row_kwargs in spend_rows:
from litellm.proxy.proxy_server import increment_spend_counters, proxy_logging_obj
await _update_database_and_spend_counters(
@ -623,12 +639,16 @@ class _ProxyDBLogger(CustomLogger):
router=get_llm_router(),
),
)
continue
return
token: Final = LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_dict)
end_user_id: Final = None if litellm.disable_end_user_cost_tracking else user_api_key_dict.end_user_id
charged: Final = [
await self._spend_writer().update_database(
token=LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_dict),
token=token,
response_cost=row_cost,
user_id=user_api_key_dict.user_id,
end_user_id=None if litellm.disable_end_user_cost_tracking else user_api_key_dict.end_user_id,
end_user_id=end_user_id,
team_id=user_api_key_dict.team_id,
kwargs=dict(row_kwargs),
completion_response=original_exception,
@ -637,6 +657,91 @@ class _ProxyDBLogger(CustomLogger):
org_id=user_api_key_dict.org_id,
project_id=user_api_key_dict.project_id,
)
for row_cost, row_kwargs in spend_rows
]
counted_rows: Final = tuple(
row for row, row_charged in zip(spend_rows, charged, strict=True) if row_charged and row[0] > 0
)
if counted_rows and not ProxyUpdateSpend.disable_spend_updates():
await self._increment_failure_spend_counters(
user_api_key_dict, token, end_user_id, counted_rows, start_time
)
async def _increment_failure_spend_counters(
self,
user_api_key_dict: UserAPIKeyAuth,
token: str | None,
end_user_id: str | None,
counted_rows: Sequence[tuple[float, Mapping[str, object]]],
start_time: datetime,
) -> None:
"""The counters the success path raises for the same spend, sent before the client sees the error. A
counter store slower than ``failure_counter_wait_seconds`` finishes them in the background instead of
holding the error back. The reservation was already released, so every counter takes the full row cost."""
increment: Final = asyncio.create_task(
self._raise_failure_spend_counters(user_api_key_dict, token, end_user_id, counted_rows, start_time)
)
self._pending_increments.add(increment)
increment.add_done_callback(self._pending_increments.discard)
finished, _ = await asyncio.wait((increment,), timeout=self._failure_counter_wait_seconds)
if not finished:
verbose_proxy_logger.warning(
"Spend counters for failure spend rows took over %ss, finishing them in the background",
self._failure_counter_wait_seconds,
)
async def _raise_failure_spend_counters(
self,
user_api_key_dict: UserAPIKeyAuth,
token: str | None,
end_user_id: str | None,
counted_rows: Sequence[tuple[float, Mapping[str, object]]],
start_time: datetime,
) -> None:
for row_cost, row_kwargs in counted_rows:
await self._raise_row_spend_counters(
user_api_key_dict, token, end_user_id, row_cost, row_kwargs, start_time
)
await flush_post_call_redis_batches()
async def _raise_row_spend_counters(
self,
user_api_key_dict: UserAPIKeyAuth,
token: str | None,
end_user_id: str | None,
row_cost: float,
row_kwargs: Mapping[str, object],
start_time: datetime,
) -> None:
standard_logging_object: Final = row_kwargs.get("standard_logging_object")
sl_object: Final = (
cast("StandardLoggingPayload", standard_logging_object) # cast-ok: the failure snapshot dict
if isinstance(standard_logging_object, dict)
else None
)
metadata: Final = _str_keyed_fields(_str_keyed_fields(row_kwargs.get("litellm_params")).get("metadata"))
served_model_id: Final = _str_keyed_fields(metadata.get("model_info")).get("id") or _str_keyed_fields(
standard_logging_object
).get("model_id")
try:
await self._spend_counters()(
token=token,
team_id=user_api_key_dict.team_id,
user_id=user_api_key_dict.user_id,
response_cost=row_cost,
org_id=user_api_key_dict.org_id,
end_user_id=end_user_id,
tags=_get_request_tags_for_cost_tracking(sl_object=sl_object, metadata=dict(metadata)),
request_started_at=start_time,
model_access_groups=debitable_model_access_groups(
attributed=get_request_model_access_groups(row_kwargs),
served_model_id=served_model_id if isinstance(served_model_id, str) else None,
router=get_llm_router(),
),
project_id=user_api_key_dict.project_id,
)
except Exception: # noqa: BLE001 # one row's counter failure must not keep the other rows' counters from landing
verbose_proxy_logger.exception("Failed to raise spend counters for a failure spend row")
async def _PROXY_track_cost_callback(
self,
@ -1060,7 +1165,7 @@ def _metadata_keys(metadata: object) -> tuple[str, ...]:
return tuple(sorted(str(key) for key in metadata))
def _get_budget_reservation_from_metadata(metadata: dict) -> dict | None:
def _get_budget_reservation_from_metadata(metadata: dict) -> dict[str, object] | None:
return budget_reservation_from_metadata(metadata)
@ -1095,6 +1200,7 @@ class _IncrementSpendCounters(Protocol):
tags: list[str] | None = None,
request_started_at: datetime | None = None,
model_access_groups: Sequence[str] | None = None,
project_id: str | None = None,
) -> None: ...
@ -1111,7 +1217,7 @@ async def _update_database_and_spend_counters(
start_time: datetime | None,
end_time: datetime | None,
response_cost: float,
budget_reservation: dict | None,
budget_reservation: dict[str, object] | None,
request_tags: list[str] | None = None,
model_access_groups: Sequence[str] | None = None,
project_id: str | None = None,
@ -1174,7 +1280,7 @@ async def _update_database_and_spend_counters_in_batch(
start_time: datetime | None,
end_time: datetime | None,
response_cost: float,
budget_reservation: dict | None,
budget_reservation: dict[str, object] | None,
request_tags: list[str] | None,
model_access_groups: Sequence[str] | None,
project_id: str | None,

View file

@ -3510,7 +3510,7 @@ async def increment_spend_counters(
user_id: str | None,
response_cost: float | None,
org_id: str | None = None,
budget_reservation: dict | None = None,
budget_reservation: dict[str, object] | None = None,
end_user_id: str | None = None,
tags: list[str] | None = None,
request_started_at: datetime | None = None,

View file

@ -0,0 +1,173 @@
import json
import threading
import uuid
from collections.abc import Callable, Iterable, Iterator
from hashlib import sha256
from typing import Final
import httpx
from integration._support.client import Gateway, Scenario, eventually, held
from integration._support.wire import Reply, Request, Wire
from openai.types.chat import ChatCompletionChunk
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
BACKEND: Final = "integration-partial-stream"
PIECE: Final = "partial stream billed tokens "
DELIVERED_PIECES: Final = 12
_FAILURE_ROWS: Final = 'SELECT spend FROM "LiteLLM_SpendLogs" WHERE api_key=%s AND status=%s ORDER BY "startTime"'
ROWS: Final = 'SELECT status, spend FROM "LiteLLM_SpendLogs" WHERE api_key=%s ORDER BY "startTime"'
class _SpendRow(BaseModel):
model_config = ConfigDict(frozen=True)
spend: float
class StatusRow(BaseModel):
model_config = ConfigDict(frozen=True)
status: str
spend: float
class _BudgetError(BaseModel):
model_config = ConfigDict(frozen=True)
type: str
class _ErrorResponse(BaseModel):
model_config = ConfigDict(frozen=True)
error: _BudgetError
def _chunk(stream_id: str, delta: dict[str, JsonValue], finish_reason: str | None = None) -> bytes:
body: Final = {
"id": stream_id,
"object": "chat.completion.chunk",
"created": 1700000000,
"model": BACKEND,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
return f"data: {json.dumps(body)}\n\n".encode()
def completion() -> bytes:
return json.dumps(
{
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": 1700000000,
"model": BACKEND,
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
).encode()
def peer(delivered: int | None, gate: threading.Event | None = None) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
if request.method != "POST":
return Reply(status=404)
if JSON_OBJECT.validate_json(request.body).get("stream") is True:
stream_id: Final = f"chatcmpl-{uuid.uuid4().hex}"
return Reply(
content_type="text/event-stream",
chunks=(
*(_chunk(stream_id, {"role": "assistant", "content": PIECE}) for _ in range(DELIVERED_PIECES)),
_chunk(stream_id, {}, "stop"),
b"data: [DONE]\n\n",
),
abort_after=delivered,
pause_between_chunks=0.02,
gate_before_first=gate,
)
return Reply(body=completion())
return respond
def priced_model(scenario: Scenario, wire: Wire) -> str:
return scenario.model(
model=f"openai/{BACKEND}",
api_base=f"{wire.url}/v1",
api_key="synthetic-openai-key",
input_cost_per_token=0.001,
output_cost_per_token=0.002,
)
def stream(gateway: Gateway, model: str, key: str, **fields: JsonValue) -> httpx.Response:
return gateway.request(
"POST",
"/v1/chat/completions",
{
"model": model,
"max_tokens": 5,
"stream": True,
"messages": [{"role": "user", "content": "partial stream"}],
**fields,
},
key=key,
)
def broken_stream(gateway: Gateway, model: str, key: str, **fields: JsonValue) -> None:
response: Final = stream(gateway, model, key, **fields)
assert response.status_code == 200, response.text
events: Final = tuple(
line.removeprefix("data: ")
for line in response.text.splitlines()
if line.startswith("data: ") and line != "data: [DONE]"
)
assert "error" in JSON_OBJECT.validate_json(events[-1]), response.text
delivered: Final = "".join(_streamed_text(ChatCompletionChunk.model_validate_json(event) for event in events[:-1]))
assert delivered == PIECE * DELIVERED_PIECES, response.text
def _streamed_text(chunks: Iterable[ChatCompletionChunk]) -> Iterator[str]:
for chunk in chunks:
yield from (choice.delta.content or "" for choice in chunk.choices)
def chat(gateway: Gateway, model: str, key: str, **fields: JsonValue) -> httpx.Response:
return gateway.request(
"POST",
"/v1/chat/completions",
{
"model": model,
"max_tokens": 5,
"messages": [{"role": "user", "content": f"after {uuid.uuid4().hex}"}],
**fields,
},
key=key,
)
def failure_spend(gateway: Gateway, key: str, rows: int = 1) -> tuple[float, ...]:
digest: Final = sha256(key.encode()).hexdigest()
eventually(lambda: gateway.rows(_FAILURE_ROWS, (digest, "failure")), lambda found: len(found) >= rows, seconds=70)
found: Final = held(
lambda: gateway.rows(_FAILURE_ROWS, (digest, "failure")), lambda latest: len(latest) == rows, holding=2
)
return tuple(_SpendRow.model_validate(row).spend for row in found)
def chats_sent(wire: Wire) -> int:
return sum(1 for request in wire.drain() if request.method == "POST")
def recovered_cost(gateway: Gateway, scenario: Scenario, model: str) -> float:
key: Final = scenario.key(models=[model], max_budget=100)
broken_stream(gateway, model, key)
(spend,) = failure_spend(gateway, key)
assert spend > 0
return spend
def assert_budget_refusal(response: httpx.Response) -> None:
assert response.status_code == 422, response.text
assert _ErrorResponse.model_validate_json(response.content).error.type == "budget_exceeded", response.text

View file

@ -0,0 +1,239 @@
import json
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from hashlib import sha256
from pathlib import Path
from typing import Final
import httpx
import pytest
import yaml
from integration._support.client import Gateway, eventually
from integration._support.process import owned_proxy_process
from integration._support.redis_process import owned_redis
from integration._support.timing import assert_not_delayed_by, timed
from integration._support.wire import Reply, Request, Wire, wire_server
from integration.spend._partial_stream import (
DELIVERED_PIECES,
assert_budget_refusal,
broken_stream,
chat,
chats_sent,
failure_spend,
peer,
priced_model,
recovered_cost,
)
from pydantic import JsonValue
from redis import Redis
from litellm.constants import REDIS_SOCKET_TIMEOUT
_BURST: Final = 12
_SPREAD: Final = 6
_ATTACK_MARKER: Final = "synthetic-attack-marker"
_POLICY_ID: Final = "synthetic-priced-policy"
_POLICY_APPLY_PATH: Final = f"/guardrail/{_POLICY_ID}/version/DRAFT/apply"
def _proxy_config(directory: Path, name: str, sections: dict[str, JsonValue]) -> Path:
base: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
path: Final = directory / f"{name}.yaml"
path.write_text(yaml.safe_dump({**base, **sections}))
return path
def _redis_counter_config(directory: Path) -> Path:
base: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
return _proxy_config(
directory,
"redis-counters",
{
"router_settings": {
**base["router_settings"],
"redis_host": "os.environ/REDIS_HOST",
"redis_port": "os.environ/REDIS_PORT",
}
},
)
def _spread_requests(gateway: Gateway, model: str, key: str) -> tuple[httpx.Response, ...]:
return tuple(
gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "max_tokens": 5, "messages": [{"role": "user", "content": f"spread {uuid.uuid4().hex}"}]},
key=key,
headers={"connection": "close"},
)
for _ in range(_SPREAD)
)
@pytest.mark.timeout(600)
def test_broken_streams_through_a_redis_stall_log_once_and_the_budget_holds_on_every_worker(
gateway: Gateway, tmp_path: Path
) -> None:
release: Final = threading.Event()
with (
owned_redis(tmp_path) as counters,
wire_server(peer(DELIVERED_PIECES)) as wire,
wire_server(peer(DELIVERED_PIECES, gate=release)) as held_wire,
owned_proxy_process(
gateway,
tmp_path,
{"REDIS_HOST": counters.host, "REDIS_PORT": str(counters.port)},
config=_redis_counter_config(tmp_path),
workers=2,
) as owned,
owned.gateway.scenario() as scenario,
):
scenario.cleanups.callback(release.set)
candidate: Final = owned.gateway
model: Final = priced_model(scenario, wire)
stalled_model: Final = priced_model(scenario, held_wire)
cost: Final = recovered_cost(candidate, scenario, model)
control: Final = timed(lambda: broken_stream(candidate, model, scenario.key(models=[model])))
burst_key: Final = scenario.key(models=[stalled_model])
chats_sent(held_wire)
with ThreadPoolExecutor(max_workers=_BURST) as pool:
pending: Final = tuple(
pool.submit(broken_stream, candidate, stalled_model, burst_key) for _ in range(_BURST)
)
eventually(held_wire.received.qsize, lambda held: held >= _BURST, seconds=30)
with counters.paused():
release.set()
drained: Final = timed(lambda: tuple(future.result() for future in pending))
assert_not_delayed_by((drained.seconds,), control=(control.seconds,), injected_delay=REDIS_SOCKET_TIMEOUT)
assert failure_spend(candidate, burst_key, rows=_BURST) == pytest.approx((cost,) * _BURST)
spent: Final = cost * _BURST
with Redis(host=counters.host, port=counters.port) as live_counters:
landed: Final = eventually(
lambda: float(live_counters.get(f"spend:key:{sha256(burst_key.encode()).hexdigest()}") or 0),
lambda counted: counted >= spent - 1e-9,
seconds=30,
return_last_on_timeout=True,
)
assert spent - 1e-9 <= landed <= 2 * spent + 1e-9, (
f"one broken stream costs {cost}. The cold counter is seeded from the database, and a spend flush"
" during the stall can land these rows there first, so up to twice their cost is expected, as for"
" completed streams held past a flush"
)
budgeted: Final = scenario.key(models=[model], max_budget=cost / 2)
broken_stream(candidate, model, budgeted)
before_kill: Final = _spread_requests(candidate, model, budgeted)
owned.kill_worker(min(owned.worker_pids()))
after_kill: Final = _spread_requests(candidate, model, budgeted)
for refused in (*before_kill, *after_kill):
assert_budget_refusal(refused)
def _policy(request: Request) -> Reply:
if request.method != "POST" or request.target != _POLICY_APPLY_PATH:
return Reply(status=404)
if _ATTACK_MARKER.encode() not in request.body:
return Reply(body=json.dumps({"action": "NONE", "outputs": [], "assessments": []}).encode())
return Reply(
body=json.dumps(
{
"action": "GUARDRAIL_INTERVENED",
"outputs": [{"text": "blocked by synthetic policy"}],
"assessments": [
{"contentPolicy": {"filters": [{"type": "VIOLENCE", "confidence": "HIGH", "action": "BLOCKED"}]}}
],
"usage": {"contentPolicyUnits": 1},
}
).encode()
)
def _policy_config(directory: Path, policy: Wire) -> Path:
return _proxy_config(
directory,
"priced-policy",
{
"guardrails": [
{
"guardrail_name": "priced-bedrock-policy",
"litellm_params": {
"guardrail": "bedrock",
"mode": "pre_call",
"default_on": True,
"guardrailIdentifier": _POLICY_ID,
"guardrailVersion": "DRAFT",
"aws_region_name": "us-east-1",
"aws_access_key_id": "AKIASYNTHETICGUARDRAIL",
"aws_secret_access_key": "synthetic-guardrail-secret",
"aws_bedrock_runtime_endpoint": policy.url,
},
}
]
},
)
def _attack(gateway: Gateway, model: str, key: str) -> httpx.Response:
blocked: Final = gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": f"{_ATTACK_MARKER} {uuid.uuid4().hex}"}]},
key=key,
)
assert blocked.status_code == 400, blocked.text
return blocked
@pytest.mark.timeout(480)
def test_guardrail_cost_on_blocked_requests_counts_toward_the_budget(gateway: Gateway, tmp_path: Path) -> None:
with (
wire_server(_policy) as policy,
wire_server(peer(None)) as wire,
owned_proxy_process(gateway, tmp_path, {}, config=_policy_config(tmp_path, policy), workers=2) as owned,
owned.gateway.scenario() as scenario,
):
candidate: Final = owned.gateway
model: Final = priced_model(scenario, wire)
meter: Final = scenario.key(models=[model], max_budget=100)
_attack(candidate, model, meter)
(cost,) = failure_spend(candidate, meter)
assert cost > 0
key: Final = scenario.key(models=[model], max_budget=cost * 1.5)
chats_sent(wire)
_attack(candidate, model, key)
_attack(candidate, model, key)
after: Final = chat(candidate, model, key)
assert_budget_refusal(after)
assert chats_sent(wire) == 0
@pytest.mark.timeout(480)
def test_broken_streams_leave_the_budget_alone_while_spend_updates_are_disabled(
gateway: Gateway, tmp_path: Path
) -> None:
base: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
config: Final = _proxy_config(
tmp_path, "spend-updates-off", {"general_settings": {**base["general_settings"], "disable_spend_updates": True}}
)
with (
wire_server(peer(DELIVERED_PIECES)) as wire,
gateway.scenario() as metering,
owned_proxy_process(gateway, tmp_path, {}, config=config) as owned,
owned.gateway.scenario() as scenario,
):
cost: Final = recovered_cost(gateway, metering, priced_model(metering, wire))
candidate: Final = owned.gateway
model: Final = priced_model(scenario, wire)
key: Final = scenario.key(models=[model], max_budget=cost / 2)
broken_stream(candidate, model, key)
after: Final = chat(candidate, model, key)
assert after.status_code == 200, after.text

View file

@ -0,0 +1,350 @@
import json
import threading
import uuid
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from hashlib import sha256
from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, eventually, held
from integration._support.wire import Reply, Request, wire_server
from integration.spend._partial_stream import (
BACKEND,
DELIVERED_PIECES,
JSON_OBJECT,
PIECE,
ROWS,
StatusRow,
assert_budget_refusal,
broken_stream,
chat,
chats_sent,
completion,
failure_spend,
peer,
priced_model,
recovered_cost,
stream,
)
from pydantic import JsonValue
_CONCURRENT_STREAMS: Final = 6
_MESSAGES_BACKEND: Final = "claude-integration-partial-stream"
_UNCACHED: Final[dict[str, JsonValue]] = {"cache": {"no-cache": True}}
@pytest.mark.timeout(240)
def test_a_broken_stream_that_spends_past_the_budget_blocks_the_next_request(gateway: Gateway) -> None:
with wire_server(peer(DELIVERED_PIECES)) as wire, gateway.scenario() as scenario:
model: Final = priced_model(scenario, wire)
cost: Final = recovered_cost(gateway, scenario, model)
key: Final = scenario.key(models=[model], max_budget=cost / 2)
chats_sent(wire)
broken_stream(gateway, model, key)
after: Final = chat(gateway, model, key)
assert_budget_refusal(after)
assert failure_spend(gateway, key, rows=2) == pytest.approx((cost, 0.0))
assert chats_sent(wire) == 1
@pytest.mark.timeout(240)
def test_broken_stream_spend_counts_once_toward_the_budget(gateway: Gateway) -> None:
with wire_server(peer(DELIVERED_PIECES)) as wire, gateway.scenario() as scenario:
model: Final = priced_model(scenario, wire)
cost: Final = recovered_cost(gateway, scenario, model)
key: Final = scenario.key(models=[model], max_budget=cost * 1.5)
broken_stream(gateway, model, key)
broken_stream(gateway, model, key)
after: Final = chat(gateway, model, key)
assert_budget_refusal(after)
assert failure_spend(gateway, key, rows=3) == pytest.approx((cost, cost, 0.0))
@pytest.mark.timeout(240)
def test_a_stream_that_breaks_before_any_chunk_leaves_the_budget_untouched(gateway: Gateway) -> None:
with wire_server(peer(0)) as wire, gateway.scenario() as scenario:
model: Final = priced_model(scenario, wire)
key: Final = scenario.key(models=[model], max_budget=0.05)
refused: Final = stream(gateway, model, key)
after: Final = chat(gateway, model, key)
assert refused.status_code == 500, refused.text
assert after.status_code == 200, after.text
assert failure_spend(gateway, key) == (0.0,)
def _broken_stream_then_refusal(
gateway: Gateway, model: str, spender: str, other: str, **fields: JsonValue
) -> httpx.Response:
broken_stream(gateway, model, spender, **fields)
return chat(gateway, model, other, **fields)
@pytest.mark.timeout(240)
def test_a_broken_stream_on_one_team_key_blocks_the_teams_other_keys(gateway: Gateway) -> None:
with wire_server(peer(DELIVERED_PIECES)) as wire, gateway.scenario() as scenario:
model: Final = priced_model(scenario, wire)
cost: Final = recovered_cost(gateway, scenario, model)
team: Final = scenario.team(models=[model], max_budget=cost / 2)
after: Final = _broken_stream_then_refusal(
gateway, model, scenario.key(team_id=team, models=[model]), scenario.key(team_id=team, models=[model])
)
assert_budget_refusal(after)
@pytest.mark.timeout(240)
def test_a_broken_stream_on_one_of_a_users_keys_blocks_their_other_keys(gateway: Gateway) -> None:
with wire_server(peer(DELIVERED_PIECES)) as wire, gateway.scenario() as scenario:
model: Final = priced_model(scenario, wire)
cost: Final = recovered_cost(gateway, scenario, model)
user: Final = scenario.user(max_budget=cost / 2)
after: Final = _broken_stream_then_refusal(
gateway, model, scenario.key(user_id=user, models=[model]), scenario.key(user_id=user, models=[model])
)
assert_budget_refusal(after)
@pytest.mark.timeout(240)
def test_a_broken_stream_for_an_end_user_blocks_their_next_request_on_any_key(gateway: Gateway) -> None:
end_user: Final = f"integration-end-user-{uuid.uuid4().hex}"
with wire_server(peer(DELIVERED_PIECES)) as wire, gateway.scenario() as scenario:
model: Final = priced_model(scenario, wire)
cost: Final = recovered_cost(gateway, scenario, model)
gateway.post("/customer/new", {"user_id": end_user, "max_budget": cost / 2})
scenario.cleanups.callback(gateway.post, "/customer/delete", {"user_ids": [end_user]})
after: Final = _broken_stream_then_refusal(
gateway, model, scenario.key(models=[model]), scenario.key(models=[model]), user=end_user
)
assert_budget_refusal(after)
@pytest.mark.timeout(240)
def test_a_broken_stream_under_a_tag_blocks_the_next_request_with_that_tag(gateway: Gateway) -> None:
tag: Final = f"integration-partial-stream-{uuid.uuid4().hex}"
with wire_server(peer(DELIVERED_PIECES)) as wire, gateway.scenario() as scenario:
model: Final = priced_model(scenario, wire)
cost: Final = recovered_cost(gateway, scenario, model)
gateway.post("/tag/new", {"name": tag, "max_budget": cost / 2})
scenario.cleanups.callback(gateway.post, "/tag/delete", {"name": tag})
key: Final = scenario.key(models=[model])
after: Final = _broken_stream_then_refusal(gateway, model, key, key, metadata={"tags": [tag]})
untagged: Final = chat(gateway, model, key)
assert_budget_refusal(after)
assert untagged.status_code == 200, untagged.text
@pytest.mark.timeout(240)
def test_a_broken_stream_fills_the_keys_budget_window(gateway: Gateway) -> None:
with wire_server(peer(DELIVERED_PIECES)) as wire, gateway.scenario() as scenario:
model: Final = priced_model(scenario, wire)
cost: Final = recovered_cost(gateway, scenario, model)
key: Final = scenario.key(models=[model], budget_limits=[{"budget_duration": "1d", "max_budget": cost / 2}])
after: Final = _broken_stream_then_refusal(gateway, model, key, key)
assert_budget_refusal(after)
@pytest.mark.timeout(240)
def test_concurrent_broken_streams_each_count_once_toward_the_budget(gateway: Gateway) -> None:
with wire_server(peer(DELIVERED_PIECES)) as wire, gateway.scenario() as scenario:
model: Final = priced_model(scenario, wire)
cost: Final = recovered_cost(gateway, scenario, model)
key: Final = scenario.key(models=[model], max_budget=cost * (_CONCURRENT_STREAMS + 0.5))
with ThreadPoolExecutor(max_workers=_CONCURRENT_STREAMS) as pool:
tuple(pool.map(lambda _: broken_stream(gateway, model, key), range(_CONCURRENT_STREAMS)))
within_budget: Final = chat(gateway, model, key)
broken_stream(gateway, model, key)
over_budget: Final = chat(gateway, model, key)
assert within_budget.status_code == 200, within_budget.text
assert_budget_refusal(over_budget)
assert sorted(failure_spend(gateway, key, rows=_CONCURRENT_STREAMS + 2)) == pytest.approx(
[0.0] + [cost] * (_CONCURRENT_STREAMS + 1)
)
@pytest.mark.timeout(240)
def test_completed_streams_count_once_toward_the_budget(gateway: Gateway) -> None:
with wire_server(peer(None)) as wire, gateway.scenario() as scenario:
model: Final = priced_model(scenario, wire)
meter: Final = scenario.key(models=[model], max_budget=100)
assert stream(gateway, model, meter, **_UNCACHED).status_code == 200
(metered,) = eventually(
lambda: gateway.rows(ROWS, (sha256(meter.encode()).hexdigest(),)),
lambda found: len(found) == 1,
seconds=70,
)
cost: Final = StatusRow.model_validate(metered).spend
key: Final = scenario.key(models=[model], max_budget=cost * 1.5)
first: Final = stream(gateway, model, key, **_UNCACHED)
second: Final = stream(gateway, model, key, **_UNCACHED)
assert (first.status_code, second.status_code) == (200, 200), (first.text, second.text)
assert _rows(gateway, key, 2) == (StatusRow(status="success", spend=cost),) * 2
third: Final = stream(gateway, model, key, **_UNCACHED)
assert_budget_refusal(third)
def _hung_then_served(release: threading.Event, hung_model: str) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
if request.method != "POST":
return Reply(status=404)
if JSON_OBJECT.validate_json(request.body).get("model") == hung_model:
return Reply(chunks=(completion(),), gate_before_first=release)
return Reply(body=completion())
return respond
def _timed_out_spend(gateway: Gateway, model: str, key: str) -> float:
timed_out: Final = chat(gateway, model, key, messages=[{"role": "user", "content": "hung"}])
assert timed_out.status_code in (408, 500), timed_out.text
digest: Final = sha256(key.encode()).hexdigest()
landed: Final = eventually(lambda: gateway.rows(ROWS, (digest,)), lambda found: len(found) >= 3, seconds=70)
rows: Final = tuple(
StatusRow.model_validate(row)
for row in held(lambda: gateway.rows(ROWS, (digest,)), lambda latest: len(latest) == len(landed), holding=2)
)
assert {row.status for row in rows} == {"failure"}, rows
return sum(row.spend for row in rows)
@pytest.mark.timeout(240)
def test_retries_the_provider_billed_before_a_timeout_count_toward_the_budget(gateway: Gateway) -> None:
hung_backend: Final = f"{BACKEND}-hung"
release: Final = threading.Event()
with wire_server(_hung_then_served(release, hung_backend)) as wire, gateway.scenario() as scenario:
scenario.cleanups.callback(release.set)
hung: Final = scenario.model(
model=f"openai/{hung_backend}",
api_base=f"{wire.url}/v1",
api_key="synthetic-openai-key",
input_cost_per_token=0.001,
output_cost_per_token=0.002,
timeout=1,
num_retries=2,
)
served: Final = priced_model(scenario, wire)
billed: Final = _timed_out_spend(gateway, hung, scenario.key(models=[hung, served], max_budget=100))
assert billed > 0
key: Final = scenario.key(models=[hung, served], max_budget=billed / 2)
assert _timed_out_spend(gateway, hung, key) == pytest.approx(billed)
after: Final = chat(gateway, served, key)
assert_budget_refusal(after)
def _messages_event(kind: str, fields: dict[str, JsonValue]) -> bytes:
return f"event: {kind}\ndata: {json.dumps({'type': kind, **fields})}\n\n".encode()
def _messages_peer(request: Request) -> Reply:
if request.method != "POST" or request.target != "/v1/messages":
return Reply(status=404)
message: Final[dict[str, JsonValue]] = {
"id": f"msg_{uuid.uuid4().hex}",
"type": "message",
"role": "assistant",
"model": _MESSAGES_BACKEND,
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 1},
}
if JSON_OBJECT.validate_json(request.body).get("stream") is not True:
return Reply(
body=json.dumps(
{**message, "content": [{"type": "text", "text": "ok"}], "stop_reason": "end_turn"}
).encode()
)
return Reply(
content_type="text/event-stream",
chunks=(
_messages_event("message_start", {"message": {**message, "content": []}}),
_messages_event("content_block_start", {"index": 0, "content_block": {"type": "text", "text": ""}}),
*(
_messages_event("content_block_delta", {"index": 0, "delta": {"type": "text_delta", "text": PIECE}})
for _ in range(DELIVERED_PIECES)
),
_messages_event("content_block_stop", {"index": 0}),
_messages_event(
"message_delta",
{"delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 60}},
),
_messages_event("message_stop", {}),
),
abort_after=DELIVERED_PIECES + 2,
pause_between_chunks=0.02,
)
def _messages(gateway: Gateway, model: str, key: str, *, streamed: bool) -> httpx.Response:
return gateway.request(
"POST",
"/v1/messages",
{
"model": model,
"max_tokens": 5,
"stream": streamed,
"messages": [{"role": "user", "content": f"partial stream {uuid.uuid4().hex}"}],
},
key=key,
)
def _broken_messages_stream(gateway: Gateway, model: str, key: str) -> None:
response: Final = _messages(gateway, model, key, streamed=True)
assert response.status_code == 200, response.text
assert response.text.count(PIECE) == DELIVERED_PIECES, response.text
assert "message_stop" not in response.text, response.text
def _rows(gateway: Gateway, key: str, count: int) -> tuple[StatusRow, ...]:
digest: Final = sha256(key.encode()).hexdigest()
eventually(lambda: gateway.rows(ROWS, (digest,)), lambda found: len(found) >= count, seconds=70)
return tuple(
StatusRow.model_validate(row)
for row in held(lambda: gateway.rows(ROWS, (digest,)), lambda latest: len(latest) == count, holding=2)
)
@pytest.mark.timeout(240)
def test_a_broken_messages_stream_counts_toward_the_budget(gateway: Gateway) -> None:
with wire_server(_messages_peer) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"anthropic/{_MESSAGES_BACKEND}",
api_base=wire.url,
api_key="synthetic-anthropic-key",
input_cost_per_token=0.001,
output_cost_per_token=0.002,
)
meter: Final = scenario.key(models=[model], max_budget=100)
_broken_messages_stream(gateway, model, meter)
(metered,) = _rows(gateway, meter, 1)
assert metered.spend > 0, metered
key: Final = scenario.key(models=[model], max_budget=metered.spend / 2)
_broken_messages_stream(gateway, model, key)
after: Final = _messages(gateway, model, key, streamed=False)
assert_budget_refusal(after)
assert _rows(gateway, key, 2) == (metered, StatusRow(status="failure", spend=0.0))

View file

@ -14,6 +14,7 @@ from fastapi import HTTPException
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.caching.dual_cache import DualCache
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
from litellm.proxy._types import ProxyException, SpendLogsPayload, UserAPIKeyAuth
from litellm.proxy.collector import SpendEventConsumer
@ -36,6 +37,7 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payloa
from litellm.proxy.utils import (
REJECTED_BEFORE_REQUEST_SETUP,
REJECTED_BEFORE_REQUEST_SETUP_KEY,
BilledFailedAttempt,
ProxyUpdateSpend,
)
from litellm.types.router import Deployment, LiteLLM_Params
@ -2676,6 +2678,229 @@ async def test_async_post_call_failure_hook_records_recovered_partial_spend():
assert mock_update_database.call_args[1]["response_cost"] == 3.5e-05
@pytest.fixture()
def live_spend_counters(monkeypatch: pytest.MonkeyPatch) -> DualCache:
counters: Final = DualCache()
monkeypatch.setattr("litellm.proxy.proxy_server.spend_counter_cache", counters)
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", DualCache())
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
return counters
def _failure_with_billed_attempts() -> Mapping[str, object]:
usage: Final = Usage(prompt_tokens=24, completion_tokens=0, total_tokens=24)
return {
"combined_usage_object": usage,
"response_cost": 0.5,
"billed_failed_attempts": (
BilledFailedAttempt(attempted_retries=0, deployment_id="dep-a", usage=usage, response_cost=0.25),
BilledFailedAttempt(attempted_retries=1, deployment_id="dep-b", usage=usage, response_cost=0.5),
),
}
_LIVE_COUNTERS: Final = {
"spend:key:hashed-key": 0.2,
"spend:team:team-1": 0.3,
"spend:team_member:user-1:team-1": 0.35,
"spend:user:user-1": 0.4,
"spend:end_user:end-user-1": 0.45,
"spend:org:org-1": 0.5,
"spend:project:project-1": 0.55,
"spend:tag:batch-jobs": 0.6,
"spend:model_access_group:premium": 0.65,
}
async def _fail_request(logger: ProxyDBLogger, failure: Mapping[str, object]) -> None:
await logger.async_post_call_failure_hook(
request_data={
"model": "gpt-4.1",
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {"tags": ["batch-jobs"], MODEL_ACCESS_GROUP_METADATA_KEY: ["premium"]},
"proxy_server_request": {"request_id": "rid"},
**failure,
},
original_exception=Exception("stream broke mid-flight"),
user_api_key_dict=UserAPIKeyAuth(
api_key="hashed-key",
team_id="team-1",
user_id="user-1",
end_user_id="end-user-1",
org_id="org-1",
project_id="project-1",
),
)
async def _counter_values(counters: DualCache) -> dict[str, float]:
return {key: await counters.async_get_cache(key) for key in _LIVE_COUNTERS}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("failure", "charged"),
[
(
{
"combined_usage_object": Usage(prompt_tokens=30, completion_tokens=40, total_tokens=70),
"response_cost": 0.125,
},
0.125,
),
(_failure_with_billed_attempts(), 1.25),
({}, 0.0),
],
)
async def test_failure_spend_rows_raise_the_live_spend_counters_by_their_cost(
live_spend_counters: DualCache, failure: Mapping[str, object], charged: float
):
"""#35568: budget checks read the live counters, so a failure row's cost has to land there as well as in the
spend log, or an over-budget key keeps passing until the counter is next repaired from the database."""
for key, spend in _LIVE_COUNTERS.items():
await live_spend_counters.async_set_cache(key, spend)
await _fail_request(ProxyDBLogger(), failure)
assert await _counter_values(live_spend_counters) == pytest.approx(
{key: spend + charged for key, spend in _LIVE_COUNTERS.items()}
)
@pytest.mark.asyncio
async def test_a_completed_cost_failure_raises_the_live_spend_counters_once(live_spend_counters: DualCache):
"""A failure whose cost was already finalized settles through the success path's write, which raises the
counters itself."""
for key, spend in _LIVE_COUNTERS.items():
await live_spend_counters.async_set_cache(key, spend)
await _fail_request(
ProxyDBLogger(), {"standard_logging_object": {"cost_finalized": True, "response_cost": 0.125}}
)
assert await _counter_values(live_spend_counters) == pytest.approx(
{key: spend + 0.125 for key, spend in _LIVE_COUNTERS.items()}
)
@pytest.mark.asyncio
async def test_failure_spend_rows_leave_the_live_spend_counters_alone_while_spend_updates_are_disabled(
live_spend_counters: DualCache, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"disable_spend_updates": True})
for key, spend in _LIVE_COUNTERS.items():
await live_spend_counters.async_set_cache(key, spend)
await _fail_request(ProxyDBLogger(), _failure_with_billed_attempts())
assert await _counter_values(live_spend_counters) == pytest.approx(_LIVE_COUNTERS)
@pytest.mark.asyncio
async def test_a_failing_spend_counter_store_is_still_sent_every_failure_spend_row():
counters: Final = AsyncMock(side_effect=RuntimeError("redis unavailable"))
await _fail_request(ProxyDBLogger(spend_counters=lambda: counters), _failure_with_billed_attempts())
assert [call.kwargs["response_cost"] for call in counters.call_args_list] == [0.5, 0.25, 0.5]
@pytest.mark.asyncio
@pytest.mark.timeout(30)
async def test_a_slow_spend_counter_store_lets_the_failure_out_and_raises_the_counters_once_it_answers():
store_answers: Final = asyncio.Event()
raised_cost: Final[asyncio.Future[object]] = asyncio.get_running_loop().create_future()
async def slow_counter_store(**counter_args: object) -> None:
await store_answers.wait()
raised_cost.set_result(counter_args["response_cost"])
logger: Final = ProxyDBLogger(spend_counters=lambda: slow_counter_store, failure_counter_wait_seconds=0)
await _fail_request(
logger,
{
"combined_usage_object": Usage(prompt_tokens=30, completion_tokens=40, total_tokens=70),
"response_cost": 0.125,
},
)
assert not raised_cost.done()
store_answers.set()
assert await raised_cost == 0.125
@pytest.mark.asyncio
async def test_a_failure_spend_row_lands_in_the_key_budget_window_as_of_the_request_start(
live_spend_counters: DualCache, monkeypatch: pytest.MonkeyPatch
):
"""The window row's one-time seed from the spend log stops at the request start, which keeps the failure row
it already logged from being counted twice."""
from litellm.proxy.proxy_server import proxy_logging_obj
window_queue: Final = proxy_logging_obj.db_spend_update_writer.window_spend_update_queue
await window_queue.flush_and_get_aggregated_window_spend_transactions()
key_cache: Final = DualCache()
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", key_cache)
request_start: Final = datetime(2026, 10, 20, 12, 0, tzinfo=timezone.utc)
reset_at: Final = request_start + timedelta(days=10)
await key_cache.async_set_cache(
"hashed-key",
UserAPIKeyAuth(
token="hashed-key",
budget_limits=[{"budget_duration": "30d", "max_budget": 100.0, "reset_at": reset_at.isoformat()}],
),
)
await _fail_request(
ProxyDBLogger(),
{
"combined_usage_object": Usage(prompt_tokens=30, completion_tokens=40, total_tokens=70),
"response_cost": 0.125,
"start_time": request_start,
},
)
assert [
(row["entity_type"], row["entity_id"], row["spend"], row["started_at"])
for row in await window_queue.flush_and_get_aggregated_window_spend_transactions()
] == [("key", "hashed-key", pytest.approx(0.125), "2026-10-20T12:00:00.000000")]
@pytest.mark.asyncio
async def test_a_billed_attempt_on_a_deployment_the_router_dropped_debits_every_attributed_access_group(
live_spend_counters: DualCache, monkeypatch: pytest.MonkeyPatch
):
"""Its spend row names the dropped deployment, which narrows nothing, so the counters must not narrow it to the
final attempt's groups either."""
monkeypatch.setattr(
"litellm.proxy.proxy_server.llm_router",
litellm.Router(
model_list=[
{
"model_name": "gpt-4.1",
"litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test"},
"model_info": {"id": "dep-b", "access_groups": ["pool"]},
}
]
),
)
await _fail_request(
ProxyDBLogger(),
{
**_failure_with_billed_attempts(),
"metadata": {MODEL_ACCESS_GROUP_METADATA_KEY: ["pool", "premium"], "model_info": {"id": "dep-b"}},
},
)
assert {
group: await live_spend_counters.async_get_cache(f"spend:model_access_group:{group}")
for group in ("premium", "pool")
} == pytest.approx({"premium": 0.25, "pool": 1.25})
@pytest.mark.asyncio
async def test_track_cost_callback_enriches_user_id_for_mcp_style_metadata():
"""MCP tool calls may only carry user_api_key; user/team rollups still need user_id."""