Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/db/spend_log_tool_index.py: 53%
59 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2Tool usage tracking for the dashboard.
4At request time the spend writer builds one ToolUsageTransaction per request that
5invoked tools (MCP namespaced tool name plus response tool_calls; declared-but-not-
6invoked tools are excluded) and queues it on the prisma client. The spend-log flush
7job drains the queue into LiteLLM_SpendLogToolIndex (per-request drill-down) and
8LiteLLM_DailyToolSpend (the per-day rollup the Cost Optimization card reads). The
9index rows are keyed on (request_id, tool_name) and written with skip_duplicates,
10so they go out as bounded standalone statements; every rollup upsert stays in one
11transaction, so a failed flush never leaves a partial rollup increment.
12"""
14from __future__ import annotations
16import asyncio
17import random
18from collections.abc import Sequence
19from dataclasses import dataclass
20from datetime import datetime, timezone
21from itertools import groupby
22from typing import TYPE_CHECKING, Any, Final
24from litellm.constants import SPEND_LOG_WRITE_BATCH_MAX_BYTES, SPEND_LOG_WRITE_BATCH_MAX_ROWS
25from litellm.proxy._types import DB_RETRY_SAFE_ERROR_TYPES
26from litellm.proxy.db.spend_log_batching import spend_log_write_batches
27from litellm.repositories.table_repositories import SpendLogToolIndexRepository
29if TYPE_CHECKING: 29 ↛ 30line 29 didn't jump to line 30 because the condition on line 29 was never true
30 from litellm.proxy.utils import PrismaClient
33@dataclass(frozen=True, slots=True)
34class ToolUsageTransaction:
35 request_id: str
36 date: str
37 start_time: datetime
38 tool_names: tuple[str, ...]
39 spend: float
40 total_tokens: int
43def response_tool_call_names(completion_response: object) -> tuple[str, ...]:
44 """Tool names invoked in a completion response, in call order, for any response
45 surface get_tool_calls_from_response understands (chat completions, Responses
46 API output items, Anthropic Messages tool_use blocks). Reads every choice of
47 an ``n>1`` chat response: each choice cost money and its tool calls ran."""
48 if completion_response is None or isinstance(completion_response, Exception):
49 return ()
50 from litellm.litellm_core_utils.prompt_templates.factory import (
51 get_tool_calls_from_response,
52 )
54 return tuple(
55 stripped
56 for tool_call in get_tool_calls_from_response(completion_response, include_all_choices=True)
57 if isinstance(name := tool_call.get("name"), str) and (stripped := name.strip())
58 )
61def build_tool_usage_transaction(
62 request_id: str,
63 start_time_iso: str,
64 mcp_namespaced_tool_name: str | None,
65 spend: float,
66 total_tokens: int,
67 completion_response: object,
68 realtime_tool_calls: Any = None,
69) -> ToolUsageTransaction | None:
70 """None when the request invoked no tools. Realtime sessions carry invoked
71 tools in kwargs["realtime_tool_calls"] (OpenAI tool_calls shape) rather than
72 on a response object, so they are normalized through the same owner by
73 wrapping them in the chat-completion shape. Date derivation must match the
74 daily spend writer's ``startTime.split("T")[0]`` so rollup rows land in the
75 same UTC day bucket as LiteLLM_DailyUserSpend."""
76 mcp_names: Final = (
77 (mcp_namespaced_tool_name.strip(),) if mcp_namespaced_tool_name and mcp_namespaced_tool_name.strip() else ()
78 )
79 realtime_names: Final = (
80 response_tool_call_names({"choices": [{"message": {"tool_calls": realtime_tool_calls}}]})
81 if realtime_tool_calls
82 else ()
83 )
84 tool_names: Final = tuple(dict.fromkeys(mcp_names + response_tool_call_names(completion_response) + realtime_names))
85 if not tool_names: 85 ↛ 87line 85 didn't jump to line 87 because the condition on line 85 was always true
86 return None
87 try:
88 start_time: Final = datetime.fromisoformat(start_time_iso.replace("Z", "+00:00"))
89 except ValueError:
90 return None
91 return ToolUsageTransaction(
92 request_id=request_id,
93 date=start_time_iso.split("T")[0],
94 start_time=start_time if start_time.tzinfo else start_time.replace(tzinfo=timezone.utc),
95 tool_names=tool_names,
96 spend=spend,
97 total_tokens=total_tokens,
98 )
101async def flush_tool_usage_transactions(
102 prisma_client: PrismaClient,
103 transactions: Sequence[ToolUsageTransaction],
104 n_retry_times: int = 3,
105) -> None:
106 """Write the index rows as bounded standalone statements, then every rollup
107 upsert for the drained queue batch in one transaction. One flush fans out to
108 transactions x tools index rows, so the index write is split by the spend-log
109 statement budgets; a split inside ``batch_()`` would not help, since the
110 batcher ships every queued statement to the query engine as one payload.
111 Retries only ConnectError, the one failure that proves the statements never
112 reached the database; replayed index rows are no-ops under skip_duplicates.
113 Post-send failures (Read timeouts and errors) are ambiguous and are NOT
114 retried: the engine can abandon the transaction open on the pooled
115 connection, so a retry's statements stack into the same transaction and one
116 commit applies both increment sets. Ambiguous failures drop the batch; the
117 caller logs it at error. Callers must not add their own retry around this
118 function."""
119 if not transactions: 119 ↛ 122line 119 didn't jump to line 122 because the condition on line 119 was always true
120 return
122 index_rows: Final = [
123 {"request_id": txn.request_id, "tool_name": tool_name, "start_time": txn.start_time}
124 for txn in transactions
125 for tool_name in txn.tool_names
126 ]
127 per_tool_day: Final = sorted(
128 ((txn.date, tool_name, txn.spend, txn.total_tokens) for txn in transactions for tool_name in txn.tool_names),
129 key=lambda entry: (entry[0], entry[1]),
130 )
132 index_table: Final = SpendLogToolIndexRepository(prisma_client).table
133 for attempt in range(n_retry_times + 1):
134 try:
135 for statement_rows in spend_log_write_batches(
136 index_rows, SPEND_LOG_WRITE_BATCH_MAX_BYTES, SPEND_LOG_WRITE_BATCH_MAX_ROWS
137 ):
138 await index_table.create_many(data=statement_rows, skip_duplicates=True)
139 async with prisma_client.db.batch_() as batcher:
140 for (date_key, tool_name), grouped in groupby(per_tool_day, key=lambda entry: (entry[0], entry[1])):
141 entries = tuple(grouped)
142 spend = sum(entry[2] for entry in entries)
143 total_tokens = sum(entry[3] for entry in entries)
144 batcher.litellm_dailytoolspend.upsert(
145 where={"date_tool_name": {"date": date_key, "tool_name": tool_name}},
146 data={
147 "create": {
148 "date": date_key,
149 "tool_name": tool_name,
150 "spend": spend,
151 "total_tokens": total_tokens,
152 "request_count": len(entries),
153 },
154 "update": {
155 "spend": {"increment": spend},
156 "total_tokens": {"increment": total_tokens},
157 "request_count": {"increment": len(entries)},
158 },
159 },
160 )
161 return
162 except DB_RETRY_SAFE_ERROR_TYPES:
163 if attempt >= n_retry_times:
164 raise
165 await asyncio.sleep(2.0**attempt + random.uniform(0, 1))