mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
* refactor(accounting): replace event scaffolding with SpendLogs projection * test: preserve spend timestamps without removed capture callback * fix: guard accounting event removal across schema update paths * refactor(accounting): retain schema and prepare durable payloads * fix(accounting): preserve dates in serialized payloads * fix(accounting): retain schema push compatibility * fix(accounting): retain span registration for the legacy schema * test(accounting): remove obsolete capture acknowledgement test * fix(accounting): date every daily projection row the way the model rollup does A space-separated startTime kept the whole timestamp as the daily key, while the model usage rollup took the calendar date. Every daily row now uses the same date helper Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(ui): regenerate dashboard API types after rebasing on main Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(accounting): project only from the stored accounting payload project() now reads entities only from the payload's accounting_entities, so the raw SpendLogs fallback branches are gone. build_accounting_payload() builds the metadata once instead of dumping it to JSON and parsing it back. Guardrail usage dates use a tolerant UTC parse, so a malformed startTime drops only the guardrail rows instead of failing the whole projection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
431 lines
18 KiB
Python
431 lines
18 KiB
Python
import json
|
|
from collections.abc import Mapping
|
|
from datetime import datetime, timezone
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from pydantic import TypeAdapter
|
|
|
|
from litellm.accounting.projection import project
|
|
from litellm.accounting.snapshot import build_accounting_payload
|
|
from litellm.constants import BILLED_RETRY_ATTEMPT_METADATA_KEY, INTERNAL_CALL_ORIGIN_METADATA_KEY
|
|
from litellm.proxy._types import SpendLogsPayload
|
|
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
|
|
from litellm.proxy.proxy_server import proxy_logging_obj
|
|
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
|
|
from litellm.proxy.utils import PrismaClient
|
|
from litellm.types.utils import ModelResponse, Usage
|
|
|
|
_NOW: Final = datetime(2026, 11, 2, tzinfo=timezone.utc)
|
|
_FIELDS: Final = TypeAdapter(dict[str, object])
|
|
|
|
|
|
@pytest.mark.parametrize("status", ["success", "failure"])
|
|
@pytest.mark.parametrize("origin", [None, "auto_router", "billed_retry"])
|
|
@pytest.mark.parametrize("spend", [0.0, 0.25])
|
|
@pytest.mark.asyncio
|
|
async def test_daily_projection_matches_legacy_writer(status: str, origin: str | None, spend: float) -> None:
|
|
payload: Final = get_logging_payload(
|
|
kwargs={
|
|
"model": "projection-test",
|
|
"custom_llm_provider": "openai",
|
|
"call_type": "acompletion",
|
|
"litellm_call_id": "projection-request",
|
|
"litellm_params": {"metadata": {"user_api_key": "key", "user_api_key_user_id": "user", "status": status}},
|
|
},
|
|
response_obj=ModelResponse(
|
|
id="projection-request", model="projection-test", usage=Usage(prompt_tokens=10, completion_tokens=5)
|
|
),
|
|
start_time=_NOW,
|
|
end_time=_NOW,
|
|
)
|
|
metadata: Final = {
|
|
**_FIELDS.validate_json(payload["metadata"]),
|
|
**({BILLED_RETRY_ATTEMPT_METADATA_KEY: True} if origin == "billed_retry" else {}),
|
|
**({INTERNAL_CALL_ORIGIN_METADATA_KEY: origin} if origin not in (None, "billed_retry") else {}),
|
|
}
|
|
row: Final = {**payload, "spend": spend, "metadata": json.dumps(metadata), "request_duration_ms": 25}
|
|
legacy: Final = await DBSpendUpdateWriter()._common_add_spend_log_transaction_to_daily_transaction(
|
|
row, PrismaClient(database_url="postgresql://unused", proxy_logging_obj=proxy_logging_obj), "user"
|
|
)
|
|
assert legacy is not None
|
|
accounting_input: Final[SpendLogsPayload] = {**row}
|
|
accounting: Final = build_accounting_payload(
|
|
accounting_input,
|
|
entities={"api_key": payload["api_key"], "user": payload["user"]},
|
|
model_access_groups=(),
|
|
windows=(),
|
|
router=lambda: None,
|
|
)
|
|
restored: Final = _FIELDS.validate_json(json.dumps(accounting, default=str))
|
|
stored_daily: Final = next(item for item in project(restored) if item.table == "LiteLLM_DailyUserSpend")
|
|
assert dict(stored_daily.increments) == {name: legacy[name] for name, _ in stored_daily.increments}
|
|
assert dict(stored_daily.key)["date"] == legacy["date"]
|
|
assert dict(stored_daily.key)["api_key"] == legacy["api_key"]
|
|
assert not {"messages", "response", "proxy_server_request", "requester_ip_address"}.intersection(accounting)
|
|
|
|
|
|
def test_projection_uses_frozen_savings_and_keeps_duplicate_entity_tags() -> None:
|
|
row: Final[Mapping[str, object]] = {
|
|
"request_id": "saved",
|
|
"startTime": _NOW,
|
|
"api_key": "key",
|
|
"user": "user",
|
|
"model": "unpriced-projection-test",
|
|
"model_group": "group",
|
|
"custom_llm_provider": "openai",
|
|
"request_tags": '["tag", "tag"]',
|
|
"spend": 0.5,
|
|
"metadata": json.dumps(
|
|
{
|
|
"accounting_savings": {
|
|
"compression": 1.0,
|
|
"prompt_caching": -2.0,
|
|
"gateway_injected_caching": 3.0,
|
|
"autorouter": 4.0,
|
|
},
|
|
"autorouter_savings": 9000,
|
|
}
|
|
),
|
|
}
|
|
contributions: Final = project(row)
|
|
tags: Final = tuple(item for item in contributions if item.table == "LiteLLM_TagTable")
|
|
daily_tags: Final = tuple(item for item in contributions if item.table == "LiteLLM_DailyTagSpend")
|
|
assert sum(dict(item.increments)["spend"] for item in tags) == 1.0
|
|
assert len(daily_tags) == 1
|
|
assert {key: value for key, value in daily_tags[0].increments if key.endswith("savings_spend")} == {
|
|
"compression_savings_spend": 1.0,
|
|
"prompt_caching_savings_spend": -2.0,
|
|
"gateway_injected_caching_savings_spend": 3.0,
|
|
"autorouter_savings_spend": 4.0,
|
|
}
|
|
|
|
|
|
def test_projection_carries_windows_and_does_not_reprice_guardrails() -> None:
|
|
row: Final = {
|
|
"request_id": "guarded",
|
|
"startTime": _NOW,
|
|
"api_key": "key",
|
|
"spend": 0.75,
|
|
"metadata": json.dumps(
|
|
{
|
|
"accounting_windows": [
|
|
{
|
|
"entity_type": "key",
|
|
"entity_id": "key",
|
|
"window_duration": "1d",
|
|
"window_start": _NOW.isoformat(),
|
|
}
|
|
],
|
|
"guardrail_information": [
|
|
{
|
|
"guardrail_id": "guard",
|
|
"guardrail_usage": {"text": 3, "images": 2},
|
|
"guardrail_cost_by_unit": {"text": 0.3, "images": None},
|
|
}
|
|
],
|
|
}
|
|
),
|
|
}
|
|
contributions: Final = project(row)
|
|
window: Final = next(item for item in contributions if item.table == "LiteLLM_BudgetWindowSpend")
|
|
units: Final = {
|
|
dict(item.key)["usage_unit"]: dict(item.increments)
|
|
for item in contributions
|
|
if item.table == "LiteLLM_DailyGuardrailUsageUnits"
|
|
}
|
|
assert dict(window.increments) == {"spend": 0.75}
|
|
assert dict(window.values)["window_start"] == _NOW.isoformat()
|
|
assert units == {
|
|
"text": {"units": 3, "cost": 0.3, "untracked_units": 0},
|
|
"images": {"units": 2, "cost": 0.0, "untracked_units": 2},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_entity_projection_matches_real_request_queues(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from litellm.proxy import proxy_server
|
|
from litellm.responses.streaming_iterator import drain_logging_tasks
|
|
|
|
client: Final = PrismaClient(database_url="postgresql://unused", proxy_logging_obj=proxy_logging_obj)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", client)
|
|
monkeypatch.setattr(proxy_server, "disable_spend_logs", False)
|
|
monkeypatch.setattr(client, "spend_log_transactions", [])
|
|
monkeypatch.setattr(client, "model_usage_transactions", [])
|
|
writer: Final = DBSpendUpdateWriter()
|
|
charged: Final = await writer.update_database(
|
|
token="sk-projection",
|
|
user_id="user",
|
|
end_user_id="end-user",
|
|
team_id="team",
|
|
org_id="org",
|
|
project_id="project",
|
|
kwargs={
|
|
"model": "projection-test",
|
|
"custom_llm_provider": "openai",
|
|
"call_type": "acompletion",
|
|
"litellm_call_id": "entity-request",
|
|
"agent_id": "agent",
|
|
"user": "end-user",
|
|
"litellm_params": {
|
|
"metadata": {
|
|
"user_api_key": "sk-projection",
|
|
"user_api_key_user_id": "user",
|
|
"user_api_key_team_id": "team",
|
|
"user_api_key_org_id": "org",
|
|
"user_api_key_project_id": "project",
|
|
"user_api_key_end_user_id": "end-user",
|
|
"tags": ["tag", "tag"],
|
|
},
|
|
"user": "end-user",
|
|
},
|
|
},
|
|
completion_response=ModelResponse(
|
|
id="entity-request", model="projection-test", usage=Usage(prompt_tokens=10, completion_tokens=5)
|
|
),
|
|
start_time=_NOW,
|
|
end_time=_NOW,
|
|
response_cost=0.25,
|
|
)
|
|
await drain_logging_tasks()
|
|
assert charged
|
|
assert len(client.spend_log_transactions) == 1
|
|
queued: Final = await writer.spend_update_queue.flush_all_updates_from_in_memory_queue()
|
|
logged: Final[SpendLogsPayload] = client.spend_log_transactions[0]
|
|
projected: Final = project(
|
|
_FIELDS.validate_json(
|
|
json.dumps(
|
|
build_accounting_payload(
|
|
logged,
|
|
entities={
|
|
"api_key": logged["api_key"],
|
|
"user": "user",
|
|
"end_user": "end-user",
|
|
"team_id": "team",
|
|
"organization_id": "org",
|
|
"project_id": "project",
|
|
"agent_id": "agent",
|
|
},
|
|
model_access_groups=(),
|
|
windows=(),
|
|
router=lambda: None,
|
|
),
|
|
default=str,
|
|
)
|
|
)
|
|
)
|
|
legacy: Final = {(item["entity_type"].value, item["entity_id"]): item["response_cost"] for item in queued}
|
|
assert legacy[("key", proxy_server.hash_token("sk-projection"))] == next(
|
|
dict(item.increments)["spend"] for item in projected if item.table == "LiteLLM_VerificationToken"
|
|
)
|
|
for table, entity, identity in (
|
|
("LiteLLM_UserTable", "user", "user"),
|
|
("LiteLLM_TeamTable", "team", "team"),
|
|
("LiteLLM_OrganizationTable", "organization", "org"),
|
|
("LiteLLM_EndUserTable", "end_user", "end-user"),
|
|
("LiteLLM_AgentsTable", "agent", "agent"),
|
|
("LiteLLM_ProjectTable", "project", "project"),
|
|
):
|
|
assert legacy[(entity, identity)] == next(
|
|
dict(item.increments)["spend"] for item in projected if item.table == table
|
|
)
|
|
assert sum(dict(item.increments)["spend"] for item in projected if item.table == "LiteLLM_TagTable") == sum(
|
|
item["response_cost"] or 0 for item in queued if item["entity_type"].value == "tag"
|
|
)
|
|
|
|
daily_queues: Final = (
|
|
("LiteLLM_DailyUserSpend", writer.daily_spend_update_queue),
|
|
("LiteLLM_DailyTeamSpend", writer.daily_team_spend_update_queue),
|
|
("LiteLLM_DailyOrganizationSpend", writer.daily_org_spend_update_queue),
|
|
("LiteLLM_DailyEndUserSpend", writer.daily_end_user_spend_update_queue),
|
|
("LiteLLM_DailyAgentSpend", writer.daily_agent_spend_update_queue),
|
|
("LiteLLM_DailyTagSpend", writer.daily_tag_spend_update_queue),
|
|
)
|
|
for table, queue in daily_queues:
|
|
transactions = await queue.flush_and_get_aggregated_daily_spend_update_transactions()
|
|
matching = tuple(item for item in projected if item.table == table)
|
|
assert matching, table
|
|
contribution = matching[0]
|
|
assert len(transactions) == 1
|
|
transaction = next(iter(transactions.values()))
|
|
assert dict(contribution.increments) == {name: transaction[name] for name, _ in contribution.increments}
|
|
assert len(client.model_usage_transactions) == 1
|
|
model: Final = client.model_usage_transactions[0]
|
|
model_projection: Final = next(item for item in projected if item.table == "LiteLLM_DailyModelUsage")
|
|
assert dict(model_projection.increments) == {
|
|
"spend": model.spend,
|
|
"prompt_tokens": model.prompt_tokens,
|
|
"completion_tokens": model.completion_tokens,
|
|
"request_count": int(model.counts_as_request),
|
|
"successful_requests": int(model.successful),
|
|
"failed_requests": int(not model.successful),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("served_id", ["requested", "fallback", "missing"])
|
|
@pytest.mark.asyncio
|
|
async def test_projection_preserves_fallback_narrowed_model_access_groups(served_id: str) -> None:
|
|
from litellm import Router
|
|
from litellm.proxy.db.db_spend_update_writer import debitable_model_access_groups
|
|
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "first",
|
|
"litellm_params": {"model": "openai/projection-test", "api_key": "unused"},
|
|
"model_info": {"id": "requested", "access_groups": ["first-group"]},
|
|
},
|
|
{
|
|
"model_name": "second",
|
|
"litellm_params": {"model": "openai/projection-test", "api_key": "unused"},
|
|
"model_info": {"id": "fallback", "access_groups": ["second-group", "unattributed"]},
|
|
},
|
|
]
|
|
)
|
|
attributed: Final = ("first-group", "second-group", "first-group")
|
|
writer: Final = DBSpendUpdateWriter()
|
|
client: Final = PrismaClient(database_url="postgresql://unused", proxy_logging_obj=proxy_logging_obj)
|
|
await writer._update_model_access_group_db(0.5, attributed, served_id, client, router)
|
|
queued: Final = await writer.spend_update_queue.flush_all_updates_from_in_memory_queue()
|
|
frozen_groups: Final = debitable_model_access_groups(attributed, served_id, router)
|
|
projected: Final = project(
|
|
{"spend": 0.5, "metadata": json.dumps({"accounting_model_access_groups": frozen_groups})}
|
|
)
|
|
assert {dict(item.key)["access_group_name"]: dict(item.increments)["spend"] for item in projected} == {
|
|
item["entity_id"]: item["response_cost"] for item in queued
|
|
}
|
|
assert "unattributed" not in frozen_groups
|
|
|
|
|
|
def test_guardrail_projection_skips_unmeasured_counters() -> None:
|
|
row: Final = {
|
|
"request_id": "guarded",
|
|
"startTime": _NOW,
|
|
"metadata": json.dumps(
|
|
{
|
|
"guardrail_information": [
|
|
{"guardrail_usage": {"text": 3}},
|
|
{
|
|
"guardrail_id": "guard",
|
|
"guardrail_usage": {"zero": 0, "negative": -1, "bool": True, "unknown": None, "text": 2},
|
|
},
|
|
]
|
|
}
|
|
),
|
|
}
|
|
units: Final = tuple(item for item in project(row) if item.table == "LiteLLM_DailyGuardrailUsageUnits")
|
|
assert len(units) == 1
|
|
assert dict(units[0].key)["usage_unit"] == "text"
|
|
assert dict(units[0].increments) == {"units": 2, "cost": 0.0, "untracked_units": 2}
|
|
|
|
|
|
def test_lean_row_without_model_does_not_fabricate_daily_entity_usage() -> None:
|
|
projected: Final = project(
|
|
{
|
|
"request_id": "lean",
|
|
"api_key": "key",
|
|
"startTime": _NOW,
|
|
"spend": 0.5,
|
|
"metadata": {"accounting_entities": {"api_key": "key"}},
|
|
}
|
|
)
|
|
assert tuple(item.table for item in projected) == ("LiteLLM_VerificationToken", "LiteLLM_DailyModelUsage")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cost_fields",
|
|
[
|
|
{"guardrail_cost_in_spend": False, "guardrail_cost_by_unit": {"text": 1.0}},
|
|
{"guardrail_cost_by_unit": {"text": -1.0}},
|
|
],
|
|
)
|
|
def test_report_only_or_invalid_guardrail_prices_remain_untracked(cost_fields: Mapping[str, object]) -> None:
|
|
row: Final = {
|
|
"request_id": "guarded",
|
|
"startTime": _NOW,
|
|
"request_tags": '["task:debugging"]',
|
|
"metadata": json.dumps(
|
|
{
|
|
"guardrail_information": [
|
|
{"guardrail_id": "guard", "guardrail_usage": {"text": 2}, **cost_fields},
|
|
]
|
|
}
|
|
),
|
|
}
|
|
contributions: Final = project(row)
|
|
guardrail: Final = next(item for item in contributions if item.table == "LiteLLM_DailyGuardrailUsageUnits")
|
|
model: Final = next(item for item in contributions if item.table == "LiteLLM_DailyModelUsage")
|
|
assert dict(guardrail.increments) == {"units": 2, "cost": 0.0, "untracked_units": 2}
|
|
assert dict(model.key)["task_type"] == "debugging"
|
|
|
|
|
|
@pytest.mark.parametrize("organization_id", [None, "", "charged-org"])
|
|
def test_daily_organization_uses_frozen_charged_identity(organization_id: str | None) -> None:
|
|
row: Final[Mapping[str, object]] = {
|
|
"startTime": _NOW,
|
|
"api_key": "key",
|
|
"model": "projection-test",
|
|
"model_group": "group",
|
|
"custom_llm_provider": "openai",
|
|
"organization_id": "stored-org",
|
|
"spend": 0.25,
|
|
"metadata": {"accounting_entities": {"organization_id": organization_id}},
|
|
}
|
|
daily: Final = tuple(item for item in project(row) if item.table == "LiteLLM_DailyOrganizationSpend")
|
|
if organization_id is None:
|
|
assert daily == ()
|
|
else:
|
|
assert len(daily) == 1
|
|
assert dict(daily[0].key)["organization_id"] == organization_id
|
|
assert dict(daily[0].increments)["spend"] == 0.25
|
|
|
|
|
|
@pytest.mark.parametrize("start", [_NOW, "2026-11-02T12:00:00+00:00", "2026-11-02 12:00:00+00:00"])
|
|
def test_every_daily_row_uses_the_calendar_date_of_the_request(start: object) -> None:
|
|
row: Final[Mapping[str, object]] = {
|
|
"request_id": "dated",
|
|
"startTime": start,
|
|
"api_key": "key",
|
|
"user": "user",
|
|
"team_id": "team",
|
|
"model": "unpriced-projection-test",
|
|
"model_group": "group",
|
|
"custom_llm_provider": "openai",
|
|
"spend": 0.5,
|
|
"metadata": "{}",
|
|
}
|
|
dates: Final = {
|
|
item.table: dict(item.key)["date"] for item in project(row) if item.table.startswith("LiteLLM_Daily")
|
|
}
|
|
assert {"LiteLLM_DailyUserSpend", "LiteLLM_DailyModelUsage"} <= dates.keys()
|
|
assert set(dates.values()) == {"2026-11-02"}
|
|
|
|
|
|
def test_unparseable_start_time_skips_only_guardrail_usage() -> None:
|
|
projected: Final = project(
|
|
{
|
|
"request_id": "bad-time",
|
|
"startTime": "yesterday at noon",
|
|
"spend": 0.5,
|
|
"metadata": {
|
|
"accounting_entities": {"api_key": "key"},
|
|
"guardrail_information": [{"guardrail_id": "guard", "guardrail_usage": {"text": 2}}],
|
|
},
|
|
}
|
|
)
|
|
tables: Final = {item.table for item in projected}
|
|
assert "LiteLLM_VerificationToken" in tables
|
|
assert "LiteLLM_DailyGuardrailUsageUnits" not in tables
|
|
|
|
|
|
def test_guardrail_usage_is_dated_in_utc() -> None:
|
|
projected: Final = project(
|
|
{
|
|
"request_id": "offset",
|
|
"startTime": "2026-11-02T22:30:00-05:00",
|
|
"metadata": {"guardrail_information": [{"guardrail_id": "guard", "guardrail_usage": {"text": 1}}]},
|
|
}
|
|
)
|
|
assert [dict(item.key)["date"] for item in projected if item.table == "LiteLLM_DailyGuardrailUsageUnits"] == [
|
|
"2026-11-03"
|
|
]
|