mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
* fix(agentic): surface a failed agentic follow-up call instead of the pre-loop tool call * fix(agentic): surface failed follow-ups from the OpenAI chat dispatcher and update pinned integration tests * fix(agentic): end a capped agentic turn on chat and responses instead of a retried 500 * fix(openai): run agentic hooks after the first call's error handling so a follow-up error keeps its own type and message * fix(agentic loop): end capped turns on every chat path and bill the first call when a follow-up fails A turn refused at max_agentic_loops now drops the internal tool calls of every hook that claims calls in it, while a call the client declared survives. The hosted_vllm and openai chat paths now carry the loop depth, fingerprints and max_agentic_loops into the follow-up, so they stop at the cap instead of looping forever. A failed follow-up call now records the first call's usage and cost on the request's failure log before the error reaches the client. A code interpreter sandbox that is missing or fails becomes the tool call's output for the model to handle, so the turn completes without a retry. On native streaming /v1/messages a hook or follow-up failure now reaches the client as an Anthropic error event with the mapped type, and compresr declares its retrieve tool so the stream holds it back. Comments and docstrings the branch added against the repo rule are removed * fix(agentic loop): bill retried attempts and clean up what failed or capped turns return The router's retry loop now carries each failed attempt's paid first call into the request's billing, so a request whose follow-up keeps failing bills every first call on its failure row, and one that recovers bills the failed attempt's first call on its success row. A failure row no longer adds unpriced estimated tokens on top of attempts that were billed, so its tokens match its spend A sandbox failure now answers the model with a fixed sentence and marks the code_interpreter_call as failed, without the sandbox's error text. Code interpreter on /v1/responses with Claude now runs its loop, because the bridged inner call no longer strips the hook's own markers Dropped internal tool calls leave the kept calls numbered from 0, also on a replayed stream. A capped native /v1/messages stream now says the loop hit its limit and that max_agentic_loops raises it, instead of asking the client to retry The agentic refusal and hook error logs no longer include the model or the exception text, and the agentic loop helpers take typed messages, params, and read-only fingerprints * fix(agentic loop): keep a recovered retry's cost breakdown on the response it returns A retried agentic request that recovers no longer adds the failed attempt's first call to its success row. The proxy keeps the follow-up's own row for that request id, so the added spend never showed up in the stored row, and that duplicate row is tracked separately. When every attempt fails, the failure row still bills each attempt's first call Pricing a first call for the failure row no longer replaces the request's cost breakdown. Before, a recovered retry answered with x-litellm-response-cost-original, -input, and -output from the failed attempt's first call. Now its headers and logged breakdown match a request that succeeds on its first try * fix(agentic): stop retrying a request whose agentic follow-up failed and bill every round it served An error raised by an agentic follow-up after the first call was served now carries a typed mark, so the router's retry loops, the mid-stream retry, and litellm's num_retries wrapper raise it as is instead of rerunning the whole request. A failure of the first call itself still retries, and fallbacks still run. The failure row bills every round the loop served before the failure, at any depth, and a later attempt that succeeds bills what the failed attempts paid on its success row, with the cost breakdown and the response cost headers adding up. The error response's x-litellm-response-cost reports the same amount its failure row bills. Failure rows outside agentic hooks keep the prompt-token estimate they had before. A refused tool call now matches by id among the refused ids, or by name among refused calls that carry no id, so a name-only refusal next to an id refusal is no longer kept. * fix(code_interpreter): delete a session sandbox whose code run failed so the next call starts a fresh one * fix(code_interpreter): delete only the session sandbox that failed, never its replacement * test(code_interpreter): type the session sandbox test helpers * fix(proxy): report what a failed agentic follow-up's failure row bills on /v1/messages errors * fix(agentic): keep the no-retry mark through error mapping and bill only failed follow-ups on success An agentic loop error that is not a provider error, such as the streaming chat cap or a crash in a hook's plan build, now keeps its no-retry mark when litellm maps it to an APIConnectionError, so the router no longer reruns every paid round. The mark is stored on the exception, so a built-in error can carry it too. A crash in a hook's plan build on /v1/messages and /v1/responses bills the first call on its failure row. A recovered attempt adds a failed attempt's usage to its success row only when that attempt was a failed agentic follow-up. Other failed attempts, such as a dropped stream, log their own row as on main. A code interpreter sandbox that fails while running the code now says the code may have run but its result is lost, instead of saying the code did not run. * test(responses): bill a retried MCP request each attempt on its own row again Round 4 stopped adding a non-agentic failed attempt to the success row, so this test goes back to main's assertions: the success row bills only its own rounds and the failed attempt's first round stays on its own failure row * fix(spend): name a failed follow-up attempt the success record already bills With the usage meter on, a failed agentic follow-up attempt gets its own charge-eligible record, and the request's success record also carries that attempt's usage and cost once the success row bills it. The success record now lists the attempt's record ids in included_attempt_ids, so a reader can tell the success record already covers it. Nothing changes when the meter is off * fix(spend): count a billed failed follow-up's tokens on the success row * fix(spend): bill a failed follow-up on a fallback served from cache * fix(spend): show a billed failed follow-up in a cached fallback's cost breakdown * fix(spend): keep a failed follow-up's billing state off the request-merged call details * fix(spend): record the cost breakdown of a successful agentic follow-up The chat agentic loop returned the follow-up response with the cost its own internal call had already stamped, so the request's logging reused that cost and never recorded cost_breakdown on the spend row. The loop now drops that stamped cost unless the follow-up was served from the cache, and the request prices the response itself --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
1738 lines
76 KiB
Python
1738 lines
76 KiB
Python
import asyncio
|
|
import json
|
|
from collections.abc import Awaitable, Mapping
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
from typing import Final
|
|
|
|
import pytest
|
|
|
|
from litellm.accounting import Metric, MetricName, Outcome
|
|
from litellm.litellm_core_utils.litellm_logging import Logging
|
|
from litellm.proxy.db.accounting_ledger import AccountingConflictError, UsageWrite
|
|
from litellm.proxy.spend_tracking.usage_meter import DurableUsageMeter, UsageCaptureError, UsageCaptureSettings
|
|
from litellm.types.utils import CallTypes
|
|
from tests.unit.proxy.spend_tracking.test_usage_meter import CommitBarrier, _manual_flush, _write
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("usage", [None, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}])
|
|
async def test_capture_keeps_unknown_usage_distinct_from_measured_zero(usage: Mapping[str, int] | None) -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_callback
|
|
|
|
database: Final = CommitBarrier()
|
|
database.release.set()
|
|
meter: Final = DurableUsageMeter(database.write, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
timestamp: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
captured: Final = capture_callback(
|
|
meter,
|
|
{"call_type": "acompletion", "litellm_call_id": "caller", "model": "unknown-model", "litellm_params": {}},
|
|
{"id": "provider", "usage": usage},
|
|
timestamp,
|
|
timestamp,
|
|
)
|
|
await meter.close()
|
|
outbound: Final = captured.writes[1].event
|
|
assert outbound.metrics == (
|
|
None
|
|
if usage is None
|
|
else tuple(Metric(name=MetricName(key), value=Decimal(value)) for key, value in usage.items())
|
|
)
|
|
envelope: Final = json.loads(captured.writes[1].envelope)
|
|
assert envelope["usage"] == usage
|
|
assert envelope["pricing"]["reported_cost"] is None
|
|
assert database.committed == ("caller:inbound", "provider")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"call_type",
|
|
[
|
|
call.value
|
|
for call in (
|
|
CallTypes.acompletion,
|
|
CallTypes.arealtime,
|
|
CallTypes.call_mcp_tool,
|
|
CallTypes.asend_message,
|
|
CallTypes.pass_through,
|
|
)
|
|
],
|
|
)
|
|
async def test_shared_callback_capture_retains_usage_and_attribution_without_bodies(call_type: str) -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_callback
|
|
|
|
database: Final = CommitBarrier()
|
|
database.release.set()
|
|
meter: Final = DurableUsageMeter(database.write, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
timestamp: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
captured: Final = capture_callback(
|
|
meter,
|
|
{
|
|
"call_type": call_type,
|
|
"litellm_call_id": "caller",
|
|
"model": "test-model",
|
|
"messages": "private prompt",
|
|
"litellm_params": {"api_key": "private-secret", "metadata": {"user_api_key_team_id": "team"}},
|
|
},
|
|
{
|
|
"id": "provider",
|
|
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
|
"output": "private answer",
|
|
},
|
|
timestamp,
|
|
timestamp,
|
|
)
|
|
await meter.close()
|
|
envelope: Final = captured.writes[1].envelope
|
|
assert all(secret not in envelope for secret in ("private prompt", "private-secret", "private answer"))
|
|
event: Final = captured.writes[1].event
|
|
assert event.call_type.value == call_type
|
|
assert event.metrics == (
|
|
Metric(name=MetricName.PROMPT_TOKENS, value=Decimal(3)),
|
|
Metric(name=MetricName.COMPLETION_TOKENS, value=Decimal(2)),
|
|
Metric(name=MetricName.TOTAL_TOKENS, value=Decimal(5)),
|
|
)
|
|
assert any(dimension.value == "team" for dimension in event.dimensions)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_duplicate_uses_caller_identity_despite_timestamped_diagnostic_ids() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_callback
|
|
|
|
database: Final = CommitBarrier()
|
|
database.release.set()
|
|
meter: Final = DurableUsageMeter(database.write, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
timestamp: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
kwargs: Final = {
|
|
"call_type": "acompletion",
|
|
"litellm_call_id": "caller",
|
|
"model": "test-model",
|
|
"cache_hit": True,
|
|
"litellm_params": {},
|
|
}
|
|
response: Final = {"id": "cached-provider-id", "usage": {"total_tokens": 5}}
|
|
first: Final = capture_callback(meter, kwargs, response, timestamp, timestamp)
|
|
second: Final = capture_callback(meter, kwargs, response, timestamp, timestamp)
|
|
await meter.close()
|
|
assert first.writes == second.writes
|
|
assert first.writes[1].event.event_id == "caller_cache_hit"
|
|
assert json.loads(first.writes[1].envelope)["charge_eligible"] is False
|
|
|
|
|
|
NOW: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
|
class StoredUsage:
|
|
def __init__(self) -> None:
|
|
self.rows: tuple[UsageWrite, ...] = ()
|
|
|
|
async def load(self, identities: tuple[str, ...]) -> tuple[UsageWrite, ...]:
|
|
return tuple(row for row in self.rows if row.event.event_id in identities)
|
|
|
|
async def insert(self, writes: tuple[UsageWrite, ...]) -> None:
|
|
existing: Final = {row.event.event_id: row for row in self.rows}
|
|
if any(existing.get(row.event.event_id, row) != row for row in writes):
|
|
raise AccountingConflictError("Same identity has different content")
|
|
unique: Final = {row.event.event_id: row for row in writes}
|
|
if any(unique[row.event.event_id] != row for row in writes):
|
|
raise AccountingConflictError("Submission contains conflicting content")
|
|
self.rows += tuple(row for identity, row in unique.items() if identity not in existing)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("text", ["parent\x00value", "pärent-✓-日本", r"\u0000"])
|
|
async def test_capture_normalizes_database_text_without_poisoning_neighbors(text: str) -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import PricingCapture, capture_callback
|
|
|
|
database: Final = StoredUsage()
|
|
batches: Final = asyncio.Queue[tuple[UsageWrite, ...]]()
|
|
|
|
async def persist(writes: tuple[UsageWrite, ...]) -> None:
|
|
assert all("\x00" not in (json.loads(write.envelope).get("parent_request_id") or "") for write in writes)
|
|
batches.put_nowait(writes)
|
|
await database.insert(writes)
|
|
|
|
meter: Final = DurableUsageMeter(persist, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
nested: Final = json.loads("[" * 100 + "3" + "]" * 100)
|
|
kwargs: Final = {
|
|
"call_type": "acompletion",
|
|
"litellm_call_id": "caller",
|
|
"model": "capture-model",
|
|
"search_context_size": text,
|
|
"_accounting_pricing_snapshot": PricingCapture(
|
|
"{}", "{}", json.dumps({"request": {"search_context_size": text}, "nested": nested})
|
|
),
|
|
"litellm_params": {"metadata": {"parent_request_id": text}},
|
|
}
|
|
response: Final = {"id": "provider", "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}}
|
|
captured: Final = capture_callback(meter, kwargs, response, NOW, NOW)
|
|
neighbor: Final = capture_callback(
|
|
meter,
|
|
{**kwargs, "litellm_call_id": "neighbor", "litellm_params": {}},
|
|
{**response, "id": "neighbor-provider"},
|
|
NOW,
|
|
NOW,
|
|
)
|
|
try:
|
|
await meter.flush()
|
|
await asyncio.gather(captured.receipt, neighbor.receipt)
|
|
assert batches.qsize() == 1
|
|
assert len(batches.get_nowait()) == 4
|
|
envelope: Final = json.loads(captured.writes[-1].envelope)
|
|
assert envelope["parent_request_id"] == text.replace("\x00", "")
|
|
assert envelope["pricing"]["request"]["search_context_size"] == text.replace("\x00", "")
|
|
assert envelope["pricing"]["nested"] == nested
|
|
assert envelope["event"] == captured.writes[-1].event.model_dump(mode="json")
|
|
assert envelope["usage"] == response["usage"]
|
|
assert kwargs["litellm_params"] == {"metadata": {"parent_request_id": text}}
|
|
repeated: Final = capture_callback(meter, kwargs, response, NOW, NOW)
|
|
await meter.flush()
|
|
await repeated.receipt
|
|
assert repeated.writes == captured.writes
|
|
assert len(database.rows) == 4
|
|
finally:
|
|
await meter.stop(0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("invalid_field", ["identity", "context"])
|
|
async def test_invalid_capture_text_is_rejected_before_admission(invalid_field: str) -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_callback
|
|
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
kwargs: Final = {"call_type": "acompletion", "litellm_call_id": "caller", "litellm_params": {}}
|
|
try:
|
|
accepted: Final = capture_callback(meter, kwargs, {"id": "provider"}, NOW, NOW)
|
|
invalid: Final = (
|
|
{**kwargs, "litellm_call_id": "caller\x00"}
|
|
if invalid_field == "identity"
|
|
else {**kwargs, "litellm_params": {"metadata": {"parent_request_id": "parent\ud800"}}}
|
|
)
|
|
with pytest.raises((UsageCaptureError, ValueError)):
|
|
capture_callback(meter, invalid, {"id": "other"}, NOW, NOW)
|
|
assert meter.pending_records == 2
|
|
await meter.flush()
|
|
await accepted.receipt
|
|
assert tuple(write.event.event_id for write in database.rows) == ("caller:inbound", "provider")
|
|
finally:
|
|
await meter.stop(0)
|
|
|
|
|
|
def _logger(call_type: str = "acompletion", *, stream: bool = False) -> Logging:
|
|
from litellm.litellm_core_utils.litellm_logging import Logging
|
|
|
|
logger: Final = Logging(
|
|
model="capture-model",
|
|
messages=[],
|
|
stream=stream,
|
|
call_type=call_type,
|
|
start_time=NOW,
|
|
litellm_call_id="caller",
|
|
function_id="function",
|
|
)
|
|
logger.update_environment_variables(
|
|
model="capture-model", custom_llm_provider="openai", litellm_params={}, optional_params={}
|
|
)
|
|
return logger
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_retrieval_reuses_accepted_snapshot_but_rejects_changed_usage() -> None:
|
|
from datetime import timedelta
|
|
|
|
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageBatchWriter, capture_callback
|
|
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
|
|
|
database: Final = StoredUsage()
|
|
writer: Final = UsageBatchWriter(database.insert, database.load)
|
|
meter: Final = DurableUsageMeter(writer.write, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
kwargs: Final = {
|
|
"call_type": "aget_responses",
|
|
"litellm_call_id": "poll-one",
|
|
"model": "capture-model",
|
|
"response_cost": 2.0,
|
|
"litellm_params": {"metadata": {INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN}},
|
|
}
|
|
response: Final = {
|
|
"id": "job",
|
|
"status": "completed",
|
|
"background": True,
|
|
"created_at": NOW.timestamp(),
|
|
"usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7},
|
|
}
|
|
first: Final = capture_callback(meter, kwargs, response, NOW, NOW)
|
|
await meter.flush()
|
|
later: Final = capture_callback(
|
|
meter, {**kwargs, "litellm_call_id": "poll-two", "response_cost": 9.0}, response, NOW, NOW + timedelta(hours=2)
|
|
)
|
|
await meter.flush()
|
|
await later.receipt
|
|
assert database.rows == first.writes
|
|
assert len(database.rows) == 1
|
|
assert database.rows[0].event.event_id == "job_background_cost"
|
|
changed: Final = capture_callback(meter, kwargs, {**response, "usage": {"total_tokens": 8}}, NOW, NOW)
|
|
await meter.close()
|
|
with pytest.raises(UsageCaptureError, match="identity"):
|
|
await changed.receipt
|
|
assert database.rows == first.writes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("terminal", [False, True])
|
|
async def test_competing_writers_reuse_only_terminal_pricing_snapshots(terminal: bool) -> None:
|
|
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageBatchWriter, capture_callback
|
|
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
|
|
|
database: Final = StoredUsage()
|
|
attempts: Final = asyncio.Queue[tuple[UsageWrite, ...]]()
|
|
competing: Final = asyncio.Event()
|
|
|
|
async def insert(writes: tuple[UsageWrite, ...]) -> None:
|
|
attempts.put_nowait(writes)
|
|
if attempts.qsize() >= 2:
|
|
competing.set()
|
|
await competing.wait()
|
|
await database.insert(writes)
|
|
|
|
meters: Final = tuple(
|
|
DurableUsageMeter(UsageBatchWriter(insert, database.load).write, UsageCaptureSettings()) for _ in range(2)
|
|
)
|
|
kwargs: Final = {
|
|
"call_type": "aget_responses" if terminal else "acompletion",
|
|
"litellm_call_id": "caller",
|
|
"model": "capture-model",
|
|
"litellm_params": {
|
|
"metadata": {INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN}
|
|
if terminal
|
|
else {}
|
|
},
|
|
}
|
|
response: Final = {
|
|
"id": "job",
|
|
"status": "completed",
|
|
"background": terminal,
|
|
"created_at": NOW.timestamp(),
|
|
"usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7},
|
|
}
|
|
captured: Final = tuple(
|
|
capture_callback(meter, {**kwargs, "response_cost": price}, response, NOW, NOW)
|
|
for meter, price in zip(meters, (2.0, 9.0), strict=True)
|
|
)
|
|
try:
|
|
await asyncio.gather(*(meter.flush() for meter in meters))
|
|
results: Final = await asyncio.gather(*(capture.receipt for capture in captured), return_exceptions=True)
|
|
assert sum(result is None for result in results) == (2 if terminal else 1)
|
|
assert sum(isinstance(result, UsageCaptureError) for result in results) == (0 if terminal else 1)
|
|
assert database.rows in tuple(capture.writes for capture in captured)
|
|
assert len(database.rows) == (1 if terminal else 2)
|
|
assert attempts.qsize() == (3 if terminal else 2)
|
|
finally:
|
|
await asyncio.gather(*(meter.stop(0) for meter in meters))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_poll_has_distinct_identity_and_only_terminal_usage_is_chargeable() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_callback
|
|
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
kwargs: Final = {
|
|
"call_type": "aretrieve_batch",
|
|
"litellm_call_id": "poll",
|
|
"model": "capture-model",
|
|
"litellm_params": {},
|
|
}
|
|
pending: Final = capture_callback(meter, kwargs, {"id": "batch", "status": "in_progress"}, NOW, NOW)
|
|
completed: Final = capture_callback(
|
|
meter, kwargs, {"id": "batch", "status": "completed", "usage": {"total_tokens": 9}}, NOW, NOW
|
|
)
|
|
await meter.close()
|
|
assert pending.writes[-1].event.event_id == "poll:status"
|
|
assert json.loads(pending.writes[-1].envelope)["charge_eligible"] is False
|
|
assert completed.writes[-1].event.event_id == "batch_batch_cost"
|
|
assert json.loads(completed.writes[-1].envelope)["charge_eligible"] is True
|
|
assert len(completed.writes) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_internal_capture_does_not_claim_reused_parent_logger(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import litellm
|
|
from litellm._internal_context import internal_sub_call
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_logging_usage
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
monkeypatch.setitem(
|
|
litellm.model_cost,
|
|
"capture-model",
|
|
{
|
|
"input_cost_per_token": 0.1,
|
|
"output_cost_per_token": 0.2,
|
|
"litellm_provider": "openai",
|
|
"mode": "chat",
|
|
},
|
|
)
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
logger: Final = _logger()
|
|
response: Final = ModelResponse(
|
|
id="response", model="capture-model", usage=Usage(prompt_tokens=2, completion_tokens=1, total_tokens=3)
|
|
)
|
|
with internal_sub_call("caller"):
|
|
capture_logging_usage(meter, logger, response, NOW, NOW)
|
|
monkeypatch.setitem(
|
|
litellm.model_cost,
|
|
"capture-model",
|
|
{
|
|
"input_cost_per_token": 0.7,
|
|
"output_cost_per_token": 0.9,
|
|
"litellm_provider": "openai",
|
|
"mode": "chat",
|
|
},
|
|
)
|
|
litellm.register_model({"capture-model": litellm.model_cost["capture-model"]})
|
|
capture_logging_usage(meter, logger, response, NOW, NOW)
|
|
capture_logging_usage(meter, logger, response, NOW, NOW)
|
|
await meter.close()
|
|
assert tuple(row.event.event_id for row in database.rows) == ("response:included", "caller:inbound", "response")
|
|
child: Final = json.loads(database.rows[0].envelope)
|
|
parent: Final = json.loads(database.rows[-1].envelope)
|
|
assert (child["ownership"], child["charge_eligible"], child["parent_request_id"]) == (
|
|
"included_in_parent",
|
|
False,
|
|
"caller",
|
|
)
|
|
assert parent["charge_eligible"] is True
|
|
assert child["pricing"]["model_map_information"]["model_map_value"]["input_cost_per_token"] == 0.1
|
|
assert parent["pricing"]["model_map_information"]["model_map_value"]["input_cost_per_token"] == 0.7
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_capture_ignores_chunks_and_preserves_terminal_usage_extras() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_logging_usage
|
|
from litellm.types.utils import ModelResponse, ModelResponseStream, Usage
|
|
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
logger: Final = _logger(stream=True)
|
|
capture_logging_usage(meter, logger, ModelResponseStream(id="chunk"), NOW, NOW)
|
|
assert meter.pending_records == 0
|
|
capture_logging_usage(
|
|
meter, logger, ModelResponse(id="stream", usage=Usage(prompt_tokens=3, provider_units=4)), NOW, NOW
|
|
)
|
|
await meter.close()
|
|
assert json.loads(database.rows[-1].envelope)["usage"]["provider_units"] == 4
|
|
assert database.rows[-1].event.event_id == "stream"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("call_type", ["acompletion", "atext_completion", "aembedding", "aresponses"])
|
|
async def test_supported_sync_ack_waits_for_commit(call_type: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import ConfigGeneralSettings
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, capture_proxy_response
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = CommitBarrier()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.write, UsageCaptureSettings(batch_size=2))
|
|
monkeypatch.setattr(proxy_server, "usage_capture", runtime)
|
|
monkeypatch.setattr(proxy_server, "general_settings", {"accounting_sync_ack": True})
|
|
deadlines: Final = asyncio.Queue[float | None]()
|
|
original_wait: Final = asyncio.wait_for
|
|
|
|
async def wait_with_deadline(awaitable: Awaitable[object], timeout: float | None) -> object:
|
|
deadlines.put_nowait(timeout)
|
|
return await original_wait(awaitable, timeout)
|
|
|
|
monkeypatch.setattr(asyncio, "wait_for", wait_with_deadline)
|
|
response: Final = asyncio.create_task(
|
|
capture_proxy_response(
|
|
_logger(call_type),
|
|
ModelResponse(id="provider", usage=Usage(prompt_tokens=3)),
|
|
)
|
|
)
|
|
try:
|
|
await database.entered.wait()
|
|
deadline: Final = deadlines.get_nowait()
|
|
assert deadline == ConfigGeneralSettings().accounting_sync_ack_timeout_seconds
|
|
assert deadline is not None and deadline > UsageCaptureSettings().flush_interval_seconds + 10
|
|
assert not response.done()
|
|
assert database.committed == ()
|
|
database.release.set()
|
|
await response
|
|
assert database.committed == ("caller:inbound", "provider")
|
|
finally:
|
|
database.release.set()
|
|
await runtime.stop(1)
|
|
await response
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("failure_policy", ["fail_closed", "log"])
|
|
async def test_sync_ack_commit_failure_follows_response_policy(
|
|
failure_policy: str, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, capture_proxy_response
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = CommitBarrier(failure=ValueError("invalid envelope"))
|
|
database.release.set()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.write, UsageCaptureSettings(batch_size=2))
|
|
meter: Final = runtime.meter
|
|
assert meter is not None
|
|
monkeypatch.setattr(proxy_server, "usage_capture", runtime)
|
|
monkeypatch.setattr(
|
|
proxy_server,
|
|
"general_settings",
|
|
{"accounting_sync_ack": True, "accounting_sync_ack_failure_policy": failure_policy},
|
|
)
|
|
logger: Final = _logger()
|
|
response: Final = ModelResponse(id="provider", usage=Usage(prompt_tokens=4))
|
|
if failure_policy == "fail_closed":
|
|
with pytest.raises(HTTPException) as error:
|
|
await capture_proxy_response(logger, response)
|
|
assert error.value.status_code == 503
|
|
else:
|
|
await capture_proxy_response(logger, response)
|
|
await runtime.stop(1.0)
|
|
assert database.committed == ()
|
|
assert meter.pending_records == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_attempt_keeps_its_usage_without_claiming_the_callers_success() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
logger: Final = _logger()
|
|
try:
|
|
for _ in range(2):
|
|
logger.record_partial_usage_for_failure(Usage(prompt_tokens=5, completion_tokens=2, total_tokens=7), 0.4)
|
|
logger.carry_failed_attempt_usage()
|
|
logger.capture_usage_before_logging(
|
|
ModelResponse(id="fallback", usage=Usage(prompt_tokens=3, total_tokens=3)), NOW, NOW
|
|
)
|
|
finally:
|
|
await runtime.stop(1)
|
|
assert tuple(row.event.event_id for row in database.rows) == (
|
|
"caller-attempt-0",
|
|
"caller-attempt-1",
|
|
"caller:inbound",
|
|
"fallback",
|
|
)
|
|
failed: Final = json.loads(database.rows[0].envelope)
|
|
assert failed["usage"]["total_tokens"] == 7
|
|
assert failed["parent_request_id"] == "caller"
|
|
assert failed["request_count_eligible"] is False
|
|
assert failed["charge_eligible"] is True
|
|
assert failed["event"]["retry_of_attempt_id"] is None
|
|
assert failed["event"]["outcome"] == "failed"
|
|
assert failed["pricing"]["reported_cost"] == 0.4
|
|
assert all(json.loads(row.envelope)["included_attempt_ids"] == [] for row in database.rows[:2])
|
|
successful: Final = json.loads(database.rows[-1].envelope)
|
|
assert successful["related_attempt_ids"] == ["caller-attempt-0", "caller-attempt-1"]
|
|
assert successful["included_attempt_ids"] == []
|
|
assert successful["usage"]["total_tokens"] == 3
|
|
assert successful["charge_eligible"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success_that_bills_a_failed_follow_up_names_that_attempt_as_included() -> None:
|
|
from litellm.litellm_core_utils.hidden_params import set_hidden_param
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
logger: Final = _logger()
|
|
fallback: Final = ModelResponse(id="fallback", usage=Usage(prompt_tokens=3, completion_tokens=1, total_tokens=4))
|
|
set_hidden_param(fallback, "response_cost", 0.1)
|
|
try:
|
|
logger.record_served_rounds_for_failure(((Usage(prompt_tokens=5, completion_tokens=2, total_tokens=7), 0.4),))
|
|
logger.carry_failed_attempt_usage()
|
|
logger.prepare_response_cost(fallback, NOW)
|
|
logger.capture_usage_before_logging(fallback, NOW, NOW)
|
|
finally:
|
|
await runtime.stop(1)
|
|
envelopes: Final = {row.event.event_id: json.loads(row.envelope) for row in database.rows}
|
|
assert envelopes["caller-attempt-0"]["usage"]["total_tokens"] == 7
|
|
assert envelopes["caller-attempt-0"]["charge_eligible"] is True
|
|
assert envelopes["fallback"]["usage"]["total_tokens"] == 4 + 7
|
|
assert envelopes["fallback"]["included_attempt_ids"] == ["caller-attempt-0"]
|
|
assert database.rows[2].event.outcome is Outcome.SUCCEEDED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("estimated", [False, True])
|
|
async def test_cancelled_usage_keeps_measured_or_estimated_provenance(estimated: bool) -> None:
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.litellm_core_utils.litellm_logging import UsageObservation
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_callback
|
|
from litellm.types.utils import Usage
|
|
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
capture_callback(
|
|
meter,
|
|
{
|
|
"litellm_call_id": "cancelled",
|
|
"call_type": "acompletion",
|
|
"model": "capture-model",
|
|
"litellm_params": {},
|
|
"combined_usage_object": Usage(prompt_tokens=4, total_tokens=4),
|
|
"_accounting_usage_observed": UsageObservation.ESTIMATED if estimated else UsageObservation.KNOWN,
|
|
},
|
|
HTTPException(499, "Client disconnected"),
|
|
NOW,
|
|
NOW,
|
|
)
|
|
await meter.close()
|
|
payload: Final = json.loads(database.rows[-1].envelope)
|
|
assert payload["event"]["outcome"] == "cancelled"
|
|
assert payload["usage"]["total_tokens"] == 4
|
|
assert payload["usage_source"] == ("estimated" if estimated else "measured")
|
|
assert payload["pricing"]["reported_cost"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_capture_keeps_metered_units_and_provider_usage_without_frames() -> None:
|
|
from litellm.constants import REALTIME_METERED_USAGE_KEY
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_logging_usage
|
|
from litellm.types.llms.xai import RealtimeMeteredUsage
|
|
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
logger: Final = _logger(CallTypes.arealtime.value)
|
|
logger.model_call_details[REALTIME_METERED_USAGE_KEY] = RealtimeMeteredUsage(True, 1.5, 2.5, 3)
|
|
capture_logging_usage(
|
|
meter,
|
|
logger,
|
|
[
|
|
{
|
|
"type": "response.done",
|
|
"response": {
|
|
"id": "turn",
|
|
"usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7},
|
|
"output": "private realtime transcript",
|
|
},
|
|
}
|
|
],
|
|
NOW,
|
|
NOW,
|
|
)
|
|
await meter.close()
|
|
payload: Final = json.loads(database.rows[-1].envelope)
|
|
assert payload["usage"]["total_tokens"] == 7
|
|
assert payload["usage"]["metered_usage"] == {
|
|
"push_to_talk": True,
|
|
"audio_seconds_sent": 1.5,
|
|
"audio_seconds_received": 2.5,
|
|
"text_messages": 3,
|
|
}
|
|
assert "private realtime transcript" not in database.rows[-1].envelope
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_http_capture_returns_before_database_commit(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, capture_proxy_response
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = CommitBarrier()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.write, UsageCaptureSettings(batch_size=2))
|
|
monkeypatch.setattr(proxy_server, "usage_capture", runtime)
|
|
monkeypatch.setattr(proxy_server, "general_settings", {})
|
|
try:
|
|
await capture_proxy_response(
|
|
_logger(),
|
|
ModelResponse(id="asynchronous", usage=Usage(prompt_tokens=1)),
|
|
)
|
|
await database.entered.wait()
|
|
assert database.committed == ()
|
|
finally:
|
|
database.release.set()
|
|
await runtime.stop(1)
|
|
assert database.committed == ("caller:inbound", "asynchronous")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deferred_capture_freezes_pricing_before_configuration_changes(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import litellm
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, capture_logging_usage
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
from litellm.utils import dispatch_success_logging
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
logger: Final = _logger()
|
|
logger.defer_async_logging = True
|
|
logger.custom_cost_per_second = 0.25
|
|
monkeypatch.setattr(litellm, "cost_discount_config", {"openai": 0.1})
|
|
response: Final = ModelResponse(id="deferred", usage=Usage(prompt_tokens=3))
|
|
try:
|
|
dispatch_success_logging(logger, response, NOW, NOW, False, False)
|
|
assert database.rows == ()
|
|
logger.custom_cost_per_second = 9
|
|
monkeypatch.setattr(litellm, "cost_discount_config", {"openai": 0.9})
|
|
assert runtime.meter is not None
|
|
capture_logging_usage(runtime.meter, logger, response, NOW, NOW)
|
|
await runtime.meter.flush()
|
|
finally:
|
|
await runtime.stop(1)
|
|
pricing: Final = json.loads(database.rows[-1].envelope)["pricing"]
|
|
assert pricing["logging"]["custom_cost_per_second"] == 0.25
|
|
assert pricing["adjustments"]["discount"] == 0.1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_synchronous_internal_capture_reserves_capacity_before_scheduling() -> None:
|
|
from litellm._internal_context import internal_sub_call
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = CommitBarrier()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.write, UsageCaptureSettings(max_pending_records=2, batch_size=2))
|
|
logger: Final = _logger()
|
|
|
|
def capture_child() -> None:
|
|
with internal_sub_call("caller"):
|
|
logger.capture_usage_before_logging(ModelResponse(id="child", usage=Usage(prompt_tokens=2)), NOW, NOW)
|
|
|
|
try:
|
|
await asyncio.to_thread(capture_child)
|
|
assert runtime.meter is not None
|
|
assert runtime.meter.pending_records == 1
|
|
receipt: Final = await asyncio.to_thread(runtime.meter.submit, (_write("second"),))
|
|
await database.entered.wait()
|
|
with pytest.raises(UsageCaptureError, match="capacity"):
|
|
await asyncio.to_thread(runtime.meter.submit, (_write("overflow"),))
|
|
database.release.set()
|
|
await receipt
|
|
finally:
|
|
database.release.set()
|
|
await runtime.stop(1)
|
|
assert database.committed == ("child:included", "second")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("has_metered_usage", [False, True])
|
|
async def test_realtime_without_provider_token_usage_does_not_invent_zero(has_metered_usage: bool) -> None:
|
|
from litellm.constants import REALTIME_METERED_USAGE_KEY
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_logging_usage
|
|
from litellm.types.llms.xai import RealtimeMeteredUsage
|
|
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
logger: Final = _logger(CallTypes.arealtime.value)
|
|
if has_metered_usage:
|
|
logger.model_call_details[REALTIME_METERED_USAGE_KEY] = RealtimeMeteredUsage(True, 0, 0, 0)
|
|
capture_logging_usage(meter, logger, [], NOW, NOW)
|
|
await meter.close()
|
|
usage: Final = json.loads(database.rows[-1].envelope)["usage"]
|
|
assert usage == (
|
|
{
|
|
"metered_usage": {
|
|
"push_to_talk": True,
|
|
"audio_seconds_sent": 0,
|
|
"audio_seconds_received": 0,
|
|
"text_messages": 0,
|
|
}
|
|
}
|
|
if has_metered_usage
|
|
else None
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("committed_before_failure", [False, True])
|
|
async def test_database_recovery_retries_frozen_usage_without_callback_or_duplicate_rows(
|
|
committed_before_failure: bool,
|
|
) -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_callback
|
|
|
|
database: Final = StoredUsage()
|
|
failures: Final = asyncio.Queue[None]()
|
|
failures.put_nowait(None)
|
|
retrying: Final = asyncio.Event()
|
|
recovered: Final = asyncio.Event()
|
|
delays: Final = asyncio.Queue[float]()
|
|
|
|
async def persist(writes: tuple[UsageWrite, ...]) -> None:
|
|
if not failures.empty():
|
|
failures.get_nowait()
|
|
if committed_before_failure:
|
|
await database.insert(writes)
|
|
raise ConnectionError("database unavailable or acknowledgement lost")
|
|
await database.insert(writes)
|
|
|
|
async def retry_delay(delay: float) -> None:
|
|
delays.put_nowait(delay)
|
|
retrying.set()
|
|
await recovered.wait()
|
|
|
|
meter: Final = DurableUsageMeter(
|
|
persist, UsageCaptureSettings(max_pending_records=2), wait_for_batch=_manual_flush, retry_delay=retry_delay
|
|
)
|
|
kwargs: Final = {
|
|
"call_type": "acompletion",
|
|
"litellm_call_id": "caller",
|
|
"model": "capture-model",
|
|
"litellm_params": {},
|
|
}
|
|
response: Final = {"id": "provider", "usage": {"total_tokens": 3}}
|
|
first: Final = capture_callback(meter, kwargs, response, NOW, NOW)
|
|
reserved: Final = meter.pending_bytes
|
|
flushing: Final = asyncio.create_task(meter.flush())
|
|
await retrying.wait()
|
|
assert not first.receipt.done()
|
|
assert (meter.pending_records, meter.pending_bytes) == (2, reserved)
|
|
with pytest.raises(UsageCaptureError, match="capacity"):
|
|
meter.submit((_write("overflow"),))
|
|
recovered.set()
|
|
await flushing
|
|
await first.receipt
|
|
duplicate: Final = capture_callback(meter, {**kwargs, "_accounting_capture": first}, response, NOW, NOW)
|
|
await meter.close()
|
|
assert duplicate is first
|
|
assert database.rows == first.writes
|
|
assert delays.get_nowait() == 0.2
|
|
assert (meter.pending_records, meter.pending_bytes) == (0, 0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cut_stream_keeps_original_model_usage_as_part_of_the_parent_aggregate() -> None:
|
|
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
from litellm.router import Router
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
logger: Final = _logger(stream=True)
|
|
stream: Final = CustomStreamWrapper(completion_stream=iter(()), model="capture-model", logging_obj=logger)
|
|
partial: Final = ModelResponse(
|
|
id="cut", model="capture-model", usage=Usage(prompt_tokens=3, completion_tokens=2, total_tokens=5)
|
|
)
|
|
try:
|
|
Router._price_cut_partial(stream, partial)
|
|
finally:
|
|
await runtime.stop(1)
|
|
assert len(database.rows) == 1
|
|
payload: Final = json.loads(database.rows[0].envelope)
|
|
assert payload["event"]["event_id"] == "cut:included"
|
|
assert payload["parent_request_id"] == "caller"
|
|
assert payload["charge_eligible"] is False
|
|
assert payload["usage"]["total_tokens"] == 5
|
|
assert payload["pricing"]["model_map_information"]["model_map_key"] == "capture-model"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_mcp_logging_captures_tool_pricing_and_identity_without_arguments() -> None:
|
|
from mcp.types import CallToolResult, TextContent
|
|
|
|
from litellm.accounting import DimensionName
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
logger: Final = _logger(CallTypes.call_mcp_tool.value)
|
|
logger.model_call_details["mcp_tool_call_metadata"] = {
|
|
"name": "lookup",
|
|
"namespaced_tool_name": "server.lookup",
|
|
"arguments": {"query": "private query"},
|
|
"mcp_server_cost_info": {"default_cost_per_query": 0.2, "tool_name_to_cost_per_query": {"lookup": 0.3}},
|
|
}
|
|
try:
|
|
await logger.async_success_handler(
|
|
CallToolResult(content=[TextContent(type="text", text="private result")]), NOW, NOW
|
|
)
|
|
finally:
|
|
await runtime.stop(1)
|
|
outbound: Final = database.rows[-1]
|
|
assert any(d.name is DimensionName.TOOL and d.value == "server.lookup" for d in outbound.event.dimensions)
|
|
payload: Final = json.loads(outbound.envelope)
|
|
assert payload["pricing"]["tool"] == {"name": "lookup", "cost_per_query": 0.3}
|
|
assert payload["usage"] is None
|
|
assert all(secret not in outbound.envelope for secret in ("private query", "private result"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_overload_is_visible_without_suppressing_legacy_tracking(
|
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, captured_callback_kwargs
|
|
|
|
database: Final = CommitBarrier()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.write, UsageCaptureSettings(max_pending_records=2, batch_size=2))
|
|
monkeypatch.setattr(proxy_server, "usage_capture", runtime)
|
|
kwargs: Final = {"call_type": "acompletion", "litellm_call_id": "one", "litellm_params": {}}
|
|
try:
|
|
for start, end in ((None, NOW), (NOW, None)):
|
|
missing_timestamp: Final = await captured_callback_kwargs(kwargs, {"id": "one"}, start, end)
|
|
assert missing_timestamp == kwargs
|
|
assert "Usage capture requires callback start and end timestamps" in caplog.text
|
|
admitted: Final = await captured_callback_kwargs(kwargs, {"id": "one"}, NOW, NOW)
|
|
await database.entered.wait()
|
|
rejected: Final = await captured_callback_kwargs({**kwargs, "litellm_call_id": "two"}, {"id": "two"}, NOW, NOW)
|
|
assert "_accounting_capture" in admitted
|
|
assert "_accounting_capture" not in rejected
|
|
assert "Usage capture failed before legacy cost tracking" in caplog.text
|
|
finally:
|
|
database.release.set()
|
|
await runtime.stop(1)
|
|
assert database.committed == ("one:inbound", "one")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("call_type", "streaming", "background"),
|
|
[("acompletion", True, False), ("aresponses", False, True), ("aocr", False, False)],
|
|
)
|
|
@pytest.mark.parametrize("overloaded", [False, True])
|
|
async def test_async_capture_preserves_response_without_commit_ack(
|
|
call_type: str, streaming: bool, background: bool, overloaded: bool, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, capture_proxy_response
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = CommitBarrier()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.write, UsageCaptureSettings(batch_size=2, max_pending_records=2))
|
|
monkeypatch.setattr(proxy_server, "usage_capture", runtime)
|
|
monkeypatch.setattr(
|
|
proxy_server,
|
|
"general_settings",
|
|
{
|
|
"accounting_sync_ack": True,
|
|
"accounting_sync_ack_timeout_seconds": 0,
|
|
},
|
|
)
|
|
meter: Final = runtime.meter
|
|
assert meter is not None
|
|
if overloaded:
|
|
meter.submit((_write("occupied-one"), _write("occupied-two")))
|
|
try:
|
|
await capture_proxy_response(
|
|
_logger(call_type, stream=streaming),
|
|
ModelResponse(id="provider", usage=Usage(prompt_tokens=3)),
|
|
background=background,
|
|
)
|
|
assert database.committed == ()
|
|
assert meter.pending_records == 2
|
|
finally:
|
|
database.release.set()
|
|
await runtime.stop(1)
|
|
assert database.committed == (("occupied-one", "occupied-two") if overloaded else ("caller:inbound", "provider"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_connection_turn_does_not_reuse_the_previous_turns_capture() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
logger: Final = _logger()
|
|
try:
|
|
logger.capture_usage_before_logging(ModelResponse(id="first", usage=Usage(prompt_tokens=2)), NOW, NOW)
|
|
next_turn: Final = logger.copy_for_new_call("second-caller", NOW)
|
|
next_turn.capture_usage_before_logging(ModelResponse(id="second", usage=Usage(prompt_tokens=4)), NOW, NOW)
|
|
finally:
|
|
await runtime.stop(1)
|
|
assert tuple(row.event.event_id for row in database.rows) == (
|
|
"caller:inbound",
|
|
"first",
|
|
"second-caller:inbound",
|
|
"second",
|
|
)
|
|
assert json.loads(database.rows[-1].envelope)["usage"]["prompt_tokens"] == 4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_billed_failure_is_captured_before_legacy_persistence_gates(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
from litellm.types.utils import Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
monkeypatch.setattr(proxy_server, "usage_capture", runtime)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
request: Final = {
|
|
"call_type": "acompletion",
|
|
"litellm_call_id": "failed",
|
|
"model": "capture-model",
|
|
"start_time": NOW,
|
|
"combined_usage_object": Usage(prompt_tokens=3, total_tokens=3),
|
|
"response_cost": 0.2,
|
|
"litellm_params": {},
|
|
}
|
|
try:
|
|
await ProxyDBLogger().async_post_call_failure_hook(request, RuntimeError("provider failed"), UserAPIKeyAuth())
|
|
finally:
|
|
await runtime.stop(1)
|
|
assert tuple(row.event.event_id for row in database.rows) == ("failed:inbound", "failed")
|
|
payload: Final = json.loads(database.rows[-1].envelope)
|
|
assert payload["event"]["outcome"] == "failed"
|
|
assert payload["usage"]["total_tokens"] == 3
|
|
assert payload["charge_eligible"] is True
|
|
assert payload["pricing"]["reported_cost"] == 0.2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("pages", [None, 0, 3])
|
|
async def test_prepared_ocr_capture_preserves_reported_pages(pages: int | None) -> None:
|
|
from litellm.llms.base_llm.ocr.transformation import OCRResponse, OCRUsageInfo
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_logging_usage
|
|
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
logger: Final = _logger("aocr")
|
|
response: Final = OCRResponse(pages=[], model="capture-model", usage_info=OCRUsageInfo(pages_processed=pages))
|
|
logger.prepare_response_cost(response, NOW)
|
|
capture_logging_usage(meter, logger, response, NOW, NOW)
|
|
await meter.close()
|
|
envelope: Final = json.loads(database.rows[-1].envelope)
|
|
assert (None if envelope["usage"] is None else envelope["usage"]["pages_processed"]) == pages
|
|
assert envelope["usage_source"] == ("unknown" if pages is None else "measured")
|
|
assert response.usage_info is not None and response.usage_info.pages_processed == pages
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("measured", [None, {"total_tokens": 0}, {"total_tokens": 3}])
|
|
async def test_batch_capture_provenance_tracks_observations_not_envelope_presence(
|
|
measured: Mapping[str, int] | None,
|
|
) -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_callback
|
|
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
usage: Final = {
|
|
"total_tokens": 0 if measured is None else measured["total_tokens"],
|
|
"batch_usage_by_model": {"missing-model": None, "other-model": measured},
|
|
"batch_unknown_usage_requests": 2 if measured is None else 1,
|
|
}
|
|
capture_callback(
|
|
meter,
|
|
{"call_type": "aretrieve_batch", "litellm_call_id": "caller", "litellm_params": {}},
|
|
{"id": "batch", "usage": usage},
|
|
NOW,
|
|
NOW,
|
|
)
|
|
await meter.close()
|
|
envelope: Final = json.loads(database.rows[-1].envelope)
|
|
assert envelope["usage_source"] == ("unknown" if measured is None else "measured")
|
|
assert envelope["usage"]["batch_usage_by_model"] == usage["batch_usage_by_model"]
|
|
assert database.rows[-1].event.metrics == (
|
|
None if measured is None else (Metric(name=MetricName.TOTAL_TOKENS, value=Decimal(measured["total_tokens"])),)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ack_admission_failure_does_not_turn_provider_success_into_failed_usage(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, capture_proxy_response
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings(batch_size=2, max_pending_records=2))
|
|
monkeypatch.setattr(proxy_server, "usage_capture", runtime)
|
|
monkeypatch.setattr(proxy_server, "general_settings", {"accounting_sync_ack": True})
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
meter: Final = runtime.meter
|
|
assert meter is not None
|
|
logger: Final = _logger()
|
|
try:
|
|
meter.submit((_write("occupied-one"), _write("occupied-two")))
|
|
with pytest.raises(HTTPException) as error:
|
|
await capture_proxy_response(logger, ModelResponse(id="caller", usage=Usage(prompt_tokens=3)))
|
|
assert error.value.status_code == 503
|
|
await meter.flush()
|
|
request: Final = {**logger.model_call_details, "litellm_logging_obj": logger, "start_time": NOW}
|
|
await ProxyDBLogger().async_post_call_failure_hook(request, error.value, UserAPIKeyAuth())
|
|
finally:
|
|
await runtime.stop(1)
|
|
assert tuple(row.event.event_id for row in database.rows) == ("occupied-one", "occupied-two")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_response_ack_timeout_preserves_success_and_later_commits(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, capture_proxy_response
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = CommitBarrier()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.write, UsageCaptureSettings(batch_size=2))
|
|
monkeypatch.setattr(proxy_server, "usage_capture", runtime)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
monkeypatch.setattr(proxy_server, "general_settings", {"accounting_sync_ack": True})
|
|
logger: Final = _logger()
|
|
|
|
async def expired_deadline(future: object, timeout: float) -> None:
|
|
raise TimeoutError("controlled response deadline")
|
|
|
|
try:
|
|
with monkeypatch.context() as clock:
|
|
clock.setattr(asyncio, "wait_for", expired_deadline)
|
|
with pytest.raises(HTTPException) as error:
|
|
await capture_proxy_response(logger, ModelResponse(id="provider", usage=Usage(prompt_tokens=3)))
|
|
assert error.value.status_code == 503
|
|
await database.entered.wait()
|
|
captured: Final = logger.model_call_details["_accounting_capture"]
|
|
request: Final = {**logger.model_call_details, "litellm_logging_obj": logger, "start_time": NOW}
|
|
await ProxyDBLogger().async_post_call_failure_hook(request, error.value, UserAPIKeyAuth())
|
|
database.release.set()
|
|
await captured.receipt
|
|
assert tuple(row.event.outcome for row in captured.writes) == (Outcome.SUCCEEDED, Outcome.SUCCEEDED)
|
|
assert database.committed == ("caller:inbound", "provider")
|
|
assert database.transactions == 1
|
|
finally:
|
|
await runtime.stop(1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("call_type", ["generate_content", "agenerate_content"])
|
|
@pytest.mark.parametrize("tokens", [None, 0, 7])
|
|
async def test_prepared_native_google_capture_preserves_reported_usage(
|
|
call_type: str, tokens: int | None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import httpx
|
|
|
|
import litellm
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_logging_usage
|
|
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
logger: Final = _logger(call_type)
|
|
monkeypatch.setitem(
|
|
litellm.model_cost,
|
|
"capture-model",
|
|
{"input_cost_per_token": 0.1, "output_cost_per_token": 0.2, "litellm_provider": "openai", "mode": "chat"},
|
|
)
|
|
reported: Final = (
|
|
{"trafficType": "ON_DEMAND"}
|
|
if tokens is None
|
|
else {
|
|
"promptTokenCount": tokens,
|
|
"candidatesTokenCount": tokens,
|
|
"totalTokenCount": 2 * tokens,
|
|
"cachedContentTokenCount": tokens,
|
|
"promptTokensDetails": [{"modality": "DOCUMENT", "tokenCount": tokens}],
|
|
"cacheTokensDetails": [{"modality": "document", "token_count": tokens}],
|
|
}
|
|
)
|
|
response: Final = {
|
|
"responseId": "native-google",
|
|
"candidates": [{"content": {"parts": [{"text": "answer"}], "role": "model"}, "finishReason": "STOP"}],
|
|
"usageMetadata": reported,
|
|
}
|
|
native_response: Final = httpx.Response(200, json=response)
|
|
logger.model_call_details["httpx_response"] = native_response
|
|
prepared: Final = logger.prepare_response_cost(response, NOW)
|
|
assert prepared is not None
|
|
capture_logging_usage(meter, logger, response, NOW, NOW)
|
|
await meter.close()
|
|
envelope: Final = json.loads(database.rows[-1].envelope)
|
|
assert (None if envelope["usage"] is None else envelope["usage"]["total_tokens"]) == (
|
|
None if tokens is None else 2 * tokens
|
|
)
|
|
assert (None if envelope["usage"] is None else envelope["usage"]["prompt_tokens_details"]["text_tokens"]) == tokens
|
|
assert (
|
|
None
|
|
if envelope["usage"] is None
|
|
else envelope["usage"]["prompt_tokens_details"]["cached_tokens_details"]["text_tokens"]
|
|
) == tokens
|
|
assert envelope["usage_source"] == ("unknown" if tokens is None else "measured")
|
|
assert response == native_response.json()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("rejected", [False, True])
|
|
async def test_retry_links_only_admitted_usage_and_preserves_later_measurement(rejected: bool) -> None:
|
|
from litellm.litellm_core_utils.litellm_logging import UsageObservation
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings(max_pending_records=4))
|
|
logger: Final = _logger()
|
|
assert runtime.meter is not None
|
|
try:
|
|
if rejected:
|
|
runtime.meter.submit(tuple(_write(f"occupied-{index}") for index in range(4)))
|
|
logger.model_call_details["_accounting_usage_observed"] = UsageObservation.UNKNOWN
|
|
logger.record_partial_usage_for_failure(Usage(prompt_tokens=5, total_tokens=5), 0.4)
|
|
logger.carry_failed_attempt_usage()
|
|
await runtime.meter.flush()
|
|
logger.capture_usage_before_logging(ModelResponse(id="success", usage=Usage(prompt_tokens=3)), NOW, NOW)
|
|
finally:
|
|
await runtime.stop(1)
|
|
successful: Final = json.loads(next(row.envelope for row in database.rows if row.event.event_id == "success"))
|
|
assert successful.get("related_attempt_ids", successful["included_attempt_ids"]) == (
|
|
[] if rejected else ["caller-attempt-0"]
|
|
)
|
|
assert successful["usage"]["prompt_tokens"] == 3
|
|
if not rejected:
|
|
failed: Final = json.loads(
|
|
next(row.envelope for row in database.rows if row.event.event_id == "caller-attempt-0")
|
|
)
|
|
assert failed["usage"]["prompt_tokens"] == 5
|
|
assert failed["usage_source"] == "measured"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("current_tokens", [None, 0, 3])
|
|
async def test_final_failure_captures_current_attempt_without_legacy_retry_aggregate(
|
|
current_tokens: int | None,
|
|
) -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, capture_callback
|
|
from litellm.proxy.utils import _failure_fields_to_lift
|
|
from litellm.types.utils import Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
logger: Final = _logger()
|
|
try:
|
|
logger.record_partial_usage_for_failure(Usage(prompt_tokens=5, total_tokens=5), 0.4)
|
|
logger.carry_failed_attempt_usage()
|
|
if current_tokens is not None:
|
|
logger.record_partial_usage_for_failure(
|
|
Usage(prompt_tokens=current_tokens, total_tokens=current_tokens), 0.2
|
|
)
|
|
lifted: Final = _failure_fields_to_lift({"litellm_logging_obj": logger}, provider_billed=True)
|
|
assert lifted["combined_usage_object"].total_tokens == 5 + (current_tokens or 0)
|
|
assert runtime.meter is not None
|
|
capture_callback(
|
|
runtime.meter,
|
|
{**logger.model_call_details, **lifted, "log_event_type": "failed_api_call"},
|
|
None,
|
|
NOW,
|
|
NOW,
|
|
)
|
|
finally:
|
|
await runtime.stop(1)
|
|
final: Final = json.loads(next(row.envelope for row in database.rows if row.event.event_id == "caller"))
|
|
assert (None if final["usage"] is None else final["usage"]["total_tokens"]) == current_tokens
|
|
assert final["usage_source"] == ("unknown" if current_tokens is None else "measured")
|
|
assert final["pricing"]["reported_cost"] == (None if current_tokens is None else 0.2)
|
|
assert final["included_attempt_ids"] == []
|
|
assert final["related_attempt_ids"] == ["caller-attempt-0"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_estimated_final_attempt_keeps_its_provenance_after_measured_retry() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, capture_callback
|
|
from litellm.proxy.utils import _failure_fields_to_lift
|
|
from litellm.types.utils import Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
logger: Final = _logger()
|
|
logger.messages = [{"role": "user", "content": "hello"}]
|
|
logger.model_call_details["messages"] = logger.messages
|
|
try:
|
|
logger.record_partial_usage_for_failure(Usage(prompt_tokens=5, total_tokens=5), 0.4)
|
|
logger.carry_failed_attempt_usage()
|
|
logger.model_call_details["current_attempt_api_call_start_time"] = NOW
|
|
lifted: Final = _failure_fields_to_lift({"litellm_logging_obj": logger}, provider_billed=True)
|
|
assert runtime.meter is not None
|
|
capture_callback(runtime.meter, {**logger.model_call_details, **lifted}, None, NOW, NOW)
|
|
finally:
|
|
await runtime.stop(1)
|
|
final: Final = json.loads(next(row.envelope for row in database.rows if row.event.event_id == "caller"))
|
|
assert final["usage_source"] == "estimated"
|
|
assert final["usage"]["total_tokens"] > 0
|
|
assert lifted["combined_usage_object"].total_tokens == final["usage"]["total_tokens"] + 5
|
|
assert final["pricing"]["reported_cost"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parent_receipt_cannot_acknowledge_when_a_linked_attempt_conflicts() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
logger: Final = _logger()
|
|
try:
|
|
logger.record_partial_usage_for_failure(Usage(prompt_tokens=5), 0.4)
|
|
logger.carry_failed_attempt_usage()
|
|
assert runtime.meter is not None
|
|
await runtime.meter.flush()
|
|
other: Final = _logger()
|
|
other.record_partial_usage_for_failure(Usage(prompt_tokens=8), 0.8)
|
|
other.carry_failed_attempt_usage()
|
|
other.capture_usage_before_logging(ModelResponse(id="success", usage=Usage(prompt_tokens=3)), NOW, NOW)
|
|
captured: Final = other.model_call_details["_accounting_capture"]
|
|
await runtime.meter.flush()
|
|
with pytest.raises(UsageCaptureError, match="dependency"):
|
|
await captured.receipt
|
|
finally:
|
|
await runtime.stop(1)
|
|
assert tuple(row.event.event_id for row in database.rows) == ("caller-attempt-0",)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unserializable_observation_does_not_interrupt_retry_or_legacy_usage() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = StoredUsage()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.insert, UsageCaptureSettings())
|
|
logger: Final = _logger()
|
|
usage: Final = Usage(prompt_tokens=5, total_tokens=5, opaque_usage=object())
|
|
try:
|
|
logger.record_partial_usage_for_failure(usage, 0.4)
|
|
assert logger.model_call_details["combined_usage_object"] is usage
|
|
logger.carry_failed_attempt_usage()
|
|
assert logger.model_call_details["failed_attempts_usage"] == ((usage, 0.4),)
|
|
logger.capture_usage_before_logging(ModelResponse(id="success", usage=Usage(prompt_tokens=3)), NOW, NOW)
|
|
finally:
|
|
await runtime.stop(1)
|
|
assert tuple(row.event.event_id for row in database.rows) == ("caller:inbound", "success")
|
|
assert json.loads(database.rows[-1].envelope)["related_attempt_ids"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("prior_committed", [False, True])
|
|
@pytest.mark.parametrize("batch_size", [2, 3])
|
|
async def test_retry_dependencies_do_not_resubmit_usage_or_expand_final_submission(
|
|
prior_committed: bool, batch_size: int
|
|
) -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
database: Final = CommitBarrier()
|
|
database.release.set()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(database.write, UsageCaptureSettings(batch_size=batch_size, max_pending_records=3))
|
|
logger: Final = _logger()
|
|
try:
|
|
logger.record_partial_usage_for_failure(Usage(prompt_tokens=5), 0.4)
|
|
logger.carry_failed_attempt_usage()
|
|
assert runtime.meter is not None
|
|
if prior_committed:
|
|
await runtime.meter.flush()
|
|
logger.capture_usage_before_logging(ModelResponse(id="success", usage=Usage(prompt_tokens=3)), NOW, NOW)
|
|
captured: Final = logger.model_call_details.get("_accounting_capture")
|
|
assert captured is not None, "Final usage must be admitted independently of prior usage batch size"
|
|
await runtime.meter.close()
|
|
await captured.receipt
|
|
assert runtime.meter.pending_records == 0
|
|
finally:
|
|
await runtime.stop(1)
|
|
assert database.committed == ("caller-attempt-0", "caller:inbound", "success")
|
|
final: Final = json.loads(captured.writes[-1].envelope)
|
|
assert final["related_attempt_ids"] == ["caller-attempt-0"]
|
|
assert final["usage"]["prompt_tokens"] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("prepared", [False, True])
|
|
@pytest.mark.parametrize(
|
|
("call_type", "response", "field", "quantity"),
|
|
[
|
|
("arerank", {"id": "rerank", "results": [], "meta": {"tokens": {"input_tokens": 7}}}, "prompt_tokens", 7),
|
|
("aocr", {"id": "ocr", "usage_info": {"pages_processed": 3}}, "pages_processed", 3),
|
|
(
|
|
"acompletion",
|
|
{"id": "google", "usageMetadata": {"promptTokenCount": 7, "totalTokenCount": 7}},
|
|
"prompt_tokens",
|
|
7,
|
|
),
|
|
],
|
|
)
|
|
async def test_capture_preserves_native_usage_before_and_after_preparation(
|
|
prepared: bool, call_type: str, response: dict[str, object], field: str, quantity: int
|
|
) -> None:
|
|
from litellm._internal_context import internal_sub_call
|
|
from litellm.proxy.spend_tracking.usage_capture import capture_logging_usage
|
|
|
|
database: Final = StoredUsage()
|
|
meter: Final = DurableUsageMeter(database.insert, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
logger: Final = _logger(call_type)
|
|
logger.model_call_details["response_cost"] = 0.25
|
|
if prepared:
|
|
assert logger.prepare_response_cost(response, NOW) is not None
|
|
with internal_sub_call("parent"):
|
|
capture_logging_usage(meter, logger, response, NOW, NOW)
|
|
await meter.close()
|
|
assert len(database.rows) == 1
|
|
envelope: Final = json.loads(database.rows[0].envelope)
|
|
assert envelope["usage"][field] == quantity
|
|
assert envelope["usage_source"] == "measured"
|
|
assert envelope["ownership"] == "included_in_parent"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("tokens", [(120000, 120000), (210000, 30000)])
|
|
@pytest.mark.parametrize("priced", [False, True])
|
|
async def test_batch_capture_retains_request_basis_for_later_tiered_pricing(
|
|
tokens: tuple[int, int], priced: bool, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import litellm
|
|
from litellm.batches.batch_utils import batch_cost_and_usage
|
|
from litellm.cost_calculator import batch_cost_calculator
|
|
from litellm.proxy.spend_tracking.usage_capture import (
|
|
UsageBatchWriter,
|
|
capture_batch_callback,
|
|
capture_logging_usage,
|
|
)
|
|
from litellm.types.utils import Usage
|
|
|
|
rates: Final = {
|
|
"input_cost_per_token_batches": 0.001,
|
|
"input_cost_per_token_above_200k_tokens_batches": 0.002,
|
|
"output_cost_per_token_batches": 0.0,
|
|
"litellm_provider": "openai",
|
|
"mode": "chat",
|
|
}
|
|
monkeypatch.setitem(litellm.model_cost, "capture-model", rates if priced else {"litellm_provider": "openai"})
|
|
rows: Final = [
|
|
{
|
|
"response": {
|
|
"status_code": 200,
|
|
"body": {
|
|
"model": "capture-model",
|
|
"usage": {"prompt_tokens": n, "completion_tokens": 0, "total_tokens": n},
|
|
},
|
|
}
|
|
}
|
|
for n in tokens
|
|
]
|
|
result: Final = batch_cost_and_usage(rows, "openai", model_info=rates if priced else {})
|
|
database: Final = StoredUsage()
|
|
writer: Final = UsageBatchWriter(database.insert, database.load)
|
|
meter: Final = DurableUsageMeter(writer.write, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
logger: Final = _logger("aretrieve_batch")
|
|
logger.model_call_details["response_cost"] = result.cost
|
|
capture_logging_usage(meter, logger, {"id": "batch", "status": "completed", "usage": result.usage}, NOW, NOW)
|
|
await capture_batch_callback(
|
|
meter,
|
|
database.load,
|
|
logger.model_call_details,
|
|
{"id": "batch", "status": "completed", "usage": result.usage},
|
|
NOW,
|
|
NOW,
|
|
)
|
|
await meter.close()
|
|
retained: Final = tuple(
|
|
{"model": "capture-model", "usage": json.loads(write.envelope)["usage"]} for write in database.rows[1:]
|
|
)
|
|
assert sorted(row["usage"]["prompt_tokens"] for row in retained) == sorted(tokens)
|
|
reconstructed: Final = sum(
|
|
sum(batch_cost_calculator(Usage(**row["usage"]), row["model"], "openai", model_info=rates)) for row in retained
|
|
)
|
|
expected: Final = sum(n * (0.002 if n > 200000 else 0.001) for n in tokens)
|
|
assert reconstructed == pytest.approx(expected)
|
|
assert result.cost == pytest.approx(expected if priced else 0)
|
|
assert database.rows[0].event.metrics is None
|
|
assert json.loads(database.rows[0].envelope)["charge_eligible"] is False
|
|
assert all(json.loads(write.envelope)["finalized_cost"] is None for write in database.rows[1:])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_large_batch_recovers_partial_commit_with_frozen_pricing_and_rejects_changed_usage() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import (
|
|
PricingCapture,
|
|
UsageBatchWriter,
|
|
capture_batch_callback,
|
|
capture_callback,
|
|
)
|
|
|
|
database: Final = StoredUsage()
|
|
transactions: Final = asyncio.Queue[tuple[UsageWrite, ...]]()
|
|
failure: Final = asyncio.Queue[bool]()
|
|
failure.put_nowait(True)
|
|
|
|
async def persist(writes: tuple[UsageWrite, ...]) -> None:
|
|
assert sum(len(write.envelope.encode()) for write in writes) <= 65536
|
|
assert len(writes) <= 32
|
|
if len(database.rows) > 1 and not failure.empty():
|
|
failure.get_nowait()
|
|
raise RuntimeError("Interrupted batch persistence")
|
|
await database.insert(writes)
|
|
transactions.put_nowait(writes)
|
|
|
|
writer: Final = UsageBatchWriter(persist, database.load)
|
|
meter: Final = DurableUsageMeter(
|
|
writer.write,
|
|
UsageCaptureSettings(batch_size=32, max_pending_records=32, max_pending_bytes=65536),
|
|
wait_for_batch=_manual_flush,
|
|
)
|
|
rows: Final = tuple(
|
|
{
|
|
"model": "capture-model" if index % 7 else None,
|
|
"usage": {"prompt_tokens": index % 5 + 1} if index % 7 else None,
|
|
}
|
|
for index in range(1000)
|
|
)
|
|
response: Final = {"id": "large-batch", "status": "completed", "usage": {"batch_usage_by_request": rows}}
|
|
pricing: Final = PricingCapture("{}", "{}", json.dumps({"batch_models": {"capture-model": {"rate": 1}}}))
|
|
kwargs: Final = {
|
|
"call_type": "aretrieve_batch",
|
|
"litellm_call_id": "poll",
|
|
"litellm_params": {},
|
|
"_accounting_pricing_snapshot": pricing,
|
|
}
|
|
failed_database: Final = CommitBarrier(failure=RuntimeError("Earlier capture failed"))
|
|
failed_database.release.set()
|
|
prior_meter: Final = DurableUsageMeter(failed_database.write, UsageCaptureSettings(), wait_for_batch=_manual_flush)
|
|
prior: Final = capture_callback(
|
|
prior_meter,
|
|
{"call_type": "acompletion", "litellm_call_id": "earlier", "litellm_params": {}},
|
|
{"id": "earlier"},
|
|
NOW,
|
|
NOW,
|
|
)
|
|
await prior_meter.close()
|
|
with pytest.raises(RuntimeError, match="Earlier capture failed"):
|
|
await capture_batch_callback(
|
|
meter,
|
|
database.load,
|
|
{**kwargs, "_accounting_prior_captures": (prior,)},
|
|
response,
|
|
NOW,
|
|
NOW,
|
|
)
|
|
assert database.rows == ()
|
|
with pytest.raises(RuntimeError, match="Interrupted batch persistence"):
|
|
await capture_batch_callback(meter, database.load, kwargs, response, NOW, NOW)
|
|
partial: Final = database.rows
|
|
assert 1 < len(partial) < len(rows) + 1
|
|
changed_pricing: Final = PricingCapture("{}", "{}", json.dumps({"batch_models": {"capture-model": {"rate": 2}}}))
|
|
retried: Final = await capture_batch_callback(
|
|
meter,
|
|
database.load,
|
|
{**kwargs, "_accounting_pricing_snapshot": changed_pricing},
|
|
{**response, "usage": {"batch_usage_by_request": tuple(reversed(rows))}},
|
|
NOW,
|
|
NOW,
|
|
)
|
|
assert retried.receipt.done() and retried.receipt.exception() is None
|
|
assert len(database.rows) == len(rows) + 1
|
|
assert database.rows[: len(partial)] == partial
|
|
envelopes: Final = tuple(json.loads(write.envelope) for write in database.rows[1:])
|
|
assert all(
|
|
envelope["pricing"]["model_map_information"] == ({"rate": 1} if envelope["usage"] is not None else None)
|
|
for envelope in envelopes
|
|
)
|
|
assert sum(envelope["usage"] is None for envelope in envelopes) == sum(row["usage"] is None for row in rows)
|
|
assert all(envelope["request_count_eligible"] is False for envelope in envelopes)
|
|
assert sum(envelope["usage"]["prompt_tokens"] for envelope in envelopes if envelope["usage"] is not None) == sum(
|
|
row["usage"]["prompt_tokens"] for row in rows if row["usage"] is not None
|
|
)
|
|
before_conflict: Final = database.rows
|
|
with pytest.raises(UsageCaptureError, match="identity conflicts"):
|
|
await capture_batch_callback(
|
|
meter,
|
|
database.load,
|
|
kwargs,
|
|
{**response, "usage": {"batch_usage_by_request": rows[:-1]}},
|
|
NOW,
|
|
NOW,
|
|
)
|
|
await meter.close()
|
|
assert database.rows == before_conflict
|
|
assert transactions.qsize() > 2
|
|
assert meter.pending_bytes == meter.pending_records == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("outcome", ["drain", "cancel", "failure"])
|
|
async def test_batch_callback_handoff_is_bounded_and_runtime_owns_persistence(
|
|
outcome: str, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.spend_tracking.usage_capture import CapturedUsage, UsageCaptureRuntime, captured_callback_kwargs
|
|
|
|
database: Final = StoredUsage()
|
|
entered: Final = asyncio.Event()
|
|
release: Final = asyncio.Event()
|
|
|
|
async def persist(writes: tuple[UsageWrite, ...]) -> None:
|
|
entered.set()
|
|
await release.wait()
|
|
if outcome == "failure":
|
|
raise RuntimeError("Persistence unavailable")
|
|
await database.insert(writes)
|
|
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(persist, UsageCaptureSettings(batch_size=2, max_pending_records=2), load=database.load)
|
|
monkeypatch.setattr(proxy_server, "usage_capture", runtime)
|
|
kwargs: Final = {"call_type": "aretrieve_batch", "litellm_call_id": "poll", "litellm_params": {}}
|
|
response: Final = {
|
|
"id": "batch",
|
|
"status": "completed",
|
|
"usage": {"batch_usage_by_request": ({"model": "model", "usage": {"prompt_tokens": 2}},) * 3},
|
|
}
|
|
callback: Final = asyncio.create_task(captured_callback_kwargs(kwargs, response, NOW, NOW))
|
|
try:
|
|
await entered.wait()
|
|
assert callback.done(), "Batch persistence must not consume the legacy callback deadline"
|
|
captured: Final = (await callback)["_accounting_capture"]
|
|
assert isinstance(captured, CapturedUsage) and not captured.receipt.done()
|
|
second: Final = await captured_callback_kwargs(kwargs, response, NOW, NOW)
|
|
assert isinstance(second.get("_accounting_capture"), CapturedUsage)
|
|
overflow: Final = await captured_callback_kwargs(kwargs, response, NOW, NOW)
|
|
assert "_accounting_capture" not in overflow
|
|
assert "Batch capture producer capacity exhausted" in caplog.text
|
|
if outcome != "cancel":
|
|
release.set()
|
|
await runtime.stop(0 if outcome == "cancel" else 1)
|
|
assert captured.receipt.done()
|
|
assert captured.receipt.cancelled() is (outcome == "cancel")
|
|
if outcome == "failure":
|
|
assert isinstance(captured.receipt.exception(), RuntimeError)
|
|
assert "Batch usage capture failed" in caplog.text
|
|
assert len(database.rows) == (4 if outcome == "drain" else 0)
|
|
with pytest.raises(UsageCaptureError, match="active ledger writer"):
|
|
runtime.submit_batch(kwargs, response, NOW, NOW)
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(callback, return_exceptions=True)
|
|
await runtime.stop(1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_producers_share_byte_capacity_and_release_it_after_drain() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime
|
|
|
|
database: Final = StoredUsage()
|
|
release: Final = asyncio.Event()
|
|
runtime: Final = UsageCaptureRuntime()
|
|
|
|
async def persist(writes: tuple[UsageWrite, ...]) -> None:
|
|
await release.wait()
|
|
assert runtime.meter is not None
|
|
assert runtime.meter.pending_bytes <= 65536
|
|
await database.insert(writes)
|
|
|
|
runtime.start(persist, UsageCaptureSettings(batch_size=32, max_pending_bytes=65536), load=database.load)
|
|
from litellm.batches.batch_utils import batch_cost_and_usage
|
|
|
|
parsed: Final = batch_cost_and_usage(
|
|
[
|
|
{
|
|
"response": {
|
|
"status_code": 200,
|
|
"body": {
|
|
"model": "capture-model",
|
|
"usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3},
|
|
},
|
|
}
|
|
}
|
|
for _ in range(1000)
|
|
],
|
|
"openai",
|
|
model_info={},
|
|
)
|
|
rows: Final = ({"model": "capture-model", "usage": {"prompt_tokens": 2}},)
|
|
response: Final = {"id": "batch", "status": "completed", "usage": parsed.usage}
|
|
kwargs: Final = {"call_type": "aretrieve_batch", "litellm_call_id": "poll", "litellm_params": {}}
|
|
try:
|
|
large: Final = asyncio.Queue[asyncio.Future[None]]()
|
|
for index in range(100):
|
|
try:
|
|
captured: Final = runtime.submit_batch(kwargs, {**response, "id": f"batch-{index}"}, NOW, NOW)
|
|
except UsageCaptureError:
|
|
break
|
|
large.put_nowait(captured.receipt)
|
|
large_count: Final = large.qsize()
|
|
assert 0 < large_count < 100
|
|
assert runtime.meter is not None and 0 < runtime.meter.pending_bytes <= 65536
|
|
release.set()
|
|
await asyncio.gather(*(large.get_nowait() for _ in range(large_count)))
|
|
assert len(database.rows) == 1001 * large_count
|
|
observations: Final = tuple(json.loads(write.envelope) for write in database.rows)
|
|
assert sum(row["usage"].get("prompt_tokens", 0) for row in observations) == 2000 * large_count
|
|
assert sum(row["usage"].get("completion_tokens", 0) for row in observations) == 1000 * large_count
|
|
assert runtime.meter.pending_bytes == 0
|
|
admitted: Final = asyncio.Queue[asyncio.Future[None]]()
|
|
for index in range(100):
|
|
try:
|
|
small: Final = runtime.submit_batch(
|
|
kwargs,
|
|
{**response, "id": f"small-{index}", "usage": {"batch_usage_by_request": rows}},
|
|
NOW,
|
|
NOW,
|
|
)
|
|
except UsageCaptureError:
|
|
break
|
|
admitted.put_nowait(small.receipt)
|
|
count: Final = admitted.qsize()
|
|
assert 1 < count < 100
|
|
await asyncio.gather(*(admitted.get_nowait() for _ in range(count)))
|
|
assert len(database.rows) == 1001 * large_count + 2 * count
|
|
assert runtime.meter.pending_bytes == 0
|
|
finally:
|
|
release.set()
|
|
await runtime.stop(1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_replay_reserves_the_accepted_pricing_snapshot_size() -> None:
|
|
from litellm.proxy.spend_tracking.usage_capture import PricingCapture, UsageBatchWriter, UsageCaptureRuntime
|
|
|
|
database: Final = StoredUsage()
|
|
writer: Final = UsageBatchWriter(database.insert, database.load)
|
|
runtime: Final = UsageCaptureRuntime()
|
|
runtime.start(writer.write, UsageCaptureSettings(max_pending_bytes=16384), load=database.load)
|
|
response: Final = {
|
|
"id": "batch",
|
|
"status": "completed",
|
|
"usage": {"batch_usage_by_request": ({"model": "model", "usage": {"prompt_tokens": 2}},)},
|
|
}
|
|
kwargs: Final = {"call_type": "aretrieve_batch", "litellm_call_id": "poll", "litellm_params": {}}
|
|
pricing: Final = PricingCapture("{}", "{}", json.dumps({"batch_models": {"model": {"source": "x" * 1200}}}))
|
|
try:
|
|
initial: Final = runtime.submit_batch({**kwargs, "_accounting_pricing_snapshot": pricing}, response, NOW, NOW)
|
|
await asyncio.shield(initial.receipt)
|
|
accepted: Final = database.rows
|
|
replay: Final = runtime.submit_batch(kwargs, response, NOW, NOW)
|
|
await asyncio.shield(replay.receipt)
|
|
assert database.rows == accepted and len(accepted) == 2
|
|
assert runtime.meter is not None and runtime.meter.pending_bytes == 0
|
|
finally:
|
|
await runtime.stop(1)
|