Merge remote-tracking branch 'origin/main' into litellm_lite_cli_reasoning_efforts

This commit is contained in:
mateo-berri 2026-10-28 14:53:53 -07:00
commit ae74f26cdc
48 changed files with 4463 additions and 120 deletions

View file

@ -137,6 +137,7 @@ jobs:
include:
- shard: core-utils
test-path: |-
tests/unit/accounting
tests/unit/batch_completion
tests/unit/decisions
tests/unit/extraction

View file

@ -4,7 +4,9 @@ needs an approving review from the spend-log owner before it can merge.
CODEOWNERS can only match file paths, and every schema change edits the same schema.prisma copies,
so a path rule would put every migration on the owner. This gate reads the diff instead: when no
added line in the watched files mentions the table it passes at once, otherwise it passes only once
the owner's latest review on the pull request is an approval. The workflow re-runs on every review.
the owner's latest review on the pull request is an approval that still covers every spend-log line
in the head (approvals survive pushes, so a later commit may not add spend-log lines the owner never
saw). The workflow re-runs on every review.
"""
from __future__ import annotations
@ -13,7 +15,7 @@ import argparse
import re
import subprocess
import sys
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Final
@ -37,6 +39,7 @@ class Review(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
state: str
user: Reviewer
commit_id: str | None = None
_REVIEWS: Final = TypeAdapter(list[Review])
@ -75,6 +78,24 @@ def approved_by(reviews: Sequence[Review], login: str) -> bool:
return bool(states) and states[-1] == "APPROVED"
def latest_approval(reviews: Sequence[Review], login: str) -> Review | None:
"""`login`'s most recent APPROVED review on the pull request, the one GitHub keeps in force after pushes."""
own: Final = tuple(review for review in reviews if review.user.login == login and review.state == "APPROVED")
return own[-1] if own else None
def approval_covers(
approval: Review, head: str, hits: Sequence[str], hits_at: Callable[[str], Sequence[str] | None]
) -> bool:
"""True when the approval is on the head, or the head adds no spend-log line the approved commit did not."""
if approval.commit_id is None:
return False
if approval.commit_id == head:
return True
reviewed: Final = hits_at(approval.commit_id)
return reviewed is not None and set(hits) <= set(reviewed)
def added_diff(base: str, head: str) -> str:
result: Final = subprocess.run(
("git", "diff", "--unified=0", "--no-color", f"{base}..{head}", "--", *WATCHED, MIGRATIONS_PREFIX),
@ -85,6 +106,15 @@ def added_diff(base: str, head: str) -> str:
return result.stdout
def fetched(commit: str) -> bool:
result: Final = subprocess.run(("git", "fetch", "--no-tags", "--depth=1", "origin", commit), check=False)
return result.returncode == 0
def reviewed_hits(base: str, commit: str) -> tuple[str, ...] | None:
return spend_log_hits(added_diff(base, commit)) if fetched(commit) else None
class Arguments(BaseModel):
model_config = ConfigDict(frozen=True)
base: str
@ -104,14 +134,21 @@ def main(argv: Sequence[str]) -> int:
if not hits:
sys.stdout.write("No added line in schema.prisma or a migration names LiteLLM_SpendLogs; nothing to review.\n")
return 0
if approved_by(_REVIEWS.validate_json(args.reviews.read_bytes()), args.approver):
reviews: Final = _REVIEWS.validate_json(args.reviews.read_bytes())
approval: Final = latest_approval(reviews, args.approver) if approved_by(reviews, args.approver) else None
if approval is not None and approval_covers(
approval, args.head, hits, lambda commit: reviewed_hits(args.base, commit)
):
sys.stdout.write(f"LiteLLM_SpendLogs change approved by {args.approver}: {', '.join(hits)}\n")
return 0
listed: Final = "".join(f" {hit}\n" for hit in hits)
sys.stdout.write(
reason: Final = (
f"This pull request changes LiteLLM_SpendLogs and needs an approving review from @{args.approver}:\n"
f"{listed}The check re-runs when a review is submitted.\n"
if approval is None
else f"LiteLLM_SpendLogs lines changed after @{args.approver} approved {approval.commit_id}, "
f"a new approving review is needed:\n"
)
sys.stdout.write(f"{reason}{listed}The check re-runs when a review is submitted.\n")
return 1

View file

@ -32,6 +32,7 @@ from litellm.integrations.custom_guardrail import (
CustomGuardrail,
log_guardrail_information,
)
from litellm.llms.base_llm.guardrail_translation.utils import effective_skip_tool_message_for_guardrail
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails._content_utils import iter_tool_json_fields, map_tool_json_text, walk_user_text
from litellm.types.guardrails import GuardrailEventHooks
@ -753,7 +754,12 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
_masked_entity_count.set({})
# Covers multimodal list content + Responses-API input.
inspected = walk_user_text(data, self.redact_text, self.redact_tool_payload) + self._redact_prompt(data)
inspected = walk_user_text(
data,
self.redact_text,
self.redact_tool_payload,
skip_tool_message=effective_skip_tool_message_for_guardrail(self),
) + self._redact_prompt(data)
if inspected == 0:
# Image-only, empty-text, and unsupported payloads inspected

View file

@ -28,7 +28,7 @@ import httpx
from fastapi import HTTPException
from openai import APIConnectionError, AsyncOpenAI
from openai.types.file_deleted import FileDeleted
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, Unpack
import litellm
@ -53,7 +53,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
extract_file_metadata,
)
from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints, FileListModelScope
from litellm.llms.base_llm.managed_resources.isolation import (
build_list_page,
build_owner_filter,
@ -309,6 +309,7 @@ def _is_xai_batch_output(
class _ManagedFileRow(Protocol):
unified_file_id: str
file_object: OpenAIFileObject
model_mappings: Mapping[str, str]
flat_model_file_ids: Sequence[str]
storage_backend: Optional[str]
storage_url: Optional[str]
@ -362,6 +363,8 @@ class _ManagedResourceDatabase(Protocol):
@property
def litellm_managedfiletable(self) -> _ManagedFileTableActions: ...
async def query_raw(self, query: str, *args: object) -> object: ...
@property
def litellm_managedobjecttable(self) -> _ManagedObjectTableActions: ...
@ -385,10 +388,83 @@ class _RouterFileCallKwargs(TypedDict, total=False):
custom_llm_provider: ReadOnly[str | None]
def _managed_database(prisma_client: _ManagedResourcePrismaClient) -> _ManagedResourceDatabase:
return prisma_client.db
def _managed_file_table(prisma_client: _ManagedResourcePrismaClient) -> _ManagedFileTableActions:
return prisma_client.db.litellm_managedfiletable
class _ListedFileRow(Protocol):
@property
def unified_file_id(self) -> str: ...
@property
def file_object(self) -> object: ...
@property
def model_mappings(self) -> Mapping[str, object]: ...
@property
def storage_backend(self) -> str | None: ...
class _ScopedFileRow(BaseModel):
model_config = ConfigDict(frozen=True)
unified_file_id: str
file_object: JsonValue = None
model_mappings: Mapping[str, JsonValue]
storage_backend: str | None = None
_SCOPED_FILE_ROWS: Final = TypeAdapter(tuple[_ScopedFileRow, ...])
def _owner_condition(owner_filter: Mapping[str, object]) -> tuple[str, tuple[str, ...]]:
match owner_filter:
case {"OR": [{"created_by": str() as user_id}, {"team_id": str() as team_id}]}:
return '("created_by" = $1 OR "team_id" = $2)', (user_id, team_id)
case {"created_by": str() as user_id}:
return '"created_by" = $1', (user_id,)
case {"team_id": str() as team_id}:
return '"team_id" = $1', (team_id,)
case _:
return ("FALSE", ()) if owner_filter else ("TRUE", ())
async def _scoped_file_rows(
database: _ManagedResourceDatabase,
owner_filter: Mapping[str, object],
model_mapping_keys: frozenset[str],
cursor_id: str | None,
direction: Literal["asc", "desc"],
take: int,
) -> tuple[_ScopedFileRow, ...]:
owner_condition, owner_params = _owner_condition(owner_filter)
keys_param: Final = len(owner_params) + 1
comparison: Final = "<" if direction == "desc" else ">"
cursor_condition: Final = (
f'AND ("created_at", "unified_file_id") {comparison} (SELECT "created_at", "unified_file_id" '
f'FROM "LiteLLM_ManagedFileTable" WHERE "unified_file_id" = ${keys_param + 1}) '
if cursor_id
else ""
)
order: Final = direction.upper()
rows: Final = await database.query_raw(
'SELECT "unified_file_id", "file_object", "model_mappings", "storage_backend" FROM "LiteLLM_ManagedFileTable" '
f'WHERE {owner_condition} AND ("model_mappings" ?| ${keys_param}::text[] OR "storage_backend" IS NOT NULL) '
f'{cursor_condition}ORDER BY "created_at" {order}, "unified_file_id" {order} '
f"LIMIT ${keys_param + (2 if cursor_id else 1)}",
*owner_params,
sorted(model_mapping_keys),
*((cursor_id,) if cursor_id else ()),
take,
)
return _SCOPED_FILE_ROWS.validate_python(rows)
def _iter_provider_file_id_pairs(
rows: Sequence[_ManagedFileRow],
requested_provider_file_ids: frozenset[str],
@ -2265,14 +2341,23 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
user_api_key_dict: UserAPIKeyAuth,
limit: Optional[int] = None,
after: Optional[str] = None,
model_scope: Optional[FileListModelScope] = None,
before: Optional[str] = None,
**data: Dict,
) -> FileListPage:
"""List the managed files the caller owns, newest first.
Pagination is keyset based on ``unified_file_id`` so a key that owns
every file on the proxy still reads one bounded page at a time.
``purpose`` is applied after parsing, because the managed file table
keeps it inside the ``file_object`` blob instead of a column, and rows
``after`` pages toward older files and ``before`` toward newer ones,
each page still ordered newest first.
``model_scope`` is applied in the query, so only rows whose
``model_mappings`` name one of its deployment ids or model names, and
storage-backed rows, leave the database; a storage-backed row then
stays only when its model name is one the router sends to the listed
provider. ``purpose`` is applied after parsing, because the managed
file table keeps it inside the ``file_object`` blob instead of a
column. Rows
whose blob will not parse drop out there too, so a chunk of rows can
yield fewer matches than the page holds. Successive chunks are read
until the page is full or the caller's rows run out, which keeps
@ -2281,9 +2366,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
scan has to continue past it, the chunk widens to
``FILE_LIST_CONTINUATION_CHUNK_SIZE``, so the walk costs one query per
that many rows instead of one per page. That bound is per query, not
per request: the work is still linear in the rows the caller owns, and
a filter matching nothing reads every one of them, with no index
covering either the owner filter or the sort.
per request: a ``purpose`` filter matching nothing still reads every
row that passes the query.
"""
validate_file_list_limit(limit)
validate_file_list_purpose(purpose)
@ -2292,36 +2376,60 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
if owner_filter is None:
return FileListPage.model_validate(build_list_page([]))
if after:
cursor_row = await _managed_file_table(self.prisma_client).find_first(
where={**owner_filter, "unified_file_id": after}
if after and before:
raise ProxyException(
message="Pass either 'after' (or 'after_id') or 'before_id' when listing files, not both.",
type="invalid_request_error",
param="before_id",
code=400,
openai_code="invalid_value",
)
cursor_param: Final = "before_id" if before else "after"
cursor: Final = before or after
database: Final = _managed_database(self.prisma_client)
if cursor:
cursor_row = await database.litellm_managedfiletable.find_first(
where={**owner_filter, "unified_file_id": cursor}
)
if cursor_row is None:
raise ProxyException(
message=f"Invalid 'after' cursor: no file found with id '{after}'.",
message=f"Invalid '{cursor_param}' cursor: no file found with id '{cursor}'.",
type="invalid_request_error",
param="after",
param=cursor_param,
code=400,
openai_code="invalid_value",
)
if model_scope is not None and not model_scope.model_mapping_keys:
return FileListPage.model_validate(build_list_page([]))
page_size: Final = min(limit or MAX_FILE_LIST_LIMIT, MAX_FILE_LIST_LIMIT)
matches: Final[List[OpenAIFileObject]] = []
cursor_id = after
cursor_id = cursor
chunk_size = page_size + 1
direction: Final[Literal["asc", "desc"]] = "asc" if before else "desc"
while len(matches) <= page_size:
cursor_args: _CursorPageArgs = {"cursor": {"unified_file_id": cursor_id}, "skip": 1} if cursor_id else {}
chunk = await _managed_file_table(self.prisma_client).find_many(
where=owner_filter,
take=chunk_size,
order=[{"created_at": "desc"}, {"unified_file_id": "desc"}],
**cursor_args,
chunk: Sequence[_ListedFileRow] = (
await _scoped_file_rows(
database, owner_filter, model_scope.model_mapping_keys, cursor_id, direction, chunk_size
)
if model_scope is not None
else await database.litellm_managedfiletable.find_many(
where=owner_filter,
take=chunk_size,
order=[{"created_at": direction}, {"unified_file_id": direction}],
**cursor_args,
)
)
matches.extend(
_public_file_object(parsed_file_object, row.unified_file_id)
for row in chunk
if (parsed_file_object := _parse_managed_file_object(row.file_object, row.unified_file_id)) is not None
if (
model_scope is None or model_scope.holds(row.model_mappings, stored=row.storage_backend is not None)
)
and (parsed_file_object := _parse_managed_file_object(row.file_object, row.unified_file_id)) is not None
and (purpose is None or parsed_file_object.purpose == purpose)
)
if len(chunk) < chunk_size:
@ -2329,7 +2437,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
cursor_id = chunk[-1].unified_file_id
chunk_size = max(chunk_size, FILE_LIST_CONTINUATION_CHUNK_SIZE)
return FileListPage.model_validate(build_list_page(matches[:page_size], has_more=len(matches) > page_size))
page: Final = matches[:page_size]
return FileListPage.model_validate(
build_list_page(page[::-1] if before else page, has_more=len(matches) > page_size)
)
def _is_batch_polling_enabled(self) -> bool:
"""

View file

@ -0,0 +1,2 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_ManagedFileTable_created_by_created_at_idx"
ON "LiteLLM_ManagedFileTable" ("created_by", "created_at" DESC);

View file

@ -0,0 +1,47 @@
SET LOCAL lock_timeout = '10s';
CREATE TYPE "AccountingEventKind" AS ENUM ('inbound_usage', 'outbound_usage', 'cost');
CREATE TYPE "AccountingDeliveryState" AS ENUM ('pending', 'applied', 'parked');
CREATE TYPE "AccountingDeliveryTarget" AS ENUM ('LiteLLM_VerificationToken', 'LiteLLM_UserTable', 'LiteLLM_TeamTable', 'LiteLLM_TeamMembership', 'LiteLLM_OrganizationTable', 'LiteLLM_OrganizationMembership', 'LiteLLM_EndUserTable', 'LiteLLM_AgentsTable', 'LiteLLM_TagTable', 'LiteLLM_ProjectTable', 'LiteLLM_ModelAccessGroupBudgetTable', 'LiteLLM_BudgetTable', 'LiteLLM_BudgetWindowSpend', 'LiteLLM_DailyUserSpend', 'LiteLLM_DailyTeamSpend', 'LiteLLM_DailyOrganizationSpend', 'LiteLLM_DailyEndUserSpend', 'LiteLLM_DailyAgentSpend', 'LiteLLM_DailyTagSpend', 'LiteLLM_DailyModelUsage', 'LiteLLM_DailyToolSpend', 'LiteLLM_AutoRouterDailySpend');
-- CreateTable
CREATE TABLE IF NOT EXISTS "LiteLLM_AccountingEvent" (
"event_id" TEXT NOT NULL,
"kind" "AccountingEventKind" NOT NULL,
"source_event_id" TEXT,
"schema_version" INTEGER NOT NULL,
"payload_hash" TEXT NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL,
"recorded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"payload" JSONB NOT NULL,
CONSTRAINT "LiteLLM_AccountingEvent_schema_version_check" CHECK ("schema_version" > 0),
CONSTRAINT "LiteLLM_AccountingEvent_pkey" PRIMARY KEY ("event_id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "LiteLLM_AccountingDelivery" (
"event_id" TEXT NOT NULL,
"target" "AccountingDeliveryTarget" NOT NULL,
"version" INTEGER NOT NULL,
"state" "AccountingDeliveryState" NOT NULL DEFAULT 'pending',
"attempts" INTEGER NOT NULL DEFAULT 0,
"next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"applied_at" TIMESTAMP(3),
"last_error" TEXT,
CONSTRAINT "LiteLLM_AccountingDelivery_version_check" CHECK ("version" > 0),
CONSTRAINT "LiteLLM_AccountingDelivery_attempts_check" CHECK ("attempts" >= 0),
CONSTRAINT "LiteLLM_AccountingDelivery_pkey" PRIMARY KEY ("event_id","target","version")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_AccountingEvent_recorded_at_idx" ON "LiteLLM_AccountingEvent"("recorded_at");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_AccountingEvent_kind_source_event_id_idx" ON "LiteLLM_AccountingEvent"("kind", "source_event_id");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_AccountingDelivery_target_state_next_attempt_at_idx" ON "LiteLLM_AccountingDelivery"("target", "state", "next_attempt_at");

View file

@ -1163,6 +1163,7 @@ model LiteLLM_ManagedFileTable {
@@index([unified_file_id])
@@index([flat_model_file_ids], type: Gin)
@@index([team_id, created_at(sort: Desc)])
@@index([created_by, created_at(sort: Desc)])
}
model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use the
@ -2060,3 +2061,73 @@ model LiteLLM_TelemetryReport {
@@index([window_end])
@@index([created_at])
}
enum AccountingEventKind {
inbound_usage
outbound_usage
cost
}
enum AccountingDeliveryState {
pending
applied
parked
}
enum AccountingDeliveryTarget {
LiteLLM_VerificationToken
LiteLLM_UserTable
LiteLLM_TeamTable
LiteLLM_TeamMembership
LiteLLM_OrganizationTable
LiteLLM_OrganizationMembership
LiteLLM_EndUserTable
LiteLLM_AgentsTable
LiteLLM_TagTable
LiteLLM_ProjectTable
LiteLLM_ModelAccessGroupBudgetTable
LiteLLM_BudgetTable
LiteLLM_BudgetWindowSpend
LiteLLM_DailyUserSpend
LiteLLM_DailyTeamSpend
LiteLLM_DailyOrganizationSpend
LiteLLM_DailyEndUserSpend
LiteLLM_DailyAgentSpend
LiteLLM_DailyTagSpend
LiteLLM_DailyModelUsage
LiteLLM_DailyToolSpend
LiteLLM_AutoRouterDailySpend
}
// Durable accounting ledger. One row per usage or cost event; payload holds the
// private event envelope versioned by schema_version. Usage rows retain request-scoped
// pricing context; cost rows include resolved rates and the finalized pricing snapshot.
model LiteLLM_AccountingEvent {
event_id String @id
kind AccountingEventKind
source_event_id String?
schema_version Int
payload_hash String
occurred_at DateTime
recorded_at DateTime @default(now())
payload Json
@@index([recorded_at])
@@index([kind, source_event_id])
}
// One row per (event, target table, version): pending work for the projector and,
// once applied in the same transaction as the target write, the receipt that stops a retry.
model LiteLLM_AccountingDelivery {
event_id String
target AccountingDeliveryTarget
version Int
state AccountingDeliveryState @default(pending)
attempts Int @default(0)
next_attempt_at DateTime @default(now())
applied_at DateTime?
last_error String?
@@id([event_id, target, version])
@@index([target, state, next_attempt_at])
}

View file

@ -13,6 +13,7 @@ const FINISH_REASONS: &[(&str, &str)] = &[
("max_tokens", "length"),
("refusal", "content_filter"),
("compaction", "length"),
("model_context_window_exceeded", "length"),
("guardrail_intervened", "content_filter"),
("content_filtered", "content_filter"),
("content_filter", "content_filter"),
@ -74,6 +75,7 @@ mod tests {
#[case::end_turn("end_turn", "stop")]
#[case::stop_sequence("stop_sequence", "stop")]
#[case::max_tokens("max_tokens", "length")]
#[case::model_context_window_exceeded("model_context_window_exceeded", "length")]
#[case::refusal("refusal", "content_filter")]
#[case::guardrail_intervened("guardrail_intervened", "content_filter")]
#[case::content_filtered("content_filtered", "content_filter")]

View file

@ -0,0 +1,62 @@
"""Public accounting contracts. Importing these values does not enable accounting."""
from litellm.accounting.measurements import measurements_for
from litellm.accounting.protocols import BudgetEnforcer, CostAggregator, CostCompiler, UsageMeter
from litellm.accounting.types import (
AdmissionDecision,
AggregateQuery,
AggregateRow,
CostEvent,
DeliveryState,
DeliveryTarget,
Dimension,
DimensionFilter,
DimensionName,
EventKind,
Granularity,
InboundUsageEvent,
Measurement,
Metric,
MetricName,
Multiplier,
NamedCost,
OutboundUsageEvent,
Outcome,
PricedQuantity,
PricingSnapshot,
Rate,
Unpriced,
validate_aggregate_rows,
)
__all__ = [
"AdmissionDecision",
"AggregateQuery",
"AggregateRow",
"BudgetEnforcer",
"CostAggregator",
"CostCompiler",
"CostEvent",
"DeliveryState",
"DeliveryTarget",
"Dimension",
"DimensionFilter",
"DimensionName",
"EventKind",
"Granularity",
"InboundUsageEvent",
"Measurement",
"Metric",
"MetricName",
"Multiplier",
"NamedCost",
"OutboundUsageEvent",
"Outcome",
"PricedQuantity",
"PricingSnapshot",
"Rate",
"Unpriced",
"UsageMeter",
"measurements_for",
"validate_aggregate_rows",
]

View file

@ -0,0 +1,13 @@
from typing import Final
from litellm.accounting.types import CostEvent, InboundUsageEvent, Measurement, OutboundUsageEvent
def measurements_for(event: InboundUsageEvent | OutboundUsageEvent | CostEvent) -> tuple[Measurement, ...]:
"""Return the complete contribution set for one event, ready for one apply call."""
metrics: Final = event.charges if isinstance(event, CostEvent) else event.metrics
occurred_at: Final = event.occurred_at if isinstance(event, CostEvent) else event.started_at
return tuple(
Measurement(source_event_id=event.event_id, occurred_at=occurred_at, dimensions=event.dimensions, metric=metric)
for metric in metrics or ()
)

View file

@ -0,0 +1,85 @@
"""Contracts only. Implementations own persistence, pricing, projections and live budgets.
The event row's ``payload`` is a private envelope versioned by the row's ``schema_version``.
Usage envelopes retain the public event and request-scoped pricing context: quantities with units,
model/provider, requested and billed tier/region when known, and per-request/deployment overrides.
These inputs must survive request teardown. Cost-map rates are resolved at compilation, not capture.
Cost envelopes retain the public CostEvent, including its required finalized pricing snapshot.
T5/T6 own envelope serialization; this public API does not introduce a second persisted schema.
"""
from typing import Protocol
from litellm.accounting.types import (
AdmissionDecision,
AggregateQuery,
AggregateRow,
CostEvent,
Dimension,
InboundUsageEvent,
Measurement,
Metric,
OutboundUsageEvent,
Unpriced,
)
class UsageMeter(Protocol):
async def record(self, event: InboundUsageEvent | OutboundUsageEvent) -> None:
"""Acknowledge commit, no-op identical IDs and reject conflicting content.
Persist the event and its request-scoped pricing context before acknowledging.
HTTP acknowledgement is a separate policy. Unknown usage remains None.
"""
...
class CostCompiler(Protocol):
async def compile(self, event: OutboundUsageEvent) -> CostEvent | Unpriced:
"""Reuse the committed charge or finalize one charge and its delivery intent.
Resolve captured request-scoped context by event identity and cost-map rates
at compilation. Persist the resolved inputs in the required snapshot. Replay
reuses the committed result and never consumes free allowance again.
Missing pricing is Unpriced, never an inferred zero.
"""
...
class CostAggregator(Protocol):
async def apply(self, measurements: tuple[Measurement, ...]) -> None:
"""Apply one source event's complete contribution set per target/version.
Each target commits all contributions and its receipt atomically. A receipt
for the first metric must not suppress the rest. Usage need not wait for cost.
"""
...
async def query(self, query: AggregateQuery) -> tuple[AggregateRow, ...]:
"""Return disjoint [start, end) rows per group using existing daily projections.
Filters combine with AND, values within a filter with IN. DAY emits daily
buckets; None emits whole-interval totals. Reject unsupported metric/scope
combinations. Different groups may overlap, including non-additive tags.
"""
...
class BudgetEnforcer(Protocol):
async def reserve(
self, reservation_id: str, dimensions: tuple[Dimension, ...], estimated: tuple[Metric, ...]
) -> AdmissionDecision:
"""Apply existing admission policies and estimates; duplicate IDs add no hold."""
...
async def settle(self, reservation_id: str | None, cost_event: CostEvent) -> None:
"""Settle the committed charge with atomic deduplication and hold mutation.
Preserve scope/window attribution and retry unfinished settlement after
restart. SQL projection receipts do not provide live-counter atomicity.
"""
...
async def release(self, reservation_id: str) -> None:
"""Idempotently release unused exposure at the request boundary, preserving charges."""
...

428
litellm/accounting/types.py Normal file
View file

@ -0,0 +1,428 @@
"""Accounting values use UTC instants, decimal strings on the wire and USD charges."""
from collections.abc import Mapping
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import Annotated, Final, Literal, TypeAlias
from pydantic import AfterValidator, AwareDatetime, BaseModel, ConfigDict, Field, model_validator
from typing_extensions import Self
from litellm.types.utils import CallTypes
_CONFIG: Final = ConfigDict(frozen=True, extra="forbid", validate_default=True)
Identifier: TypeAlias = Annotated[str, Field(strict=True, min_length=1, pattern=r"\S")]
Quantity: TypeAlias = Annotated[Decimal, Field(strict=True, ge=0, allow_inf_nan=False)]
def _utc(value: datetime) -> datetime:
return value.astimezone(timezone.utc)
Instant: TypeAlias = Annotated[AwareDatetime, AfterValidator(_utc)]
class MetricName(str, Enum):
"""Reasoning tokens are included in completion tokens; above-1hr writes are included in cache creation tokens."""
REQUESTS = "requests"
SUCCESSFUL_REQUESTS = "successful_requests"
FAILED_REQUESTS = "failed_requests"
PROMPT_TOKENS = "prompt_tokens"
COMPLETION_TOKENS = "completion_tokens"
REASONING_TOKENS = "reasoning_tokens"
TOTAL_TOKENS = "total_tokens"
CACHE_READ_INPUT_TOKENS = "cache_read_input_tokens"
CACHE_CREATION_INPUT_TOKENS = "cache_creation_input_tokens"
CACHE_CREATION_INPUT_TOKENS_ABOVE_1HR = "cache_creation_input_tokens_above_1hr"
SPEND = "spend"
class DimensionName(str, Enum):
KEY = "key"
USER = "user"
TEAM = "team"
ORGANIZATION = "organization"
END_USER = "end_user"
MODEL = "model"
MODEL_GROUP = "model_group"
DEPLOYMENT = "deployment"
PROVIDER = "provider"
TOOL = "tool"
AGENT = "agent"
PROJECT = "project"
MODEL_ACCESS_GROUP = "model_access_group"
API_BASE = "api_base"
SERVICE_TIER = "service_tier"
TAG = "tag"
class Outcome(str, Enum):
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
class Granularity(str, Enum):
DAY = "day"
class EventKind(str, Enum):
INBOUND_USAGE = "inbound_usage"
OUTBOUND_USAGE = "outbound_usage"
COST = "cost"
class DeliveryState(str, Enum):
PENDING = "pending"
APPLIED = "applied"
PARKED = "parked"
class DeliveryTarget(str, Enum):
"""One member per existing table the projector writes; each is applied and receipted independently."""
KEY_SPEND = "LiteLLM_VerificationToken"
USER_SPEND = "LiteLLM_UserTable"
TEAM_SPEND = "LiteLLM_TeamTable"
TEAM_MEMBER_SPEND = "LiteLLM_TeamMembership"
ORGANIZATION_SPEND = "LiteLLM_OrganizationTable"
ORGANIZATION_MEMBER_SPEND = "LiteLLM_OrganizationMembership"
END_USER_SPEND = "LiteLLM_EndUserTable"
AGENT_SPEND = "LiteLLM_AgentsTable"
TAG_SPEND = "LiteLLM_TagTable"
PROJECT_SPEND = "LiteLLM_ProjectTable"
MODEL_ACCESS_GROUP_SPEND = "LiteLLM_ModelAccessGroupBudgetTable"
BUDGET_SPEND = "LiteLLM_BudgetTable"
BUDGET_WINDOW_SPEND = "LiteLLM_BudgetWindowSpend"
DAILY_USER_SPEND = "LiteLLM_DailyUserSpend"
DAILY_TEAM_SPEND = "LiteLLM_DailyTeamSpend"
DAILY_ORGANIZATION_SPEND = "LiteLLM_DailyOrganizationSpend"
DAILY_END_USER_SPEND = "LiteLLM_DailyEndUserSpend"
DAILY_AGENT_SPEND = "LiteLLM_DailyAgentSpend"
DAILY_TAG_SPEND = "LiteLLM_DailyTagSpend"
DAILY_MODEL_USAGE = "LiteLLM_DailyModelUsage"
DAILY_TOOL_SPEND = "LiteLLM_DailyToolSpend"
AUTOROUTER_DAILY_SPEND = "LiteLLM_AutoRouterDailySpend"
_SUBSET_PAIRS: Final = (
(MetricName.REASONING_TOKENS, MetricName.COMPLETION_TOKENS),
(MetricName.CACHE_CREATION_INPUT_TOKENS_ABOVE_1HR, MetricName.CACHE_CREATION_INPUT_TOKENS),
)
def _check_subsets(values: Mapping[str, Decimal]) -> None:
for part, whole in _SUBSET_PAIRS:
if part.value in values and whole.value in values and values[part.value] > values[whole.value]:
raise ValueError(f"{part.value} cannot exceed {whole.value}")
class Metric(BaseModel):
model_config = _CONFIG
name: MetricName
value: Quantity
unit: Literal["count", "USD"] = "count"
@model_validator(mode="after")
def validate_unit(self) -> Self:
if (self.name is MetricName.SPEND) != (self.unit == "USD"):
raise ValueError("SPEND requires USD; usage metrics require count")
if self.unit == "count" and self.value != self.value.to_integral_value():
raise ValueError("Count metrics require integral values")
return self
class Dimension(BaseModel):
model_config = _CONFIG
name: DimensionName
value: Identifier
_REPEATABLE_DIMENSIONS: Final = frozenset({DimensionName.TAG, DimensionName.MODEL_ACCESS_GROUP})
def _dimensions(values: tuple[Dimension, ...]) -> tuple[Dimension, ...]:
if len(frozenset(values)) != len(values):
raise ValueError("Duplicate dimension values")
single_names: Final = tuple(value.name for value in values if value.name not in _REPEATABLE_DIMENSIONS)
if len(frozenset(single_names)) != len(single_names):
raise ValueError("Only tag and model access group dimensions may have multiple values")
return values
Dimensions: TypeAlias = Annotated[tuple[Dimension, ...], AfterValidator(_dimensions)]
_CALLER_METRICS: Final = frozenset((MetricName.REQUESTS, MetricName.SUCCESSFUL_REQUESTS, MetricName.FAILED_REQUESTS))
def _metrics(values: tuple[Metric, ...]) -> tuple[Metric, ...]:
if len(frozenset(value.name for value in values)) != len(values):
raise ValueError("Duplicate metric names")
return values
Metrics: TypeAlias = Annotated[tuple[Metric, ...], AfterValidator(_metrics)]
def _interval(start: datetime, end: datetime, *, allow_empty: bool = False) -> None:
if end < start or (end == start and not allow_empty):
raise ValueError("End must follow start")
class InboundUsageEvent(BaseModel):
model_config = _CONFIG
event_id: Identifier
request_id: Identifier
call_type: CallTypes
started_at: Instant
ended_at: Instant
outcome: Outcome
dimensions: Dimensions
metrics: Metrics
@model_validator(mode="after")
def validate_caller_activity(self) -> Self:
_interval(self.started_at, self.ended_at, allow_empty=True)
if any(metric.name not in _CALLER_METRICS for metric in self.metrics):
raise ValueError("Inbound events own only caller request metrics")
if not any(metric.name is MetricName.REQUESTS and metric.value == 1 for metric in self.metrics):
raise ValueError("Inbound events count exactly one caller request")
for metric in self.metrics:
if metric.name is MetricName.SUCCESSFUL_REQUESTS and metric.value != int(self.outcome is Outcome.SUCCEEDED):
raise ValueError("Successful request count must match outcome")
if metric.name is MetricName.FAILED_REQUESTS and metric.value != int(self.outcome is not Outcome.SUCCEEDED):
raise ValueError("Failed request count must match outcome")
return self
class OutboundUsageEvent(BaseModel):
model_config = _CONFIG
event_id: Identifier
attempt_id: Identifier
request_id: Identifier | None
retry_of_attempt_id: Identifier | None
call_type: CallTypes
started_at: Instant
ended_at: Instant
outcome: Outcome
dimensions: Dimensions
metrics: Metrics | None
@model_validator(mode="after")
def validate_attempt_usage(self) -> Self:
_interval(self.started_at, self.ended_at, allow_empty=True)
if self.retry_of_attempt_id == self.attempt_id:
raise ValueError("An attempt cannot retry itself")
if self.metrics is not None and any(
metric.name in _CALLER_METRICS or metric.name is MetricName.SPEND for metric in self.metrics
):
raise ValueError("Outbound events own only attempt usage metrics")
if self.metrics is not None:
_check_subsets({metric.name.value: metric.value for metric in self.metrics})
return self
PricingUnit: TypeAlias = Literal["token", "second", "character", "image", "request"]
class Rate(BaseModel):
model_config = _CONFIG
measure: Identifier
unit: PricingUnit
usd_per_unit: Quantity
class PricedQuantity(BaseModel):
"""A pricing input in its billing unit. Only seconds may be fractional."""
model_config = _CONFIG
measure: Identifier
unit: PricingUnit
value: Quantity
@model_validator(mode="after")
def validate_discrete_quantity(self) -> Self:
if self.unit != "second" and self.value != self.value.to_integral_value():
raise ValueError("Discrete priced quantities require integral values")
return self
class Multiplier(BaseModel):
"""A regional or tier factor applied on top of the priced key, e.g. data_residency=eu or vertex_location=us-east5."""
model_config = _CONFIG
name: Identifier
value: Identifier
factor: Annotated[Decimal, Field(strict=True, gt=0, allow_inf_nan=False)]
class NamedCost(BaseModel):
"""A free-form extra charge by name, e.g. azure_model_router_flat_cost."""
model_config = _CONFIG
name: Identifier
amount: Quantity
class PricingSnapshot(BaseModel):
"""Retained inputs and result of authoritative compilation, reproducible after cost-map rates change.
Request-scoped inputs and overrides come from the usage row's versioned payload. Cost-map rates are
resolved at compilation, not frozen at capture. Retries reuse the committed charge and snapshot.
Component names match ``CostBreakdown``: reasoning_cost is part of output_cost, not an additional charge.
Component sums and float tolerances belong to reconciliation, not this structural contract.
"""
model_config = _CONFIG
schema_version: Literal[1] = 1
calculator_version: Identifier
model: Identifier
provider: Identifier | None
pricing_source: Literal["cost_map", "deployment", "per_request", "upstream_header"]
priced_model_key: Identifier
multipliers: tuple[Multiplier, ...] = ()
rates: tuple[Rate, ...] = ()
usage: tuple[PricedQuantity, ...] = ()
input_cost: Quantity | None = None
output_cost: Quantity | None = None
cache_read_cost: Quantity | None = None
cache_creation_cost: Quantity | None = None
reasoning_cost: Quantity | None = None
tool_usage_cost: Quantity | None = None
guardrail_cost: Quantity | None = None
additional_costs: tuple[NamedCost, ...] = ()
original_cost: Quantity | None = None
discount_percent: Quantity | None = None
discount_amount: Quantity | None = None
margin_percent: Quantity | None = None
margin_fixed_amount: Quantity | None = None
margin_total_amount: Quantity | None = None
upstream_reported_cost: Quantity | None = None
total_cost: Quantity
@model_validator(mode="after")
def validate_snapshot(self) -> Self:
rate_keys: Final = frozenset((rate.measure, rate.unit) for rate in self.rates)
quantity_keys: Final = frozenset((quantity.measure, quantity.unit) for quantity in self.usage)
if len(rate_keys) != len(self.rates):
raise ValueError("Duplicate rate measures and units")
if len(quantity_keys) != len(self.usage):
raise ValueError("Duplicate priced quantity measures and units")
if not rate_keys.issubset(quantity_keys):
raise ValueError("Every rate requires one priced quantity with the same measure and unit")
if len(frozenset(cost.name for cost in self.additional_costs)) != len(self.additional_costs):
raise ValueError("Duplicate additional cost names")
if len(frozenset(multiplier.name for multiplier in self.multipliers)) != len(self.multipliers):
raise ValueError("Duplicate multiplier names")
_check_subsets({quantity.measure: quantity.value for quantity in self.usage if quantity.unit == "token"})
return self
class CostEvent(BaseModel):
model_config = _CONFIG
event_id: Identifier
usage_event_id: Identifier
occurred_at: Instant
dimensions: Dimensions
charges: Metrics
snapshot: PricingSnapshot
@model_validator(mode="after")
def validate_charge(self) -> Self:
if len(self.charges) != 1 or self.charges[0].name is not MetricName.SPEND:
raise ValueError("A cost event requires one authoritative USD spend metric")
if self.event_id == self.usage_event_id:
raise ValueError("Cost and usage events require distinct identities")
if self.snapshot.total_cost != self.charges[0].value:
raise ValueError("Snapshot total must equal the authoritative spend")
return self
class Unpriced(BaseModel):
model_config = _CONFIG
usage_event_id: Identifier
reason: Identifier
class Measurement(BaseModel):
model_config = _CONFIG
source_event_id: Identifier
occurred_at: Instant
dimensions: Dimensions
metric: Metric
class DimensionFilter(BaseModel):
model_config = _CONFIG
name: DimensionName
values: Annotated[tuple[Identifier, ...], Field(min_length=1)]
class AggregateQuery(BaseModel):
model_config = _CONFIG
metric: MetricName
filters: tuple[DimensionFilter, ...]
group_by: tuple[DimensionName, ...]
start: Instant
end: Instant
granularity: Granularity | None
@model_validator(mode="after")
def validate_query(self) -> Self:
_interval(self.start, self.end)
if len(frozenset(item.name for item in self.filters)) != len(self.filters):
raise ValueError("Use one IN filter per dimension; filters combine with AND")
if len(frozenset(self.group_by)) != len(self.group_by):
raise ValueError("Duplicate group-by dimensions")
if any(
(instant.hour, instant.minute, instant.second, instant.microsecond) != (0, 0, 0, 0)
for instant in (self.start, self.end)
):
raise ValueError("Initial reporting queries require UTC day boundaries")
return self
class AggregateRow(BaseModel):
model_config = _CONFIG
start: Instant
end: Instant
dimensions: Dimensions
value: Quantity
@model_validator(mode="after")
def validate_bounds(self) -> Self:
_interval(self.start, self.end)
return self
def validate_aggregate_rows(rows: tuple[AggregateRow, ...]) -> tuple[AggregateRow, ...]:
"""Validate [start, end) buckets per group; overlapping tag groups are not additive."""
groups: Final = frozenset(frozenset(row.dimensions) for row in rows)
for group in groups:
ordered = sorted((row for row in rows if frozenset(row.dimensions) == group), key=lambda row: row.start)
if any(left.end > right.start for left, right in zip(ordered, ordered[1:])):
raise ValueError("Aggregate buckets overlap within a dimension group")
return rows
class AdmissionDecision(BaseModel):
model_config = _CONFIG
allowed: bool
reason: Identifier | None = None

View file

@ -129,6 +129,7 @@ MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES: Final = 1024
S3_LOG_PROMPTS_ONLY_ENV_VAR: Final = "S3_LOG_PROMPTS_ONLY"
S3_PARTITION_GRANULARITY_ENV_VAR: Final = "S3_PARTITION_GRANULARITY"
MAX_FILE_LIST_LIMIT: Final = 10000
ANTHROPIC_DEFAULT_FILE_LIST_LIMIT: Final = 20
DEFAULT_SQS_FLUSH_INTERVAL_SECONDS: Final = get_env_int("DEFAULT_SQS_FLUSH_INTERVAL_SECONDS", 10)
DEFAULT_NUM_WORKERS_LITELLM_PROXY: Final = get_env_int("DEFAULT_NUM_WORKERS_LITELLM_PROXY", 1)
GRACEFUL_SHUTDOWN_TEARDOWN_HEADROOM_SECONDS: Final = 10

View file

@ -338,6 +338,8 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
"LiteLLM_ToolTable",
"LiteLLM_TelemetryReport",
"LiteLLM_AccessGroupTable",
"LiteLLM_AccountingDelivery",
"LiteLLM_AccountingEvent",
"LiteLLM_ClaudeCodePluginTable",
"LiteLLM_MemoryTable",
"LiteLLM_AdaptiveRouterState",

View file

@ -266,6 +266,7 @@ _FINISH_REASON_MAP: Final[dict[str, OpenAIChatCompletionFinishReason]] = {
"tool_use": "tool_calls",
"refusal": "content_filter",
"compaction": "length",
"model_context_window_exceeded": "length",
# Cohere
"COMPLETE": "stop",
"ERROR_TOXIC": "content_filter",

View file

@ -842,6 +842,7 @@ class Logging(LiteLLMLoggingBaseClass):
self._async_success_enqueued: bool = False
self._sync_success_scheduled: bool = False
self.on_detached_stream_failure: Callable[[Exception], Awaitable[None]] | None = None
self._stream_failure_claimed: bool = False
self.shadow_eval_request_snapshot: GuardrailRequestSnapshot | None = None
def copy_for_new_call(self, litellm_call_id: str, start_time: datetime.datetime) -> "Logging":
@ -2561,6 +2562,12 @@ class Logging(LiteLLMLoggingBaseClass):
},
)
def claim_stream_failure_report(self) -> bool:
if self._stream_failure_claimed:
return False
self._stream_failure_claimed = True
return True
def record_assembled_response_for_failure(self, assembled: ModelResponse) -> None:
"""Bill a fully streamed response on the failure log when a post-call hook rejects it."""
usage: Final = getattr(assembled, "usage", None)

View file

@ -2,15 +2,16 @@
## Translates OpenAI call to Anthropic `/v1/messages` format
import asyncio
from collections import deque
from collections.abc import AsyncGenerator, Iterator, Mapping
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator, Mapping
from typing import TYPE_CHECKING, Any, Final
import anyio
from pydantic import ConfigDict, field_validator
from litellm import verbose_logger
from litellm._logging import redact_internal_details_from_client_message
from litellm._uuid import uuid
from litellm.exceptions import MidStreamFallbackError
from litellm.exceptions import APIError, MidStreamFallbackError
from litellm.litellm_core_utils.prompt_templates.common_utils import (
encrypted_reasoning_signature,
)
@ -22,6 +23,7 @@ from litellm.llms.anthropic.messages.native.utils import (
from litellm.llms.anthropic.messages.safeguards import StreamedSafeguardResults
from litellm.llms.anthropic.messages.utils import anthropic_sse_frame
from litellm.responses.streaming_iterator import (
map_stream_error_to_exception,
partial_responses_incomplete_event_on_disconnect,
stream_error_status_and_message,
)
@ -130,10 +132,12 @@ class AnthropicResponsesStreamWrapper:
) -> None:
self.responses_stream = responses_stream
self.model = model
self.litellm_logging_obj = litellm_logging_obj
self.safeguards_evaluator: Final = safeguards_evaluator
self._message_id: str = f"msg_{uuid.uuid4()}"
if litellm_logging_obj is not None:
litellm_logging_obj.record_streamed_anthropic_message_id(self._message_id)
self._unreported_failure: Exception | None = None
self._current_block_index: int = -1
# Map item_id -> content_block_index so we can stop the right block later
self._item_id_to_block_index: dict[str, int] = {}
@ -377,16 +381,24 @@ class AnthropicResponsesStreamWrapper:
if event_type == "response.failed":
failed: Final = _FailedResponseEvent.model_validate(event)
status_code, message = stream_error_status_and_message(
failed.response.error if failed.response is not None else None
)
upstream_error: Final = failed.response.error if failed.response is not None else None
status_code, message = stream_error_status_and_message(upstream_error)
verbose_logger.error(
"AnthropicResponsesStreamWrapper: upstream Responses stream for %s failed (%s): %s",
self.model,
status_code,
message,
)
self._fail_stream(status_code, message)
self._fail_stream(
status_code,
message,
failure=map_stream_error_to_exception(
upstream_error,
self.model,
getattr(self.responses_stream, "custom_llm_provider", None) or "",
logging_obj=self.litellm_logging_obj,
),
)
return
# ---- response completed -> message_delta + message_stop ----
@ -439,10 +451,36 @@ class AnthropicResponsesStreamWrapper:
self._sent_message_stop = True
return
def _fail_stream(self, status_code: int, message: str) -> None:
def _fail_stream(self, status_code: int, message: str, failure: Exception | None = None) -> None:
self._stream_failed = True
self._unreported_failure = failure or APIError(
status_code=status_code,
message=message,
llm_provider=getattr(self.responses_stream, "custom_llm_provider", None),
model=self.model,
)
self._chunk_queue.append(_anthropic_error_chunk(status_code, message))
async def _report_unreported_failure(self) -> None:
failure: Final = self._unreported_failure
if failure is None:
return
self._unreported_failure = None
on_failure: Final[Callable[[Exception], Awaitable[None]] | None] = getattr(
self.litellm_logging_obj, "_on_detached_stream_failure", None
)
if on_failure is None:
return
try:
with anyio.CancelScope(shield=True):
await on_failure(failure)
except Exception as hook_failure: # noqa: BLE001 # a failing proxy hook must not hide the error event from the client
verbose_logger.warning(
"AnthropicResponsesStreamWrapper: stream failure hook raised: %s(%s)",
type(hook_failure).__name__,
hook_failure,
)
def __aiter__(self) -> "AnthropicResponsesStreamWrapper":
return self
@ -450,6 +488,7 @@ class AnthropicResponsesStreamWrapper:
if self._chunk_queue:
return self._chunk_queue.popleft()
if self._stream_failed:
await self._report_unreported_failure()
raise StopAsyncIteration
if not self._sent_message_start:
@ -478,7 +517,7 @@ class AnthropicResponsesStreamWrapper:
verbose_logger.exception(
"AnthropicResponsesStreamWrapper: upstream Responses stream for %s failed", self.model
)
self._fail_stream(*_failure_status_and_message(e))
self._fail_stream(*_failure_status_and_message(e), failure=_original_failure(e))
if not self._chunk_queue and not self._sent_message_stop and not self._stream_failed:
verbose_logger.error(
@ -498,8 +537,14 @@ class AnthropicResponsesStreamWrapper:
async def _sse_frames(self) -> AsyncGenerator[bytes, None]:
safeguard_results: Final = StreamedSafeguardResults(self.safeguards_evaluator)
async for chunk in self:
yield anthropic_sse_frame(await safeguard_results.observe(chunk))
try:
async for chunk in self:
yield anthropic_sse_frame(await safeguard_results.observe(chunk))
finally:
from litellm.llms.anthropic.messages.native.streaming_iterator import closing_superseded_attempt
if not closing_superseded_attempt():
await self._report_unreported_failure()
def partial_response_on_disconnect(self) -> "ResponseIncompleteEvent | None":
return partial_responses_incomplete_event_on_disconnect(self.responses_stream)

View file

@ -1,6 +1,7 @@
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Iterator, Mapping
from typing import TYPE_CHECKING, Any, Union
from collections.abc import AsyncGenerator, Callable, Iterable, Iterator, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final, Union
import httpx
from openai.types.file_deleted import FileDeleted
@ -38,6 +39,22 @@ else:
UserAPIKeyAuth = Any
def _routes_nowhere(_model_name: str) -> bool:
return False
@dataclass(frozen=True, slots=True)
class FileListModelScope:
model_mapping_keys: frozenset[str]
routes_stored_model_name: Callable[[str], bool] = _routes_nowhere
def holds(self, model_mapping_keys: Iterable[str], stored: bool) -> bool:
mapped_keys: Final = frozenset(model_mapping_keys)
return not self.model_mapping_keys.isdisjoint(mapped_keys) or (
stored and any(map(self.routes_stored_model_name, mapped_keys))
)
class BaseFileUploadStream(ABC):
"""Re-iterable request body that yields an upload's bytes lazily.
@ -274,6 +291,8 @@ class BaseFileEndpoints(ABC):
user_api_key_dict: UserAPIKeyAuth,
limit: int | None = None,
after: str | None = None,
model_scope: FileListModelScope | None = None,
before: str | None = None,
**data: dict,
) -> FileListPage:
pass

View file

@ -4,6 +4,7 @@ import json
import logging
import math
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from functools import lru_cache
from itertools import chain
@ -136,7 +137,7 @@ from litellm.proxy.utils import ( # noqa: F401 # legacy module exports
_check_and_merge_model_level_guardrails, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
check_and_merge_model_level_guardrails,
)
from litellm.responses.streaming_iterator import partial_responses_incomplete_event_on_disconnect
from litellm.responses.streaming_iterator import partial_responses_incomplete_event_on_disconnect, spawn_logging_task
from litellm.router import Router
from litellm.router_utils.add_retry_fallback_headers import get_hidden_params_dict
from litellm.router_utils.auto_router_model_naming import AUTO_ROUTER_MODEL_PREFIX
@ -2006,6 +2007,49 @@ def _content_text(fields: Mapping[str, object]) -> str:
return str(fields.get("content", ""))
async def _no_stream_failure_to_release() -> None:
return None
@dataclass(slots=True)
class _ClientStreamFailureReport:
logging_obj: LiteLLMLoggingObj
report: Callable[[Exception], Awaitable[None]]
held: Exception | None = None
@classmethod
def hold_while_streaming(cls, logging_obj: LiteLLMLoggingObj | None) -> Callable[[], Awaitable[None]]:
if logging_obj is None or logging_obj.on_detached_stream_failure is None:
return _no_stream_failure_to_release
return cls(logging_obj, logging_obj.on_detached_stream_failure)._start_holding()
def _start_holding(self) -> Callable[[], Awaitable[None]]:
self.logging_obj.on_detached_stream_failure = self.hold
return self.release
async def hold(self, failure: Exception) -> None:
if self.held is None:
self.held = failure
async def release(self) -> None:
self.logging_obj.on_detached_stream_failure = self.report
if self.held is None:
return
await asyncio.shield(
spawn_logging_task(
asyncio.get_running_loop(), self._report_held(self.held), task_name="stream failure report"
)
)
async def _report_held(self, failure: Exception) -> None:
try:
await self.report(failure)
except Exception as hook_failure: # noqa: BLE001 # a failing failure hook must not skip the stream cleanup
verbose_proxy_logger.warning(
"Stream failure hook raised: %s(%s)", type(hook_failure).__name__, sanitize_for_log(hook_failure)
)
class ProxyBaseLLMRequestProcessing:
def __init__(self, data: dict, served_model: str | None = None):
self.data = data
@ -3498,6 +3542,8 @@ class ProxyBaseLLMRequestProcessing:
request_data: Final = self.data
async def _on_detached_stream_failure(exc: Exception) -> None:
if not logging_obj.claim_stream_failure_report():
return
try:
await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
@ -4448,11 +4494,15 @@ class ProxyBaseLLMRequestProcessing:
client_disconnected = False
delivered_chunk = False
recent_tail = SSE_STREAM_START_TAIL # rebind-ok: rolling window over the yielded bytes
stream_logging_obj: Final[object] = request_data.get("litellm_logging_obj") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # legacy request data is narrowed to Logging below
guarded_stream: Final[AsyncGenerator[object, None]] = proxy_logging_obj.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=response,
request_data=request_data,
)
release_stream_failure: Final = _ClientStreamFailureReport.hold_while_streaming(
stream_logging_obj if isinstance(stream_logging_obj, LiteLLMLoggingObj) else None
)
try:
str_so_far = ""
async for chunk in guarded_stream:
@ -4523,10 +4573,18 @@ class ProxyBaseLLMRequestProcessing:
verbose_proxy_logger.exception(
"litellm.proxy.proxy_server.async_data_generator(): Exception occured - %s", e
)
transformed_exception: Final = await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
original_exception=e,
request_data=request_data,
already_reported: Final = (
isinstance(stream_logging_obj, LiteLLMLoggingObj)
and not stream_logging_obj.claim_stream_failure_report()
)
transformed_exception: Final = (
None
if already_reported
else await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
original_exception=e,
request_data=request_data,
)
)
if transformed_exception is not None:
e = transformed_exception
@ -4549,15 +4607,18 @@ class ProxyBaseLLMRequestProcessing:
seal: Final = "" if seal_open_frame is None else seal_open_frame(recent_tail)
yield seal + error_frame if seal else error_frame
finally:
await ProxyBaseLLMRequestProcessing.finalize_streaming_generator_cleanup(
request=request,
request_data=request_data,
response=response,
stream_completed=stream_completed,
client_disconnected=client_disconnected,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
)
try:
await release_stream_failure()
finally:
await ProxyBaseLLMRequestProcessing.finalize_streaming_generator_cleanup(
request=request,
request_data=request_data,
response=response,
stream_completed=stream_completed,
client_disconnected=client_disconnected,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
)
@staticmethod
def async_sse_data_generator(

View file

@ -305,6 +305,11 @@ def iter_message_text(data: Mapping[str, object]) -> Iterator[str]:
yield from _iter_text_parts_in_content(message.get("content"))
def _is_tool_message(message: object) -> bool:
fields: Final = _text_object(message)
return fields is not None and str(fields.get("role") or "").lower() == "tool"
def _call_item(item: object) -> tuple[str, object] | None:
"""Return ``(field, value)`` for a Responses tool-call input item, else None."""
fields: Final = _text_object(item)
@ -316,11 +321,14 @@ def walk_user_text(
data: dict[str, object],
visit: Callable[[str], str],
visit_tool: Callable[[JsonValue], JsonValue] | None = None,
*,
skip_tool_message: bool = False,
) -> int:
"""Rewrite every text fragment in place via ``visit``.
Mutates ``data["messages"]`` and ``data["input"]`` (including tool payloads), ``data["system"]`` and
``data["instructions"]``. Returns the number
``data["instructions"]``. ``skip_tool_message`` leaves ``role: tool`` messages and ``tool_result``
blocks as sent. Returns the number
of fragments visited so callers can short-circuit when nothing was
inspected.
"""
@ -353,6 +361,8 @@ def walk_user_text(
def _rewrite_part(part: Mapping[str, object], native: bool) -> Mapping[str, object]:
nonlocal visited
if skip_tool_message and part.get("type") == "tool_result":
return part
text: Final = _part_text(part)
rewritten: Final = {**part, **({"text": visit(text)} if text is not None else {})}
if text is not None:
@ -384,18 +394,17 @@ def walk_user_text(
return content
messages: Final = data.get("messages")
if isinstance(messages, list):
for message in messages:
if not isinstance(message, dict):
continue
if "content" in message:
message["content"] = _rewrite_content(message["content"])
calls = message.get("tool_calls")
if isinstance(calls, list):
for call in calls:
function = call.get("function") if isinstance(call, dict) else None
if isinstance(function, dict) and "arguments" in function:
function["arguments"] = _rewrite_arguments(function["arguments"])
for message in _object_list(messages) or ():
if (skip_tool_message and _is_tool_message(message)) or not isinstance(message, dict):
continue
if "content" in message:
message["content"] = _rewrite_content(message["content"])
calls = message.get("tool_calls")
if isinstance(calls, list):
for call in calls:
function = call.get("function") if isinstance(call, dict) else None
if isinstance(function, dict) and "arguments" in function:
function["arguments"] = _rewrite_arguments(function["arguments"])
if "system" in data:
data["system"] = _rewrite_content(data["system"]) # rebind-ok: preserve the native hook's in-place contract

View file

@ -6,9 +6,12 @@
######################################################################
import asyncio
import functools
import itertools
import traceback
from collections.abc import Mapping, Sequence
from typing import Any, BinaryIO, Final, TypedDict, cast, get_args
from collections.abc import Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, BinaryIO, Final, NoReturn, TypedDict, cast, get_args
import httpx
from fastapi import (
@ -28,12 +31,13 @@ from typing_extensions import ReadOnly
import litellm
from litellm import CreateFileRequest, get_secret_str
from litellm._logging import verbose_proxy_logger
from litellm.constants import ANTHROPIC_DEFAULT_FILE_LIST_LIMIT
from litellm.litellm_core_utils.cloud_storage_security import (
is_managed_cloud_storage_uri,
)
from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket
from litellm.llms.base_llm.files.litellm_db_storage_backend import LITELLM_DB_STORAGE_BACKEND_NAME
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints, FileListModelScope
from litellm.llms.base_llm.managed_resources.isolation import build_list_page
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
@ -112,6 +116,9 @@ from litellm.types.llms.openai import (
OpenAIFileObject,
OpenAIFilesPurpose,
)
from litellm.types.proxy.auth.user_api_key_auth import user_api_key_has_admin_view
from litellm.types.router import Deployment
from litellm.types.utils import LlmProviders
router: Final = APIRouter()
@ -1673,6 +1680,185 @@ def _as_file_list_page(response: object) -> object:
return FileListPage.model_validate(page)
async def _list_owned_managed_files(
managed_files_obj: BaseFileEndpoints,
user_api_key_dict: UserAPIKeyAuth,
purpose: str | None,
limit: int | None,
after: str | None,
before: str | None,
model_scope: FileListModelScope | None,
) -> FileListPage:
return await managed_files_obj.afile_list(
purpose=purpose,
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
user_api_key_dict=user_api_key_dict,
limit=limit,
after=after,
before=before,
model_scope=model_scope,
)
@dataclass(frozen=True, slots=True)
class _UnscopedFileList:
status_code: int
detail: str
@dataclass(frozen=True, slots=True)
class _ListedFiles:
model_scope: FileListModelScope | None
custom_llm_provider: str | None
def _raise_unscoped_file_list(failure: _UnscopedFileList) -> NoReturn:
raise HTTPException(status_code=failure.status_code, detail=failure.detail)
def _single_target_model_name(target_model_names: str) -> str | _UnscopedFileList:
target_model_names_list: Final = target_model_names.split(",")
if len(target_model_names_list) != 1:
return _UnscopedFileList(
status_code=400,
detail="target_model_names on list files must be a list of one model name. Example: ['gpt-4o']",
)
return target_model_names_list[0]
async def _requested_custom_llm_provider(request: Request, provider: str | None) -> str | None:
return (
provider
or get_custom_llm_provider_from_request_headers(request=request)
or get_custom_llm_provider_from_request_query(request=request)
or await get_custom_llm_provider_from_request_body(request=request)
)
def _default_file_list_limit(custom_llm_provider: str | None) -> int | None:
return ANTHROPIC_DEFAULT_FILE_LIST_LIMIT if custom_llm_provider == LlmProviders.ANTHROPIC.value else None
def _deployment_model_mapping_keys(deployment: Deployment) -> frozenset[str]:
return frozenset(key for key in (deployment.model_name, deployment.model_info.id) if key is not None)
def _model_mapping_keys(deployments: Iterable[Deployment]) -> frozenset[str]:
return frozenset(itertools.chain.from_iterable(map(_deployment_model_mapping_keys, deployments)))
def _deployments_serving_model(llm_router: Router, model: str) -> tuple[Deployment, ...]:
deployment: Final = llm_router.get_deployment(model_id=model) if llm_router.has_model_id(model) else None
if deployment is not None:
return (deployment,)
return tuple(Deployment.model_validate(listed) for listed in llm_router.get_model_list(model_name=model) or ())
def _resolved_deployment_provider(deployment: Deployment) -> str | None:
try:
return litellm.get_llm_provider(
model=deployment.litellm_params.model,
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
)[1]
except litellm.BadRequestError:
return None
def _deployments_on_provider(llm_router: Router | None, custom_llm_provider: str) -> Iterator[Deployment]:
if llm_router is None:
return iter(())
return (
deployment
for deployment_id in llm_router.get_model_ids()
if (deployment := llm_router.get_deployment(model_id=deployment_id)) is not None
and _resolved_deployment_provider(deployment) == custom_llm_provider
)
def _routed_model_name_on_provider(
llm_router: Router, custom_llm_provider: str, provider_model_names: frozenset[str], model_name: str
) -> bool:
if model_name in llm_router.model_names:
return model_name in provider_model_names
return llm_router.pattern_router.routes_to_provider(model_name, custom_llm_provider)
def _stored_model_name_routes_to_provider(
llm_router: Router, custom_llm_provider: str, provider_model_names: frozenset[str], model_name: str
) -> bool:
routing_group: Final = llm_router.get_routing_group(model_name)
routed_model_names: Final = (
tuple(routing_group.models) if routing_group is not None else (llm_router.routable_model_group(model_name),)
)
return any(
_routed_model_name_on_provider(llm_router, custom_llm_provider, provider_model_names, routed_model_name)
for routed_model_name in routed_model_names
)
def _files_on_provider(llm_router: Router | None, custom_llm_provider: str) -> _ListedFiles | _UnscopedFileList:
from litellm.proxy.pass_through_endpoints.managed_id_rewriter import passthrough_file_model_id
if custom_llm_provider not in litellm.provider_list:
return _UnscopedFileList(status_code=400, detail=f"Unknown provider '{custom_llm_provider}' for listing files.")
passthrough_model_id: Final = passthrough_file_model_id(custom_llm_provider)
deployments: Final = tuple(_deployments_on_provider(llm_router, custom_llm_provider))
model_mapping_keys: Final = _model_mapping_keys(deployments) | frozenset(
(passthrough_model_id,) if passthrough_model_id is not None else ()
)
if llm_router is None:
return _ListedFiles(FileListModelScope(model_mapping_keys), custom_llm_provider)
return _ListedFiles(
FileListModelScope(
model_mapping_keys,
routes_stored_model_name=functools.partial(
_stored_model_name_routes_to_provider,
llm_router,
custom_llm_provider,
frozenset(deployment.model_name for deployment in deployments),
),
),
custom_llm_provider,
)
def _files_serving_model(llm_router: Router, model: str) -> _ListedFiles:
deployments: Final = _deployments_serving_model(llm_router, model)
providers: Final = frozenset(map(_resolved_deployment_provider, deployments))
return _ListedFiles(
FileListModelScope(_model_mapping_keys(deployments)), next(iter(providers)) if len(providers) == 1 else None
)
async def _listed_files(
llm_router: Router | None,
user_api_key_dict: UserAPIKeyAuth,
custom_llm_provider: str | None,
target_model_names: str | None,
routed_model: str | None,
) -> _ListedFiles | _UnscopedFileList:
if routed_model is not None and llm_router is not None:
return _files_serving_model(llm_router, routed_model)
if target_model_names:
target_model_name: Final = _single_target_model_name(target_model_names)
if isinstance(target_model_name, _UnscopedFileList):
return target_model_name
if llm_router is None:
return _UnscopedFileList(
status_code=500, detail="LLM Router not initialized. Ensure models added to proxy."
)
await get_authorized_credentials_for_model(
llm_router=llm_router,
model_id=target_model_name,
user_api_key_dict=user_api_key_dict,
operation_context="file list",
)
return _files_serving_model(llm_router, target_model_name)
if custom_llm_provider is None:
return _ListedFiles(None, None)
return _files_on_provider(llm_router, custom_llm_provider)
@router.get(
"/{provider}/v1/files",
dependencies=[Depends(user_api_key_auth)],
@ -1697,6 +1883,8 @@ async def list_files(
purpose: str | None = None,
limit: int | None = None,
after: str | None = None,
after_id: str | None = None,
before_id: str | None = None,
):
"""
Returns information about a specific file. that can be used across - Assistants API, Batch API
@ -1739,6 +1927,10 @@ async def list_files(
)
response: Any | None = None
owned_files_hook: Final = proxy_logging_obj.get_proxy_hook("managed_files")
list_owned_files_only: Final = litellm.require_managed_files is True and not user_api_key_has_admin_view(
user_api_key_dict
)
# Check for model-based credential routing (no file_id encoding check for list)
should_route, model_used, _, credentials = await handle_model_based_routing(
@ -1750,7 +1942,36 @@ async def list_files(
check_file_id_encoding=False,
)
if should_route and credentials is not None:
if list_owned_files_only:
if after and after_id and after != after_id:
raise HTTPException(
status_code=400,
detail="Pass the list cursor as either 'after' or 'after_id', not both with different ids.",
)
if not isinstance(owned_files_hook, BaseFileEndpoints):
raise HTTPException(
status_code=500,
detail="Managed file listing is unavailable, so require_managed_files cannot scope the list to the caller.",
)
listed_files: Final = await _listed_files(
llm_router=llm_router,
user_api_key_dict=user_api_key_dict,
custom_llm_provider=await _requested_custom_llm_provider(request=request, provider=provider),
target_model_names=target_model_names,
routed_model=model_used if should_route else None,
)
if isinstance(listed_files, _UnscopedFileList):
_raise_unscoped_file_list(listed_files)
response = await _list_owned_managed_files( # rebind-ok: each dispatch branch binds response
managed_files_obj=owned_files_hook,
user_api_key_dict=user_api_key_dict,
purpose=purpose,
limit=limit if limit is not None else _default_file_list_limit(listed_files.custom_llm_provider),
after=after or after_id,
before=before_id,
model_scope=listed_files.model_scope,
)
elif should_route and credentials is not None:
# Use model-based routing with credentials from config
prepare_data_with_credentials(data=data, credentials=credentials, include_internal_credentials=True)
response = await litellm.afile_list(
@ -1762,12 +1983,9 @@ async def list_files(
verbose_proxy_logger.debug("Listed files using model: %s", model_used)
elif target_model_names and isinstance(target_model_names, str):
target_model_names_list: Final = target_model_names.split(",")
if len(target_model_names_list) != 1:
raise HTTPException(
status_code=400,
detail="target_model_names on list files must be a list of one model name. Example: ['gpt-4o']",
)
target_model_name: Final = _single_target_model_name(target_model_names)
if isinstance(target_model_name, _UnscopedFileList):
_raise_unscoped_file_list(target_model_name)
if llm_router is None:
raise HTTPException(
status_code=500,
@ -1775,7 +1993,7 @@ async def list_files(
)
credentials = await get_authorized_credentials_for_model(
llm_router=llm_router,
model_id=target_model_names_list[0],
model_id=target_model_name,
user_api_key_dict=user_api_key_dict,
operation_context="file list",
)
@ -1786,20 +2004,17 @@ async def list_files(
**data,
)
else:
custom_llm_provider: Final = (
provider
or get_custom_llm_provider_from_request_headers(request=request)
or get_custom_llm_provider_from_request_query(request=request)
or await get_custom_llm_provider_from_request_body(request=request)
)
custom_llm_provider: Final = await _requested_custom_llm_provider(request=request, provider=provider)
managed_files_obj: Final = proxy_logging_obj.get_proxy_hook("managed_files")
if custom_llm_provider is None and isinstance(managed_files_obj, BaseFileEndpoints):
response = await managed_files_obj.afile_list(
purpose=purpose,
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
response = await _list_owned_managed_files( # rebind-ok: each dispatch branch binds response
managed_files_obj=managed_files_obj,
user_api_key_dict=user_api_key_dict,
purpose=purpose,
limit=limit,
after=after,
before=None,
model_scope=None,
)
else:
resolved_custom_llm_provider: Final = custom_llm_provider or "openai"

View file

@ -242,6 +242,12 @@ def _passthrough_sentinel_model_id(provider: str) -> str:
return f"_passthrough_{provider}"
def passthrough_file_model_id(provider: str) -> str | None:
if (provider, "/v1/files") not in _LIST_ROUTE_TABLE:
return None
return _passthrough_sentinel_model_id(provider)
# Key under which the provider marker is stored in a file row's model_mappings.
# Its value lands in flat_model_file_ids (built from model_mappings.values()),
# giving the file table a DB-queryable provider scope it otherwise lacks.

View file

@ -1163,6 +1163,7 @@ model LiteLLM_ManagedFileTable {
@@index([unified_file_id])
@@index([flat_model_file_ids], type: Gin)
@@index([team_id, created_at(sort: Desc)])
@@index([created_by, created_at(sort: Desc)])
}
model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use the
@ -2060,3 +2061,73 @@ model LiteLLM_TelemetryReport {
@@index([window_end])
@@index([created_at])
}
enum AccountingEventKind {
inbound_usage
outbound_usage
cost
}
enum AccountingDeliveryState {
pending
applied
parked
}
enum AccountingDeliveryTarget {
LiteLLM_VerificationToken
LiteLLM_UserTable
LiteLLM_TeamTable
LiteLLM_TeamMembership
LiteLLM_OrganizationTable
LiteLLM_OrganizationMembership
LiteLLM_EndUserTable
LiteLLM_AgentsTable
LiteLLM_TagTable
LiteLLM_ProjectTable
LiteLLM_ModelAccessGroupBudgetTable
LiteLLM_BudgetTable
LiteLLM_BudgetWindowSpend
LiteLLM_DailyUserSpend
LiteLLM_DailyTeamSpend
LiteLLM_DailyOrganizationSpend
LiteLLM_DailyEndUserSpend
LiteLLM_DailyAgentSpend
LiteLLM_DailyTagSpend
LiteLLM_DailyModelUsage
LiteLLM_DailyToolSpend
LiteLLM_AutoRouterDailySpend
}
// Durable accounting ledger. One row per usage or cost event; payload holds the
// private event envelope versioned by schema_version. Usage rows retain request-scoped
// pricing context; cost rows include resolved rates and the finalized pricing snapshot.
model LiteLLM_AccountingEvent {
event_id String @id
kind AccountingEventKind
source_event_id String?
schema_version Int
payload_hash String
occurred_at DateTime
recorded_at DateTime @default(now())
payload Json
@@index([recorded_at])
@@index([kind, source_event_id])
}
// One row per (event, target table, version): pending work for the projector and,
// once applied in the same transaction as the target write, the receipt that stops a retry.
model LiteLLM_AccountingDelivery {
event_id String
target AccountingDeliveryTarget
version Int
state AccountingDeliveryState @default(pending)
attempts Int @default(0)
next_attempt_at DateTime @default(now())
applied_at DateTime?
last_error String?
@@id([event_id, target, version])
@@index([target, state, next_attempt_at])
}

View file

@ -41,7 +41,11 @@ from litellm.exceptions import MidStreamFallbackError, RateLimitError
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.exception_mapping_utils import with_deployment_api_base
from litellm.litellm_core_utils.internal_call_metadata import without_budget_reservation
from litellm.litellm_core_utils.internal_call_metadata import (
is_background_response,
requests_background,
without_budget_reservation,
)
from litellm.litellm_core_utils.litellm_logging import BilledRoundsSpend
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.llm_response_utils.get_api_base import get_api_base
@ -65,7 +69,7 @@ from litellm.types.llms.openai import (
ResponsesAPIStreamingResponse,
)
from litellm.types.responses.main import GenericResponseOutputItem, OutputText
from litellm.types.utils import CallTypes
from litellm.types.utils import CallTypes, ModelResponse
from litellm.utils import async_post_call_success_deployment_hook
if TYPE_CHECKING:
@ -288,7 +292,7 @@ def stream_error_status_and_message(error_obj: object) -> tuple[int, str]:
return _status_code_for_error_fields(error_type, error_code), message
def _map_stream_error_to_exception(
def map_stream_error_to_exception(
error_obj: object,
model: str,
custom_llm_provider: str,
@ -578,6 +582,7 @@ class BaseResponsesAPIStreamingIterator:
self.request_data.get("input"),
self.request_data,
self._generated_content + self._generated_tool_arguments,
use_default_image_token_count=False,
)
if _estimate_wanted
else None
@ -731,7 +736,7 @@ class BaseResponsesAPIStreamingIterator:
self._earlier_rounds_spend = earlier
def _map_error_event_exception(self, error_obj: object) -> Exception:
return _map_stream_error_to_exception(
return map_stream_error_to_exception(
error_obj,
self.model or "",
self.custom_llm_provider or "",
@ -981,6 +986,39 @@ class BaseResponsesAPIStreamingIterator:
except Exception:
pass
def _record_partial_usage_for_failure(self) -> None:
generated_text: Final = self._generated_content + self._generated_tool_arguments
if (
not generated_text
or requests_background(self.request_data)
or is_background_response(self._created_response)
or self._completed_response_logged
or self.logging_obj.model_call_details.get("combined_usage_object") is not None
):
return
estimate: Final = _estimate_usage_safely(
self.model or "",
self.request_data.get("input"),
self.request_data,
generated_text,
use_default_image_token_count=True,
)
if estimate is None:
return
usage: Final = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( # pyright: ignore[reportPrivateUsage] # same shared transform the success path above uses
estimate
)
served_tier: Final[object] = getattr(self._created_response, "service_tier", None)
assembled: Final = ModelResponse(
model=self.model, usage=usage, service_tier=served_tier if isinstance(served_tier, str) else None
)
if self._earlier_rounds_spend is None:
self.logging_obj.record_assembled_response_for_failure(assembled)
return
self.logging_obj.record_failure_after_earlier_rounds(
self._earlier_rounds_spend, usage, self.logging_obj.response_cost_calculator(result=assembled) or 0.0
)
def _handle_failure(self, exception: Exception):
"""
Trigger failure handlers before bubbling the exception.
@ -990,6 +1028,7 @@ class BaseResponsesAPIStreamingIterator:
if self._failure_handled:
return
self._failure_handled = True
self._record_partial_usage_for_failure()
traceback_exception: Final = traceback.format_exc()
end_time: Final = datetime.now()
@ -1060,15 +1099,20 @@ class BaseResponsesAPIStreamingIterator:
the same shape the success handler assembles for a natural end of stream.
Its response is the response.created payload (it carries the served
service_tier) plus the text and tool arguments relayed so far, with counted
usage since the provider never sent any. None when nothing was relayed yet
or the stream already reached its terminal event and billed itself.
usage since the provider never sent any. None when nothing was relayed yet,
the stream already reached its terminal event and billed itself, or its failure
log already billed the relayed output.
"""
created: Final = self._created_response
generated: Final = self._generated_content + self._generated_tool_arguments
if self.completed_response is not None or created is None or not generated:
if created is None or not generated or self._billed_without_disconnect():
return None
usage: Final = _estimate_usage_safely(
self.model or "", self.request_data.get("input"), self.request_data, generated
self.model or "",
self.request_data.get("input"),
self.request_data,
generated,
use_default_image_token_count=False,
)
if usage is None:
return None
@ -1082,6 +1126,9 @@ class BaseResponsesAPIStreamingIterator:
stamp_responses_usage_cost(partial, self.logging_obj)
return ResponseIncompleteEvent(type=ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, response=partial)
def _billed_without_disconnect(self) -> bool:
return self.completed_response is not None or self._failure_handled
def _note_yielded_event(self, event: ResponsesAPIStreamingResponse) -> None:
self._yielded_first_chunk = True
if event.type not in PRE_OUTPUT_LIFECYCLE_EVENT_TYPES:
@ -1218,6 +1265,9 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator):
raise StopAsyncIteration from e
self._raise_for_transport_error(e)
except httpx.TimeoutException as e:
if self.completed_response is not None:
self.finished = True
raise StopAsyncIteration from e
self._raise_for_transport_timeout(e)
except httpx.HTTPError as e:
# Handle HTTP errors
@ -1303,6 +1353,9 @@ class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator):
raise StopIteration from e
self._raise_for_transport_error(e)
except httpx.TimeoutException as e:
if self.completed_response is not None:
self.finished = True
raise StopIteration from e
self._raise_for_transport_timeout(e)
except httpx.HTTPError as e:
# Handle HTTP errors
@ -1727,6 +1780,8 @@ def _estimate_usage_from_text(
request_input: object,
responses_api_request: Mapping[str, object],
generated_text: str,
*,
use_default_image_token_count: bool = False,
) -> ResponseAPIUsage:
messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( # pyright: ignore[reportUnknownMemberType] # the transformer's signature is partially untyped
input=request_input, # pyright: ignore[reportArgumentType] # the raw Responses API input is a str or ResponseInputParam list, matching the helper's declared union
@ -1734,7 +1789,7 @@ def _estimate_usage_from_text(
only_since_compaction=True,
)
input_tokens: Final = litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is untyped
model=model, messages=messages
model=model, messages=messages, use_default_image_token_count=use_default_image_token_count
)
output_tokens: Final = litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is untyped
model=model, text=generated_text, count_response_tokens=True
@ -1751,6 +1806,8 @@ def _estimate_usage_safely(
request_input: object,
responses_api_request: Mapping[str, object],
generated_text: str,
*,
use_default_image_token_count: bool,
) -> ResponseAPIUsage | None:
try:
return _estimate_usage_from_text(
@ -1758,6 +1815,7 @@ def _estimate_usage_safely(
request_input=request_input,
responses_api_request=responses_api_request,
generated_text=generated_text,
use_default_image_token_count=use_default_image_token_count,
)
except Exception as e:
verbose_logger.debug("Could not estimate usage from stream text, billing $0: %s", e)
@ -2276,7 +2334,7 @@ class _ResponsesWsTurn:
)
if failed_event is None:
return None
return _map_stream_error_to_exception(
return map_stream_error_to_exception(
_ws_event_error(failed_event), model, custom_llm_provider, logging_obj=connection_logging_obj
)

View file

@ -6456,6 +6456,9 @@ class Router:
hop_kwargs: Final = mid_stream_fallback_hop_kwargs(
model=model, original_generic_function=original_generic_function, controls=controls, kwargs=kwargs
)
shared_logging: Final = kwargs.get("litellm_logging_obj")
if isinstance(shared_logging, LiteLLMLogging):
shared_logging.carry_failed_attempt_usage()
response: Final = await self._ageneric_api_call_with_fallbacks_helper(
model=model, original_generic_function=original_generic_function, **kwargs
)

View file

@ -1163,6 +1163,7 @@ model LiteLLM_ManagedFileTable {
@@index([unified_file_id])
@@index([flat_model_file_ids], type: Gin)
@@index([team_id, created_at(sort: Desc)])
@@index([created_by, created_at(sort: Desc)])
}
model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use the
@ -2060,3 +2061,73 @@ model LiteLLM_TelemetryReport {
@@index([window_end])
@@index([created_at])
}
enum AccountingEventKind {
inbound_usage
outbound_usage
cost
}
enum AccountingDeliveryState {
pending
applied
parked
}
enum AccountingDeliveryTarget {
LiteLLM_VerificationToken
LiteLLM_UserTable
LiteLLM_TeamTable
LiteLLM_TeamMembership
LiteLLM_OrganizationTable
LiteLLM_OrganizationMembership
LiteLLM_EndUserTable
LiteLLM_AgentsTable
LiteLLM_TagTable
LiteLLM_ProjectTable
LiteLLM_ModelAccessGroupBudgetTable
LiteLLM_BudgetTable
LiteLLM_BudgetWindowSpend
LiteLLM_DailyUserSpend
LiteLLM_DailyTeamSpend
LiteLLM_DailyOrganizationSpend
LiteLLM_DailyEndUserSpend
LiteLLM_DailyAgentSpend
LiteLLM_DailyTagSpend
LiteLLM_DailyModelUsage
LiteLLM_DailyToolSpend
LiteLLM_AutoRouterDailySpend
}
// Durable accounting ledger. One row per usage or cost event; payload holds the
// private event envelope versioned by schema_version. Usage rows retain request-scoped
// pricing context; cost rows include resolved rates and the finalized pricing snapshot.
model LiteLLM_AccountingEvent {
event_id String @id
kind AccountingEventKind
source_event_id String?
schema_version Int
payload_hash String
occurred_at DateTime
recorded_at DateTime @default(now())
payload Json
@@index([recorded_at])
@@index([kind, source_event_id])
}
// One row per (event, target table, version): pending work for the projector and,
// once applied in the same transaction as the target write, the receipt that stops a retry.
model LiteLLM_AccountingDelivery {
event_id String
target AccountingDeliveryTarget
version Int
state AccountingDeliveryState @default(pending)
attempts Int @default(0)
next_attempt_at DateTime @default(now())
applied_at DateTime?
last_error String?
@@id([event_id, target, version])
@@index([target, state, next_attempt_at])
}

View file

@ -0,0 +1,128 @@
import os
import subprocess
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import Final
import psycopg
import pytest
from psycopg import sql
from litellm.accounting import DeliveryState, DeliveryTarget, EventKind
from tests.integration._support.database import scratch_database
_SCHEMA: Final = Path(__file__).resolve().parents[3] / "litellm-proxy-extras/litellm_proxy_extras/schema.prisma"
_EVENT_INSERT: Final = (
'INSERT INTO "LiteLLM_AccountingEvent" '
"(event_id, kind, schema_version, payload_hash, occurred_at, payload) "
"VALUES ('event', %s, %s, 'hash', '2026-01-01', '{}') RETURNING kind::text, schema_version"
)
_DELIVERY_INSERT: Final = (
'INSERT INTO "LiteLLM_AccountingDelivery" (event_id, target, version, state, attempts) '
"VALUES ('event', %s, %s, %s, %s) RETURNING target::text, version, state::text, attempts"
)
@pytest.fixture(scope="module")
def ledger_database() -> Iterator[str]:
with scratch_database("accounting") as database_url:
result: Final = subprocess.run(
[sys.executable, "-I", "-m", "prisma", "migrate", "deploy", "--schema", str(_SCHEMA)],
env={**os.environ, "DATABASE_URL": database_url},
capture_output=True,
text=True,
timeout=300,
)
assert result.returncode == 0, result.stdout + result.stderr
yield database_url
@pytest.fixture
def ledger(ledger_database: str) -> Iterator[psycopg.Connection[tuple[object, ...]]]:
with psycopg.connect(ledger_database) as connection:
try:
yield connection
finally:
connection.rollback()
@pytest.mark.parametrize("kind", tuple(EventKind))
def test_ledger_accepts_every_public_event_kind(
ledger: psycopg.Connection[tuple[object, ...]], kind: EventKind
) -> None:
row: Final = ledger.execute(_EVENT_INSERT, (kind.value, 1)).fetchone()
assert row == (kind.value, 1)
@pytest.mark.parametrize("target", tuple(DeliveryTarget))
@pytest.mark.parametrize("state", tuple(DeliveryState))
def test_delivery_accepts_every_public_target_and_state(
ledger: psycopg.Connection[tuple[object, ...]], target: DeliveryTarget, state: DeliveryState
) -> None:
row: Final = ledger.execute(_DELIVERY_INSERT, (target.value, 1, state.value, 0)).fetchone()
assert row == (target.value, 1, state.value, 0)
@pytest.mark.parametrize(
("enum_name", "values"),
[
("AccountingEventKind", tuple(value.value for value in EventKind)),
("AccountingDeliveryState", tuple(value.value for value in DeliveryState)),
("AccountingDeliveryTarget", tuple(value.value for value in DeliveryTarget)),
],
)
def test_migrated_enums_match_the_public_contract(
ledger: psycopg.Connection[tuple[object, ...]], enum_name: str, values: tuple[str, ...]
) -> None:
rows: Final = ledger.execute(
"SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid WHERE t.typname = %s",
(enum_name,),
).fetchall()
assert frozenset(row[0] for row in rows) == frozenset(values)
@pytest.mark.parametrize("version", [0, -1])
def test_event_rejects_nonpositive_schema_versions(
ledger: psycopg.Connection[tuple[object, ...]], version: int
) -> None:
with pytest.raises(psycopg.errors.CheckViolation, match="AccountingEvent_schema_version_check"):
ledger.execute(_EVENT_INSERT, (EventKind.OUTBOUND_USAGE.value, version))
@pytest.mark.parametrize(
("version", "attempts", "constraint"),
[
(0, 0, "AccountingDelivery_version_check"),
(-1, 0, "AccountingDelivery_version_check"),
(1, -1, "AccountingDelivery_attempts_check"),
],
)
def test_delivery_rejects_invalid_versions_and_attempts(
ledger: psycopg.Connection[tuple[object, ...]], version: int, attempts: int, constraint: str
) -> None:
with pytest.raises(psycopg.errors.CheckViolation, match=constraint):
ledger.execute(_DELIVERY_INSERT, (DeliveryTarget.KEY_SPEND.value, version, "pending", attempts))
@pytest.mark.parametrize("column", ["state", "target"])
def test_delivery_rejects_unknown_enum_values(ledger: psycopg.Connection[tuple[object, ...]], column: str) -> None:
ledger.execute(_DELIVERY_INSERT, (DeliveryTarget.KEY_SPEND.value, 1, "pending", 0))
with pytest.raises(psycopg.errors.InvalidTextRepresentation, match="invalid input value for enum"):
ledger.execute(
sql.SQL('UPDATE "LiteLLM_AccountingDelivery" SET {} = %s').format(sql.Identifier(column)), ("invalid",)
)
def test_event_rejects_unknown_kind(ledger: psycopg.Connection[tuple[object, ...]]) -> None:
with pytest.raises(psycopg.errors.InvalidTextRepresentation, match="invalid input value for enum"):
ledger.execute(_EVENT_INSERT, ("invalid", 1))
def test_new_delivery_defaults_to_pending_with_no_attempts(ledger: psycopg.Connection[tuple[object, ...]]) -> None:
row: Final = ledger.execute(
'INSERT INTO "LiteLLM_AccountingDelivery" (event_id, target, version) '
"VALUES ('event', %s, 1) RETURNING state::text, attempts",
(DeliveryTarget.KEY_SPEND.value,),
).fetchone()
assert row == ("pending", 0)

View file

@ -0,0 +1,122 @@
import json
import uuid
from collections.abc import Mapping
from typing import Final
import pytest
from integration._support.client import Gateway
from integration._support.wire import Reply, Request, wire_server
from pydantic import JsonValue
MODEL: Final = "claude-haiku-4-5"
def _sse(event: Mapping[str, JsonValue]) -> bytes:
return f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode()
def _message(identity: str, stop_reason: str | None) -> dict[str, JsonValue]:
return {
"id": "msg_" + identity,
"type": "message",
"role": "assistant",
"model": MODEL,
"content": [{"type": "text", "text": "alpha bravo"}],
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": {"input_tokens": 199878, "output_tokens": 12799},
}
def _stream(identity: str, stop_reason: str) -> tuple[bytes, ...]:
return (
_sse({"type": "message_start", "message": {**_message(identity, None), "content": []}}),
_sse({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
_sse({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "alpha bravo"}}),
_sse({"type": "content_block_stop", "index": 0}),
_sse(
{
"type": "message_delta",
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
"usage": {"output_tokens": 12799},
}
),
_sse({"type": "message_stop"}),
)
def _anthropic(identity: str, stop_reason: str):
def respond(request: Request) -> Reply:
assert request.method == "POST" and request.target == "/v1/messages", request.target
if json.loads(request.body).get("stream"):
return Reply(content_type="text/event-stream", chunks=_stream(identity, stop_reason))
return Reply(body=json.dumps(_message(identity, stop_reason)).encode())
return respond
def _events(gateway: Gateway, path: str, body: Mapping[str, JsonValue]) -> tuple[dict[str, JsonValue], ...]:
with gateway.client.stream(
"POST", path, json={**body, "stream": True}, headers={"Authorization": f"Bearer {gateway.key}"}
) as response:
assert response.status_code == 200, response.read().decode()
return tuple(
json.loads(line.removeprefix("data: "))
for line in response.iter_lines()
if line.startswith("data: ") and line != "data: [DONE]"
)
def _chat_finish_reason(gateway: Gateway, model: str, *, stream: bool) -> JsonValue:
body: Final = {"model": model, "max_tokens": 64000, "messages": [{"role": "user", "content": "copy the list"}]}
if stream:
reasons: Final = tuple(
choice["finish_reason"]
for chunk in _events(gateway, "/v1/chat/completions", body)
for choice in chunk["choices"] # comprehension-ok: chunk -> choices flatten
if choice.get("finish_reason")
)
assert len(reasons) == 1, reasons
return reasons[0]
response: Final = gateway.request("POST", "/v1/chat/completions", body)
assert response.status_code == 200, response.text
return response.json()["choices"][0]["finish_reason"]
def _responses_status(gateway: Gateway, model: str, *, stream: bool) -> JsonValue:
body: Final = {"model": model, "max_output_tokens": 64000, "input": "copy the list"}
if stream:
terminal: Final = tuple(
event["response"]
for event in _events(gateway, "/v1/responses", body)
if event["type"] in ("response.completed", "response.incomplete", "response.failed")
)
assert len(terminal) == 1, terminal
return terminal[0]["status"]
response: Final = gateway.request("POST", "/v1/responses", body)
assert response.status_code == 200, response.text
return response.json()["status"]
@pytest.mark.parametrize("stream", [False, True], ids=["json", "stream"])
@pytest.mark.parametrize(
("stop_reason", "finish_reason"),
[("model_context_window_exceeded", "length"), ("max_tokens", "length"), ("end_turn", "stop")],
)
def test_chat_maps_anthropic_stop_reason(gateway: Gateway, stop_reason: str, finish_reason: str, stream: bool) -> None:
identity: Final = uuid.uuid4().hex
with wire_server(_anthropic(identity, stop_reason)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"anthropic/{MODEL}", api_base=wire.url, api_key="scripted-anthropic-key")
assert _chat_finish_reason(gateway, model, stream=stream) == finish_reason
@pytest.mark.parametrize("stream", [False, True], ids=["json", "stream"])
@pytest.mark.parametrize(
("stop_reason", "status"),
[("model_context_window_exceeded", "incomplete"), ("max_tokens", "incomplete"), ("end_turn", "completed")],
)
def test_responses_maps_anthropic_stop_reason(gateway: Gateway, stop_reason: str, status: str, stream: bool) -> None:
identity: Final = uuid.uuid4().hex
with wire_server(_anthropic(identity, stop_reason)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"anthropic/{MODEL}", api_base=wire.url, api_key="scripted-anthropic-key")
assert _responses_status(gateway, model, stream=stream) == status

View file

@ -413,3 +413,71 @@ def test_resent_tool_history_is_scanned_once(single_worker: Rig, shape: str) ->
second: Final = timed(lambda: single_worker.proxy.request("POST", path, following))
assert first.value.status_code == 200 and second.value.status_code == 200, (first.value.text, second.value.text)
assert_within_factor_of_control(second.seconds, control=first.seconds, factor=0.5)
@pytest.mark.parametrize("placement", ("guardrail", "global"))
def test_skip_tool_message_in_guardrail_forwards_tool_results(tmp_path: Path, placement: str) -> None:
def configure(configuration: dict[str, JsonValue]) -> None:
guardrails: Final = configuration["guardrails"]
settings: Final = configuration["litellm_settings"]
assert isinstance(guardrails, list) and isinstance(guardrails[0], dict) and isinstance(settings, dict)
params: Final = guardrails[0]["litellm_params"]
assert isinstance(params, dict)
(params if placement == "guardrail" else settings)["skip_tool_message_in_guardrail"] = True
with tool_rig(tmp_path, configure=configure) as rig:
rig.wire.drain()
replies: Final = (
rig.proxy.request(
"POST",
"/v1/messages",
{
"model": "claude",
"max_tokens": 32,
"cache": {"no-cache": True},
"messages": [
{"role": "user", "content": TEXT},
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {"env": TEXT}}
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "tool_1", "content": TEXT}],
},
],
},
),
rig.proxy.request(
"POST",
"/v1/chat/completions",
{
"model": "gpt",
"cache": {"no-cache": True},
"messages": [
{"role": "user", "content": TEXT},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": json.dumps({"env": TEXT})},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": TEXT},
],
},
),
)
assert all(reply.status_code == 200 for reply in replies), [reply.text for reply in replies]
sent: Final = rig.wire.drain()
assert len(sent) == 2
for request in sent:
messages = json.loads(request.body)["messages"]
assert SECRET not in json.dumps(messages[:2])
assert SECRET in json.dumps(messages[2])

View file

@ -0,0 +1,263 @@
import json
import uuid
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass, field
from itertools import count, islice
from pathlib import Path
from types import MappingProxyType
from typing import Final
from urllib.parse import parse_qs, urlsplit
import pytest
import yaml
from integration._support.client import Gateway, gateway_from_environment
from integration._support.process import owned_proxy
from integration._support.wire import Reply, Request, Wire, wire_server
from anthropic import Anthropic
from openai import OpenAI
from pydantic import BaseModel, ConfigDict, JsonValue
pytestmark: Final = pytest.mark.shares_worker_state
UNMANAGED_FILE: Final = "file-uploaded-straight-to-the-provider"
UPLOAD: Final = b"isolation check\n"
@dataclass(frozen=True, slots=True)
class Deployment:
provider: str
model: str
litellm_model: str
api_key: str
api_path: str
OPENAI: Final = Deployment("openai", "gpt-4.1-managed-listing", "openai/gpt-4.1", "sk-managed-listing", "/v1")
ANTHROPIC: Final = Deployment(
"anthropic", "claude-managed-listing", "anthropic/claude-haiku-5-5", "sk-ant-managed-listing", ""
)
DEPLOYMENTS: Final = (pytest.param(OPENAI, id="openai"), pytest.param(ANTHROPIC, id="anthropic"))
@dataclass(frozen=True, slots=True)
class ListCall:
path: str
params: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
headers: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
LIST_FORMS: Final = (
pytest.param(lambda d: ListCall(f"/{d.provider}/v1/files"), id="provider-path"),
pytest.param(lambda d: ListCall("/v1/files", params={"provider": d.provider}), id="provider-query"),
pytest.param(lambda d: ListCall("/v1/files", headers={"custom-llm-provider": d.provider}), id="provider-header"),
pytest.param(lambda d: ListCall("/v1/files", params={"target_model_names": d.model}), id="target-model-names"),
pytest.param(lambda d: ListCall("/v1/files", headers={"x-litellm-model": d.model}), id="model-header"),
)
class FilePage(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
data: list[dict[str, JsonValue]]
has_more: bool
@property
def ids(self) -> list[str]:
return [str(entry["id"]) for entry in self.data]
def _openai_file(file_id: str) -> dict[str, JsonValue]:
return {
"id": file_id,
"object": "file",
"bytes": len(UPLOAD),
"created_at": 1700000000,
"filename": "notes.txt",
"purpose": "user_data",
"status": "processed",
}
def _anthropic_file(file_id: str) -> dict[str, JsonValue]:
return {
"id": file_id,
"type": "file",
"filename": "notes.txt",
"mime_type": "text/plain",
"size_bytes": len(UPLOAD),
"created_at": "2026-01-01T00:00:00Z",
"downloadable": False,
}
def _reply(body: Mapping[str, JsonValue]) -> Reply:
return Reply(body=json.dumps(body).encode())
def _list_reply(provider: str, file_ids: tuple[str, ...]) -> Reply:
if provider == ANTHROPIC.provider:
return _reply(
{
"data": [_anthropic_file(file_id) for file_id in file_ids],
"has_more": False,
"first_id": file_ids[0],
"last_id": file_ids[-1],
}
)
return _reply({"object": "list", "data": [_openai_file(file_id) for file_id in file_ids]})
def _responder(provider_files: Mapping[str, tuple[str, ...]]) -> Callable[[Request], Reply]:
uploads: Final = {provider: count() for provider in provider_files}
def respond(request: Request) -> Reply:
target: Final = urlsplit(request.target)
path: Final = target.path
provider: Final = ANTHROPIC.provider if "x-api-key" in request.headers else OPENAI.provider
files: Final = provider_files[provider]
if (request.method, path) == ("GET", "/v1/models"):
return _reply({"object": "list", "data": []})
if (request.method, path) == ("POST", "/v1/files"):
uploaded: Final = files[next(uploads[provider])]
return _reply(_anthropic_file(uploaded) if provider == ANTHROPIC.provider else _openai_file(uploaded))
if (request.method, path) == ("GET", "/v1/files"):
limit: Final = int(parse_qs(target.query).get("limit", ["10000"])[0])
return _list_reply(provider, (UNMANAGED_FILE, *reversed(files))[:limit])
return Reply(status=404, body=json.dumps({"error": {"message": f"unscripted {request.target}"}}).encode())
return respond
def _config(wire: Wire) -> dict[str, JsonValue]:
return {
"model_list": [
{
"model_name": deployment.model,
"litellm_params": {
"model": deployment.litellm_model,
"api_key": deployment.api_key,
"api_base": f"{wire.url}{deployment.api_path}",
},
}
for deployment in (OPENAI, ANTHROPIC)
],
"litellm_settings": {"require_managed_files": True},
"general_settings": {"master_key": "os.environ/LITELLM_MASTER_KEY", "database_url": "os.environ/DATABASE_URL"},
}
@dataclass(frozen=True, slots=True)
class Rig:
proxy: Gateway
owner: str
intruder: str
files_by_provider: Mapping[str, tuple[str, ...]]
every_file: tuple[str, ...]
@property
def base_url(self) -> str:
return str(self.proxy.client.base_url).rstrip("/")
def list(self, call: ListCall, key: str | None = None) -> FilePage:
response: Final = self.proxy.request("GET", call.path, key=key, params=call.params, headers=call.headers)
assert response.status_code == 200, response.text
return FilePage.model_validate_json(response.text)
def _upload(proxy: Gateway, key: str, deployments: tuple[Deployment, ...]) -> str:
with OpenAI(base_url=f"{str(proxy.client.base_url).rstrip('/')}/openai/v1", api_key=key, max_retries=0) as client:
uploaded: Final = client.files.with_raw_response.create(
file=("notes.txt", UPLOAD, "text/plain"),
purpose="user_data",
extra_body={"target_model_names": ",".join(deployment.model for deployment in deployments)},
)
assert uploaded.status_code == 200, uploaded.text
return uploaded.parse().id
@contextmanager
def _rig(directory: Path) -> Iterator[Rig]:
build: Final = uuid.uuid4().hex
provider_files: Final = {
OPENAI.provider: (f"file-older-{build}", f"file-newer-{build}"),
ANTHROPIC.provider: (f"file_older_{build}", f"file_newer_{build}"),
}
with gateway_from_environment() as shared, wire_server(_responder(provider_files)) as wire:
config: Final = directory / "config.yaml"
config.write_text(yaml.safe_dump(_config(wire)))
with owned_proxy(shared, directory, {}, config=config) as proxy, proxy.scenario() as scenario:
owner: Final = scenario.key(user_id=scenario.user())
intruder: Final = scenario.key(user_id=scenario.user())
on_both: Final = _upload(proxy, owner, (OPENAI, ANTHROPIC))
on_openai: Final = _upload(proxy, owner, (OPENAI,))
on_anthropic: Final = _upload(proxy, owner, (ANTHROPIC,))
yield Rig(
proxy,
owner,
intruder,
MappingProxyType(
{OPENAI.provider: (on_openai, on_both), ANTHROPIC.provider: (on_anthropic, on_both)}
),
(on_anthropic, on_openai, on_both),
)
@pytest.fixture(scope="module")
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
with _rig(tmp_path_factory.mktemp("managed-file-listing")) as built:
yield built
@pytest.mark.parametrize("deployment", DEPLOYMENTS)
@pytest.mark.parametrize("form", LIST_FORMS)
def test_another_key_lists_none_of_the_files_on_the_shared_key(
rig: Rig, deployment: Deployment, form: Callable[[Deployment], ListCall]
) -> None:
page: Final = rig.list(form(deployment), key=rig.intruder)
assert (page.ids, page.has_more) == ([], False)
@pytest.mark.parametrize("deployment", DEPLOYMENTS)
@pytest.mark.parametrize("form", LIST_FORMS)
def test_owner_lists_their_own_managed_files_on_that_provider_or_model_newest_first(
rig: Rig, deployment: Deployment, form: Callable[[Deployment], ListCall]
) -> None:
page: Final = rig.list(form(deployment), key=rig.owner)
assert (page.ids, page.has_more) == (list(rig.files_by_provider[deployment.provider]), False)
def test_owner_lists_every_managed_file_when_no_provider_or_model_is_named(rig: Rig) -> None:
page: Final = rig.list(ListCall("/v1/files"), key=rig.owner)
assert (page.ids, page.has_more) == (list(rig.every_file), False)
@pytest.mark.parametrize("cursor_param", ["after", "after_id"])
def test_owner_pages_through_a_provider_list_with_limit_and_a_cursor(rig: Rig, cursor_param: str) -> None:
first: Final = rig.list(ListCall("/openai/v1/files", params={"limit": "1"}), key=rig.owner)
second: Final = rig.list(
ListCall("/openai/v1/files", params={"limit": "1", cursor_param: first.ids[0]}), key=rig.owner
)
expected: Final = rig.files_by_provider[OPENAI.provider]
assert [(page.ids, page.has_more) for page in (first, second)] == [
([expected[0]], True),
([expected[1]], False),
]
def test_anthropic_sdk_auto_pages_the_owners_files_to_the_end(rig: Rig) -> None:
with Anthropic(base_url=f"{rig.base_url}/anthropic", api_key=rig.owner, max_retries=0) as client:
listed: Final = [file.id for file in islice(client.beta.files.list(limit=1), 5)]
assert listed == list(rig.files_by_provider[ANTHROPIC.provider])
def test_anthropic_sdk_pages_backwards_to_the_newest_file_and_stops(rig: Rig) -> None:
newest_first: Final = rig.files_by_provider[ANTHROPIC.provider]
with Anthropic(base_url=f"{rig.base_url}/anthropic", api_key=rig.owner, max_retries=0) as client:
listed: Final = [file.id for file in islice(client.beta.files.list(limit=1, before_id=newest_first[-1]), 5)]
assert listed == list(reversed(newest_first[:-1]))
def test_master_key_still_lists_the_files_on_the_shared_provider_key(rig: Rig) -> None:
listed: Final = rig.list(ListCall(f"/{ANTHROPIC.provider}/v1/files")).ids
assert UNMANAGED_FILE in listed

View file

View file

@ -0,0 +1,235 @@
from datetime import datetime, timezone
from decimal import Decimal
from typing import Final
import pytest
from litellm.accounting import (
CostEvent,
Dimension,
DimensionName,
InboundUsageEvent,
Measurement,
Metric,
MetricName,
Outcome,
OutboundUsageEvent,
PricingSnapshot,
measurements_for,
)
from litellm.types.utils import CallTypes
_START: Final = datetime(2026, 1, 1, 23, 59, 59, tzinfo=timezone.utc)
_END: Final = datetime(2026, 1, 2, tzinfo=timezone.utc)
_DIMENSIONS: Final = (
Dimension(name=DimensionName.TEAM, value="team"),
Dimension(name=DimensionName.TAG, value="alpha"),
Dimension(name=DimensionName.TAG, value="beta"),
)
def _snapshot(amount: Decimal) -> PricingSnapshot:
return PricingSnapshot(
calculator_version="test",
model="test-model",
provider=None,
pricing_source="upstream_header",
priced_model_key="test-model",
upstream_reported_cost=amount,
total_cost=amount,
)
@pytest.mark.parametrize(
("outcome", "outcome_metric"),
[
(Outcome.SUCCEEDED, MetricName.SUCCESSFUL_REQUESTS),
(Outcome.FAILED, MetricName.FAILED_REQUESTS),
(Outcome.CANCELLED, MetricName.FAILED_REQUESTS),
],
)
def test_caller_measurements_keep_both_request_and_outcome_counts(outcome, outcome_metric):
event = InboundUsageEvent(
event_id="caller-event",
request_id="caller",
call_type=CallTypes.acompletion,
started_at=_START,
ended_at=_END,
outcome=outcome,
dimensions=_DIMENSIONS,
metrics=(Metric(name=MetricName.REQUESTS, value=Decimal(1)), Metric(name=outcome_metric, value=Decimal(1))),
)
assert measurements_for(event) == (
Measurement(
source_event_id="caller-event",
occurred_at=_START,
dimensions=_DIMENSIONS,
metric=Metric(name=MetricName.REQUESTS, value=Decimal(1)),
),
Measurement(
source_event_id="caller-event",
occurred_at=_START,
dimensions=_DIMENSIONS,
metric=Metric(name=outcome_metric, value=Decimal(1)),
),
)
def test_usage_measurements_keep_every_metric_in_the_start_time_bucket():
event = OutboundUsageEvent(
event_id="usage-event",
attempt_id="attempt",
request_id="caller",
retry_of_attempt_id=None,
call_type=CallTypes.acompletion,
started_at=_START,
ended_at=_END,
outcome=Outcome.SUCCEEDED,
dimensions=_DIMENSIONS,
metrics=(
Metric(name=MetricName.PROMPT_TOKENS, value=Decimal(10)),
Metric(name=MetricName.COMPLETION_TOKENS, value=Decimal(2)),
Metric(name=MetricName.TOTAL_TOKENS, value=Decimal(12)),
Metric(name=MetricName.CACHE_READ_INPUT_TOKENS, value=Decimal(3)),
Metric(name=MetricName.CACHE_CREATION_INPUT_TOKENS, value=Decimal(1)),
),
)
assert measurements_for(event) == (
Measurement(
source_event_id="usage-event",
occurred_at=_START,
dimensions=_DIMENSIONS,
metric=Metric(name=MetricName.PROMPT_TOKENS, value=Decimal(10)),
),
Measurement(
source_event_id="usage-event",
occurred_at=_START,
dimensions=_DIMENSIONS,
metric=Metric(name=MetricName.COMPLETION_TOKENS, value=Decimal(2)),
),
Measurement(
source_event_id="usage-event",
occurred_at=_START,
dimensions=_DIMENSIONS,
metric=Metric(name=MetricName.TOTAL_TOKENS, value=Decimal(12)),
),
Measurement(
source_event_id="usage-event",
occurred_at=_START,
dimensions=_DIMENSIONS,
metric=Metric(name=MetricName.CACHE_READ_INPUT_TOKENS, value=Decimal(3)),
),
Measurement(
source_event_id="usage-event",
occurred_at=_START,
dimensions=_DIMENSIONS,
metric=Metric(name=MetricName.CACHE_CREATION_INPUT_TOKENS, value=Decimal(1)),
),
)
@pytest.mark.parametrize("metrics", [None, ()])
def test_unknown_or_empty_usage_does_not_fabricate_zero_measurements(metrics):
event = OutboundUsageEvent(
event_id="usage-event",
attempt_id="attempt",
request_id=None,
retry_of_attempt_id=None,
call_type=CallTypes.acompletion,
started_at=_START,
ended_at=_END,
outcome=Outcome.SUCCEEDED,
dimensions=(),
metrics=metrics,
)
assert measurements_for(event) == ()
@pytest.mark.parametrize("amount", [Decimal(0), Decimal("0.1234567890123456789")])
def test_cost_measurements_preserve_authoritative_amount_identity_and_occurrence(amount):
event = CostEvent(
event_id="cost-event",
usage_event_id="usage-event",
occurred_at=_END,
dimensions=_DIMENSIONS,
charges=(Metric(name=MetricName.SPEND, value=amount, unit="USD"),),
snapshot=_snapshot(amount),
)
assert measurements_for(event) == (
Measurement(
source_event_id="cost-event",
occurred_at=_END,
dimensions=_DIMENSIONS,
metric=Metric(name=MetricName.SPEND, value=amount, unit="USD"),
),
)
def test_billed_retry_and_success_contribute_one_caller_request_and_both_charges():
caller = InboundUsageEvent(
event_id="caller-event",
request_id="caller",
call_type=CallTypes.acompletion,
started_at=_START,
ended_at=_END,
outcome=Outcome.SUCCEEDED,
dimensions=_DIMENSIONS,
metrics=(Metric(name=MetricName.REQUESTS, value=Decimal(1)),),
)
retry = OutboundUsageEvent(
event_id="retry-usage",
attempt_id="retry",
request_id="caller",
retry_of_attempt_id=None,
call_type=CallTypes.acompletion,
started_at=_START,
ended_at=_END,
outcome=Outcome.FAILED,
dimensions=_DIMENSIONS,
metrics=(Metric(name=MetricName.TOTAL_TOKENS, value=Decimal(4)),),
)
success = OutboundUsageEvent(
event_id="success-usage",
attempt_id="success",
request_id="caller",
retry_of_attempt_id="retry",
call_type=CallTypes.acompletion,
started_at=_START,
ended_at=_END,
outcome=Outcome.SUCCEEDED,
dimensions=_DIMENSIONS,
metrics=(Metric(name=MetricName.TOTAL_TOKENS, value=Decimal(12)),),
)
retry_cost = CostEvent(
event_id="retry-cost",
usage_event_id="retry-usage",
occurred_at=_START,
dimensions=_DIMENSIONS,
charges=(Metric(name=MetricName.SPEND, value=Decimal("0.025"), unit="USD"),),
snapshot=_snapshot(Decimal("0.025")),
)
success_cost = CostEvent(
event_id="success-cost",
usage_event_id="success-usage",
occurred_at=_START,
dimensions=_DIMENSIONS,
charges=(Metric(name=MetricName.SPEND, value=Decimal("0.125"), unit="USD"),),
snapshot=_snapshot(Decimal("0.125")),
)
measurements = (
*measurements_for(caller),
*measurements_for(retry),
*measurements_for(success),
*measurements_for(retry_cost),
*measurements_for(success_cost),
)
assert sum(m.metric.value for m in measurements if m.metric.name is MetricName.REQUESTS) == Decimal(1)
assert sum(m.metric.value for m in measurements if m.metric.name is MetricName.TOTAL_TOKENS) == Decimal(16)
assert sum(m.metric.value for m in measurements if m.metric.name is MetricName.SPEND) == Decimal("0.150")
assert {m.source_event_id for m in measurements} == {
"caller-event",
"retry-usage",
"success-usage",
"retry-cost",
"success-cost",
}

View file

@ -0,0 +1,484 @@
import re
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from enum import Enum
from pathlib import Path
from typing import Final
import pytest
from pydantic import ValidationError
from litellm.accounting import (
AdmissionDecision,
AggregateQuery,
AggregateRow,
CostEvent,
DeliveryState,
DeliveryTarget,
Dimension,
DimensionFilter,
DimensionName,
EventKind,
Granularity,
InboundUsageEvent,
Metric,
MetricName,
Multiplier,
Outcome,
OutboundUsageEvent,
PricedQuantity,
PricingSnapshot,
Rate,
validate_aggregate_rows,
)
from litellm.accounting.types import PricingUnit
from litellm.types.utils import CallTypes
_START: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
_END: Final = datetime(2026, 1, 2, tzinfo=timezone.utc)
_REQUEST: Final = Metric(name=MetricName.REQUESTS, value=Decimal(1))
_TOKEN: Final = Metric(name=MetricName.PROMPT_TOKENS, value=Decimal(10))
_SPEND: Final = Metric(name=MetricName.SPEND, value=Decimal("0.1234567890123456789"), unit="USD")
_TEAM: Final = Dimension(name=DimensionName.TEAM, value="team")
def _inbound(metrics=(_REQUEST,), outcome=Outcome.SUCCEEDED):
return InboundUsageEvent(
event_id="inbound:request",
request_id="request",
call_type=CallTypes.acompletion,
started_at=_START,
ended_at=_START,
outcome=outcome,
dimensions=(_TEAM,),
metrics=metrics,
)
def _outbound(metrics=(_TOKEN,), **changes):
return OutboundUsageEvent.model_validate(
{
"event_id": "outbound:attempt",
"attempt_id": "attempt",
"request_id": "request",
"retry_of_attempt_id": None,
"call_type": CallTypes.acompletion,
"started_at": _START,
"ended_at": _END,
"outcome": Outcome.SUCCEEDED,
"dimensions": (_TEAM,),
"metrics": metrics,
**changes,
}
)
def _query(**changes):
return AggregateQuery.model_validate(
{
"metric": MetricName.SPEND,
"filters": (DimensionFilter(name=DimensionName.TEAM, values=("one", "two")),),
"group_by": (DimensionName.MODEL,),
"start": _START,
"end": _END,
"granularity": Granularity.DAY,
**changes,
}
)
def test_wire_values_preserve_decimal_precision_and_normalize_aware_times():
offset = timezone(timedelta(hours=-8))
event = CostEvent(
event_id="cost:attempt",
usage_event_id="outbound:attempt",
occurred_at=datetime(2025, 12, 31, 16, tzinfo=offset),
dimensions=(_TEAM,),
charges=(_SPEND,),
snapshot=_snapshot(),
)
wire = event.model_dump(mode="json")
assert wire["occurred_at"] == "2026-01-01T00:00:00Z"
assert wire["charges"] == [{"name": "spend", "value": "0.1234567890123456789", "unit": "USD"}]
assert event.occurred_at == _START
assert CostEvent.model_validate_json(event.model_dump_json()).charges[0].value == _SPEND.value
@pytest.mark.parametrize(
("name", "value", "unit", "message"),
[
(MetricName.SPEND, Decimal(1), "count", "SPEND requires USD"),
(MetricName.REQUESTS, Decimal(1), "USD", "SPEND requires USD"),
(MetricName.PROMPT_TOKENS, Decimal("1.5"), "count", "integral"),
(MetricName.SPEND, Decimal("NaN"), "USD", "finite"),
(MetricName.SPEND, Decimal("Infinity"), "USD", "finite"),
(MetricName.SPEND, Decimal(-1), "USD", "greater than or equal"),
(MetricName.SPEND, 0.1, "USD", "instance of Decimal"),
],
)
def test_metrics_reject_wrong_units_nonfinite_values_and_binary_float_inputs(name, value, unit, message):
with pytest.raises(ValidationError, match=message):
Metric(name=name, value=value, unit=unit)
@pytest.mark.parametrize("metrics", [(_TOKEN,), (_SPEND,), ()])
def test_inbound_activity_cannot_own_usage_or_cost_or_omit_request_count(metrics):
with pytest.raises(ValidationError, match="Inbound events"):
_inbound(metrics)
@pytest.mark.parametrize(
("outcome", "metric"),
[
(Outcome.FAILED, Metric(name=MetricName.SUCCESSFUL_REQUESTS, value=Decimal(1))),
(Outcome.SUCCEEDED, Metric(name=MetricName.FAILED_REQUESTS, value=Decimal(1))),
],
)
def test_inbound_outcome_counts_cannot_contradict_outcome(outcome, metric):
with pytest.raises(ValidationError, match="count must match outcome"):
_inbound((_REQUEST, metric), outcome)
@pytest.mark.parametrize("metrics", [(_REQUEST,), (_SPEND,)])
def test_outbound_attempts_cannot_own_caller_counts_or_charges(metrics):
with pytest.raises(ValidationError, match="Outbound events own only"):
_outbound(metrics)
@pytest.mark.parametrize(
("changes", "message"),
[
({"started_at": datetime(2026, 1, 1)}, "timezone"),
({"ended_at": _START - timedelta(seconds=1)}, "End must follow start"),
({"retry_of_attempt_id": "attempt"}, "cannot retry itself"),
({"dimensions": (_TEAM, _TEAM)}, "Duplicate dimension values"),
({"dimensions": (_TEAM, Dimension(name=DimensionName.TEAM, value="other"))}, "Only tag and model access group"),
(
{
"metrics": (
Metric(name=MetricName.COMPLETION_TOKENS, value=Decimal(5)),
Metric(name=MetricName.REASONING_TOKENS, value=Decimal(6)),
)
},
"reasoning_tokens cannot exceed completion_tokens",
),
(
{
"metrics": (
Metric(name=MetricName.CACHE_CREATION_INPUT_TOKENS, value=Decimal(5)),
Metric(name=MetricName.CACHE_CREATION_INPUT_TOKENS_ABOVE_1HR, value=Decimal(6)),
)
},
"cache_creation_input_tokens_above_1hr cannot exceed cache_creation_input_tokens",
),
({"event_id": " "}, "pattern"),
({"metrics": (_TOKEN, _TOKEN)}, "Duplicate metric names"),
],
)
def test_outbound_events_reject_invalid_identity_time_and_duplicate_attribution(changes, message):
with pytest.raises(ValidationError, match=message):
_outbound(**changes)
def test_unknown_and_known_zero_usage_are_distinct_and_values_are_immutable():
unknown = _outbound(None)
zero = _outbound((Metric(name=MetricName.PROMPT_TOKENS, value=Decimal(0)),))
assert unknown.metrics is None
assert zero.metrics is not None and zero.metrics[0].value == 0
assert _inbound().started_at == _inbound().ended_at
with pytest.raises(ValidationError, match="frozen"):
zero.attempt_id = "different"
with pytest.raises(ValidationError, match="frozen"):
zero.metrics[0].value = Decimal(10)
@pytest.mark.parametrize("charges", [(), (_TOKEN,), (_SPEND, _SPEND)])
def test_cost_events_require_one_authoritative_spend(charges):
with pytest.raises(ValidationError, match=r"authoritative USD spend|Duplicate metric"):
CostEvent(
event_id="cost",
usage_event_id="usage",
occurred_at=_START,
dimensions=(),
charges=charges,
snapshot=_snapshot(),
)
def test_cost_event_cannot_reuse_its_usage_identity():
with pytest.raises(ValidationError, match="distinct identities"):
CostEvent(
event_id="usage",
usage_event_id="usage",
occurred_at=_START,
dimensions=(),
charges=(_SPEND,),
snapshot=_snapshot(),
)
@pytest.mark.parametrize("granularity", [Granularity.DAY, None])
def test_daily_and_whole_interval_queries_preserve_filter_and_group_semantics(granularity):
query = _query(granularity=granularity)
assert query.granularity is granularity
assert query.filters[0].values == ("one", "two")
assert query.group_by == (DimensionName.MODEL,)
assert query.start == _START and query.end == _END
@pytest.mark.parametrize(
("changes", "message"),
[
({"end": _START}, "End must follow start"),
({"start": _START + timedelta(hours=1)}, "UTC day boundaries"),
({"end": _END + timedelta(microseconds=1)}, "UTC day boundaries"),
({"granularity": "hour"}, "day"),
({"group_by": (DimensionName.TEAM, DimensionName.TEAM)}, "Duplicate group-by"),
({"filters": (DimensionFilter(name=DimensionName.TEAM, values=("one",)),) * 2}, "one IN filter"),
({"filters": ({"name": "team", "values": ()},)}, "at least 1"),
],
)
def test_queries_reject_unsupported_or_ambiguous_ranges_and_filters(changes, message):
with pytest.raises(ValidationError, match=message):
_query(**changes)
def test_rows_allow_adjacent_buckets_and_overlapping_distinct_groups():
first = AggregateRow(start=_START, end=_END, dimensions=(_TEAM,), value=Decimal(1))
second = AggregateRow(start=_END, end=_END + timedelta(days=1), dimensions=(_TEAM,), value=Decimal(2))
other = AggregateRow(start=_START, end=_END, dimensions=(), value=Decimal(3))
rows = (second, other, first)
assert validate_aggregate_rows(rows) is rows
assert validate_aggregate_rows(()) == ()
def test_rows_reject_overlap_within_a_group_regardless_of_dimension_order():
model = Dimension(name=DimensionName.MODEL, value="model")
first = AggregateRow(start=_START, end=_END, dimensions=(_TEAM, model), value=Decimal(1))
overlapping = AggregateRow(start=_START, end=_END, dimensions=(model, _TEAM), value=Decimal(2))
with pytest.raises(ValueError, match="overlap within a dimension group"):
validate_aggregate_rows((first, overlapping))
def test_rows_reject_empty_intervals():
with pytest.raises(ValidationError, match="End must follow start"):
AggregateRow(start=_START, end=_START, dimensions=(), value=Decimal(0))
def test_admission_decision_preserves_a_denial_reason():
decision = AdmissionDecision(allowed=False, reason="Team budget exhausted")
assert not decision.allowed
assert decision.reason == "Team budget exhausted"
def _snapshot(**changes: object) -> PricingSnapshot:
return PricingSnapshot.model_validate(
{
"calculator_version": "1.80.0",
"model": "gpt-4o",
"provider": "openai",
"pricing_source": "cost_map",
"priced_model_key": "openai/gpt-4o",
"multipliers": (Multiplier(name="data_residency", value="eu", factor=Decimal("1.1")),),
"rates": (Rate(measure="input", unit="token", usd_per_unit=Decimal("0.0000025")),),
"usage": (PricedQuantity(measure="input", unit="token", value=_TOKEN.value),),
"input_cost": Decimal("0.000025"),
"total_cost": _SPEND.value,
**changes,
}
)
def test_cost_event_carries_a_snapshot_whose_total_matches_the_spend():
event = CostEvent(
event_id="cost:attempt",
usage_event_id="outbound:attempt",
occurred_at=_START,
dimensions=(_TEAM,),
charges=(_SPEND,),
snapshot=_snapshot(),
)
assert event.snapshot is not None
assert event.snapshot.total_cost == _SPEND.value
assert event.snapshot.multipliers[0].factor == Decimal("1.1")
with pytest.raises(ValidationError, match="Snapshot total must equal the authoritative spend"):
CostEvent(
event_id="cost:attempt",
usage_event_id="outbound:attempt",
occurred_at=_START,
dimensions=(_TEAM,),
charges=(_SPEND,),
snapshot=_snapshot(total_cost=Decimal("0.5")),
)
@pytest.mark.parametrize(
("changes", "message"),
(
(
{
"rates": (
Rate(measure="input", unit="token", usd_per_unit=Decimal(1)),
Rate(measure="input", unit="token", usd_per_unit=Decimal(2)),
)
},
"Duplicate rate measures and units",
),
({"usage": (_SPEND,)}, "PricedQuantity"),
({"usage": (_REQUEST,)}, "PricedQuantity"),
({"pricing_source": "guess"}, "pricing_source"),
({"multipliers": ({"name": "x", "value": "y", "factor": Decimal(0)},)}, "factor"),
({"schema_version": 2}, "schema_version"),
(
{"additional_costs": ({"name": "fee", "amount": Decimal(1)}, {"name": "fee", "amount": Decimal(2)})},
"Duplicate additional cost names",
),
),
)
def test_snapshots_reject_ambiguous_rates_wrong_usage_and_unknown_sources(changes: Mapping[str, object], message: str):
with pytest.raises(ValidationError, match=message):
_snapshot(**changes)
@pytest.mark.parametrize(
("unit", "value"),
[("token", "10"), ("second", "1.25"), ("character", "25"), ("image", "2"), ("request", "1")],
)
def test_snapshot_preserves_matching_rates_and_quantities_for_each_billing_unit(unit: PricingUnit, value: str) -> None:
quantity: Final = PricedQuantity(measure="input", unit=unit, value=Decimal(value))
rate: Final = Rate(measure="input", unit=unit, usd_per_unit=Decimal("0.003"))
amount: Final = quantity.value * rate.usd_per_unit
snapshot: Final = _snapshot(rates=(rate,), usage=(quantity,), total_cost=amount)
restored: Final = PricingSnapshot.model_validate_json(snapshot.model_dump_json())
assert restored == snapshot
assert restored.usage[0].value * restored.rates[0].usd_per_unit == amount
@pytest.mark.parametrize(
("usage", "message"),
[
((), "Every rate requires"),
(({"measure": "output", "unit": "token", "value": Decimal(10)},), "Every rate requires"),
(({"measure": "input", "unit": "second", "value": Decimal(10)},), "Every rate requires"),
(
({"measure": "input", "unit": "token", "value": Decimal(10)},) * 2,
"Duplicate priced quantity",
),
],
)
def test_snapshot_rejects_missing_mismatched_or_duplicate_priced_quantities(
usage: tuple[Mapping[str, object], ...], message: str
) -> None:
with pytest.raises(ValidationError, match=message):
_snapshot(usage=usage)
@pytest.mark.parametrize("unit", ["token", "character", "image", "request"])
def test_priced_counts_remain_integral(unit: PricingUnit) -> None:
with pytest.raises(ValidationError, match="integral"):
PricedQuantity(measure="input", unit=unit, value=Decimal("1.25"))
@pytest.mark.parametrize(
("value", "message"),
[
(Decimal(-1), "greater than or equal"),
(Decimal("NaN"), "finite"),
(Decimal("Infinity"), "finite"),
(1.25, "instance of Decimal"),
],
)
def test_pricing_values_reject_invalid_amounts(value: Decimal | float, message: str) -> None:
with pytest.raises(ValidationError, match=message):
PricedQuantity.model_validate({"measure": "input", "unit": "second", "value": value})
with pytest.raises(ValidationError, match=message):
Rate.model_validate({"measure": "input", "unit": "second", "usd_per_unit": value})
@pytest.mark.parametrize("snapshot_fields", [{}, {"snapshot": None}])
def test_cost_event_cannot_omit_its_derivation(snapshot_fields: Mapping[str, object]) -> None:
with pytest.raises(ValidationError, match="snapshot"):
CostEvent.model_validate(
{
"event_id": "cost",
"usage_event_id": "usage",
"occurred_at": _START,
"dimensions": (),
"charges": (_SPEND,),
**snapshot_fields,
}
)
def test_upstream_charge_snapshot_preserves_reported_cost_without_inventing_rates() -> None:
snapshot: Final = _snapshot(
pricing_source="upstream_header", rates=(), usage=(), upstream_reported_cost=_SPEND.value
)
restored: Final = PricingSnapshot.model_validate_json(snapshot.model_dump_json())
assert restored.upstream_reported_cost == restored.total_cost == _SPEND.value
assert restored.rates == restored.usage == ()
def test_subset_token_counts_round_trip_without_changing_totals() -> None:
metrics: Final = (
Metric(name=MetricName.COMPLETION_TOKENS, value=Decimal(10)),
Metric(name=MetricName.REASONING_TOKENS, value=Decimal(3)),
Metric(name=MetricName.CACHE_CREATION_INPUT_TOKENS, value=Decimal(8)),
Metric(name=MetricName.CACHE_CREATION_INPUT_TOKENS_ABOVE_1HR, value=Decimal(2)),
)
event: Final = _outbound(metrics=metrics)
assert OutboundUsageEvent.model_validate_json(event.model_dump_json()).metrics == metrics
@pytest.mark.parametrize(
"schema_path",
[
"schema.prisma",
"litellm/proxy/schema.prisma",
"litellm-proxy-extras/litellm_proxy_extras/schema.prisma",
],
)
@pytest.mark.parametrize(
("enum_name", "python_enum"),
[
("AccountingEventKind", EventKind),
("AccountingDeliveryState", DeliveryState),
("AccountingDeliveryTarget", DeliveryTarget),
],
)
def test_prisma_accepts_exactly_the_public_accounting_enum_values(
schema_path: str, enum_name: str, python_enum: type[Enum]
) -> None:
schema: Final = (Path(__file__).resolve().parents[3] / schema_path).read_text()
declaration: Final = re.search(r"enum " + enum_name + r"\s*\{([^}]+)\}", schema)
assert declaration is not None, f"Missing persisted enum {enum_name}"
assert frozenset(declaration[1].split()) == frozenset(member.value for member in python_enum)
def test_one_event_carries_every_matched_model_access_group():
groups = (
Dimension(name=DimensionName.MODEL_ACCESS_GROUP, value="gpt-group"),
Dimension(name=DimensionName.MODEL_ACCESS_GROUP, value="claude-group"),
)
assert _outbound(dimensions=(_TEAM, *groups)).dimensions == (_TEAM, *groups)
with pytest.raises(ValidationError, match="Duplicate dimension values"):
_outbound(dimensions=(groups[0], groups[0]))
def test_snapshots_reject_duplicate_multipliers_and_contradictory_token_counts():
multiplier = Multiplier(name="data_residency", value="eu", factor=Decimal("1.1"))
with pytest.raises(ValidationError, match="Duplicate multiplier names"):
_snapshot(multipliers=(multiplier, multiplier))
with pytest.raises(ValidationError, match="reasoning_tokens cannot exceed completion_tokens"):
_snapshot(
usage=(
PricedQuantity(measure="input", unit="token", value=_TOKEN.value),
PricedQuantity(measure="completion_tokens", unit="token", value=Decimal(5)),
PricedQuantity(measure="reasoning_tokens", unit="token", value=Decimal(6)),
)
)
def test_agent_live_spend_is_a_delivery_target():
assert DeliveryTarget.AGENT_SPEND.value == "LiteLLM_AgentsTable"

View file

@ -15,6 +15,7 @@ import pytest
from typing import Final, Optional
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.llms.base_llm.files.transformation import FileListModelScope
from litellm.proxy._types import LitellmUserRoles, ProxyException, UserAPIKeyAuth
from litellm.proxy.openai_files_endpoints.common_utils import BATCH_CREATE_HIDDEN_PARAM
from litellm.types.llms.openai import FileListPage, OpenAIFileObject
@ -649,6 +650,22 @@ async def test_afile_list_denies_a_caller_with_no_identity_at_all():
assert table.find_many_calls == []
@pytest.mark.asyncio
async def test_afile_list_reads_no_rows_when_no_deployment_can_hold_a_file() -> None:
managed_files, table = _make_managed_files_over_rows([_make_managed_file_row("unified-mine")])
response: Final = await managed_files.afile_list(
purpose=None,
litellm_parent_otel_span=None,
user_api_key_dict=_make_user_api_key_dict(),
model_scope=FileListModelScope(frozenset()),
)
assert response.data == []
assert response.has_more is False
assert table.find_many_calls == []
@pytest.mark.asyncio
async def test_afile_list_filters_by_purpose():
managed_files, _ = _make_managed_files_over_rows(

View file

@ -256,6 +256,7 @@ class TestMapFinishReasonAnthropic:
("max_tokens", "length"),
("tool_use", "tool_calls"),
("compaction", "length"),
("model_context_window_exceeded", "length"),
("content_filtered", "content_filter"),
],
)

View file

@ -1516,9 +1516,9 @@ def test_bedrock_mantle_openai_envelope_context_overflow_maps_to_context_window_
def test_bedrock_mantle_streamed_context_overflow_event_maps_to_context_window_exceeded():
from litellm.responses.streaming_iterator import _map_stream_error_to_exception
from litellm.responses.streaming_iterator import map_stream_error_to_exception
mapped_exception = _map_stream_error_to_exception(
mapped_exception = map_stream_error_to_exception(
{
"code": "context_length_exceeded",
"message": "Your input exceeds the context window of this model. Please adjust your input and try again.",

View file

@ -4,20 +4,25 @@ Tests for AnthropicResponsesStreamWrapper
"""
import asyncio
import datetime
import json
import os
import sys
from collections.abc import AsyncIterator, Awaitable, Callable
from types import SimpleNamespace
import anyio
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../..")))
import litellm
from litellm.exceptions import MidStreamFallbackError
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
from litellm.litellm_core_utils.prompt_templates.common_utils import (
encrypted_reasoning_signature,
)
from litellm.llms.anthropic.messages.native.streaming_iterator import aclose_superseded_attempt
from litellm.llms.anthropic.messages.native.utils import INCOMPLETE_STREAM_ERROR_MESSAGE
from litellm.llms.anthropic.messages.responses_adapters.streaming_iterator import (
AnthropicResponsesStreamWrapper,
@ -622,3 +627,194 @@ class TestUpstreamFailureEndsStreamWithErrorEvent:
chunks = _collect(iter(()))
assert [chunk["type"] for chunk in chunks] == ["message_start", "error"]
assert chunks[1]["error"] == {"type": "api_error", "message": INCOMPLETE_STREAM_ERROR_MESSAGE}
class _FailureHookRecorder:
def __init__(self) -> None:
self.received: tuple[Exception, ...] = ()
async def __call__(self, failure: Exception) -> None:
self.received = (*self.received, failure)
def _logging_obj_with_failure_hook(on_failure: Callable[[Exception], Awaitable[None]]) -> LitellmLogging:
logging_obj = LitellmLogging(
model="m",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="anthropic_messages",
start_time=datetime.datetime(2026, 1, 1),
litellm_call_id="lit-6871-test",
function_id="lit-6871-test",
)
logging_obj._on_detached_stream_failure = on_failure
return logging_obj
def _collect_with_logging(
stream: AsyncIterator[dict[str, object]], logging_obj: LitellmLogging
) -> list[dict[str, object]]:
async def _run() -> list[dict[str, object]]:
wrapper = AnthropicResponsesStreamWrapper(responses_stream=stream, model="m", litellm_logging_obj=logging_obj)
return [chunk async for chunk in wrapper]
return asyncio.run(_run())
class _DeliveryCountingConsumer:
def __init__(self) -> None:
self.delivered: tuple[dict[str, object], ...] = ()
self.delivered_when_hook_fired: tuple[int, ...] = ()
async def hook(self, failure: Exception) -> None:
self.delivered_when_hook_fired = (*self.delivered_when_hook_fired, len(self.delivered))
async def drain(self, wrapper: AnthropicResponsesStreamWrapper) -> None:
async for chunk in wrapper:
self.delivered = (*self.delivered, chunk)
def _failing_after_one_delta() -> AsyncIterator[dict[str, object]]:
async def _gen() -> AsyncIterator[dict[str, object]]:
yield {"type": "response.created"}
yield {"type": "response.output_item.added", "item": {"type": "message", "id": "msg_1"}}
yield {"type": "response.output_text.delta", "item_id": "msg_1", "delta": "Hi"}
raise litellm.RateLimitError(message="You have no credits remaining.", llm_provider="openai", model="m")
return _gen()
class TestUpstreamFailureReachesTheProxyFailureHook:
def test_raised_upstream_failure_reaches_the_hook_unwrapped_once_the_error_event_was_consumed(self):
rate_limit = litellm.RateLimitError(message="You have no credits remaining.", llm_provider="openai", model="m")
wrapped = MidStreamFallbackError(
message=str(rate_limit),
model="m",
llm_provider="openai",
original_exception=rate_limit,
is_pre_first_chunk=False,
)
recorder = _FailureHookRecorder()
async def _gen() -> AsyncIterator[dict[str, object]]:
yield {"type": "response.created"}
yield {"type": "response.output_item.added", "item": {"type": "message", "id": "msg_1"}}
yield {"type": "response.output_text.delta", "item_id": "msg_1", "delta": "Hi"}
raise wrapped
chunks = _collect_with_logging(_gen(), _logging_obj_with_failure_hook(recorder))
assert [chunk["type"] for chunk in chunks] == [
"message_start",
"content_block_start",
"content_block_delta",
"error",
]
assert recorder.received == (rate_limit,)
def test_response_failed_event_reaches_the_hook_as_an_exception_carrying_the_upstream_status(self):
recorder = _FailureHookRecorder()
failed = SimpleNamespace(
status="failed",
output=[],
usage=None,
error={"code": "rate_limit_exceeded", "message": "Rate limit reached for gpt-5.5, try again in 20s."},
)
async def _gen() -> AsyncIterator[dict[str, object]]:
yield {"type": "response.created"}
yield {"type": "response.failed", "response": failed}
chunks = _collect_with_logging(_gen(), _logging_obj_with_failure_hook(recorder))
assert chunks[-1]["type"] == "error"
(failure,) = recorder.received
assert isinstance(failure, litellm.RateLimitError)
assert failure.status_code == 429
assert "Rate limit reached for gpt-5.5, try again in 20s." in failure.message
def test_a_raising_hook_never_hides_the_error_event_from_the_client(self):
async def _exploding_hook(failure: Exception) -> None:
raise RuntimeError("spend writer is down")
logging_obj = _logging_obj_with_failure_hook(_exploding_hook)
async def _gen() -> AsyncIterator[dict[str, object]]:
yield {"type": "response.created"}
raise ConnectionResetError("Response payload is not completed")
chunks = _collect_with_logging(_gen(), logging_obj)
assert [chunk["type"] for chunk in chunks] == ["message_start", "error"]
assert chunks[-1]["error"] == {"type": "api_error", "message": "Response payload is not completed"}
def test_the_hook_fires_only_after_the_consumer_took_the_error_event(self):
consumer = _DeliveryCountingConsumer()
async def _run() -> None:
wrapper = AnthropicResponsesStreamWrapper(
responses_stream=_failing_after_one_delta(),
model="m",
litellm_logging_obj=_logging_obj_with_failure_hook(consumer.hook),
)
await consumer.drain(wrapper)
with pytest.raises(StopAsyncIteration):
await wrapper.__anext__()
asyncio.run(_run())
assert [chunk["type"] for chunk in consumer.delivered] == [
"message_start",
"content_block_start",
"content_block_delta",
"error",
]
assert consumer.delivered_when_hook_fired == (len(consumer.delivered),)
@pytest.mark.parametrize(
("close", "expected_reports"),
[
pytest.param(lambda sse: sse.aclose(), 1, id="client_closes"),
pytest.param(aclose_superseded_attempt, 0, id="fallback_takes_over"),
],
)
def test_closing_the_stream_on_the_error_event_reports_unless_a_fallback_takes_over(self, close, expected_reports):
recorder = _FailureHookRecorder()
async def _close_on_the_error_event() -> None:
wrapper = AnthropicResponsesStreamWrapper(
responses_stream=_failing_after_one_delta(), model="m", litellm_logging_obj=_logging_obj_with_failure_hook(recorder)
)
sse = wrapper.async_anthropic_sse_wrapper()
async for frame in sse:
if frame.startswith(b"event: error\n"):
await close(sse)
return
pytest.fail("the stream ended without an error event")
asyncio.run(_close_on_the_error_event())
assert len(recorder.received) == expected_reports
def test_a_client_disconnect_during_the_failure_hook_lets_the_hook_finish(self):
hook_started = anyio.Event()
hook_released = anyio.Event()
finished: list[Exception] = []
async def _held_hook(failure: Exception) -> None:
hook_started.set()
await hook_released.wait()
finished.append(failure)
async def _disconnect_mid_hook() -> None:
wrapper = AnthropicResponsesStreamWrapper(
responses_stream=_failing_after_one_delta(), model="m", litellm_logging_obj=_logging_obj_with_failure_hook(_held_hook)
)
async def _consume() -> None:
async for _ in wrapper.async_anthropic_sse_wrapper():
pass
async with anyio.create_task_group() as task_group:
task_group.start_soon(_consume)
await hook_started.wait()
task_group.cancel_scope.cancel()
hook_released.set()
asyncio.run(_disconnect_mid_hook())
assert len(finished) == 1

View file

@ -2,6 +2,7 @@
import json
from functools import reduce
from typing import Final
import pytest
from pydantic import JsonValue
@ -924,6 +925,34 @@ def test_native_writer_visits_each_tool_and_system_slot(data):
assert "payload" not in json.dumps(data)
def test_skip_tool_message_leaves_tool_results_and_masks_the_rest() -> None:
data: Final[dict[str, object]] = {
"messages": [
{"role": "user", "content": "secret"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "tool", "name": "read", "input": {"env": "secret"}}],
"tool_calls": [{"id": "call", "function": {"name": "read", "arguments": '{"env": "secret"}'}}],
},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool", "content": "secret"}]},
{"role": "tool", "tool_call_id": "call", "content": "secret"},
]
}
assert walk_user_text(data, lambda text: text.replace("secret", "[MASKED]"), skip_tool_message=True) == 3
assert data == {
"messages": [
{"role": "user", "content": "[MASKED]"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "tool", "name": "read", "input": {"env": "[MASKED]"}}],
"tool_calls": [{"id": "call", "function": {"name": "read", "arguments": '{"env": "[MASKED]"}'}}],
},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool", "content": "secret"}]},
{"role": "tool", "tool_call_id": "call", "content": "secret"},
]
}
@pytest.mark.parametrize(
"part",
(

View file

@ -1,3 +1,4 @@
import itertools
import json
from typing import Final, List, Optional
from urllib.parse import quote
@ -12,6 +13,7 @@ from pytest_mock import MockerFixture
import litellm
from litellm import Router
from litellm.constants import ANTHROPIC_DEFAULT_FILE_LIST_LIMIT
from litellm.files.types import FileContentStreamingResult
from litellm.proxy._types import LiteLLM_UserTableFiltered, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.hooks import get_proxy_hook
@ -4969,6 +4971,8 @@ def _managed_file_row(
stored_file_id: Optional[str] = None,
created_by: str = "test-user",
team_id: Optional[str] = None,
model_mappings: Optional[dict[str, str]] = None,
storage_backend: Optional[str] = None,
) -> MagicMock:
file_object = OpenAIFileObject(
id=stored_file_id or unified_file_id,
@ -4984,6 +4988,8 @@ def _managed_file_row(
file_object=file_object.model_dump(),
created_by=created_by,
team_id=team_id,
model_mappings=model_mappings or {},
storage_backend=storage_backend,
)
@ -5010,16 +5016,49 @@ class _ManagedFileTableOverRows:
return next(iter(self._owned_rows(where)), None)
async def find_many(self, where, take=None, order=None, cursor=None, skip=0):
rows = self._owned_rows(where)
if cursor is not None:
start = next(
index
for index, row in enumerate(rows)
if row.unified_file_id == cursor["unified_file_id"]
)
rows = rows[start + skip :]
owned_rows: Final = self._owned_rows(where)
oldest_first: Final = bool(order) and order[0].get("created_at") == "asc"
ordered_rows: Final = owned_rows[::-1] if oldest_first else owned_rows
start: Final = (
0
if cursor is None
else skip
+ next(index for index, row in enumerate(ordered_rows) if row.unified_file_id == cursor["unified_file_id"])
)
rows: Final = ordered_rows[start:]
return rows if take is None else rows[:take]
async def query_raw(self, query: str, *params: object) -> list[dict[str, object]]:
keys_index: Final = next(index for index, param in enumerate(params) if isinstance(param, list))
owner_ids: Final = frozenset(params[:keys_index])
model_mapping_keys: Final = frozenset(params[keys_index])
cursor_id: Final = params[keys_index + 1] if len(params) == keys_index + 3 else None
ordered_rows: Final = [
row
for row in (self.rows[::-1] if '"created_at" ASC' in query else self.rows)
if not owner_ids or {row.created_by, row.team_id} & owner_ids
]
start: Final = (
0
if cursor_id is None
else 1 + next(index for index, row in enumerate(ordered_rows) if row.unified_file_id == cursor_id)
)
listed_rows: Final = [
row
for row in ordered_rows[start:]
if not model_mapping_keys.isdisjoint(row.model_mappings)
or ('"storage_backend" IS NOT NULL' in query and row.storage_backend is not None)
]
return [
{
"unified_file_id": row.unified_file_id,
"file_object": row.file_object,
"model_mappings": row.model_mappings,
"storage_backend": row.storage_backend,
}
for row in listed_rows[: params[-1]]
]
def _setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router: Router, rows
@ -5041,7 +5080,9 @@ def _setup_unscoped_list_files_route_over_real_hook(
managed_files = PROXY_LiteLLMManagedFiles(
internal_usage_cache=MagicMock(), prisma_client=MagicMock()
)
managed_files.prisma_client.db.litellm_managedfiletable = _ManagedFileTableOverRows(rows)
managed_file_table = _ManagedFileTableOverRows(rows)
managed_files.prisma_client.db.litellm_managedfiletable = managed_file_table
managed_files.prisma_client.db.query_raw = managed_file_table.query_raw
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router)
proxy_logging_obj.proxy_hook_mapping["managed_files"] = managed_files
@ -6263,3 +6304,513 @@ def test_create_file_passthrough_fails_closed_when_guardrails_would_scan_the_bat
assert error["param"] == "passthrough"
assert "guardrails" in error["message"]
assert forwarded_calls == []
def _callers_files_on_every_deployment() -> list[MagicMock]:
return [
_managed_file_row("unified-gpt-newer", model_mappings={"gpt-3.5-turbo-id": "file-gpt-newer"}),
_managed_file_row("unified-azure", model_mappings={"azure-gpt-3-5-turbo-id": "file-azure"}),
_managed_file_row("unified-gemini", model_mappings={"gemini-2.0-flash-id": "file-gemini"}),
_managed_file_row(
"unified-theirs", created_by="other-user", model_mappings={"gpt-3.5-turbo-id": "file-theirs"}
),
_managed_file_row("unified-gpt-older", model_mappings={"gpt-3.5-turbo-id": "file-gpt-older"}),
]
def _list_files_as_test_user(path: str, params: dict[str, str], headers: dict[str, str]) -> httpx.Response:
import litellm.proxy.proxy_server as ps
try:
return client.get(path, params=params, headers={"Authorization": "Bearer test-key", **headers})
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.parametrize(
("path", "params", "headers", "expected_ids"),
[
("/openai/v1/files", {}, {}, ["unified-gpt-newer", "unified-gpt-older"]),
("/v1/files", {"provider": "azure"}, {}, ["unified-azure"]),
("/v1/files", {}, {"custom-llm-provider": "gemini"}, ["unified-gemini"]),
("/v1/files", {"target_model_names": "gemini-2.0-flash"}, {}, ["unified-gemini"]),
("/v1/files", {}, {"x-litellm-model": "gpt-3.5-turbo"}, ["unified-gpt-newer", "unified-gpt-older"]),
("/v1/files", {}, {}, ["unified-gpt-newer", "unified-azure", "unified-gemini", "unified-gpt-older"]),
],
ids=["provider-path", "provider-query", "provider-header", "target-model-names", "model-header", "no-provider"],
)
def test_require_managed_files_lists_the_callers_files_on_the_named_provider_or_model(
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
llm_router: Router,
path: str,
params: dict[str, str],
headers: dict[str, str],
expected_ids: list[str],
) -> None:
_, provider_list = _setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router, _callers_files_on_every_deployment()
)
monkeypatch.setattr("litellm.require_managed_files", True)
response: Final = _list_files_as_test_user(path, params, headers)
assert response.status_code == 200, response.text
assert [file["id"] for file in response.json()["data"]] == expected_ids
provider_list.assert_not_awaited()
def test_require_managed_files_lists_a_teammates_files_on_the_provider_and_no_other_teams(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
) -> None:
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
_setup_unscoped_list_files_route_over_real_hook(
mocker,
monkeypatch,
llm_router,
[
_managed_file_row(
"unified-teammate", created_by="teammate", team_id="team-a", model_mappings={"gpt-3.5-turbo-id": "f-1"}
),
_managed_file_row("unified-own", model_mappings={"gpt-3.5-turbo-id": "f-2"}),
_managed_file_row(
"unified-other-team", created_by="outsider", team_id="team-b", model_mappings={"gpt-3.5-turbo-id": "f-3"}
),
],
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key", user_role=LitellmUserRoles.INTERNAL_USER, user_id="test-user", team_id="team-a"
)
monkeypatch.setattr("litellm.require_managed_files", True)
response: Final = _list_files_as_test_user("/openai/v1/files", {}, {})
assert response.status_code == 200, response.text
assert [file["id"] for file in response.json()["data"]] == ["unified-teammate", "unified-own"]
def _router_with_wildcard_and_unprefixed_anthropic_deployments() -> Router:
unprefixed_anthropic_model: Final = next(model for model in sorted(litellm.anthropic_models) if "/" not in model)
return Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "openai_api_key"},
"model_info": {"id": "gpt-3.5-turbo-id"},
},
{
"model_name": "anthropic/*",
"litellm_params": {"model": "anthropic/*", "api_key": "anthropic_api_key"},
"model_info": {"id": "anthropic-wildcard-id"},
},
{
"model_name": "claude-unprefixed",
"litellm_params": {"model": unprefixed_anthropic_model, "api_key": "anthropic_api_key"},
"model_info": {"id": "claude-unprefixed-id"},
},
],
model_group_alias={"claude-alias": "claude-unprefixed"},
routing_groups=[
{"group_name": "claude-group", "models": ["claude-unprefixed"], "routing_strategy": "simple-shuffle"}
],
)
def _callers_files_on_wildcard_unprefixed_and_passthrough_deployments() -> list[MagicMock]:
return [
_managed_file_row("unified-wildcard", model_mappings={"anthropic-wildcard-id": "file-wildcard"}),
_managed_file_row("unified-unprefixed", model_mappings={"claude-unprefixed-id": "file-unprefixed"}),
_managed_file_row("unified-gpt", model_mappings={"gpt-3.5-turbo-id": "file-gpt"}),
_managed_file_row(
"unified-passthrough",
model_mappings={
"_passthrough_openai": "file-passthrough",
"_passthrough_provider_marker": "_passthrough_provider:openai",
},
),
_managed_file_row(
"unified-storage",
model_mappings={"claude-unprefixed": "litellm_db://unified-storage"},
storage_backend="litellm_db",
),
_managed_file_row(
"unified-wildcard-storage",
model_mappings={"anthropic/claude-haiku-5-5": "litellm_db://unified-wildcard-storage"},
storage_backend="litellm_db",
),
_managed_file_row(
"unified-alias-storage",
model_mappings={"claude-alias": "s3://bucket/unified-alias-storage"},
storage_backend="s3",
),
_managed_file_row(
"unified-group-storage",
model_mappings={"claude-group": "s3://bucket/unified-group-storage"},
storage_backend="s3",
),
]
@pytest.mark.parametrize(
("path", "params", "headers", "expected_ids"),
[
(
"/v1/files",
{"target_model_names": "anthropic/claude-haiku-5-5"},
{},
["unified-wildcard", "unified-wildcard-storage"],
),
(
"/v1/files",
{},
{"x-litellm-model": "anthropic/claude-haiku-5-5"},
["unified-wildcard", "unified-wildcard-storage"],
),
(
"/anthropic/v1/files",
{},
{},
[
"unified-wildcard",
"unified-unprefixed",
"unified-storage",
"unified-wildcard-storage",
"unified-alias-storage",
"unified-group-storage",
],
),
("/openai/v1/files", {}, {}, ["unified-gpt", "unified-passthrough"]),
("/v1/files", {"target_model_names": "claude-unprefixed"}, {}, ["unified-unprefixed", "unified-storage"]),
],
ids=[
"wildcard-target-model-names",
"wildcard-model-header",
"unprefixed-provider",
"passthrough-provider",
"storage-backed-model",
],
)
def test_require_managed_files_lists_the_callers_files_wherever_the_router_put_them(
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
path: str,
params: dict[str, str],
headers: dict[str, str],
expected_ids: list[str],
) -> None:
_, provider_list = _setup_unscoped_list_files_route_over_real_hook(
mocker,
monkeypatch,
_router_with_wildcard_and_unprefixed_anthropic_deployments(),
_callers_files_on_wildcard_unprefixed_and_passthrough_deployments(),
)
monkeypatch.setattr("litellm.require_managed_files", True)
response: Final = _list_files_as_test_user(path, params, headers)
assert response.status_code == 200, response.text
assert [file["id"] for file in response.json()["data"]] == expected_ids
provider_list.assert_not_awaited()
def _router_with_a_catch_all_openai_deployment() -> Router:
unprefixed_anthropic_model: Final = next(model for model in sorted(litellm.anthropic_models) if "/" not in model)
return Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "openai_api_key"},
"model_info": {"id": "gpt-3.5-turbo-id"},
},
{
"model_name": "*",
"litellm_params": {"model": "openai/*", "api_key": "openai_api_key"},
"model_info": {"id": "catch-all-id"},
},
{
"model_name": "claude-unprefixed",
"litellm_params": {"model": unprefixed_anthropic_model, "api_key": "anthropic_api_key"},
"model_info": {"id": "claude-unprefixed-id"},
},
]
)
@pytest.mark.parametrize(
("path", "expected_ids"),
[
("/openai/v1/files", ["unified-gpt", "unified-catch-all-storage"]),
("/anthropic/v1/files", ["unified-unprefixed", "unified-storage"]),
],
ids=["catch-all-provider", "explicit-provider"],
)
def test_require_managed_files_keeps_a_catch_all_route_from_claiming_files_on_other_providers(
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
path: str,
expected_ids: list[str],
) -> None:
_, provider_list = _setup_unscoped_list_files_route_over_real_hook(
mocker,
monkeypatch,
_router_with_a_catch_all_openai_deployment(),
[
_managed_file_row("unified-unprefixed", model_mappings={"claude-unprefixed-id": "file-unprefixed"}),
_managed_file_row("unified-gpt", model_mappings={"gpt-3.5-turbo-id": "file-gpt"}),
_managed_file_row(
"unified-storage",
model_mappings={"claude-unprefixed": "litellm_db://unified-storage"},
storage_backend="litellm_db",
),
_managed_file_row(
"unified-catch-all-storage",
model_mappings={"model-only-the-catch-all-serves": "litellm_db://unified-catch-all-storage"},
storage_backend="litellm_db",
),
],
)
monkeypatch.setattr("litellm.require_managed_files", True)
response: Final = _list_files_as_test_user(path, {}, {})
assert response.status_code == 200, response.text
assert [file["id"] for file in response.json()["data"]] == expected_ids
provider_list.assert_not_awaited()
@pytest.mark.parametrize(
("path", "params"),
[
("/antropic/v1/files", {}),
("/openai/v1/files", {"after": "unified-gpt", "after_id": "unified-wildcard"}),
("/v1/files", {"target_model_names": "gpt-3.5-turbo,claude-unprefixed"}),
],
ids=["unknown-provider", "conflicting-after-cursors", "two-target-model-names"],
)
def test_require_managed_files_rejects_a_list_it_cannot_scope(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, path: str, params: dict[str, str]
) -> None:
_, provider_list = _setup_unscoped_list_files_route_over_real_hook(
mocker,
monkeypatch,
_router_with_wildcard_and_unprefixed_anthropic_deployments(),
_callers_files_on_wildcard_unprefixed_and_passthrough_deployments(),
)
monkeypatch.setattr("litellm.require_managed_files", True)
response: Final = _list_files_as_test_user(path, params, {})
assert response.status_code == 400, response.text
provider_list.assert_not_awaited()
@pytest.mark.parametrize("cursor_param", ["after", "after_id"])
def test_require_managed_files_pages_a_provider_list_with_either_cursor_param(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router, cursor_param: str
) -> None:
import litellm.proxy.proxy_server as ps
_setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router, _callers_files_on_every_deployment()
)
monkeypatch.setattr("litellm.require_managed_files", True)
try:
first_page: Final = client.get(
"/openai/v1/files", params={"limit": "1"}, headers={"Authorization": "Bearer test-key"}
)
second_page: Final = client.get(
"/openai/v1/files",
params={"limit": "1", cursor_param: first_page.json()["last_id"]},
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert (first_page.status_code, second_page.status_code) == (200, 200), (first_page.text, second_page.text)
assert [file["id"] for file in first_page.json()["data"]] == ["unified-gpt-newer"]
assert first_page.json()["has_more"] is True
assert [file["id"] for file in second_page.json()["data"]] == ["unified-gpt-older"]
assert second_page.json()["has_more"] is False
def test_require_managed_files_pages_toward_newer_files_with_before_id(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
) -> None:
import litellm.proxy.proxy_server as ps
_setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router, _callers_files_on_every_deployment()
)
monkeypatch.setattr("litellm.require_managed_files", True)
try:
page_before_oldest: Final = client.get(
"/v1/files",
params={"limit": "2", "before_id": "unified-gpt-older"},
headers={"Authorization": "Bearer test-key"},
)
newest_page: Final = client.get(
"/v1/files",
params={"limit": "2", "before_id": page_before_oldest.json()["first_id"]},
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert (page_before_oldest.status_code, newest_page.status_code) == (200, 200), (
page_before_oldest.text,
newest_page.text,
)
assert [file["id"] for file in page_before_oldest.json()["data"]] == ["unified-azure", "unified-gemini"]
assert page_before_oldest.json()["has_more"] is True
assert [file["id"] for file in newest_page.json()["data"]] == ["unified-gpt-newer"]
assert newest_page.json()["has_more"] is False
def test_managed_file_list_rejects_both_cursor_directions(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
) -> None:
_setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router, _callers_files_on_every_deployment()
)
monkeypatch.setattr("litellm.require_managed_files", True)
response: Final = _list_files_as_test_user(
"/v1/files", {"after": "unified-gpt-newer", "before_id": "unified-gpt-older"}, {}
)
assert response.status_code == 400, response.text
assert "not both" in response.json()["error"]["message"]
@pytest.mark.parametrize(
("params", "headers"),
[({"target_model_names": "gemini-2.0-flash"}, {}), ({}, {"x-litellm-model": "gemini-2.0-flash"})],
ids=["target-model-names", "model-header"],
)
def test_require_managed_files_list_rejects_a_model_the_key_cannot_use(
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
llm_router: Router,
params: dict[str, str],
headers: dict[str, str],
) -> None:
import litellm.proxy.proxy_server as ps
_setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router, _callers_files_on_every_deployment()
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="test-user",
models=["gpt-3.5-turbo"],
)
monkeypatch.setattr("litellm.require_managed_files", True)
response: Final = _list_files_as_test_user("/v1/files", params, headers)
assert response.status_code == 403, response.text
assert "unified-gemini" not in response.text
def test_require_managed_files_list_fails_closed_without_the_managed_files_hook(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
) -> None:
proxy_logging_obj, provider_list = _setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router, _callers_files_on_every_deployment()
)
proxy_logging_obj.proxy_hook_mapping.pop("managed_files")
provider_list.return_value = [_managed_file("file-on-the-shared-key")]
monkeypatch.setattr("litellm.require_managed_files", True)
response: Final = _list_files_as_test_user("/openai/v1/files", {}, {})
assert response.status_code == 500, response.text
assert "file-on-the-shared-key" not in response.text
assert "require_managed_files cannot scope the list to the caller" in response.json()["error"]["message"]
provider_list.assert_not_awaited()
@pytest.mark.parametrize("hook_loaded", [True, False], ids=["hook-loaded", "no-hook"])
def test_require_managed_files_keeps_the_provider_list_for_an_admin(
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
llm_router: Router,
hook_loaded: bool,
) -> None:
import litellm.proxy.proxy_server as ps
proxy_logging_obj, provider_list = _setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router, _callers_files_on_every_deployment()
)
if not hook_loaded:
proxy_logging_obj.proxy_hook_mapping.pop("managed_files")
provider_list.return_value = [_managed_file("file-on-the-shared-key")]
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key", user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user"
)
monkeypatch.setattr("litellm.require_managed_files", True)
response: Final = _list_files_as_test_user("/anthropic/v1/files", {}, {})
assert response.status_code == 200, response.text
assert [file["id"] for file in response.json()["data"]] == ["file-on-the-shared-key"]
def test_managed_file_list_without_require_managed_files_ignores_cursor_params_openai_does_not_define(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
) -> None:
_setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router, _callers_files_on_every_deployment()
)
monkeypatch.setattr("litellm.require_managed_files", False)
response: Final = _list_files_as_test_user(
"/v1/files", {"after": "unified-gpt-newer", "after_id": "unified-gemini", "before_id": "file-raw"}, {}
)
assert response.status_code == 200, response.text
assert [file["id"] for file in response.json()["data"]] == ["unified-azure", "unified-gemini", "unified-gpt-older"]
@pytest.mark.parametrize(
("path", "headers", "expected_count", "has_more"),
[
("/anthropic/v1/files", {}, ANTHROPIC_DEFAULT_FILE_LIST_LIMIT, True),
("/v1/files", {"x-litellm-model": "claude-unprefixed"}, ANTHROPIC_DEFAULT_FILE_LIST_LIMIT, True),
("/anthropic/v1/files", {"x-litellm-model": "gpt-3.5-turbo"}, ANTHROPIC_DEFAULT_FILE_LIST_LIMIT + 1, False),
("/v1/files", {}, 2 * (ANTHROPIC_DEFAULT_FILE_LIST_LIMIT + 1), False),
],
ids=["anthropic-provider", "anthropic-model-header", "openai-model-header", "every-provider"],
)
def test_require_managed_files_pages_an_anthropic_list_at_the_anthropic_default_size(
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
path: str,
headers: dict[str, str],
expected_count: int,
has_more: bool,
) -> None:
_setup_unscoped_list_files_route_over_real_hook(
mocker,
monkeypatch,
_router_with_wildcard_and_unprefixed_anthropic_deployments(),
[
_managed_file_row(
f"unified-{deployment_id}-{index:03d}", model_mappings={deployment_id: f"file-{index:03d}"}
)
for deployment_id, index in itertools.product(
("claude-unprefixed-id", "gpt-3.5-turbo-id"), range(ANTHROPIC_DEFAULT_FILE_LIST_LIMIT + 1)
)
],
)
monkeypatch.setattr("litellm.require_managed_files", True)
response: Final = _list_files_as_test_user(path, {}, headers)
assert response.status_code == 200, response.text
assert (len(response.json()["data"]), response.json()["has_more"]) == (expected_count, has_more)

View file

@ -10826,12 +10826,19 @@ class TestResolveLitellmCallId:
class _FailureHookRecorder:
"""Stands in for ProxyLogging.post_call_failure_hook, recording what the detached-failure closure hands it."""
def __init__(self, raises: Optional[Exception] = None):
def __init__(self, raises: Optional[Exception] = None, gate: Optional[asyncio.Event] = None):
self.calls = []
self.entered = asyncio.Event()
self.reported = asyncio.Event()
self._raises = raises
self._gate = gate
async def post_call_failure_hook(self, **kwargs):
self.entered.set()
if self._gate is not None:
await self._gate.wait()
self.calls.append(kwargs)
self.reported.set()
if self._raises is not None:
raise self._raises
@ -10932,6 +10939,171 @@ class TestDetachedStreamFailureHook:
assert [call["original_exception"] for call in recorder.calls] == [failure]
@staticmethod
def _armed_stream(recorder: _FailureHookRecorder, iterator_hook_for, response: Optional[MagicMock] = None):
from litellm.proxy._types import UserAPIKeyAuth as RealUserAPIKeyAuth
logging_obj = LiteLLMLoggingObj(
model="gpt-6.1-sol",
messages=[{"role": "user", "content": "Write a story."}],
stream=True,
call_type="anthropic_messages",
start_time=None,
litellm_call_id="call-lit6871",
function_id="function-lit6871",
)
user_api_key_dict = RealUserAPIKeyAuth(api_key="sk-test")
processing_obj = ProxyBaseLLMRequestProcessing(data={"litellm_logging_obj": logging_obj})
ProxyLogging._callback_capabilities_cache.clear()
proxy_logging_obj = ProxyLogging(user_api_key_cache=MagicMock())
proxy_logging_obj.post_call_failure_hook = recorder.post_call_failure_hook
proxy_logging_obj.async_post_call_streaming_iterator_hook = iterator_hook_for(logging_obj)
processing_obj._arm_detached_stream_failure_hook(
logging_obj=logging_obj,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
)
stream = ProxyBaseLLMRequestProcessing.async_streaming_data_generator(
response=MagicMock() if response is None else response,
user_api_key_dict=user_api_key_dict,
request_data=processing_obj.data,
proxy_logging_obj=proxy_logging_obj,
serialize_chunk=lambda chunk: "data: chunk\n\n",
serialize_error=lambda error: "data: error\n\n",
)
return logging_obj, stream
@staticmethod
def _hook_reporting(upstream_failure: Exception, after_report=None):
def iterator_hook_for(logging_obj):
async def iterator_hook(**_kwargs):
yield b"event: message_start\n\n"
await logging_obj.on_detached_stream_failure(upstream_failure)
yield b"event: error\n\n"
yield b"event: message_stop\n\n"
if after_report is not None:
raise after_report
return iterator_hook
return iterator_hook_for
@pytest.mark.asyncio
async def test_iterator_hook_failure_after_a_bridged_failure_is_the_one_the_failure_hook_gets(self):
recorder = _FailureHookRecorder()
upstream_failure = RuntimeError("upstream Responses stream failed")
hook_failure = RuntimeError("iterator hook rejected the stream")
_, stream = self._armed_stream(recorder, self._hook_reporting(upstream_failure, after_report=hook_failure))
frames = [frame async for frame in stream]
assert frames == ["data: chunk\n\n"] * 3 + ["data: error\n\n"]
assert [call["original_exception"] for call in recorder.calls] == [hook_failure]
@pytest.mark.asyncio
async def test_failure_hook_replacing_the_iterator_hook_failure_aborts_the_stream(self):
replacement = HTTPException(status_code=418, detail="rejected by a custom failure hook")
recorder = _FailureHookRecorder(raises=replacement)
upstream_failure = RuntimeError("upstream Responses stream failed")
hook_failure = RuntimeError("iterator hook rejected the stream")
_, stream = self._armed_stream(recorder, self._hook_reporting(upstream_failure, after_report=hook_failure))
frames = [await stream.__anext__() for _ in range(3)]
with pytest.raises(HTTPException) as raised:
await stream.__anext__()
assert raised.value is replacement
assert frames == ["data: chunk\n\n"] * 3
assert [call["original_exception"] for call in recorder.calls] == [hook_failure]
@pytest.mark.asyncio
async def test_failure_reported_by_the_stream_reaches_the_failure_hook_once_after_the_stream_ends(self):
recorder = _FailureHookRecorder()
upstream_failure = RuntimeError("upstream Responses stream failed")
_, stream = self._armed_stream(recorder, self._hook_reporting(upstream_failure))
frames = []
async for frame in stream:
frames.append(frame)
assert recorder.calls == []
assert frames == ["data: chunk\n\n"] * 3
assert [call["original_exception"] for call in recorder.calls] == [upstream_failure]
@pytest.mark.asyncio
async def test_failure_report_and_stream_cleanup_survive_the_client_task_being_cancelled_mid_report(self):
gate = asyncio.Event()
recorder = _FailureHookRecorder(gate=gate)
upstream_failure = RuntimeError("upstream Responses stream failed")
response = MagicMock()
response.aclose = AsyncMock()
_, stream = self._armed_stream(recorder, self._hook_reporting(upstream_failure), response=response)
async def client_reads_the_whole_stream():
return [frame async for frame in stream]
client = asyncio.create_task(client_reads_the_whole_stream())
await asyncio.wait_for(recorder.entered.wait(), timeout=5)
client.cancel()
with pytest.raises(asyncio.CancelledError):
await client
response.aclose.assert_awaited_once()
gate.set()
await asyncio.wait_for(recorder.reported.wait(), timeout=5)
assert [call["original_exception"] for call in recorder.calls] == [upstream_failure]
@pytest.mark.asyncio
async def test_failure_reported_by_the_stream_reaches_the_failure_hook_once_when_the_client_leaves_early(self):
recorder = _FailureHookRecorder()
upstream_failure = RuntimeError("upstream Responses stream failed")
_, stream = self._armed_stream(recorder, self._hook_reporting(upstream_failure))
await stream.__anext__()
await stream.__anext__()
await stream.aclose()
assert [call["original_exception"] for call in recorder.calls] == [upstream_failure]
@pytest.mark.asyncio
async def test_failure_reported_after_the_stream_ends_still_reaches_the_failure_hook(self):
recorder = _FailureHookRecorder()
upstream_failure = RuntimeError("upstream Responses stream failed after the client stream closed")
async def iterator_hook(**_kwargs):
yield b"event: message_start\n\n"
logging_obj, stream = self._armed_stream(recorder, lambda _logging_obj: iterator_hook)
frames = [frame async for frame in stream]
await logging_obj.on_detached_stream_failure(upstream_failure)
assert frames == ["data: chunk\n\n"]
assert [call["original_exception"] for call in recorder.calls] == [upstream_failure]
@pytest.mark.asyncio
async def test_failure_reported_by_an_iterator_hook_is_not_reported_again_when_the_stream_closes(self):
recorder = _FailureHookRecorder()
upstream_failure = RuntimeError("upstream Responses stream failed")
hook_failure = RuntimeError("iterator hook rejected the error frame")
async def iterator_hook(**_kwargs):
yield b"event: message_start\n\n"
raise hook_failure
logging_obj, stream = self._armed_stream(recorder, lambda _logging_obj: iterator_hook)
frames = [frame async for frame in stream]
await logging_obj.on_detached_stream_failure(upstream_failure)
assert frames == ["data: chunk\n\n", "data: error\n\n"]
assert [call["original_exception"] for call in recorder.calls] == [hook_failure]
class TestPostCallMaskedOutputReachesDeferredLogging:
@pytest.mark.asyncio

View file

@ -2668,6 +2668,65 @@ async def test_stream_auto_execute_bills_the_paid_first_round_on_its_success_row
assert _request_failure_row(logging_obj, recorder.logged[0]).get("response_cost", 0.0) == 0.0
class _CutAfterStreamedText(httpx.AsyncByteStream):
async def __aiter__(self):
opened: Final = {**json.loads(json.dumps(_ROUND_TWO_BODY, default=dict)), "status": "in_progress"}
yield _sse(
(
{"type": "response.created", "response": opened, "sequence_number": 0},
{
"type": "response.output_text.delta",
"item_id": "msg_round_two",
"output_index": 0,
"content_index": 0,
"delta": "PING " * 40,
"sequence_number": 1,
},
)
)
raise httpx.ReadError("connection reset mid-stream")
@pytest.mark.asyncio
async def test_stream_auto_execute_bills_a_follow_up_cut_after_streamed_text_with_no_second_fixed_margin(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
from litellm.proxy.utils import ProxyLogging
monkeypatch.setattr(litellm, "cost_margin_config", {"openai": {"fixed_amount": _FIXED_MARGIN}})
_serve_responses(
respx_mock,
monkeypatch,
(_streamed(_ROUND_ONE_BODY), httpx.Response(200, stream=_CutAfterStreamedText(), headers=SSE_HEADERS)),
)
recorder: Final = _RoundRecorder()
logging_obj: Final = _rounds_logging_obj(recorder, "mcp-stream-follow-up-cut-after-text")
with pytest.raises(httpx.ReadError):
await _stream_auto_execute(logging_obj)
ProxyLogging.fire_deferred_stream_logging({"litellm_logging_obj": logging_obj})
await _settle(lambda: len(recorder.logged) > 0 and len(recorder.failed) > 0)
_assert_one_row_bills(recorder.logged, _ROUND_ONE_BODY)
assert len(recorder.failed) == 1
failed: Final = recorder.failed[0]
assert failed["completion_tokens"] > 0
priced_with_its_own_margin: Final = litellm.completion_cost(
completion_response=ModelResponse(
model="gpt-5.6",
usage=Usage(
prompt_tokens=failed["prompt_tokens"],
completion_tokens=failed["completion_tokens"],
total_tokens=failed["prompt_tokens"] + failed["completion_tokens"],
),
),
model="gpt-5.6",
custom_llm_provider="openai",
)
assert failed["response_cost"] == pytest.approx(priced_with_its_own_margin - _FIXED_MARGIN)
_assert_breakdown_agrees_with_the_billed_cost(failed)
@pytest.mark.asyncio
async def test_stream_auto_execute_blocked_by_a_post_call_guardrail_keeps_both_rounds_on_the_success_row(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch

View file

@ -9,20 +9,23 @@ crashed with exit 139 whenever any CustomLogger was registered).
import asyncio
import json
import time
from collections.abc import Mapping
from typing import Final
from collections.abc import AsyncIterator, Mapping
from datetime import datetime
from typing import Final, cast
import httpx
import pytest
import litellm
from litellm.exceptions import MidStreamFallbackError
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils import thread_pool_executor as thread_pool_executor_module
from litellm.responses import streaming_iterator as responses_streaming_iterator_module
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.responses import streaming_iterator as responses_streaming_iterator_module
from litellm.responses.streaming_iterator import ResponsesAPIStreamingIterator
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.llms.openai import ResponsesAPIResponse, ResponsesAPIStreamingResponse
from litellm.types.utils import StandardLoggingPayload
class RecordingCustomLogger(CustomLogger):
@ -169,6 +172,240 @@ async def test_sync_callbacks_run_only_after_async_handler_completes(recording_e
assert submit_times[0] >= recorder.async_hook_finished
class FailureRecorder(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.failure_payloads: tuple[StandardLoggingPayload, ...] = ()
self.success_payloads: tuple[StandardLoggingPayload, ...] = ()
async def async_log_failure_event(
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
) -> None:
self.failure_payloads = (
*self.failure_payloads,
cast(StandardLoggingPayload, kwargs["standard_logging_object"]),
)
async def async_log_success_event(
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
) -> None:
self.success_payloads = (
*self.success_payloads,
cast(StandardLoggingPayload, kwargs["standard_logging_object"]),
)
class _ChunkRecorder:
def __init__(self) -> None:
self.chunks: tuple[ResponsesAPIStreamingResponse, ...] = ()
async def drain(self, iterator: ResponsesAPIStreamingIterator) -> None:
async for chunk in iterator:
self.chunks = (*self.chunks, chunk)
_SEA_DELTAS = ("The sea ", "is wide ", "and deep.")
_SEA_PROMPT = "Write a 300 word story about the sea."
_UNREACHABLE_IMAGE_INPUT: list[dict[str, object]] = [
{
"role": "user",
"content": [
{"type": "input_text", "text": "Describe this picture."},
{"type": "input_image", "image_url": "http://images.invalid/photo.png", "detail": "high"},
],
}
]
_TOOL_ARGUMENT_DELTAS = ('{"city": ', '"Lisbon", ', '"unit": "celsius"}')
class _UpstreamThatTimesOutAfterThreeDeltas:
headers: dict[str, str] = {}
def __init__(self, event_type: str = "response.output_text.delta", deltas: tuple[str, ...] = _SEA_DELTAS) -> None:
self.event_type = event_type
self.deltas = deltas
async def aiter_bytes(self) -> AsyncIterator[bytes]:
for sequence_number, delta in enumerate(self.deltas):
event = {
"type": self.event_type,
"item_id": "item_1",
"output_index": 0,
"content_index": 0,
"delta": delta,
"sequence_number": sequence_number,
}
yield f"data: {json.dumps(event)}\n\n".encode()
raise httpx.ReadTimeout("Timeout on reading data from socket")
class _UpstreamThatFailsWithUsageAfterThreeDeltas:
headers: dict[str, str] = {}
async def aiter_bytes(self) -> AsyncIterator[bytes]:
for sequence_number, delta in enumerate(_SEA_DELTAS):
event = {
"type": "response.output_text.delta",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"delta": delta,
"sequence_number": sequence_number,
}
yield f"data: {json.dumps(event)}\n\n".encode()
failed = {
"type": "response.failed",
"sequence_number": len(_SEA_DELTAS),
"response": {
"id": "resp_1",
"object": "response",
"created_at": 1700000000,
"status": "failed",
"model": "gpt-5.4-nano",
"output": [],
"error": {"code": "server_error", "message": "The model had an internal error"},
"usage": {"input_tokens": 400, "output_tokens": 9, "total_tokens": 409},
},
}
yield f"data: {json.dumps(failed)}\n\n".encode()
async def _failure_payload_after_mid_stream_timeout(
monkeypatch: pytest.MonkeyPatch,
request_input: str | list[dict[str, object]],
upstream: _UpstreamThatTimesOutAfterThreeDeltas | None = None,
background_fields: Mapping[str, object] | None = None,
) -> tuple[FailureRecorder, StandardLoggingPayload]:
upstream_response: Final = upstream or _UpstreamThatTimesOutAfterThreeDeltas()
recorder = FailureRecorder()
monkeypatch.setattr(litellm, "failure_callback", [recorder])
monkeypatch.setattr(litellm, "_async_failure_callback", [recorder])
monkeypatch.setattr(litellm, "success_callback", [recorder])
monkeypatch.setattr(litellm, "_async_success_callback", [recorder])
logging_obj = _make_logging_obj()
logging_obj.update_environment_variables(
model="gpt-5.4-nano",
optional_params={"stream": True},
litellm_params={"custom_llm_provider": "openai", "aresponses": True},
)
iterator = ResponsesAPIStreamingIterator(
response=cast(httpx.Response, upstream_response),
model="gpt-5.4-nano",
responses_api_provider_config=OpenAIResponsesAPIConfig(),
logging_obj=logging_obj,
custom_llm_provider="openai",
request_data={"model": "gpt-5.4-nano", "input": request_input, "stream": True, **(background_fields or {})},
)
received = _ChunkRecorder()
with pytest.raises(litellm.Timeout, match="Timeout on reading data from socket"):
await received.drain(iterator)
assert [getattr(chunk, "delta", None) for chunk in received.chunks] == list(upstream_response.deltas)
await iterator._await_pending_logging()
(payload,) = recorder.failure_payloads
return recorder, payload
@pytest.mark.asyncio
async def test_mid_stream_read_timeout_logs_failure_with_partial_usage_and_no_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder, payload = await _failure_payload_after_mid_stream_timeout(monkeypatch, _SEA_PROMPT)
assert payload["status"] == "failure"
assert payload["prompt_tokens"] == litellm.token_counter(
model="gpt-5.4-nano", messages=[{"role": "user", "content": _SEA_PROMPT}]
)
assert payload["completion_tokens"] == litellm.token_counter(
model="gpt-5.4-nano", text="".join(_SEA_DELTAS), count_response_tokens=True
)
assert payload["response_cost"] > 0
assert recorder.success_payloads == ()
@pytest.mark.asyncio
async def test_mid_stream_read_timeout_bills_the_tool_call_arguments_already_streamed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
upstream: Final = _UpstreamThatTimesOutAfterThreeDeltas(
event_type="response.function_call_arguments.delta", deltas=_TOOL_ARGUMENT_DELTAS
)
_, payload = await _failure_payload_after_mid_stream_timeout(monkeypatch, _SEA_PROMPT, upstream)
assert payload["status"] == "failure"
assert payload["completion_tokens"] == litellm.token_counter(
model="gpt-5.4-nano", text="".join(_TOOL_ARGUMENT_DELTAS), count_response_tokens=True
)
assert payload["response_cost"] > 0
@pytest.mark.asyncio
async def test_mid_stream_failure_of_a_background_response_bills_nothing_since_retrieving_it_bills_the_whole_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_, payload = await _failure_payload_after_mid_stream_timeout(
monkeypatch, _SEA_PROMPT, background_fields={"background": True}
)
assert payload["status"] == "failure"
assert payload["completion_tokens"] == 0
assert payload["response_cost"] == 0
@pytest.mark.asyncio
async def test_mid_stream_failure_usage_estimate_never_fetches_image_dimensions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_, payload = await _failure_payload_after_mid_stream_timeout(monkeypatch, _UNREACHABLE_IMAGE_INPUT)
assert payload["status"] == "failure"
assert payload["prompt_tokens"] == litellm.token_counter(
model="gpt-5.4-nano",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this picture."},
{"type": "image_url", "image_url": {"url": "http://images.invalid/photo.png", "detail": "high"}},
],
}
],
use_default_image_token_count=True,
)
assert payload["completion_tokens"] > 0
@pytest.mark.asyncio
async def test_mid_stream_failure_keeps_the_usage_the_provider_reported(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = FailureRecorder()
monkeypatch.setattr(litellm, "failure_callback", [recorder])
monkeypatch.setattr(litellm, "_async_failure_callback", [recorder])
logging_obj = _make_logging_obj()
logging_obj.update_environment_variables(
model="gpt-5.4-nano",
optional_params={"stream": True},
litellm_params={"custom_llm_provider": "openai", "aresponses": True},
)
iterator = ResponsesAPIStreamingIterator(
response=cast(httpx.Response, _UpstreamThatFailsWithUsageAfterThreeDeltas()),
model="gpt-5.4-nano",
responses_api_provider_config=OpenAIResponsesAPIConfig(),
logging_obj=logging_obj,
custom_llm_provider="openai",
request_data={"model": "gpt-5.4-nano", "input": _SEA_PROMPT, "stream": True},
)
with pytest.raises(MidStreamFallbackError, match="The model had an internal error"):
await _ChunkRecorder().drain(iterator)
await iterator._await_pending_logging()
(payload,) = recorder.failure_payloads
assert (payload["prompt_tokens"], payload["completion_tokens"]) == (400, 9)
_CREATED_RESPONSE_WITH_PRIORITY_TIER: Final[Mapping[str, object]] = {
"id": "resp_partial",
"object": "response",
@ -211,17 +448,135 @@ def _completed_after(text: str) -> Mapping[str, object]:
}
def _live_iterator(logging_obj: LitellmLogging) -> ResponsesAPIStreamingIterator:
def _live_iterator(
logging_obj: LitellmLogging, upstream: object = None, background: bool = False
) -> ResponsesAPIStreamingIterator:
return ResponsesAPIStreamingIterator(
response=httpx.Response(200),
response=cast(httpx.Response, upstream or httpx.Response(200)),
model="gpt-5.4-nano",
responses_api_provider_config=OpenAIResponsesAPIConfig(),
logging_obj=logging_obj,
request_data={"model": "gpt-5.4-nano", "input": "tell me a story"},
request_data={"model": "gpt-5.4-nano", "input": "tell me a story", "background": background},
custom_llm_provider="openai",
)
class _UpstreamThatTimesOutAfterRelayingText:
headers: dict[str, str] = {}
async def aiter_bytes(self) -> AsyncIterator[bytes]:
for event in (_CREATED_WITH_PRIORITY_TIER, _text_delta("Once upon a time", 1)):
yield f"data: {json.dumps(event)}\n\n".encode()
raise httpx.ReadTimeout("Timeout on reading data from socket")
class _UpstreamThatCompletesThenTimesOut:
headers: dict[str, str] = {}
async def aiter_bytes(self) -> AsyncIterator[bytes]:
events: Final = (
_CREATED_WITH_PRIORITY_TIER,
*(_text_delta(delta, sequence_number) for sequence_number, delta in enumerate(_SEA_DELTAS, start=1)),
_completed_after("".join(_SEA_DELTAS)),
)
for event in events:
yield f"data: {json.dumps(event)}\n\n".encode()
raise httpx.ReadTimeout("Timeout on reading data from socket")
@pytest.mark.asyncio
async def test_timeout_after_completed_response_ends_the_stream_and_bills_it_once(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder: Final = FailureRecorder()
monkeypatch.setattr(litellm, "failure_callback", [recorder])
monkeypatch.setattr(litellm, "_async_failure_callback", [recorder])
monkeypatch.setattr(litellm, "success_callback", [recorder])
monkeypatch.setattr(litellm, "_async_success_callback", [recorder])
logging_obj: Final = _make_logging_obj()
logging_obj.update_environment_variables(
model="gpt-5.4-nano",
optional_params={"stream": True},
litellm_params={"custom_llm_provider": "openai", "aresponses": True},
)
iterator: Final = _live_iterator(logging_obj, upstream=_UpstreamThatCompletesThenTimesOut())
chunks: Final = _ChunkRecorder()
await chunks.drain(iterator)
await iterator._await_pending_logging()
assert chunks.chunks[-1].type == "response.completed"
assert len(recorder.success_payloads) == 1
success_payload: Final = recorder.success_payloads[0]
response_payload: Final = success_payload["response"]
assert isinstance(response_payload, dict)
usage: Final = cast(dict[str, object], response_payload["usage"])
assert (usage["prompt_tokens"], usage["completion_tokens"], usage["total_tokens"]) == (5, 7, 12)
assert recorder.failure_payloads == ()
assert logging_obj.model_call_details.get("combined_usage_object") is None
class _UpstreamThatTimesOutAfterCreated:
headers: dict[str, str] = {}
def __init__(self, **created_fields: object) -> None:
self.created_fields = created_fields
async def aiter_bytes(self) -> AsyncIterator[bytes]:
created: Final = {
**_CREATED_WITH_PRIORITY_TIER,
"response": {**_CREATED_RESPONSE_WITH_PRIORITY_TIER, **self.created_fields},
}
for event in (created, _text_delta("Once upon a time", 1)):
yield f"data: {json.dumps(event)}\n\n".encode()
raise httpx.ReadTimeout("Timeout on reading data from socket")
async def _mid_stream_failure_payload(
monkeypatch: pytest.MonkeyPatch, requested_tier: str | None, **created_fields: object
) -> StandardLoggingPayload:
recorder: Final = FailureRecorder()
monkeypatch.setattr(litellm, "failure_callback", [recorder])
monkeypatch.setattr(litellm, "_async_failure_callback", [recorder])
logging_obj: Final = _make_logging_obj()
logging_obj.update_environment_variables(
model="gpt-5.4-nano",
optional_params={"stream": True}
if requested_tier is None
else {"stream": True, "service_tier": requested_tier},
litellm_params={"custom_llm_provider": "openai", "aresponses": True},
)
iterator: Final = _live_iterator(logging_obj, upstream=_UpstreamThatTimesOutAfterCreated(**created_fields))
with pytest.raises(litellm.Timeout):
await _ChunkRecorder().drain(iterator)
await iterator._await_pending_logging()
(payload,) = recorder.failure_payloads
return payload
@pytest.mark.asyncio
async def test_mid_stream_failure_bills_the_relayed_text_at_the_tier_the_provider_served(
monkeypatch: pytest.MonkeyPatch,
) -> None:
flex_requested: Final = await _mid_stream_failure_payload(monkeypatch, "flex", service_tier="default")
nothing_requested: Final = await _mid_stream_failure_payload(monkeypatch, None, service_tier="default")
assert nothing_requested["response_cost"] > 0
assert flex_requested["response_cost"] == nothing_requested["response_cost"]
@pytest.mark.asyncio
async def test_mid_stream_failure_bills_nothing_when_the_provider_runs_the_response_in_background(
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload: Final = await _mid_stream_failure_payload(monkeypatch, None, background=True)
assert payload["status"] == "failure"
assert (payload["completion_tokens"], payload["response_cost"]) == (0, 0)
def test_partial_response_on_disconnect_bills_the_relayed_text_at_the_served_service_tier():
iterator: Final = _live_iterator(_make_logging_obj())
for event in (_CREATED_WITH_PRIORITY_TIER, _text_delta("Once upon a time ", 1), _text_delta("a fox slept.", 2)):
@ -263,3 +618,25 @@ async def test_partial_response_on_disconnect_is_none_when_there_is_nothing_to_b
assert iterator.partial_response_on_disconnect() is None
await iterator._await_pending_logging()
@pytest.mark.asyncio
async def test_partial_response_on_disconnect_is_none_once_the_failure_log_billed_the_relayed_text():
iterator: Final = _live_iterator(_make_logging_obj(), upstream=_UpstreamThatTimesOutAfterRelayingText())
with pytest.raises(litellm.Timeout):
await _ChunkRecorder().drain(iterator)
assert iterator.partial_response_on_disconnect() is None
await iterator._await_pending_logging()
def test_partial_response_on_disconnect_reports_the_relayed_text_of_a_background_response():
iterator: Final = _live_iterator(_make_logging_obj(), background=True)
for event in (_CREATED_WITH_PRIORITY_TIER, _text_delta("Once upon a time", 1)):
iterator._process_chunk(json.dumps(event))
incomplete_event: Final = iterator.partial_response_on_disconnect()
assert incomplete_event is not None
assert incomplete_event.response.output[0].content[0].text == "Once upon a time"

View file

@ -210,9 +210,11 @@ _TRAILING_ERRORS = [
httpx.RemoteProtocolError("peer closed connection without sending complete message body"),
]
_TRAILING_ERRORS_AFTER_COMPLETED = [*_TRAILING_ERRORS, httpx.ReadTimeout("Timeout on reading data from socket")]
@pytest.mark.asyncio
@pytest.mark.parametrize("trailing_error", _TRAILING_ERRORS, ids=type)
@pytest.mark.parametrize("trailing_error", _TRAILING_ERRORS_AFTER_COMPLETED, ids=type)
async def test_transport_error_after_completed_event_ends_stream_cleanly(trailing_error):
"""A sloppy connection close after `response.completed` must not turn a
complete stream into an error (regression guard for the transport no longer
@ -243,7 +245,7 @@ async def test_transport_error_before_completed_event_raises():
pass
@pytest.mark.parametrize("trailing_error", _TRAILING_ERRORS, ids=type)
@pytest.mark.parametrize("trailing_error", _TRAILING_ERRORS_AFTER_COMPLETED, ids=type)
def test_sync_transport_error_after_completed_event_ends_stream_cleanly(trailing_error):
iterator = _make_sync_iterator(
sse_events=_COMPLETE_STREAM_EVENTS,
@ -1205,7 +1207,9 @@ async def test_completed_event_survives_a_failing_usage_estimate():
falls back to usage None."""
malformed_input: Final = [{"type": "message", "role": "user", "content": 42}]
with pytest.raises(ValueError, match="Invalid content type"):
_estimate_usage_from_text("gpt-4o-mini", malformed_input, {"input": malformed_input}, "hello world")
_estimate_usage_from_text(
"gpt-4o-mini", malformed_input, {"input": malformed_input}, "hello world"
)
response = _responses_api_response_without_usage()
iterator = _make_iterator(

View file

@ -3774,6 +3774,132 @@ async def test_continued_stream_sharing_the_proxy_logging_object_bills_the_cut_t
)
def _responses_sse(*events: Mapping[str, object]) -> str:
return "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events)
def _responses_envelope(response_id: str, model: str, status: str, **fields: object) -> dict[str, object]:
return {
"id": response_id,
"object": "response",
"created_at": 1700000000,
"status": status,
"model": model,
"output": [],
**fields,
}
@pytest.mark.asyncio
async def test_responses_fallback_sharing_the_proxy_logging_object_logs_the_fallback_usage(
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
recorder: Final = _SpendRecorder()
monkeypatch.setattr(litellm, "callbacks", [recorder])
respx_mock.post("https://primary.example/v1/responses").mock(
return_value=httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text=_responses_sse(
{"type": "response.created", "response": _responses_envelope("resp_primary", "gpt-4o", "in_progress")},
*(
{
"type": "response.output_text.delta",
"item_id": "msg_primary",
"output_index": 0,
"content_index": 0,
"delta": word,
}
for word in ("Once ", "upon ", "a ", "time ", "there")
),
{
"type": "response.failed",
"response": _responses_envelope(
"resp_primary",
"gpt-4o",
"failed",
error={"code": "server_error", "message": "The model had an internal error"},
),
},
),
)
)
respx_mock.post("https://backup.example/v1/responses").mock(
return_value=httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text=_responses_sse(
{"type": "response.created", "response": _responses_envelope("resp_backup", "gpt-4o-mini", "in_progress")},
{
"type": "response.completed",
"response": _responses_envelope(
"resp_backup",
"gpt-4o-mini",
"completed",
output=[
{
"type": "message",
"id": "msg_backup",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "Once upon a time", "annotations": []}],
}
],
usage={"input_tokens": 1000, "output_tokens": 700, "total_tokens": 1700},
),
},
),
)
)
router: Final = Router(
model_list=[
{
"model_name": "primary",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "k1", "api_base": "https://primary.example/v1"},
},
{
"model_name": "backup",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "k2",
"api_base": "https://backup.example/v1",
},
},
],
fallbacks=[{"primary": ["backup"]}],
num_retries=0,
)
shared_logging, _ = litellm.utils.function_setup(
original_function="aresponses",
rules_obj=litellm.utils.Rules(),
start_time=datetime.now(),
model="primary",
input="Tell me a story",
stream=True,
litellm_call_id="shared-call",
)
response: Final = await router.aresponses(
model="primary",
input="Tell me a story",
stream=True,
litellm_call_id="shared-call",
litellm_logging_obj=shared_logging,
)
_ = [event async for event in response]
await asyncio.wait_for(recorder.succeeded.wait(), timeout=10)
success: Final = recorder.successes[-1]
assert (success["prompt_tokens"], success["completion_tokens"]) == (1000, 700)
spend_row: Final = recorder.spend_rows[-1]
assert (spend_row["prompt_tokens"], spend_row["completion_tokens"], spend_row["spend"]) == (
1000,
700,
success["response_cost"],
)
def test_carry_partial_spend_to_fallback_adds_the_partial_the_cut_stream_inherited():
from litellm.types.utils import Usage

View file

@ -42,6 +42,10 @@ class TestSpendLogHits:
assert guard.spend_log_hits(diff) == ("litellm/proxy/schema.prisma: a LiteLLM_SpendLogs",)
def _review(login: str, state: str, commit_id: str | None = "head") -> "guard.Review":
return guard.Review(state=state, user=guard.Reviewer(login=login), commit_id=commit_id)
class TestApprovedBy:
def test_the_latest_review_of_the_approver_decides(self):
reviews: Final = tuple(
@ -61,6 +65,42 @@ class TestApprovedBy:
assert guard.approved_by((), "kerry-berri") is False
class TestLatestApproval:
def test_the_approvers_last_approved_review_is_returned_with_its_commit(self):
reviews: Final = (
_review("kerry-berri", "APPROVED", "old"),
_review("someone-else", "APPROVED", "head"),
_review("kerry-berri", "APPROVED", "new"),
_review("kerry-berri", "COMMENTED", "head"),
)
assert guard.latest_approval(reviews, "kerry-berri") is reviews[2]
assert guard.latest_approval(reviews[:2], "kerry-berri") is reviews[0]
assert guard.latest_approval(reviews[1:2], "kerry-berri") is None
assert guard.latest_approval((), "kerry-berri") is None
class TestApprovalCovers:
_HITS: Final = ("schema.prisma: a LiteLLM_SpendLogs", "schema.prisma: b LiteLLM_SpendLogs")
def test_an_approval_on_the_head_covers_it_without_reading_the_approved_commit(self):
def unreadable(commit: str) -> None:
raise AssertionError(f"read {commit}")
assert guard.approval_covers(_review("kerry-berri", "APPROVED", "head"), "head", self._HITS, unreadable) is True
def test_an_older_approval_covers_the_head_only_when_it_saw_every_spend_log_line(self):
approval: Final = _review("kerry-berri", "APPROVED", "old")
assert guard.approval_covers(approval, "head", self._HITS, lambda commit: self._HITS) is True
assert guard.approval_covers(approval, "head", self._HITS[:1], lambda commit: self._HITS) is True
assert guard.approval_covers(approval, "head", self._HITS, lambda commit: self._HITS[:1]) is False
def test_an_approval_whose_commit_cannot_be_read_does_not_cover_the_head(self):
gone: Final = _review("kerry-berri", "APPROVED", "gone")
assert guard.approval_covers(gone, "head", self._HITS, lambda commit: None) is False
without_commit: Final = _review("kerry-berri", "APPROVED", None)
assert guard.approval_covers(without_commit, "head", self._HITS, lambda commit: self._HITS) is False
class TestExitCode:
def test_a_pull_request_without_spend_log_lines_passes_without_reviews(self, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(guard, "added_diff", lambda base, head: _diff("schema.prisma", " foo String", 1))
@ -75,5 +115,15 @@ class TestExitCode:
reviews.write_text('[{"user": {"login": "someone-else"}, "state": "APPROVED"}]')
assert guard.main(["--base", "a", "--head", "b", "--approver", "kerry-berri", "--reviews", str(reviews)]) == 1
assert "schema.prisma: x LiteLLM_SpendLogs" in capsys.readouterr().out
reviews.write_text('[{"user": {"login": "kerry-berri"}, "state": "APPROVED"}]')
reviews.write_text('[{"user": {"login": "kerry-berri"}, "state": "APPROVED", "commit_id": "b"}]')
assert guard.main(["--base", "a", "--head", "b", "--approver", "kerry-berri", "--reviews", str(reviews)]) == 0
def test_an_approval_before_a_new_spend_log_line_was_pushed_fails(self, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(guard, "added_diff", lambda base, head: _diff("schema.prisma", " x LiteLLM_SpendLogs", 7))
monkeypatch.setattr(guard, "reviewed_hits", lambda base, commit: () if commit == "old" else None, raising=False)
reviews: Final = tmp_path / "r.json"
reviews.write_text('[{"user": {"login": "kerry-berri"}, "state": "APPROVED", "commit_id": "old"}]')
assert guard.main(["--base", "a", "--head", "b", "--approver", "kerry-berri", "--reviews", str(reviews)]) == 1
assert "changed after @kerry-berri approved old" in capsys.readouterr().out
reviews.write_text('[{"user": {"login": "kerry-berri"}, "state": "APPROVED", "commit_id": "gone"}]')
assert guard.main(["--base", "a", "--head", "b", "--approver", "kerry-berri", "--reviews", str(reviews)]) == 1

View file

@ -0,0 +1,21 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { setServerRootPath } from "@/lib/serverRootPath";
import { AriaAvatar } from "./AriaAvatar";
describe("AriaAvatar", () => {
afterEach(() => {
setServerRootPath("/");
});
it.each(["/", "/litellm"])("loads the bundled image when the server root is %s", (root) => {
setServerRootPath(root);
render(<AriaAvatar />);
expect(screen.getByRole("presentation", { hidden: true })).toHaveAttribute(
"src",
"/_next/static/media/aria-agent.png",
);
});
});

View file

@ -1,10 +1,10 @@
import { resolveLogoSrc } from "@/lib/assetPaths";
import ariaAgentImage from "../../../public/assets/aria-agent.png";
import { cn } from "@/lib/cva.config";
export function AriaAvatar({ size = 28, className }: { size?: number; className?: string }) {
return (
<img
src={resolveLogoSrc("/ui/assets/aria-agent.png")}
src={ariaAgentImage.src}
width={size}
height={size}
alt=""

View file

@ -59861,6 +59861,8 @@ export interface operations {
purpose?: string | null;
limit?: number | null;
after?: string | null;
after_id?: string | null;
before_id?: string | null;
};
header?: never;
path?: never;
@ -79057,6 +79059,8 @@ export interface operations {
purpose?: string | null;
limit?: number | null;
after?: string | null;
after_id?: string | null;
before_id?: string | null;
};
header?: never;
path?: never;
@ -86392,6 +86396,8 @@ export interface operations {
purpose?: string | null;
limit?: number | null;
after?: string | null;
after_id?: string | null;
before_id?: string | null;
};
header?: never;
path: {