mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
Merge remote-tracking branch 'origin/main' into litellm_lite_cli_reasoning_efforts
This commit is contained in:
commit
ae74f26cdc
48 changed files with 4463 additions and 120 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -137,6 +137,7 @@ jobs:
|
|||
include:
|
||||
- shard: core-utils
|
||||
test-path: |-
|
||||
tests/unit/accounting
|
||||
tests/unit/batch_completion
|
||||
tests/unit/decisions
|
||||
tests/unit/extraction
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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");
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
62
litellm/accounting/__init__.py
Normal file
62
litellm/accounting/__init__.py
Normal 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",
|
||||
]
|
||||
13
litellm/accounting/measurements.py
Normal file
13
litellm/accounting/measurements.py
Normal 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 ()
|
||||
)
|
||||
85
litellm/accounting/protocols.py
Normal file
85
litellm/accounting/protocols.py
Normal 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
428
litellm/accounting/types.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
128
tests/integration/database/test_accounting_ledger.py
Normal file
128
tests/integration/database/test_accounting_ledger.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
263
tests/integration/security/test_managed_file_listing.py
Normal file
263
tests/integration/security/test_managed_file_listing.py
Normal 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
|
||||
0
tests/unit/accounting/__init__.py
Normal file
0
tests/unit/accounting/__init__.py
Normal file
235
tests/unit/accounting/test_measurements.py
Normal file
235
tests/unit/accounting/test_measurements.py
Normal 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",
|
||||
}
|
||||
484
tests/unit/accounting/test_types.py
Normal file
484
tests/unit/accounting/test_types.py
Normal 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"
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -256,6 +256,7 @@ class TestMapFinishReasonAnthropic:
|
|||
("max_tokens", "length"),
|
||||
("tool_use", "tool_calls"),
|
||||
("compaction", "length"),
|
||||
("model_context_window_exceeded", "length"),
|
||||
("content_filtered", "content_filter"),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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=""
|
||||
|
|
|
|||
6
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
6
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue