mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
fix(responses): bill a background response once, on the first read or poll that sees it finished (#45703)
* fix(responses): bill a background response once, from the poll job
Reading a completed background response with GET /v1/responses/{id} wrote a
full-cost spend row on every read, on top of the row the background cost poll
writes. Reads of a Responses object (GET, DELETE, cancel) now never bill, and
the poll job is the single biller.
The poll job now bills with the creating request's attribution (key hash, team,
org, user, aliases, tags) read off the managed object row, through a helper it
shares with the batch cost poll. The background create persists the creating
key and request tags so that attribution survives until the poll runs.
* test(proxy): a background response read reports zero cost
* fix(responses): bill a background response once, on the first read or poll that sees it finished
A conditional update moves the stored row out of queued or in_progress so only one worker or pod bills it, through one re-read stamped with the creator's attribution. A failed billing read puts the row back for the next poll
* fix(responses): keep the caller's response id out of the billing failure warning
* fix(responses): keep the creating client's User-Agent tags on the one background charge
* fix(responses): keep managed background reads free and bill each response once
The managed files hook marks reads and cancels of proxy-issued response ids, so only those stay free. SDK and database-less reads of a background response still bill, extra_body background creates are tracked, and the poll's status read no longer cools down a deployment on a 404.
* fix(responses): bill finished background responses from the fetched body and store rows Postgres accepts
* fix(responses): give the background billing event its own spend log row and bill responses the create left untracked
* fix(responses): narrow the managed files hook with a protocol instead of an enterprise import
* fix(responses): leave a read the caller retargets with client-side credentials to the cost poll
* fix(responses): bill background responses on the creator's end user and report the charge on the settling read
The read that settles the bill returns the charge in x-litellm-response-cost, the billed event
carries the creator's end user and the key, team, and user budget fields, and its spend row is
named after the id the client got. Override reads, cancels, and deletes settle from the
configured deployment, a streamed create marks its row billed, stored rows keep no model
output, and model insights counts the billed row's spend without a request.
* test(responses): cover background response billing across proxies, reads and chaos
* fix(responses): settle background billing only for callers allowed to address the response
A delete now settles the bill only after the response id access check passes, a streamed
create is marked billed when it sets background inside extra_body or its completed event
arrives after the last chunk, the billing event keeps the deployment's litellm_params
base_model, a key with no user and no team bills no user instead of its owner scope, and the
enterprise cost check import is optional
* fix(responses): record the bound on the pending status IN filters
* fix(responses): send the billed cost split on the settling read and keep the batch cost event as it was
The read that settles a background response now sends the whole cost header
family from the billed event, so the breakdown headers add up to the charge.
Batch cost events go back to main's attribution (creator id as the user, no
budget fields); only background responses get the end user and budgets.
Integration tests expect the charge on the settling read (or none when the
poll billed first), one charge for a streamed create, and SDK flows that poll
with the id each read returns.
* fix(responses): bill each background response once across upgrades, streams, and failed settles
Rows stored before this billing model are found by the issued id and rekeyed by the
provider id, and the cost poll retires one a read already billed. A streamed background
create is free on its own and bills the finished response through the same managed row
before the client sees response.completed, so a racing read cannot bill it again. A
settle that fails retries in the background, a retargeted read with no row tracks and
bills from the configured deployment, and a delete keeps its billed cost header.
Reverts the unrelated json.dumps narrowing in the completion transformation.
* fix(responses): bill cache-polled background streams and match poll rows by the issued id
* test(responses): cover deletes, failed settles, budgets, base models, and cache-polled background billing
Adds integration cells for the background response billing paths the audit
inventory lists: a DELETE that settles the bill and refuses a stranger, a
settle the database refuses that retries once it recovers, the Prometheus
remaining budget, base_model pricing from model_info and litellm_params, the
daily model usage rollup, the end user, the settling read's cost split, stored
rows without model output, a forged managed marker on the create, streamed
creates with background in extra_body, and the cache-polled create
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
c9ce9a733d
commit
fbc2e358bc
41 changed files with 5110 additions and 549 deletions
|
|
@ -8,21 +8,20 @@ from datetime import datetime, timedelta, timezone
|
|||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, List, Literal, Optional, Protocol, Tuple, cast
|
||||
|
||||
from litellm_enterprise.proxy.common_utils.managed_object_attribution import (
|
||||
ManagedObjectCreatorAttribution,
|
||||
ManagedObjectRow,
|
||||
)
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import (
|
||||
CLI_SESSION_KEY_PREFIX,
|
||||
MANAGED_OBJECT_STALENESS_CUTOFF_DAYS,
|
||||
MAX_OBJECTS_PER_POLL_CYCLE,
|
||||
)
|
||||
from litellm.repositories.table_repositories import ManagedObjectRepository
|
||||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.repositories.user_repository import UserRepository
|
||||
from litellm.repositories.verification_token_repository import VerificationTokenRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import models as prisma_models
|
||||
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
from litellm.repositories.prisma_protocols import TableActions
|
||||
|
|
@ -47,29 +46,6 @@ TERMINAL_MANAGED_OBJECT_STATUSES: Final[Tuple[str, ...]] = (
|
|||
)
|
||||
|
||||
|
||||
class _ManagedObjectRow(Protocol):
|
||||
@property
|
||||
def id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def unified_object_id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def created_by(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def file_object(self) -> object: ...
|
||||
|
||||
@property
|
||||
def org_id(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def api_key(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def team_id(self) -> str | None: ...
|
||||
|
||||
|
||||
class _ReadableFileContent(Protocol):
|
||||
async def read(self) -> bytes: ...
|
||||
|
||||
|
|
@ -87,22 +63,10 @@ async def _file_content_bytes(file_content: object) -> bytes:
|
|||
return cast(bytes, file_content)
|
||||
|
||||
|
||||
def _managed_object_table(prisma_client: "PrismaClient") -> "TableActions[_ManagedObjectRow]":
|
||||
def _managed_object_table(prisma_client: "PrismaClient") -> "TableActions[ManagedObjectRow]":
|
||||
return ManagedObjectRepository(prisma_client).table
|
||||
|
||||
|
||||
def _user_table(prisma_client: "PrismaClient") -> "TableActions[prisma_models.LiteLLM_UserTable]":
|
||||
return UserRepository(prisma_client).table
|
||||
|
||||
|
||||
def _token_table(prisma_client: "PrismaClient") -> "TableActions[prisma_models.LiteLLM_VerificationToken]":
|
||||
return VerificationTokenRepository(prisma_client).table
|
||||
|
||||
|
||||
def _team_table(prisma_client: "PrismaClient") -> "TableActions[prisma_models.LiteLLM_TeamTable]":
|
||||
return TeamRepository(prisma_client).table
|
||||
|
||||
|
||||
class CheckBatchCost:
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -118,6 +82,7 @@ class CheckBatchCost:
|
|||
self.prisma_client: PrismaClient = prisma_client
|
||||
self.llm_router: Router = llm_router
|
||||
self._track_unmanaged_batch_cost = track_unmanaged_batch_cost
|
||||
self._creator_attribution: Final = ManagedObjectCreatorAttribution(prisma_client, "CheckBatchCost")
|
||||
# Cached after the first poll cycle. Once we know the column is absent we skip
|
||||
# the guaranteed-failing primary query on every subsequent cycle.
|
||||
self._has_batch_processed_column: bool = True
|
||||
|
|
@ -149,133 +114,6 @@ class CheckBatchCost:
|
|||
return
|
||||
self.batch_processed_support_confirmed = True
|
||||
|
||||
async def _get_user_info(self, batch_id: str, user_id: Optional[str]) -> dict[str, str | None]:
|
||||
"""
|
||||
Look up user email and key alias by user_id for enriching the S3 callback metadata.
|
||||
Returns a dict with user_api_key_user_email and user_api_key_alias (both may be None).
|
||||
Returns an empty dict when user_id is None: batches created by a team or service
|
||||
account key carry no user id, and find_unique(where={"user_id": None}) raises.
|
||||
"""
|
||||
if not user_id:
|
||||
return {}
|
||||
try:
|
||||
user_row: prisma_models.LiteLLM_UserTable | None = await _user_table(self.prisma_client).find_unique(
|
||||
where={"user_id": user_id}
|
||||
)
|
||||
if user_row is None:
|
||||
return {}
|
||||
return {
|
||||
"user_api_key_user_email": getattr(user_row, "user_email", None),
|
||||
"user_api_key_alias": getattr(user_row, "user_alias", None),
|
||||
}
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"CheckBatchCost: could not look up user {user_id} for batch {batch_id}: {e}")
|
||||
return {}
|
||||
|
||||
async def _get_key_alias(self, batch_id: str, api_key: str | None, created_by: str | None) -> str | None:
|
||||
"""Resolve the creating virtual key's alias from its hashed token."""
|
||||
if not api_key:
|
||||
return None
|
||||
if created_by and api_key == f"{CLI_SESSION_KEY_PREFIX}-{created_by}":
|
||||
return api_key
|
||||
try:
|
||||
key_row: prisma_models.LiteLLM_VerificationToken | None = await _token_table(
|
||||
self.prisma_client
|
||||
).find_unique(where={"token": api_key})
|
||||
return getattr(key_row, "key_alias", None) if key_row is not None else None
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"CheckBatchCost: could not look up key alias for batch {batch_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _get_team_alias(self, team_id: str | None) -> str | None:
|
||||
"""Resolve a team's alias from its id."""
|
||||
if not team_id:
|
||||
return None
|
||||
try:
|
||||
team_row: prisma_models.LiteLLM_TeamTable | None = await _team_table(self.prisma_client).find_unique(
|
||||
where={"team_id": team_id}
|
||||
)
|
||||
return getattr(team_row, "team_alias", None) if team_row is not None else None
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"CheckBatchCost: could not look up team alias for team {team_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _get_org_id(self, job: "_ManagedObjectRow", batch_id: str) -> str | None:
|
||||
org_id: Final = getattr(job, "org_id", None)
|
||||
if org_id:
|
||||
return org_id
|
||||
api_key: Final = getattr(job, "api_key", None)
|
||||
team_id: Final = getattr(job, "team_id", None)
|
||||
if api_key:
|
||||
try:
|
||||
key_row: prisma_models.LiteLLM_VerificationToken | None = await _token_table(
|
||||
self.prisma_client
|
||||
).find_unique(where={"token": api_key})
|
||||
key_org_id: Final = (
|
||||
cast(str | None, getattr(key_row, "organization_id", None))
|
||||
if key_row is not None
|
||||
else None
|
||||
)
|
||||
if key_org_id:
|
||||
return key_org_id
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(
|
||||
f"CheckBatchCost: could not resolve the key's org for batch {batch_id}, "
|
||||
f"still trying the team's: {e}"
|
||||
)
|
||||
if not team_id:
|
||||
return None
|
||||
try:
|
||||
team_row: prisma_models.LiteLLM_TeamTable | None = await _team_table(self.prisma_client).find_unique(
|
||||
where={"team_id": team_id}
|
||||
)
|
||||
return cast(str | None, getattr(team_row, "organization_id", None)) if team_row is not None else None
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"CheckBatchCost: could not resolve the team's org for batch {batch_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _build_creator_attribution_metadata(
|
||||
self, job: "_ManagedObjectRow", batch_id: str
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Rebuild the spend-tracking metadata for the key, team, and tags that created the
|
||||
batch so the batch-cost spend log is attributed the same way a non-batch request
|
||||
is. Rows created before api_key and request_tags were persisted carry only
|
||||
created_by and team_id, and fall back to those. A named creating key owns
|
||||
user_api_key_alias; when it has no alias, or the key has since been rotated or
|
||||
deleted, the field keeps the creating user's alias that _get_user_info filled in,
|
||||
because a resolvable name is more useful on the spend row than a null.
|
||||
|
||||
user_api_key_org_id must be resolved here too: the spend update writer reads it
|
||||
off this metadata to increment organization spend, so leaving it out silently
|
||||
drops batch cost from org accounting for keys and teams that belong to one.
|
||||
"""
|
||||
api_key = getattr(job, "api_key", None)
|
||||
team_id = getattr(job, "team_id", None)
|
||||
request_tags = getattr(job, "request_tags", None)
|
||||
|
||||
metadata: dict[str, object] = {
|
||||
"user_api_key_user_id": job.created_by,
|
||||
"user_api_key": api_key,
|
||||
"user_api_key_hash": api_key,
|
||||
"user_api_key_team_id": team_id,
|
||||
**(await self._get_user_info(batch_id, job.created_by)),
|
||||
}
|
||||
|
||||
key_alias = await self._get_key_alias(batch_id, api_key, job.created_by)
|
||||
if key_alias is not None:
|
||||
metadata["user_api_key_alias"] = key_alias
|
||||
team_alias = await self._get_team_alias(team_id)
|
||||
if team_alias is not None:
|
||||
metadata["user_api_key_team_alias"] = team_alias
|
||||
org_id: Final = await self._get_org_id(job, batch_id)
|
||||
if org_id is not None:
|
||||
metadata["user_api_key_org_id"] = org_id
|
||||
if isinstance(request_tags, list) and request_tags:
|
||||
metadata["tags"] = [tag for tag in request_tags if isinstance(tag, str)]
|
||||
|
||||
return metadata
|
||||
|
||||
async def _cleanup_stale_managed_objects(self) -> None:
|
||||
"""
|
||||
Mark managed objects older than MANAGED_OBJECT_STALENESS_CUTOFF_DAYS days
|
||||
|
|
@ -317,7 +155,7 @@ class CheckBatchCost:
|
|||
f"{MANAGED_OBJECT_STALENESS_CUTOFF_DAYS} days that were never costed"
|
||||
)
|
||||
|
||||
async def _fallback_find_jobs(self) -> "Sequence[_ManagedObjectRow]":
|
||||
async def _fallback_find_jobs(self) -> "Sequence[ManagedObjectRow]":
|
||||
"""Query batch jobs without the batch_processed filter (for older schemas)."""
|
||||
return await _managed_object_table(self.prisma_client).find_many(
|
||||
where={
|
||||
|
|
@ -337,7 +175,7 @@ class CheckBatchCost:
|
|||
order={"created_at": "asc"},
|
||||
)
|
||||
|
||||
async def _retire_job(self, job: "_ManagedObjectRow", reason: str) -> None:
|
||||
async def _retire_job(self, job: "ManagedObjectRow", reason: str) -> None:
|
||||
"""
|
||||
Take a row that can never be costed out of the poll page. Leaving it selectable
|
||||
would burn one of the MAX_OBJECTS_PER_POLL_CYCLE slots on every future cycle, and
|
||||
|
|
@ -364,7 +202,7 @@ class CheckBatchCost:
|
|||
"so it will no longer be polled"
|
||||
)
|
||||
|
||||
async def _claim_job_for_costing(self, job: "_ManagedObjectRow") -> bool:
|
||||
async def _claim_job_for_costing(self, job: "ManagedObjectRow") -> bool:
|
||||
"""
|
||||
Atomically flip batch_processed from false to true, returning whether this pod won
|
||||
the row. Every pod and uvicorn worker schedules its own poller against the shared
|
||||
|
|
@ -390,7 +228,7 @@ class CheckBatchCost:
|
|||
return False
|
||||
return claimed > 0
|
||||
|
||||
async def _release_job_claim(self, job: "_ManagedObjectRow") -> None:
|
||||
async def _release_job_claim(self, job: "ManagedObjectRow") -> None:
|
||||
"""Give a claimed row back once billing it failed, so a later poll cycle retries it.
|
||||
|
||||
Safe to match on batch_processed=True: while this poller is active the retrieve
|
||||
|
|
@ -411,7 +249,7 @@ class CheckBatchCost:
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def _has_unified_id_without_model(job: "_ManagedObjectRow") -> bool:
|
||||
def _has_unified_id_without_model(job: "ManagedObjectRow") -> bool:
|
||||
"""A unified id that decodes but carries no model_id can never be routed."""
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
convert_b64_uid_to_unified_uid,
|
||||
|
|
@ -460,7 +298,7 @@ class CheckBatchCost:
|
|||
return isinstance(error, (NotFoundError, openai.NotFoundError)) and output_file_id in str(error)
|
||||
|
||||
async def _finalize_unbilled_terminal_job(
|
||||
self, job: "_ManagedObjectRow", response: "LiteLLMBatch"
|
||||
self, job: "ManagedObjectRow", response: "LiteLLMBatch"
|
||||
) -> None:
|
||||
"""Persist a terminal batch that has nothing billable, converting any raw
|
||||
provider file ids to managed ids, and take it out of the poll page."""
|
||||
|
|
@ -505,7 +343,7 @@ class CheckBatchCost:
|
|||
|
||||
def _resolve_job_routing(
|
||||
self,
|
||||
job: "_ManagedObjectRow",
|
||||
job: "ManagedObjectRow",
|
||||
prom_logger: Optional["PrometheusLogger"],
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
|
|
@ -582,7 +420,7 @@ class CheckBatchCost:
|
|||
|
||||
def _resolve_unmanaged_provider_routing(
|
||||
self,
|
||||
job: "_ManagedObjectRow",
|
||||
job: "ManagedObjectRow",
|
||||
prom_logger: Optional["PrometheusLogger"],
|
||||
llm_provider: str,
|
||||
bare_model_name: str,
|
||||
|
|
@ -678,7 +516,7 @@ class CheckBatchCost:
|
|||
@classmethod
|
||||
def _get_managed_file_model_name(
|
||||
cls,
|
||||
job: "_ManagedObjectRow",
|
||||
job: "ManagedObjectRow",
|
||||
deployment_info: "Deployment",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
|
|
@ -698,7 +536,7 @@ class CheckBatchCost:
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_input_file_id(job: "_ManagedObjectRow") -> Optional[str]:
|
||||
def _get_input_file_id(job: "ManagedObjectRow") -> Optional[str]:
|
||||
import json
|
||||
|
||||
from litellm.types.utils import LiteLLMBatch
|
||||
|
|
@ -719,7 +557,7 @@ class CheckBatchCost:
|
|||
|
||||
async def _track_completed_batch_cost(
|
||||
self,
|
||||
job: "_ManagedObjectRow",
|
||||
job: "ManagedObjectRow",
|
||||
response: "LiteLLMBatch",
|
||||
model_id: str,
|
||||
batch_id: str,
|
||||
|
|
@ -735,9 +573,9 @@ class CheckBatchCost:
|
|||
|
||||
"""
|
||||
from litellm.batches.batch_utils import (
|
||||
calculate_batch_cost_and_usage,
|
||||
count_error_file_failed_requests,
|
||||
get_file_content_as_dictionary,
|
||||
calculate_batch_cost_and_usage,
|
||||
)
|
||||
from litellm.files.main import afile_content
|
||||
from litellm.files.types import FileContentCallOptions
|
||||
|
|
@ -911,7 +749,7 @@ class CheckBatchCost:
|
|||
},
|
||||
**({"api_base": mask_api_base_credentials(deployment_api_base)} if deployment_api_base else {}),
|
||||
"metadata": {
|
||||
**(await self._build_creator_attribution_metadata(job, batch_id)),
|
||||
**(await self._creator_attribution.batch_metadata(job, batch_id)),
|
||||
# spend logs read the deployment identity off these metadata keys, so
|
||||
# without them the batch cost row carries no model_id or model_group
|
||||
"model_info": {"id": model_id},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,36 @@
|
|||
"""
|
||||
Polls LiteLLM_ManagedObjectTable to check if the response is complete.
|
||||
Cost tracking is handled by the get-responses call, which prices normally only because the
|
||||
poll stamps itself with BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN; user-facing reads of the
|
||||
same route are non-inference and free.
|
||||
A background response is billed exactly once: whichever client read or poll first sees it
|
||||
finished moves its row out of a pending status, then logs the body that read already fetched as
|
||||
one spend event stamped with BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN and the creating key's
|
||||
attribution, priced against the deployment that served it. The poll's status checks are stamped
|
||||
with BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN and client reads are marked managed by the
|
||||
managed files hook, so both stay free. The spend event carries the id the client holds, the row's
|
||||
unified_object_id, so its spend log is found under that id with the _background_cost suffix.
|
||||
A read that finds no row, because the create could not store one, stores it under the reading key
|
||||
and bills it the same way, so a response the create left untracked is still billed once. A streamed
|
||||
background create is free on its own; its stream stores the row under the creating key and bills
|
||||
the finished response through it before the client sees response.completed.
|
||||
A read the caller retargets with client-side credentials, and a delete of a still-pending response,
|
||||
settle the bill from a fetch through the configured deployment instead: the client controls what a
|
||||
retargeted read returns, and a deleted response leaves the poll nothing to fetch.
|
||||
A row stored before this billing model keeps the encrypted create id as its model_object_id. The
|
||||
first lookup by that id rekeys it by the provider id, and the poll retires such a row without
|
||||
billing it once a read has already tracked the response under the provider id.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Dict, Final, Protocol, cast
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
|
||||
from litellm_enterprise.proxy.common_utils.managed_object_attribution import (
|
||||
ManagedObjectCreatorAttribution,
|
||||
ManagedObjectRow,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -14,36 +38,124 @@ from litellm.constants import (
|
|||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
MANAGED_OBJECT_STALENESS_CUTOFF_DAYS,
|
||||
MAX_OBJECTS_PER_POLL_CYCLE,
|
||||
PENDING_RESPONSE_STATUSES,
|
||||
STALE_OBJECT_CLEANUP_BATCH_SIZE,
|
||||
TERMINAL_RESPONSE_STATUSES,
|
||||
)
|
||||
from litellm.proxy.hooks.responses_id_security import decrypted_response_id
|
||||
from litellm.repositories.table_repositories import ManagedObjectRepository
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
from litellm.types.router import Deployment
|
||||
from litellm.types.utils import (
|
||||
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
from litellm.repositories.prisma_protocols import TableActions
|
||||
from litellm.router import Router
|
||||
|
||||
TERMINAL_RESPONSE_STATUSES = frozenset({"completed", "failed", "cancelled", "incomplete"})
|
||||
_STORED_RESPONSE_ADAPTER: Final[TypeAdapter[dict[str, object]]] = TypeAdapter(dict[str, object])
|
||||
_RETRIEVE_RESPONSE_CALL_TYPE: Final = "aget_responses"
|
||||
|
||||
|
||||
class _ManagedObjectRow(Protocol):
|
||||
@property
|
||||
def id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def unified_object_id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def created_by(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def file_object(self) -> object: ...
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PollTarget:
|
||||
response_id: str
|
||||
model_metadata: Mapping[str, object]
|
||||
deployment: Deployment | None
|
||||
|
||||
|
||||
def _managed_object_table(prisma_client: "PrismaClient") -> "TableActions[_ManagedObjectRow]":
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BilledBackgroundResponse:
|
||||
response: ResponsesAPIResponse
|
||||
model: str
|
||||
custom_llm_provider: str | None
|
||||
litellm_params: Mapping[str, object]
|
||||
metadata: Mapping[str, object]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BilledCharge:
|
||||
cost: float | None
|
||||
logging_obj: "LiteLLMLogging"
|
||||
|
||||
|
||||
BilledResponseLogger = Callable[[BilledBackgroundResponse], Awaitable[BilledCharge]]
|
||||
UntrackedResponseTracker = Callable[[ResponsesAPIResponse], Awaitable[bool]]
|
||||
|
||||
|
||||
def _without_read_price(response: ResponsesAPIResponse) -> ResponsesAPIResponse:
|
||||
unpriced: Final = response.model_copy(deep=True)
|
||||
unpriced.hidden_params = {key: value for key, value in response.hidden_params.items() if key != "response_cost"}
|
||||
return unpriced
|
||||
|
||||
|
||||
async def log_billed_background_response(billed: BilledBackgroundResponse) -> BilledCharge:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
||||
logging_obj: Final = LiteLLMLogging(
|
||||
model=billed.model,
|
||||
messages=[{"role": "user", "content": "<retrieve_response>"}],
|
||||
stream=False,
|
||||
call_type=_RETRIEVE_RESPONSE_CALL_TYPE,
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id=str(uuid.uuid4()),
|
||||
function_id=str(uuid.uuid4()),
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
litellm_params={**billed.litellm_params, "metadata": dict(billed.metadata)},
|
||||
optional_params={},
|
||||
custom_llm_provider=billed.custom_llm_provider,
|
||||
)
|
||||
unpriced: Final = _without_read_price(billed.response)
|
||||
cost: Final = logging_obj.response_cost_calculator(result=unpriced)
|
||||
end_time: Final = datetime.now()
|
||||
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(
|
||||
async_coroutine=logging_obj.async_success_handler(
|
||||
result=unpriced, start_time=logging_obj.start_time, end_time=end_time
|
||||
)
|
||||
)
|
||||
logging_obj.handle_sync_success_callbacks_for_async_calls(
|
||||
result=unpriced, start_time=logging_obj.start_time, end_time=end_time
|
||||
)
|
||||
return BilledCharge(cost=cost, logging_obj=logging_obj)
|
||||
|
||||
|
||||
class _DeploymentBaseModel(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
base_model: str | None = None
|
||||
|
||||
|
||||
def _deployment_litellm_params(deployment: Deployment) -> Mapping[str, object]:
|
||||
from litellm.litellm_core_utils.litellm_logging import mask_api_base_credentials
|
||||
|
||||
api_base: Final = deployment.litellm_params.api_base
|
||||
base_model: Final = _DeploymentBaseModel.model_validate(deployment.litellm_params, from_attributes=True).base_model
|
||||
return MappingProxyType(
|
||||
{
|
||||
**({"api_base": mask_api_base_credentials(api_base)} if api_base else {}),
|
||||
**({"base_model": base_model} if base_model else {}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _deployment_metadata(deployment: Deployment) -> Mapping[str, object]:
|
||||
return MappingProxyType(
|
||||
{
|
||||
"model_info": deployment.model_info.model_dump(exclude_none=True),
|
||||
"model_group": deployment.model_name,
|
||||
"deployment": deployment.litellm_params.model,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _managed_object_table(prisma_client: "PrismaClient") -> "TableActions[ManagedObjectRow]":
|
||||
return ManagedObjectRepository(prisma_client).table
|
||||
|
||||
|
||||
|
|
@ -51,12 +163,17 @@ def _is_response_gone_at_provider(error: Exception, provider_response_id: str) -
|
|||
return getattr(error, "status_code", None) == 404 and provider_response_id in str(error)
|
||||
|
||||
|
||||
def _keyed_by_issued_id(job: ManagedObjectRow) -> bool:
|
||||
return decrypted_response_id(job.model_object_id) != job.model_object_id
|
||||
|
||||
|
||||
class CheckResponsesCost:
|
||||
def __init__(
|
||||
self,
|
||||
proxy_logging_obj: "ProxyLogging",
|
||||
prisma_client: "PrismaClient",
|
||||
llm_router: "Router",
|
||||
log_billed_response: BilledResponseLogger = log_billed_background_response,
|
||||
):
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
from litellm.router import Router
|
||||
|
|
@ -64,15 +181,17 @@ class CheckResponsesCost:
|
|||
self.proxy_logging_obj: ProxyLogging = proxy_logging_obj
|
||||
self.prisma_client: PrismaClient = prisma_client
|
||||
self.llm_router: Router = llm_router
|
||||
self._creator_attribution: Final = ManagedObjectCreatorAttribution(prisma_client, "CheckResponsesCost")
|
||||
self._log_billed_response: Final = log_billed_response
|
||||
|
||||
def _resolve_deployment(self, response_id: str) -> bool:
|
||||
def _resolve_deployment(self, response_id: str) -> Deployment | None:
|
||||
model_id: str | None = ResponsesAPIRequestUtils.get_model_id_from_response_id(response_id)
|
||||
return model_id is not None and self.llm_router.get_deployment(model_id=model_id) is not None
|
||||
return self.llm_router.get_deployment(model_id=model_id) if model_id is not None else None
|
||||
|
||||
async def _get_response(
|
||||
self,
|
||||
response_id: str,
|
||||
litellm_metadata: Dict[str, str],
|
||||
litellm_metadata: dict[str, object],
|
||||
via_router: bool,
|
||||
) -> ResponsesAPIResponse:
|
||||
"""Fetch the upstream response, using deployment credentials when available.
|
||||
|
|
@ -91,9 +210,17 @@ class CheckResponsesCost:
|
|||
)
|
||||
return cast(ResponsesAPIResponse, router_response)
|
||||
|
||||
async def _expire_stale_rows(
|
||||
self, cutoff: datetime, batch_size: int
|
||||
) -> int:
|
||||
async def _fetch_unbilled(self, target: _PollTarget) -> ResponsesAPIResponse:
|
||||
return await self._get_response(
|
||||
response_id=target.response_id,
|
||||
litellm_metadata={
|
||||
**target.model_metadata,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN,
|
||||
},
|
||||
via_router=target.deployment is not None,
|
||||
)
|
||||
|
||||
async def _expire_stale_rows(self, cutoff: datetime, batch_size: int) -> int:
|
||||
"""Execute the bounded UPDATE that marks stale rows as 'stale_expired'.
|
||||
|
||||
Isolated so it can be swapped / mocked in tests without touching the
|
||||
|
|
@ -140,14 +267,154 @@ class CheckResponsesCost:
|
|||
f"(older than {MANAGED_OBJECT_STALENESS_CUTOFF_DAYS} days) as stale_expired"
|
||||
)
|
||||
|
||||
def _target(self, response_id: str, model_name: str | None) -> _PollTarget:
|
||||
return _PollTarget(
|
||||
response_id=response_id,
|
||||
model_metadata=MappingProxyType({"model": model_name, "model_group": model_name} if model_name else {}),
|
||||
deployment=self._resolve_deployment(response_id),
|
||||
)
|
||||
|
||||
def _poll_target(self, job: ManagedObjectRow) -> _PollTarget:
|
||||
stored_model: Final = _STORED_RESPONSE_ADAPTER.validate_python(job.file_object).get("model")
|
||||
return self._target(
|
||||
decrypted_response_id(job.unified_object_id), stored_model if isinstance(stored_model, str) else None
|
||||
)
|
||||
|
||||
async def _claim_for_billing(self, job: ManagedObjectRow) -> bool:
|
||||
claimed: Final = await _managed_object_table(self.prisma_client).update_many(
|
||||
# bounded-ok: PENDING_RESPONSE_STATUSES is a two-value constant tuple
|
||||
where={"id": job.id, "status": {"in": list(PENDING_RESPONSE_STATUSES)}},
|
||||
data={"status": "completed"},
|
||||
)
|
||||
return claimed > 0
|
||||
|
||||
async def _release_billing_claim(self, job: ManagedObjectRow) -> None:
|
||||
await _managed_object_table(self.prisma_client).update_many(
|
||||
where={"id": job.id, "status": "completed"},
|
||||
data={"status": job.status or PENDING_RESPONSE_STATUSES[0]},
|
||||
)
|
||||
|
||||
async def _billed_response(
|
||||
self,
|
||||
job: ManagedObjectRow,
|
||||
target: _PollTarget,
|
||||
response: ResponsesAPIResponse,
|
||||
) -> BilledBackgroundResponse:
|
||||
deployment: Final = target.deployment
|
||||
return BilledBackgroundResponse(
|
||||
response=response.model_copy(update={"id": job.unified_object_id}),
|
||||
model=deployment.litellm_params.model if deployment is not None else (response.model or ""),
|
||||
custom_llm_provider=ResponsesAPIRequestUtils.decode_responses_api_response_id(target.response_id).get(
|
||||
"custom_llm_provider"
|
||||
),
|
||||
litellm_params=_deployment_litellm_params(deployment) if deployment is not None else MappingProxyType({}),
|
||||
metadata=MappingProxyType(
|
||||
{
|
||||
**(await self._creator_attribution.response_metadata(job, job.unified_object_id)),
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
**target.model_metadata,
|
||||
**(_deployment_metadata(deployment) if deployment is not None else {}),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def _bill_once(
|
||||
self,
|
||||
job: ManagedObjectRow,
|
||||
target: _PollTarget,
|
||||
response: ResponsesAPIResponse,
|
||||
) -> BilledCharge | None:
|
||||
if not await self._claim_for_billing(job):
|
||||
verbose_proxy_logger.info(f"Response {job.unified_object_id} was already billed elsewhere, skipping")
|
||||
return None
|
||||
try:
|
||||
charge: Final = await self._log_billed_response(await self._billed_response(job, target, response))
|
||||
except BaseException:
|
||||
await self._release_billing_claim(job)
|
||||
raise
|
||||
verbose_proxy_logger.info(f"Response {job.unified_object_id} reached a terminal status and was billed")
|
||||
return charge
|
||||
|
||||
async def _job_for_provider_id(self, provider_response_id: str) -> ManagedObjectRow | None:
|
||||
return await _managed_object_table(self.prisma_client).find_first(
|
||||
where={"model_object_id": provider_response_id, "file_purpose": "response"}
|
||||
)
|
||||
|
||||
async def _rekey_by_provider_id(self, job: ManagedObjectRow) -> None:
|
||||
await _managed_object_table(self.prisma_client).update_many(
|
||||
where={"id": job.id}, data={"model_object_id": decrypted_response_id(job.model_object_id)}
|
||||
)
|
||||
|
||||
async def _tracked_job(self, response_id: str) -> ManagedObjectRow | None:
|
||||
if tracked := await self._job_for_provider_id(decrypted_response_id(response_id)):
|
||||
return tracked
|
||||
stored_by_issued_id: Final = await _managed_object_table(self.prisma_client).find_first(
|
||||
where={"unified_object_id": response_id, "file_purpose": "response"}
|
||||
)
|
||||
if stored_by_issued_id is not None and _keyed_by_issued_id(stored_by_issued_id):
|
||||
await self._rekey_by_provider_id(stored_by_issued_id)
|
||||
return stored_by_issued_id
|
||||
|
||||
async def _superseded_by_a_provider_id_row(self, job: ManagedObjectRow) -> bool:
|
||||
if not _keyed_by_issued_id(job):
|
||||
return False
|
||||
if await self._job_for_provider_id(decrypted_response_id(job.model_object_id)) is None:
|
||||
await self._rekey_by_provider_id(job)
|
||||
return False
|
||||
await self._claim_for_billing(job)
|
||||
return True
|
||||
|
||||
async def _track_from_deployment(
|
||||
self, response_id: str, track_untracked: UntrackedResponseTracker | None
|
||||
) -> BilledCharge | None:
|
||||
target: Final = self._target(decrypted_response_id(response_id), None)
|
||||
if track_untracked is None or target.deployment is None:
|
||||
return None
|
||||
return await self.bill_if_finished(response_id, await self._fetch_unbilled(target), track_untracked)
|
||||
|
||||
async def _start_tracking(
|
||||
self, response_id: str, response: ResponsesAPIResponse, track_untracked: UntrackedResponseTracker | None
|
||||
) -> ManagedObjectRow | None:
|
||||
if track_untracked is None or not await track_untracked(response):
|
||||
return None
|
||||
return await self._tracked_job(response_id)
|
||||
|
||||
async def bill_if_finished(
|
||||
self,
|
||||
response_id: str,
|
||||
response: ResponsesAPIResponse,
|
||||
track_untracked: UntrackedResponseTracker | None = None,
|
||||
) -> BilledCharge | None:
|
||||
if response.status not in TERMINAL_RESPONSE_STATUSES:
|
||||
return None
|
||||
job: Final = await self._tracked_job(response_id) or await self._start_tracking(
|
||||
response_id, response, track_untracked
|
||||
)
|
||||
if job is None or job.status not in PENDING_RESPONSE_STATUSES:
|
||||
return None
|
||||
return await self._bill_once(job, self._poll_target(job), response)
|
||||
|
||||
async def bill_from_deployment(
|
||||
self, response_id: str, track_untracked: UntrackedResponseTracker | None = None
|
||||
) -> BilledCharge | None:
|
||||
job: Final = await self._tracked_job(response_id)
|
||||
if job is None:
|
||||
return await self._track_from_deployment(response_id, track_untracked)
|
||||
if job.status not in PENDING_RESPONSE_STATUSES:
|
||||
return None
|
||||
target: Final = self._poll_target(job)
|
||||
response: Final = await self._fetch_unbilled(target)
|
||||
if response.status not in TERMINAL_RESPONSE_STATUSES:
|
||||
return None
|
||||
return await self._bill_once(job, target, response)
|
||||
|
||||
async def check_responses_cost(self):
|
||||
"""
|
||||
Check if background responses are complete and track their cost.
|
||||
- Get all status="queued" or "in_progress" and file_purpose="response" jobs
|
||||
- Query the provider to check if response is complete
|
||||
- Cost is tracked by the get-responses call, billed because the poll is stamped
|
||||
with BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
- Mark responses in a terminal state as complete in the database
|
||||
- Query the provider, unbilled, to check if the response is complete
|
||||
- Claim each terminal job by moving its row to completed, then log the body that check fetched
|
||||
as its one billed event, so two pollers racing over the same job bill it once
|
||||
- Mark responses the provider no longer has (404 through a resolved
|
||||
deployment) as stale_expired
|
||||
"""
|
||||
|
|
@ -160,99 +427,53 @@ class CheckResponsesCost:
|
|||
|
||||
jobs = await _managed_object_table(self.prisma_client).find_many(
|
||||
where={
|
||||
"status": {"in": ["queued", "in_progress"]},
|
||||
# bounded-ok: PENDING_RESPONSE_STATUSES is a two-value constant tuple
|
||||
"status": {"in": list(PENDING_RESPONSE_STATUSES)},
|
||||
"file_purpose": "response",
|
||||
},
|
||||
take=MAX_OBJECTS_PER_POLL_CYCLE,
|
||||
order={"created_at": "asc"},
|
||||
)
|
||||
|
||||
|
||||
verbose_proxy_logger.debug(f"Found {len(jobs)} response jobs to check")
|
||||
completed_jobs: Final[list[_ManagedObjectRow]] = []
|
||||
expired_jobs: Final[list[_ManagedObjectRow]] = []
|
||||
expired_jobs: Final[list[ManagedObjectRow]] = []
|
||||
|
||||
for job in jobs:
|
||||
unified_object_id = job.unified_object_id
|
||||
|
||||
try:
|
||||
from litellm.proxy.hooks.responses_id_security import (
|
||||
ResponsesIDSecurity,
|
||||
)
|
||||
|
||||
# Get the stored response object to extract model information
|
||||
stored_response = job.file_object
|
||||
model_name = stored_response.get("model", None)
|
||||
|
||||
# Decrypt the response ID
|
||||
responses_id_security, _, _ = ResponsesIDSecurity()._decrypt_response_id(unified_object_id)
|
||||
|
||||
# Prepare metadata with model information for cost tracking
|
||||
litellm_metadata = {
|
||||
"user_api_key_user_id": job.created_by or "default-user-id",
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
}
|
||||
|
||||
# Add model information if available
|
||||
if model_name:
|
||||
litellm_metadata["model"] = model_name
|
||||
litellm_metadata["model_group"] = model_name # Use same value for model_group
|
||||
via_router = self._resolve_deployment(responses_id_security)
|
||||
if await self._superseded_by_a_provider_id_row(job):
|
||||
verbose_proxy_logger.info(f"Response {unified_object_id} is billed under its provider id row")
|
||||
continue
|
||||
target = self._poll_target(job)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
f"Skipping job {unified_object_id} due to error: {e}"
|
||||
)
|
||||
verbose_proxy_logger.warning(f"Skipping job {unified_object_id} due to error: {e}")
|
||||
continue
|
||||
|
||||
provider_response_id = ResponsesAPIRequestUtils.decode_responses_api_response_id(
|
||||
responses_id_security
|
||||
).get("response_id", responses_id_security)
|
||||
provider_response_id = ResponsesAPIRequestUtils.decode_responses_api_response_id(target.response_id).get(
|
||||
"response_id", target.response_id
|
||||
)
|
||||
try:
|
||||
response = await self._get_response(
|
||||
response_id=responses_id_security,
|
||||
litellm_metadata=litellm_metadata,
|
||||
via_router=via_router,
|
||||
)
|
||||
response = await self._fetch_unbilled(target)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"Response {unified_object_id} status: {response.status}, model: {model_name}"
|
||||
)
|
||||
verbose_proxy_logger.debug(f"Response {unified_object_id} status: {response.status}")
|
||||
if response.status in TERMINAL_RESPONSE_STATUSES:
|
||||
await self._bill_once(job, target, response)
|
||||
|
||||
except Exception as e:
|
||||
if via_router and _is_response_gone_at_provider(e, provider_response_id):
|
||||
if target.deployment is not None and _is_response_gone_at_provider(e, provider_response_id):
|
||||
verbose_proxy_logger.info(
|
||||
f"Response {unified_object_id} no longer available at provider (404), marking stale_expired: {e}"
|
||||
)
|
||||
expired_jobs.append(job)
|
||||
continue
|
||||
verbose_proxy_logger.warning(
|
||||
f"Skipping job {unified_object_id} due to error: {e}"
|
||||
)
|
||||
verbose_proxy_logger.warning(f"Skipping job {unified_object_id} due to error: {e}")
|
||||
continue
|
||||
|
||||
if response.status in TERMINAL_RESPONSE_STATUSES:
|
||||
verbose_proxy_logger.info(
|
||||
f"Response {unified_object_id} has terminal status {response.status}, marking as complete"
|
||||
)
|
||||
completed_jobs.append(job)
|
||||
|
||||
# Mark completed jobs in the database
|
||||
if len(completed_jobs) > 0:
|
||||
await _managed_object_table(self.prisma_client).update_many(
|
||||
# bounded-ok: at most MAX_OBJECTS_PER_POLL_CYCLE rows per cycle, the find_many take above
|
||||
where={"id": {"in": [job.id for job in completed_jobs]}},
|
||||
data={"status": "completed"},
|
||||
)
|
||||
verbose_proxy_logger.info(
|
||||
f"Marked {len(completed_jobs)} response jobs as completed"
|
||||
)
|
||||
|
||||
if len(expired_jobs) > 0:
|
||||
await _managed_object_table(self.prisma_client).update_many(
|
||||
# bounded-ok: at most MAX_OBJECTS_PER_POLL_CYCLE rows per cycle, the find_many take above
|
||||
where={"id": {"in": [job.id for job in expired_jobs]}},
|
||||
data={"status": "stale_expired"},
|
||||
)
|
||||
verbose_proxy_logger.info(
|
||||
f"Marked {len(expired_jobs)} response jobs as stale_expired"
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(f"Marked {len(expired_jobs)} response jobs as stale_expired")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
"""Creator attribution for the spend row a managed-object cost poll writes.
|
||||
|
||||
A batch or a background response is billed by a poll job that runs long after the creating
|
||||
request returned, so the managed object row's creator columns are the only record of which
|
||||
key, team, organization, user, end user, and tags carry the spend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final, Protocol, cast
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import CLI_SESSION_KEY_PREFIX
|
||||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.repositories.user_repository import UserRepository
|
||||
from litellm.repositories.verification_token_repository import VerificationTokenRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import models as prisma_models
|
||||
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.repositories.prisma_protocols import TableActions
|
||||
|
||||
|
||||
class ManagedObjectRow(Protocol):
|
||||
@property
|
||||
def id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def unified_object_id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def model_object_id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def created_by(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def status(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def file_object(self) -> object: ...
|
||||
|
||||
@property
|
||||
def org_id(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def api_key(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def team_id(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def request_tags(self) -> object: ...
|
||||
|
||||
@property
|
||||
def end_user_id(self) -> str | None: ...
|
||||
|
||||
|
||||
_TAG_LIST_ADAPTER: Final[TypeAdapter[list[object]]] = TypeAdapter(list[object])
|
||||
_KEY_OWNER_SCOPE_PREFIX: Final = "key:"
|
||||
|
||||
|
||||
def _string_tags(request_tags: object) -> list[str] | None:
|
||||
if not isinstance(request_tags, list):
|
||||
return None
|
||||
return [tag for tag in _TAG_LIST_ADAPTER.validate_python(request_tags) if isinstance(tag, str)] or None
|
||||
|
||||
|
||||
def _creator_user_id(created_by: str | None) -> str | None:
|
||||
if created_by is None or created_by.startswith(_KEY_OWNER_SCOPE_PREFIX):
|
||||
return None
|
||||
return created_by
|
||||
|
||||
|
||||
def _user_table(prisma_client: PrismaClient) -> TableActions[prisma_models.LiteLLM_UserTable]:
|
||||
return UserRepository(prisma_client).table
|
||||
|
||||
|
||||
def _token_table(prisma_client: PrismaClient) -> TableActions[prisma_models.LiteLLM_VerificationToken]:
|
||||
return VerificationTokenRepository(prisma_client).table
|
||||
|
||||
|
||||
def _team_table(prisma_client: PrismaClient) -> TableActions[prisma_models.LiteLLM_TeamTable]:
|
||||
return TeamRepository(prisma_client).table
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CreatorRows:
|
||||
key: prisma_models.LiteLLM_VerificationToken | None
|
||||
team: prisma_models.LiteLLM_TeamTable | None
|
||||
user: prisma_models.LiteLLM_UserTable | None
|
||||
|
||||
|
||||
class ManagedObjectCreatorAttribution:
|
||||
"""Rebuilds the spend-tracking metadata of whoever created a managed object, so the poll
|
||||
job's spend row is attributed like the creating request: key hash, team, organization,
|
||||
user, aliases, and tags.
|
||||
|
||||
Rows created before api_key and request_tags were persisted carry only created_by and
|
||||
team_id, and fall back to those. A named creating key owns user_api_key_alias; when it
|
||||
has no alias, or the key has since been rotated or deleted, the field keeps the creating
|
||||
user's alias, because a resolvable name is more useful on the spend row than a null.
|
||||
user_api_key_org_id is resolved too: the spend update writer reads it off this metadata
|
||||
to increment organization spend, so leaving it out drops the cost from org accounting.
|
||||
A background response also carries its end user and the key, team, and user budgets, the
|
||||
way the read that billed it before did, and never names a key-scoped creator as its user.
|
||||
"""
|
||||
|
||||
def __init__(self, prisma_client: PrismaClient, poller_name: str) -> None:
|
||||
self._prisma_client: Final = prisma_client
|
||||
self._poller_name: Final = poller_name
|
||||
|
||||
async def batch_metadata(self, job: ManagedObjectRow, object_id: str) -> Mapping[str, object]:
|
||||
rows: Final = await self._rows(job, object_id)
|
||||
return {"user_api_key_user_id": job.created_by, **_shared_metadata(job, rows)}
|
||||
|
||||
async def response_metadata(self, job: ManagedObjectRow, object_id: str) -> Mapping[str, object]:
|
||||
rows: Final = await self._rows(job, object_id)
|
||||
end_user_id: Final = getattr(job, "end_user_id", None)
|
||||
return {
|
||||
"user_api_key_user_id": _creator_user_id(job.created_by),
|
||||
**_shared_metadata(job, rows),
|
||||
**({"user_api_key_end_user_id": end_user_id} if end_user_id else {}),
|
||||
**_budget_fields(rows.key, "user_api_key_spend", "user_api_key_max_budget"),
|
||||
**_budget_fields(rows.team, "user_api_key_team_spend", "user_api_key_team_max_budget"),
|
||||
**_budget_fields(rows.user, "user_api_key_user_spend", "user_api_key_user_max_budget"),
|
||||
}
|
||||
|
||||
async def _rows(self, job: ManagedObjectRow, object_id: str) -> _CreatorRows:
|
||||
return _CreatorRows(
|
||||
key=await self._key_row(object_id, getattr(job, "api_key", None), job.created_by),
|
||||
team=await self._team_row(object_id, getattr(job, "team_id", None)),
|
||||
user=await self._user_row(object_id, _creator_user_id(job.created_by)),
|
||||
)
|
||||
|
||||
async def _user_row(self, object_id: str, user_id: str | None) -> prisma_models.LiteLLM_UserTable | None:
|
||||
"""Objects created by a team or service account key carry no user id, and
|
||||
find_unique(where={"user_id": None}) raises, so those resolve no row."""
|
||||
if not user_id:
|
||||
return None
|
||||
try:
|
||||
return await _user_table(self._prisma_client).find_unique(where={"user_id": user_id})
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"{self._poller_name}: could not look up user {user_id} for {object_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _key_row(
|
||||
self, object_id: str, api_key: str | None, created_by: str | None
|
||||
) -> prisma_models.LiteLLM_VerificationToken | None:
|
||||
if not api_key or _is_cli_session_key(api_key, created_by):
|
||||
return None
|
||||
try:
|
||||
return await _token_table(self._prisma_client).find_unique(where={"token": api_key})
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"{self._poller_name}: could not look up the creating key for {object_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _team_row(self, object_id: str, team_id: str | None) -> prisma_models.LiteLLM_TeamTable | None:
|
||||
if not team_id:
|
||||
return None
|
||||
try:
|
||||
return await _team_table(self._prisma_client).find_unique(where={"team_id": team_id})
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"{self._poller_name}: could not look up team {team_id} for {object_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _shared_metadata(job: ManagedObjectRow, rows: _CreatorRows) -> Mapping[str, object]:
|
||||
api_key: Final = getattr(job, "api_key", None)
|
||||
team_id: Final = getattr(job, "team_id", None)
|
||||
key_alias: Final = _key_alias(api_key, job.created_by, rows.key)
|
||||
team_alias: Final = getattr(rows.team, "team_alias", None)
|
||||
org_id: Final = getattr(job, "org_id", None) or _organization_id(rows.key) or _organization_id(rows.team)
|
||||
tags: Final = _string_tags(getattr(job, "request_tags", None))
|
||||
return {
|
||||
"user_api_key": api_key,
|
||||
"user_api_key_hash": api_key,
|
||||
"user_api_key_team_id": team_id,
|
||||
**(
|
||||
{
|
||||
"user_api_key_user_email": getattr(rows.user, "user_email", None),
|
||||
"user_api_key_alias": getattr(rows.user, "user_alias", None),
|
||||
}
|
||||
if rows.user is not None
|
||||
else {}
|
||||
),
|
||||
**({"user_api_key_alias": key_alias} if key_alias is not None else {}),
|
||||
**({"user_api_key_team_alias": team_alias} if team_alias is not None else {}),
|
||||
**({"user_api_key_org_id": org_id} if org_id is not None else {}),
|
||||
**({"tags": tags} if tags else {}),
|
||||
}
|
||||
|
||||
|
||||
def _is_cli_session_key(api_key: str, created_by: str | None) -> bool:
|
||||
return bool(created_by) and api_key == f"{CLI_SESSION_KEY_PREFIX}-{created_by}"
|
||||
|
||||
|
||||
def _key_alias(
|
||||
api_key: str | None, created_by: str | None, key_row: prisma_models.LiteLLM_VerificationToken | None
|
||||
) -> str | None:
|
||||
if api_key and _is_cli_session_key(api_key, created_by):
|
||||
return api_key
|
||||
return getattr(key_row, "key_alias", None) if key_row is not None else None
|
||||
|
||||
|
||||
def _organization_id(row: object) -> str | None:
|
||||
return cast("str | None", getattr(row, "organization_id", None)) if row is not None else None
|
||||
|
||||
|
||||
def _budget_fields(row: object, spend_key: str, max_budget_key: str) -> Mapping[str, object]:
|
||||
if row is None:
|
||||
return {}
|
||||
return {spend_key: getattr(row, "spend", None), max_budget_key: getattr(row, "max_budget", None)}
|
||||
|
|
@ -24,7 +24,9 @@ from uuid import NAMESPACE_URL, uuid5
|
|||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from openai import APIConnectionError, AsyncOpenAI
|
||||
from openai.types.file_deleted import FileDeleted
|
||||
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
|
||||
from typing_extensions import ReadOnly, Unpack
|
||||
|
||||
import litellm
|
||||
|
|
@ -35,17 +37,17 @@ from litellm.caching.caching import DualCache
|
|||
from litellm.constants import (
|
||||
BATCH_OUTPUT_FILE_FALLBACK_RETRY_AFTER_SECONDS,
|
||||
BATCH_OUTPUT_FILE_LOOKUP_TIMEOUT_SECONDS,
|
||||
MANAGED_BACKGROUND_RESPONSE_METADATA_KEY,
|
||||
MAX_FILE_LIST_LIMIT,
|
||||
PENDING_RESPONSE_STATUSES,
|
||||
)
|
||||
from litellm.files.types import FileRetrieveCallOptions, FileRetrieveProvider
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR
|
||||
from litellm.litellm_core_utils.internal_call_metadata import requests_background
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
extract_file_metadata,
|
||||
)
|
||||
from openai import APIConnectionError, AsyncOpenAI
|
||||
from openai.types.file_deleted import FileDeleted
|
||||
|
||||
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.managed_resources.isolation import (
|
||||
|
|
@ -88,6 +90,7 @@ from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attributio
|
|||
request_tags_from_metadata,
|
||||
)
|
||||
from litellm.repositories.managed_file_repository import ManagedFileRepository
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccessIssue]
|
||||
AllMessageValues,
|
||||
AsyncCursorPage,
|
||||
|
|
@ -100,6 +103,7 @@ from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccess
|
|||
ResponsesAPIResponse,
|
||||
)
|
||||
from litellm.types.utils import (
|
||||
ADDRESSED_RESPONSE_ID_FIELD,
|
||||
CallTypesLiteral,
|
||||
LiteLLMBatch,
|
||||
LiteLLMFineTuningJob,
|
||||
|
|
@ -346,6 +350,8 @@ class _ManagedObjectTableActions(Protocol):
|
|||
|
||||
async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int: ...
|
||||
|
||||
async def create(self, data: Mapping[str, object]) -> "PrismaManagedObjectRow": ...
|
||||
|
||||
|
||||
class _ManagedResourceDatabase(Protocol):
|
||||
@property
|
||||
|
|
@ -392,6 +398,39 @@ def _managed_object_table(prisma_client: _ManagedResourcePrismaClient) -> _Manag
|
|||
return prisma_client.db.litellm_managedobjecttable
|
||||
|
||||
|
||||
_JSON_VALUE_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
||||
_POSTGRES_UNSTORABLE_CHARACTER: Final = "\x00"
|
||||
|
||||
|
||||
def _storable_text(text: str) -> str:
|
||||
return text.replace(_POSTGRES_UNSTORABLE_CHARACTER, "")
|
||||
|
||||
|
||||
def _storable_json_value(value: JsonValue) -> JsonValue:
|
||||
if isinstance(value, str):
|
||||
return _storable_text(value)
|
||||
if isinstance(value, list):
|
||||
return [_storable_json_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {_storable_text(key): _storable_json_value(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _postgres_storable_json(file_object: BaseModel) -> str:
|
||||
dumped: Final = file_object.model_dump_json()
|
||||
if "\\u0000" not in dumped:
|
||||
return dumped
|
||||
return _JSON_VALUE_ADAPTER.dump_json(_storable_json_value(_JSON_VALUE_ADAPTER.validate_json(dumped))).decode()
|
||||
|
||||
|
||||
def _without_model_output(
|
||||
file_object: LiteLLMBatch | LiteLLMFineTuningJob | ResponsesAPIResponse,
|
||||
) -> LiteLLMBatch | LiteLLMFineTuningJob | ResponsesAPIResponse:
|
||||
return (
|
||||
file_object.model_copy(update={"output": []}) if isinstance(file_object, ResponsesAPIResponse) else file_object
|
||||
)
|
||||
|
||||
|
||||
def _storage_metadata_of(file_object: OpenAIFileObject | None) -> Mapping[str, str]:
|
||||
hidden_params: Final = cast( # cast-ok: _hidden_params is an untyped attribute the upload path sets
|
||||
"Mapping[str, object]", getattr(file_object, "_hidden_params", None) or {}
|
||||
|
|
@ -405,6 +444,40 @@ def _storage_metadata_of(file_object: OpenAIFileObject | None) -> Mapping[str, s
|
|||
)
|
||||
|
||||
|
||||
_MANAGED_RESPONSE_READ_CALL_TYPES: Final = frozenset({"aget_responses", "acancel_responses"})
|
||||
_REQUEST_FIELDS: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
def _addresses_a_proxy_issued_response(data: Mapping[str, object]) -> bool:
|
||||
from litellm.proxy.hooks.responses_id_security import decrypted_response_id
|
||||
|
||||
addressed_id: Final = data.get(ADDRESSED_RESPONSE_ID_FIELD) or data.get("response_id")
|
||||
return (
|
||||
isinstance(addressed_id, str)
|
||||
and ResponsesAPIRequestUtils.get_model_id_from_response_id(decrypted_response_id(addressed_id)) is not None
|
||||
)
|
||||
|
||||
|
||||
def _is_streamed_background_create(request: Mapping[str, object]) -> bool:
|
||||
return (
|
||||
request.get("stream") is True
|
||||
and isinstance(request.get("litellm_metadata"), dict)
|
||||
and requests_background(request)
|
||||
)
|
||||
|
||||
|
||||
def _with_managed_background_marker(request: Mapping[str, object]) -> dict[str, object]:
|
||||
metadata_field: Final = "litellm_metadata" if isinstance(request.get("litellm_metadata"), dict) else "metadata"
|
||||
existing_metadata: Final = request.get(metadata_field)
|
||||
return {
|
||||
**request,
|
||||
metadata_field: {
|
||||
**(_REQUEST_FIELDS.validate_python(existing_metadata) if isinstance(existing_metadata, dict) else {}),
|
||||
MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_MANAGED_FILES_TARGET: Final = "managed_files"
|
||||
_PROVIDER_FILE_RETRIEVE_RETRY_DELAYS_SECONDS: Final = (0.5, 1.0, 2.0)
|
||||
|
||||
|
|
@ -529,11 +602,14 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
) -> None:
|
||||
"""Persist a managed object row, caching it and upserting it in the DB.
|
||||
|
||||
persist_attribution is set only by the batch create, which is the one caller
|
||||
that can speak for the creator; it gates the api_key and request_tags columns
|
||||
that CheckBatchCost bills against, so a later poll or retrieve of the same
|
||||
batch cannot record itself as the paying key. Like created_by and team_id,
|
||||
both are written only in the upsert create branch, never on update.
|
||||
persist_attribution is set only by the batch and background response creates,
|
||||
the callers that can speak for the creator; it gates the api_key, request_tags, and,
|
||||
for a response, end_user_id columns that the cost poll jobs bill against, so a later poll or
|
||||
retrieve of the same object cannot record itself as the paying key. Like created_by and team_id,
|
||||
they are written only in the upsert create branch, never on update.
|
||||
|
||||
A stored response keeps no model output: billing reads only its status and model from
|
||||
the row, so the row never holds generated content.
|
||||
|
||||
create_if_missing is cleared by callers that observe a batch they did not
|
||||
create, such as a poll. They still refresh status and file_object, but a
|
||||
|
|
@ -546,11 +622,12 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
It is written only in the upsert create branch.
|
||||
"""
|
||||
verbose_logger.info(f"Storing LiteLLM Managed {file_purpose} object with id={unified_object_id} in cache")
|
||||
stored_object: Final = _without_model_output(file_object)
|
||||
litellm_managed_object = LiteLLM_ManagedObjectTable(
|
||||
unified_object_id=unified_object_id,
|
||||
model_object_id=model_object_id,
|
||||
file_purpose=file_purpose,
|
||||
file_object=file_object,
|
||||
file_object=stored_object,
|
||||
)
|
||||
await self.internal_usage_cache.async_set_cache(
|
||||
key=unified_object_id,
|
||||
|
|
@ -561,32 +638,35 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
from prisma import Json
|
||||
|
||||
api_key = LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_dict) or None
|
||||
end_user_id: Final = user_api_key_dict.end_user_id if file_purpose == "response" else None
|
||||
attribution_columns = (
|
||||
{
|
||||
**({"api_key": api_key} if api_key is not None else {}),
|
||||
**({"request_tags": Json(list(request_tags))} if request_tags else {}),
|
||||
**({"request_tags": Json([_storable_text(tag) for tag in request_tags])} if request_tags else {}),
|
||||
**({"end_user_id": end_user_id} if end_user_id else {}),
|
||||
}
|
||||
if persist_attribution
|
||||
else {}
|
||||
)
|
||||
# FIX: Update status and file_object on every operation to keep state in sync
|
||||
update_columns: Final = {
|
||||
"file_object": file_object.model_dump_json(),
|
||||
"file_object": _postgres_storable_json(stored_object),
|
||||
"status": file_object.status,
|
||||
"updated_by": user_api_key_dict.user_id,
|
||||
}
|
||||
managed_object_table: Final = _managed_object_table(self.prisma_client)
|
||||
if not create_if_missing:
|
||||
await _managed_object_table(self.prisma_client).update_many(
|
||||
await managed_object_table.update_many(
|
||||
where={"unified_object_id": unified_object_id},
|
||||
data=update_columns,
|
||||
)
|
||||
return
|
||||
await _managed_object_table(self.prisma_client).upsert(
|
||||
await managed_object_table.upsert(
|
||||
where={"unified_object_id": unified_object_id},
|
||||
data={
|
||||
"create": {
|
||||
"unified_object_id": unified_object_id,
|
||||
"file_object": file_object.model_dump_json(),
|
||||
"file_object": _postgres_storable_json(stored_object),
|
||||
"model_object_id": model_object_id,
|
||||
"file_purpose": file_purpose,
|
||||
"created_by": resolve_resource_owner_id(user_api_key_dict),
|
||||
|
|
@ -601,6 +681,42 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
},
|
||||
)
|
||||
|
||||
async def track_untracked_background_response(
|
||||
self,
|
||||
response_id: str,
|
||||
response: ResponsesAPIResponse,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
request_tags: Sequence[str] | None = None,
|
||||
) -> bool:
|
||||
if not _addresses_a_proxy_issued_response({"response_id": response_id}):
|
||||
return False
|
||||
from prisma import Json
|
||||
from prisma.errors import UniqueViolationError
|
||||
|
||||
from litellm.proxy.hooks.responses_id_security import decrypted_response_id
|
||||
|
||||
api_key: Final = LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_dict) or None
|
||||
try:
|
||||
await _managed_object_table(self.prisma_client).create(
|
||||
data={
|
||||
"unified_object_id": response_id,
|
||||
"model_object_id": decrypted_response_id(response_id),
|
||||
"file_purpose": "response",
|
||||
"file_object": _postgres_storable_json(_without_model_output(response)),
|
||||
"status": PENDING_RESPONSE_STATUSES[0],
|
||||
"created_by": resolve_resource_owner_id(user_api_key_dict),
|
||||
"team_id": user_api_key_dict.team_id,
|
||||
"org_id": await self._resolve_creator_org_id(user_api_key_dict),
|
||||
"updated_by": user_api_key_dict.user_id,
|
||||
**({"api_key": api_key} if api_key is not None else {}),
|
||||
**({"end_user_id": user_api_key_dict.end_user_id} if user_api_key_dict.end_user_id else {}),
|
||||
**({"request_tags": Json([_storable_text(tag) for tag in request_tags])} if request_tags else {}),
|
||||
}
|
||||
)
|
||||
except UniqueViolationError:
|
||||
return False
|
||||
return True
|
||||
|
||||
@with_service_target(_MANAGED_FILES_TARGET)
|
||||
async def get_unified_file_id(
|
||||
self, file_id: str, litellm_parent_otel_span: Optional[Span] = None
|
||||
|
|
@ -1375,6 +1491,9 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
unified_vs_ids = self.get_vector_store_ids_from_file_search_tools(tools)
|
||||
if unified_vs_ids:
|
||||
await self.check_vector_store_ids_access(unified_vs_ids, user_api_key_dict)
|
||||
create_request: Final = _REQUEST_FIELDS.validate_python(data)
|
||||
if _is_streamed_background_create(create_request):
|
||||
return _with_managed_background_marker(create_request)
|
||||
elif call_type == CallTypes.afile_content.value:
|
||||
retrieve_file_id = cast(Optional[str], data.get("file_id"))
|
||||
potential_file_id = _is_base64_encoded_unified_file_id(retrieve_file_id) if retrieve_file_id else False
|
||||
|
|
@ -1432,6 +1551,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
data[accessor_key] = get_batch_id_from_unified_batch_id(potential_llm_object_id)
|
||||
elif retrieve_object_id and accessor_key == "batch_id":
|
||||
await self.enforce_batch_object_access(retrieve_object_id, user_api_key_dict)
|
||||
elif call_type in _MANAGED_RESPONSE_READ_CALL_TYPES:
|
||||
request: Final = _REQUEST_FIELDS.validate_python(data)
|
||||
if _addresses_a_proxy_issued_response(request):
|
||||
return _with_managed_background_marker(request)
|
||||
elif call_type == CallTypes.acreate_fine_tuning_job.value:
|
||||
fine_tuning_file_ids: Final = tuple(
|
||||
file_id
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
SET LOCAL lock_timeout = '10s';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_ManagedObjectTable" ADD COLUMN IF NOT EXISTS "end_user_id" TEXT;
|
||||
|
|
@ -1179,6 +1179,7 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t
|
|||
org_id String? // creating key's organization at submission time; CheckBatchCost bills org spend against it
|
||||
api_key String?
|
||||
request_tags Json? @default("[]")
|
||||
end_user_id String?
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String?
|
||||
|
||||
|
|
|
|||
|
|
@ -1622,6 +1622,7 @@ CONSUMED_REQUEST_TAGS_METADATA_KEY: Final = "_consumed_request_tags"
|
|||
ROUTING_REQUEST_TAGS_METADATA_KEY: Final = "_routing_request_tags"
|
||||
ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY: Final = "_litellm_router_usage_counted_tokens"
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
|
||||
MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: Final = "_managed_background_response"
|
||||
SESSION_ID_GENERATED_METADATA_KEY: Final = "litellm_session_id_generated"
|
||||
SESSION_ID_OMITTED_METADATA_KEY: Final = "litellm_session_id_omitted"
|
||||
LITELLM_TRUNCATED_PAYLOAD_FIELD: Final = "litellm_truncated"
|
||||
|
|
@ -1862,6 +1863,8 @@ RESET_BUDGET_JOB_LOCK_TTL_SECONDS: Final[int] = 900
|
|||
PROXY_BATCH_POLLING_INTERVAL: Final = get_env_int("PROXY_BATCH_POLLING_INTERVAL", 3600)
|
||||
MAX_OBJECTS_PER_POLL_CYCLE: Final = max(1, get_env_int("MAX_OBJECTS_PER_POLL_CYCLE", 50))
|
||||
MANAGED_OBJECT_STALENESS_CUTOFF_DAYS: Final = max(1, get_env_int("MANAGED_OBJECT_STALENESS_CUTOFF_DAYS", 7))
|
||||
PENDING_RESPONSE_STATUSES: Final = ("queued", "in_progress")
|
||||
TERMINAL_RESPONSE_STATUSES: Final = frozenset({"completed", "failed", "cancelled", "incomplete"})
|
||||
BATCH_OUTPUT_FILE_LOOKUP_TIMEOUT_SECONDS: Final = 10.0
|
||||
BATCH_OUTPUT_FILE_FALLBACK_RETRY_AFTER_SECONDS: Final = 60
|
||||
STALE_OBJECT_CLEANUP_BATCH_SIZE: Final = max(1, get_env_int("STALE_OBJECT_CLEANUP_BATCH_SIZE", 1000))
|
||||
|
|
|
|||
|
|
@ -21,9 +21,17 @@ from collections.abc import Mapping
|
|||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY, NON_INFERENCE_CALL_TYPES
|
||||
from litellm.constants import (
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
MANAGED_BACKGROUND_RESPONSE_METADATA_KEY,
|
||||
NON_INFERENCE_CALL_TYPES,
|
||||
)
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import initialize_standard_callback_dynamic_params
|
||||
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN, InternalCallOrigin
|
||||
from litellm.types.utils import (
|
||||
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN,
|
||||
InternalCallOrigin,
|
||||
)
|
||||
|
||||
BUDGET_RESERVATION_METADATA_KEYS: Final = frozenset({"user_api_key_budget_reservation"})
|
||||
|
||||
|
|
@ -62,10 +70,32 @@ def is_background_response(response: object) -> bool:
|
|||
because the callers hold it in both shapes.
|
||||
"""
|
||||
if isinstance(response, Mapping):
|
||||
return response.get("background") is True
|
||||
return "background" in response and response["background"] is True
|
||||
return getattr(response, "background", None) is True
|
||||
|
||||
|
||||
def requests_background(request_body: Mapping[str, object]) -> bool:
|
||||
return bool(request_body.get("background")) or is_background_response(request_body.get("extra_body"))
|
||||
|
||||
|
||||
_RESPONSE_CREATE_CALL_TYPES: Final = frozenset({"responses", "aresponses"})
|
||||
|
||||
|
||||
def _finished_response(response: object) -> object:
|
||||
wrapped: Final = response.get("response") if isinstance(response, Mapping) else getattr(response, "response", None)
|
||||
return response if wrapped is None else wrapped
|
||||
|
||||
|
||||
def _may_be_unbilled(call_type: str | None, response: object) -> bool:
|
||||
if call_type in _RESPONSE_CREATE_CALL_TYPES:
|
||||
return is_background_response(_finished_response(response))
|
||||
return call_type in NON_INFERENCE_CALL_TYPES
|
||||
|
||||
|
||||
def _is_managed_background_call(metadata: Mapping[str, object] | None) -> bool:
|
||||
return metadata is not None and metadata.get(MANAGED_BACKGROUND_RESPONSE_METADATA_KEY) is True
|
||||
|
||||
|
||||
def is_unbilled_non_inference_call(
|
||||
call_type: str | None,
|
||||
metadata: Mapping[str, object] | None,
|
||||
|
|
@ -74,18 +104,25 @@ def is_unbilled_non_inference_call(
|
|||
"""A read/management route priced at zero, because the usage it reports belongs to the
|
||||
call that created the object it just read.
|
||||
|
||||
Retrieving a background response is the exception, and the enterprise cost poller's read
|
||||
is the same exception seen from the other side: that job's create billed nothing, so its
|
||||
retrieval is the only place the spend is ever visible. Pricing those at zero would lose
|
||||
the spend rather than deduplicate it.
|
||||
A background response is the exception, since its create billed nothing. When the proxy
|
||||
tracks it as a managed object, the read is marked with
|
||||
``MANAGED_BACKGROUND_RESPONSE_METADATA_KEY`` and stays free, because the one billing event
|
||||
the proxy logs from the finished response, stamped with
|
||||
``BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN``, bills the job. A streamed background create
|
||||
the proxy tracks carries the same marker and is free for the same reason. Any other
|
||||
read of a background response (the SDK, a proxy without a database) is the only place its
|
||||
spend is ever visible, so it prices normally.
|
||||
"""
|
||||
if call_type not in NON_INFERENCE_CALL_TYPES:
|
||||
if not _may_be_unbilled(call_type, response):
|
||||
return False
|
||||
if is_background_response(response):
|
||||
if call_type in _RESPONSE_CREATE_CALL_TYPES:
|
||||
return _is_managed_background_call(metadata)
|
||||
origin: Final = metadata.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) if metadata is not None else None
|
||||
if origin == BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN:
|
||||
return False
|
||||
if metadata is None:
|
||||
if not is_background_response(response):
|
||||
return True
|
||||
return metadata.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
return origin == BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN or _is_managed_background_call(metadata)
|
||||
|
||||
|
||||
def is_unbilled_non_inference_call_from_params(
|
||||
|
|
@ -95,10 +132,10 @@ def is_unbilled_non_inference_call_from_params(
|
|||
) -> bool:
|
||||
""":func:`is_unbilled_non_inference_call` for callers holding raw ``litellm_params``.
|
||||
|
||||
The call-type membership test runs first so that inference traffic, which is every
|
||||
The call-type and background tests run first so that inference traffic, which is every
|
||||
request in a normal workload, never pays for the metadata merge behind it.
|
||||
"""
|
||||
if call_type not in NON_INFERENCE_CALL_TYPES:
|
||||
if not _may_be_unbilled(call_type, response):
|
||||
return False
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
|
||||
|
|
|
|||
|
|
@ -5856,7 +5856,7 @@ class StandardLoggingPayloadSetup:
|
|||
return messages
|
||||
|
||||
@staticmethod
|
||||
def merge_litellm_metadata(litellm_params: Mapping[str, object]) -> dict:
|
||||
def merge_litellm_metadata(litellm_params: Mapping[str, object]) -> dict[str, object]:
|
||||
"""
|
||||
Merge both litellm_metadata and metadata from litellm_params.
|
||||
|
||||
|
|
@ -6483,7 +6483,7 @@ class StandardLoggingPayloadSetup:
|
|||
@staticmethod
|
||||
def get_request_tags(
|
||||
litellm_params: dict[str, object],
|
||||
proxy_server_request: dict[str, object],
|
||||
proxy_server_request: Mapping[str, object],
|
||||
) -> list[str]:
|
||||
metadata: Final = cast( # cast-ok: request metadata is caller-provided
|
||||
Mapping[str, object], litellm_params.get("metadata") or {}
|
||||
|
|
|
|||
|
|
@ -674,6 +674,15 @@ def is_request_body_safe(
|
|||
return True
|
||||
|
||||
|
||||
def carries_client_side_overrides(request_body: object) -> bool:
|
||||
if not isinstance(request_body, dict):
|
||||
return False
|
||||
try:
|
||||
return not is_request_body_safe(request_body=request_body, general_settings={}, llm_router=None, model="")
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
def _coerce_metadata_to_dict(value: object) -> dict[str, object] | None:
|
||||
"""Return ``value`` as a dict, parsing it from JSON if delivered as a string.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from types import MappingProxyType
|
|||
from typing import TYPE_CHECKING, Any, Final, Literal, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -68,7 +67,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( # noqa: F401 #
|
|||
update_batch_in_database,
|
||||
validate_managed_id_requirement,
|
||||
)
|
||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import request_tags_from_metadata
|
||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import request_tags_from_request_data
|
||||
from litellm.proxy.route_llm_request import raise_if_required_body_param_missing
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging, handle_exception_on_proxy, is_known_model
|
||||
from litellm.repositories.managed_batch_repository import ManagedBatchRepository
|
||||
|
|
@ -81,7 +80,6 @@ if TYPE_CHECKING:
|
|||
from prisma.models import LiteLLM_ManagedObjectTable
|
||||
|
||||
router: Final = APIRouter()
|
||||
_METADATA_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
def _hidden_param_string(hidden_params: Mapping[str, object], key: str) -> str:
|
||||
|
|
@ -89,13 +87,6 @@ def _hidden_param_string(hidden_params: Mapping[str, object], key: str) -> str:
|
|||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _request_tags(data: Mapping[str, object]) -> tuple[str, ...] | None:
|
||||
metadata: Final = data.get("litellm_metadata")
|
||||
if metadata is None:
|
||||
return None
|
||||
return request_tags_from_metadata(_METADATA_ADAPTER.validate_python(metadata))
|
||||
|
||||
|
||||
def _require_batch_response(response: LLMResponseTypes) -> LiteLLMBatch:
|
||||
if not isinstance(response, LiteLLMBatch):
|
||||
raise TypeError("Batch endpoint received a non-batch response")
|
||||
|
|
@ -444,7 +435,7 @@ async def create_batch(
|
|||
model=model,
|
||||
provider=executed_provider,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_tags=_request_tags(_create_batch_data),
|
||||
request_tags=request_tags_from_request_data(_create_batch_data),
|
||||
)
|
||||
if executed_provider is not None
|
||||
else await _create_provider_batch_for_managed_file(
|
||||
|
|
|
|||
|
|
@ -1605,6 +1605,31 @@ def _get_cost_breakdown_from_logging_obj(
|
|||
)
|
||||
|
||||
|
||||
def _header_value(value: float | None) -> str | None:
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def cost_headers(
|
||||
response_cost: float | str | None, litellm_logging_obj: LiteLLMLoggingObj | None
|
||||
) -> Mapping[str, str | None]:
|
||||
cost_breakdown: Final = _get_cost_breakdown_from_logging_obj(
|
||||
litellm_logging_obj=litellm_logging_obj, response_cost=response_cost
|
||||
)
|
||||
return {
|
||||
"x-litellm-response-cost": str(response_cost),
|
||||
"x-litellm-response-cost-original": _header_value(cost_breakdown.original_cost),
|
||||
"x-litellm-response-cost-discount-amount": _header_value(cost_breakdown.discount_amount),
|
||||
"x-litellm-response-cost-margin-amount": _header_value(cost_breakdown.margin_total_amount),
|
||||
"x-litellm-response-cost-margin-percent": _header_value(cost_breakdown.margin_percent),
|
||||
"x-litellm-response-cost-input": _header_value(cost_breakdown.input_cost),
|
||||
"x-litellm-response-cost-output": _header_value(cost_breakdown.output_cost),
|
||||
"x-litellm-response-cost-cache-read": _header_value(cost_breakdown.cache_read_cost),
|
||||
"x-litellm-response-cost-cache-creation": _header_value(cost_breakdown.cache_creation_cost),
|
||||
"x-litellm-response-cost-reasoning": _header_value(cost_breakdown.reasoning_cost),
|
||||
"x-litellm-response-cost-tool-usage": _header_value(cost_breakdown.tool_usage_cost),
|
||||
}
|
||||
|
||||
|
||||
def _classifier_cost_from_request_data(request_data: Mapping[str, object] | None) -> float | None:
|
||||
"""Cost of the auto-router's LLM classifier call, read from the request's routing_decision.
|
||||
|
||||
|
|
@ -1878,10 +1903,6 @@ class ProxyBaseLLMRequestProcessing:
|
|||
use_logging_obj=read_timing_from_logging_obj,
|
||||
)
|
||||
|
||||
cost_breakdown: Final = _get_cost_breakdown_from_logging_obj(
|
||||
litellm_logging_obj=litellm_logging_obj, response_cost=response_cost
|
||||
)
|
||||
|
||||
# Calculate updated spend for header (include current response_cost)
|
||||
current_spend: Final = user_api_key_dict.spend or 0.0
|
||||
updated_spend = current_spend
|
||||
|
|
@ -1908,37 +1929,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
), # don't include query params, risk of leaking sensitive info
|
||||
"x-litellm-version": version,
|
||||
"x-litellm-model-region": model_region,
|
||||
"x-litellm-response-cost": str(response_cost),
|
||||
"x-litellm-response-cost-original": (
|
||||
str(cost_breakdown.original_cost) if cost_breakdown.original_cost is not None else None
|
||||
),
|
||||
"x-litellm-response-cost-discount-amount": (
|
||||
str(cost_breakdown.discount_amount) if cost_breakdown.discount_amount is not None else None
|
||||
),
|
||||
"x-litellm-response-cost-margin-amount": (
|
||||
str(cost_breakdown.margin_total_amount) if cost_breakdown.margin_total_amount is not None else None
|
||||
),
|
||||
"x-litellm-response-cost-margin-percent": (
|
||||
str(cost_breakdown.margin_percent) if cost_breakdown.margin_percent is not None else None
|
||||
),
|
||||
"x-litellm-response-cost-input": (
|
||||
str(cost_breakdown.input_cost) if cost_breakdown.input_cost is not None else None
|
||||
),
|
||||
"x-litellm-response-cost-output": (
|
||||
str(cost_breakdown.output_cost) if cost_breakdown.output_cost is not None else None
|
||||
),
|
||||
"x-litellm-response-cost-cache-read": (
|
||||
str(cost_breakdown.cache_read_cost) if cost_breakdown.cache_read_cost is not None else None
|
||||
),
|
||||
"x-litellm-response-cost-cache-creation": (
|
||||
str(cost_breakdown.cache_creation_cost) if cost_breakdown.cache_creation_cost is not None else None
|
||||
),
|
||||
"x-litellm-response-cost-reasoning": (
|
||||
str(cost_breakdown.reasoning_cost) if cost_breakdown.reasoning_cost is not None else None
|
||||
),
|
||||
"x-litellm-response-cost-tool-usage": (
|
||||
str(cost_breakdown.tool_usage_cost) if cost_breakdown.tool_usage_cost is not None else None
|
||||
),
|
||||
**cost_headers(response_cost, litellm_logging_obj),
|
||||
"x-litellm-classifier-cost": (str(classifier_cost) if classifier_cost is not None else None),
|
||||
"x-litellm-key-tpm-limit": str(user_api_key_dict.tpm_limit),
|
||||
"x-litellm-key-rpm-limit": str(user_api_key_dict.rpm_limit),
|
||||
|
|
@ -3164,7 +3155,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_completed_responses_response(stream_response: object) -> object:
|
||||
def extract_completed_responses_response(stream_response: object) -> object:
|
||||
"""Pull the assembled ``ResponsesAPIResponse`` off a streaming iterator.
|
||||
|
||||
``ResponsesAPIStreamingIterator`` stores the terminal stream event
|
||||
|
|
@ -3200,9 +3191,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
appears, ahead of the chunk carrying ``response.completed``.
|
||||
"""
|
||||
async for chunk in wrapped_generator:
|
||||
completed_obj = ProxyBaseLLMRequestProcessing._extract_completed_responses_response(
|
||||
original_stream_response
|
||||
)
|
||||
completed_obj = ProxyBaseLLMRequestProcessing.extract_completed_responses_response(original_stream_response)
|
||||
if completed_obj is None:
|
||||
yield chunk
|
||||
continue
|
||||
|
|
@ -3214,7 +3203,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
async for remaining_chunk in wrapped_generator:
|
||||
yield remaining_chunk
|
||||
return
|
||||
late_completed_obj: Final = ProxyBaseLLMRequestProcessing._extract_completed_responses_response(
|
||||
late_completed_obj: Final = ProxyBaseLLMRequestProcessing.extract_completed_responses_response(
|
||||
original_stream_response
|
||||
)
|
||||
if late_completed_obj is not None:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.constants import (
|
|||
from litellm.proxy._types import DB_RETRY_SAFE_ERROR_TYPES, SpendLogsPayload
|
||||
from litellm.proxy.db.model_insights_tasks import load_model_insight_tasks
|
||||
from litellm.proxy.db.rollup_lock_timeout import ROLLUP_LOCK_TIMEOUT_SQL, rollup_lock_timeout_setting
|
||||
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
|
@ -59,6 +60,7 @@ class ModelUsageTransaction:
|
|||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
successful: bool
|
||||
counts_as_request: bool = True
|
||||
|
||||
|
||||
def model_usage_task_type(request_tags: str) -> str:
|
||||
|
|
@ -78,12 +80,12 @@ def model_usage_task_type(request_tags: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _is_internal_call(metadata: str) -> bool:
|
||||
def _internal_call_origin(metadata: str) -> object:
|
||||
try:
|
||||
decoded: Final = _METADATA.validate_json(metadata)
|
||||
except ValidationError:
|
||||
return False
|
||||
return bool(decoded.get(INTERNAL_CALL_ORIGIN_METADATA_KEY))
|
||||
return None
|
||||
return decoded.get(INTERNAL_CALL_ORIGIN_METADATA_KEY)
|
||||
|
||||
|
||||
def _date_from_start_time(start_time: datetime | str) -> str | None:
|
||||
|
|
@ -94,7 +96,8 @@ def _date_from_start_time(start_time: datetime | str) -> str | None:
|
|||
|
||||
def build_model_usage_transaction(payload: SpendLogsPayload) -> ModelUsageTransaction | None:
|
||||
date: Final = _date_from_start_time(payload["startTime"])
|
||||
if date is None or _is_internal_call(payload["metadata"]):
|
||||
origin: Final = _internal_call_origin(payload["metadata"])
|
||||
if date is None or (origin and origin != BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN):
|
||||
return None
|
||||
model: Final = payload["model"] or "unknown"
|
||||
return ModelUsageTransaction(
|
||||
|
|
@ -109,6 +112,7 @@ def build_model_usage_transaction(payload: SpendLogsPayload) -> ModelUsageTransa
|
|||
prompt_tokens=payload["prompt_tokens"],
|
||||
completion_tokens=payload["completion_tokens"],
|
||||
successful=payload["status"] == "success",
|
||||
counts_as_request=not origin,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -142,8 +146,9 @@ async def flush_model_usage_transactions(
|
|||
spend = sum(entry.spend for entry in entries)
|
||||
prompt_tokens = sum(entry.prompt_tokens for entry in entries)
|
||||
completion_tokens = sum(entry.completion_tokens for entry in entries)
|
||||
successful = sum(1 for entry in entries if entry.successful)
|
||||
failed = len(entries) - successful
|
||||
requests = tuple(entry for entry in entries if entry.counts_as_request)
|
||||
successful = sum(1 for entry in requests if entry.successful)
|
||||
failed = len(requests) - successful
|
||||
key_fields = {
|
||||
"date": key.date,
|
||||
"model_group": key.model_group,
|
||||
|
|
@ -159,7 +164,7 @@ async def flush_model_usage_transactions(
|
|||
"spend": spend,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"request_count": len(entries),
|
||||
"request_count": len(requests),
|
||||
"successful_requests": successful,
|
||||
"failed_requests": failed,
|
||||
},
|
||||
|
|
@ -167,7 +172,7 @@ async def flush_model_usage_transactions(
|
|||
"spend": {"increment": spend},
|
||||
"prompt_tokens": {"increment": prompt_tokens},
|
||||
"completion_tokens": {"increment": completion_tokens},
|
||||
"request_count": {"increment": len(entries)},
|
||||
"request_count": {"increment": len(requests)},
|
||||
"successful_requests": {"increment": successful},
|
||||
"failed_requests": {"increment": failed},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -140,12 +140,12 @@ class ResponsesIDSecurity(CustomLogger):
|
|||
)
|
||||
if not isinstance(addressed_id, str) or not addressed_id:
|
||||
return data
|
||||
authorized_id: Final = self._authorize_response_id(addressed_id, user_api_key_dict)
|
||||
authorized_id: Final = self.authorize_response_id(addressed_id, user_api_key_dict)
|
||||
data[addressed_id_field] = authorized_id
|
||||
data[ADDRESSED_RESPONSE_ID_FIELD] = addressed_id
|
||||
return data
|
||||
|
||||
def _authorize_response_id(
|
||||
def authorize_response_id(
|
||||
self,
|
||||
response_id: str,
|
||||
user_api_key_dict: "UserAPIKeyAuth",
|
||||
|
|
@ -344,3 +344,9 @@ class ResponsesIDSecurity(CustomLogger):
|
|||
):
|
||||
chunk = self._encrypt_response_id(chunk, user_api_key_dict, request_encryption_cache)
|
||||
yield chunk
|
||||
|
||||
|
||||
def decrypted_response_id(response_id: str) -> str:
|
||||
"""The id a client-facing response id stands for, the same for every encryption of one response."""
|
||||
original_response_id, _, _ = ResponsesIDSecurity()._decrypt_response_id(response_id)
|
||||
return original_response_id
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from litellm.constants import (
|
|||
CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
LITELLM_PROXY_MASTER_KEY_ALIAS,
|
||||
MANAGED_BACKGROUND_RESPONSE_METADATA_KEY,
|
||||
OTEL_SERVICE_NAME_METADATA_KEYS,
|
||||
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
|
||||
ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY,
|
||||
|
|
@ -378,6 +379,7 @@ _UNTRUSTED_METADATA_CONTROL_FIELDS: Final = (
|
|||
CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
ROUTING_REQUEST_TAGS_METADATA_KEY,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
MANAGED_BACKGROUND_RESPONSE_METADATA_KEY,
|
||||
"standard_logging_object",
|
||||
"litellm_roi_estimator",
|
||||
"proxy_server_request",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import asyncio
|
|||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.litellm_core_utils.safe_json_dumps import strip_null_bytes
|
||||
|
||||
|
|
@ -45,6 +47,17 @@ def request_tags_from_metadata(request_metadata: Mapping[str, object]) -> tuple[
|
|||
return None
|
||||
|
||||
|
||||
_METADATA_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
def request_tags_from_request_data(data: Mapping[str, object]) -> tuple[str, ...] | None:
|
||||
"""Tags of a request whose metadata travels as litellm_metadata (batches, responses)"""
|
||||
metadata: Final = data.get("litellm_metadata")
|
||||
if metadata is None:
|
||||
return None
|
||||
return request_tags_from_metadata(_METADATA_ADAPTER.validate_python(metadata))
|
||||
|
||||
|
||||
def log_batch_registration_result(
|
||||
finished: asyncio.Task[None],
|
||||
provider: str,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Mapping, Sequence
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, TypeAlias, cast, get_args
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Final,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Protocol,
|
||||
TypeAlias,
|
||||
cast,
|
||||
get_args,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
import fastapi
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
|
@ -17,25 +29,32 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
|||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import EMPTY_MAPPING
|
||||
from litellm.constants import EMPTY_MAPPING, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY, TERMINAL_RESPONSE_STATUSES
|
||||
from litellm.integrations.custom_guardrail import ModifyResponseException
|
||||
from litellm.litellm_core_utils.internal_call_metadata import is_background_response, requests_background
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
from litellm.llms.openai.responses.guardrail_translation.handler import (
|
||||
OpenAIResponsesHandler,
|
||||
build_blocked_response,
|
||||
)
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.auth_utils import carries_client_side_overrides
|
||||
from litellm.proxy.auth.user_api_key_auth import (
|
||||
UserAPIKeyAuth,
|
||||
user_api_key_auth,
|
||||
user_api_key_auth_websocket,
|
||||
)
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing, create_response
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing, cost_headers, create_response
|
||||
from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # legacy module exports
|
||||
_read_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
_safe_set_request_parsed_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
read_request_body,
|
||||
safe_set_request_parsed_body,
|
||||
)
|
||||
from litellm.proxy.hooks.responses_id_security import ResponsesIDSecurity, decrypted_response_id
|
||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import (
|
||||
request_tags_from_request_data,
|
||||
)
|
||||
from litellm.proxy.route_llm_request import raise_if_required_body_param_missing
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
from litellm.types.llms.openai import (
|
||||
|
|
@ -47,10 +66,13 @@ from litellm.types.responses.main import DeleteResponseResult
|
|||
from litellm.types.utils import TokenCountResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.router import Router
|
||||
|
||||
router: Final = APIRouter()
|
||||
_RESPONSES_WS_CONFIG_VALUE_ADAPTER: Final[TypeAdapter[object | None]] = TypeAdapter(object | None)
|
||||
_PROXY_SERVER_REQUEST_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
|
||||
|
||||
_ResponseDocSchemas: TypeAlias = dict[int | str, dict[str, object]] # fastapi's responses kwarg
|
||||
|
||||
|
|
@ -344,7 +366,7 @@ async def responses_api(
|
|||
asyncio.create_task(
|
||||
background_streaming_task(
|
||||
polling_id=polling_id,
|
||||
data=data.copy(),
|
||||
data=dict(_without_managed_background_marker(data)),
|
||||
polling_handler=polling_handler,
|
||||
request=request,
|
||||
fastapi_response=fastapi_response,
|
||||
|
|
@ -379,7 +401,11 @@ async def responses_api(
|
|||
llm_router=llm_router,
|
||||
general_settings=general_settings,
|
||||
proxy_config=proxy_config,
|
||||
select_data_generator=native_data_generator,
|
||||
select_data_generator=(
|
||||
partial(_streamed_background_data_generator, native_data_generator, proxy_logging_obj)
|
||||
if requests_background(_PROXY_SERVER_REQUEST_ADAPTER.validate_python(data))
|
||||
else native_data_generator
|
||||
),
|
||||
model=None,
|
||||
user_model=user_model,
|
||||
user_temperature=user_temperature,
|
||||
|
|
@ -389,49 +415,38 @@ async def responses_api(
|
|||
version=version,
|
||||
)
|
||||
|
||||
# Store in managed objects table if background mode is enabled
|
||||
if data.get("background") and isinstance(response, ResponsesAPIResponse):
|
||||
if response.status in ["queued", "in_progress"]:
|
||||
from litellm_enterprise.proxy.hooks.managed_files import (
|
||||
PROXY_LiteLLMManagedFiles,
|
||||
)
|
||||
if isinstance(response, ResponsesAPIResponse) and (data.get("background") or is_background_response(response)):
|
||||
managed_files_obj: Final = _managed_files_hook(proxy_logging_obj)
|
||||
|
||||
managed_files_obj: Final = cast(
|
||||
PROXY_LiteLLMManagedFiles | None,
|
||||
proxy_logging_obj.get_proxy_hook("managed_files"),
|
||||
)
|
||||
if managed_files_obj and llm_router:
|
||||
try:
|
||||
hidden_params: Final = getattr(response, "_hidden_params", {}) or {}
|
||||
model_id: Final = hidden_params.get("model_id", None)
|
||||
|
||||
if managed_files_obj and llm_router:
|
||||
try:
|
||||
# Get the actual deployment model_id from hidden params
|
||||
hidden_params: Final = getattr(response, "_hidden_params", {}) or {}
|
||||
model_id: Final = hidden_params.get("model_id", None)
|
||||
|
||||
if not model_id:
|
||||
verbose_proxy_logger.warning(
|
||||
"No model_id found in response hidden params for response %s, skipping managed object storage",
|
||||
response.id,
|
||||
)
|
||||
raise Exception("No model_id found in response hidden params")
|
||||
# Store in managed objects table
|
||||
await managed_files_obj.store_unified_object_id(
|
||||
unified_object_id=response.id,
|
||||
file_object=response,
|
||||
litellm_parent_otel_span=None,
|
||||
model_object_id=response.id,
|
||||
file_purpose="response",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
"Stored background response %s in managed objects table with unified_id=%s",
|
||||
response.id,
|
||||
if not model_id:
|
||||
verbose_proxy_logger.warning(
|
||||
"No model_id found in response hidden params for response %s, skipping managed object storage",
|
||||
response.id,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(
|
||||
"Failed to store background response in managed objects table: %s", e
|
||||
)
|
||||
raise Exception("No model_id found in response hidden params")
|
||||
await managed_files_obj.store_unified_object_id(
|
||||
unified_object_id=response.id,
|
||||
file_object=response,
|
||||
litellm_parent_otel_span=None,
|
||||
model_object_id=decrypted_response_id(response.id),
|
||||
file_purpose="response",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_tags=_background_response_spend_tags(processor.data),
|
||||
persist_attribution=True,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
"Stored background response %s in managed objects table with unified_id=%s",
|
||||
response.id,
|
||||
response.id,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("Failed to store background response in managed objects table: %s", e)
|
||||
|
||||
return response
|
||||
except ModifyResponseException as e:
|
||||
|
|
@ -764,10 +779,11 @@ async def get_response(
|
|||
|
||||
# Normal provider response flow
|
||||
data: Final = await read_request_body(request=request)
|
||||
read_settles_billing: Final = not carries_client_side_overrides(data)
|
||||
data["response_id"] = response_id
|
||||
processor: Final = ProxyBaseLLMRequestProcessing(data=data)
|
||||
try:
|
||||
return await processor.base_process_llm_request(
|
||||
response: Final[object] = await processor.base_process_llm_request(
|
||||
request=request,
|
||||
fastapi_response=fastapi_response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
|
@ -792,6 +808,266 @@ async def get_response(
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
version=version,
|
||||
)
|
||||
await _bill_finished_background_response(
|
||||
response=response,
|
||||
response_id=response_id,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
fastapi_response=fastapi_response,
|
||||
read_settles_billing=read_settles_billing,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _background_response_spend_tags(data: Mapping[str, object]) -> tuple[str, ...] | None:
|
||||
proxy_server_request: Final = data.get("proxy_server_request")
|
||||
client_tags: Final = (
|
||||
StandardLoggingPayloadSetup.get_request_tags(
|
||||
{}, _PROXY_SERVER_REQUEST_ADAPTER.validate_python(proxy_server_request)
|
||||
)
|
||||
if isinstance(proxy_server_request, dict)
|
||||
else []
|
||||
)
|
||||
tags: Final = (*(request_tags_from_request_data(data) or ()), *client_tags)
|
||||
return tags or None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _BackgroundResponseStore(Protocol):
|
||||
async def store_unified_object_id(
|
||||
self,
|
||||
unified_object_id: str,
|
||||
file_object: ResponsesAPIResponse,
|
||||
litellm_parent_otel_span: None,
|
||||
model_object_id: str,
|
||||
file_purpose: Literal["response"],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
request_tags: Sequence[str] | None = None,
|
||||
persist_attribution: bool = False,
|
||||
) -> None: ...
|
||||
|
||||
async def track_untracked_background_response(
|
||||
self,
|
||||
response_id: str,
|
||||
response: ResponsesAPIResponse,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
request_tags: Sequence[str] | None = None,
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
def _managed_files_hook(proxy_logging_obj: "ProxyLogging") -> _BackgroundResponseStore | None:
|
||||
hook: Final = proxy_logging_obj.get_proxy_hook("managed_files")
|
||||
return hook if isinstance(hook, _BackgroundResponseStore) else None
|
||||
|
||||
|
||||
async def _bill_finished_background_response(
|
||||
response: object,
|
||||
response_id: str,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
fastapi_response: Response,
|
||||
read_settles_billing: bool,
|
||||
) -> None:
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
|
||||
if not isinstance(response, ResponsesAPIResponse) or not is_background_response(response):
|
||||
return
|
||||
if response.status not in TERMINAL_RESPONSE_STATUSES:
|
||||
return
|
||||
managed_files_hook: Final = _managed_files_hook(proxy_logging_obj)
|
||||
track_untracked: Final = (
|
||||
partial(
|
||||
managed_files_hook.track_untracked_background_response,
|
||||
response_id,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
if managed_files_hook is not None
|
||||
else None
|
||||
)
|
||||
charge: Final = await _settle_background_billing(
|
||||
(lambda cost_check: cost_check.bill_if_finished(response_id, response, track_untracked))
|
||||
if read_settles_billing
|
||||
else (lambda cost_check: cost_check.bill_from_deployment(response_id, track_untracked))
|
||||
)
|
||||
_report_charge(charge, fastapi_response)
|
||||
|
||||
|
||||
class _BilledCharge(Protocol):
|
||||
@property
|
||||
def cost(self) -> float | None: ...
|
||||
|
||||
@property
|
||||
def logging_obj(self) -> "LiteLLMLoggingObj": ...
|
||||
|
||||
|
||||
_UntrackedResponseTracker: TypeAlias = Callable[[ResponsesAPIResponse], Awaitable[bool]]
|
||||
|
||||
|
||||
class _BackgroundCostCheck(Protocol):
|
||||
async def bill_from_deployment(
|
||||
self, response_id: str, track_untracked: _UntrackedResponseTracker | None = None
|
||||
) -> _BilledCharge | None: ...
|
||||
|
||||
async def bill_if_finished(
|
||||
self,
|
||||
response_id: str,
|
||||
response: ResponsesAPIResponse,
|
||||
track_untracked: _UntrackedResponseTracker | None = None,
|
||||
) -> _BilledCharge | None: ...
|
||||
|
||||
|
||||
def _background_cost_check() -> _BackgroundCostCheck | None:
|
||||
from litellm.proxy.proxy_server import llm_router, prisma_client, proxy_logging_obj
|
||||
|
||||
if prisma_client is None or llm_router is None:
|
||||
return None
|
||||
try:
|
||||
from litellm_enterprise.proxy.common_utils.check_responses_cost import CheckResponsesCost
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
return CheckResponsesCost( # pyright: ignore[reportUnknownVariableType] # litellm_enterprise is absent from the type-check venv
|
||||
proxy_logging_obj=proxy_logging_obj, prisma_client=prisma_client, llm_router=llm_router
|
||||
)
|
||||
|
||||
|
||||
_BillingSettlement: TypeAlias = Callable[[_BackgroundCostCheck], Awaitable[_BilledCharge | None]]
|
||||
_BILLING_RETRY_DELAYS_SECONDS: Final = (5.0, 30.0, 120.0)
|
||||
_billing_retries: Final[set["asyncio.Task[None]"]] = set() # mutable-ok: the event loop keeps only weak task refs
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _BillingFailed:
|
||||
error: Exception
|
||||
|
||||
|
||||
async def _attempt_billing(
|
||||
settle: _BillingSettlement, cost_check: _BackgroundCostCheck
|
||||
) -> _BilledCharge | None | _BillingFailed:
|
||||
try:
|
||||
return await settle(cost_check)
|
||||
except Exception as e:
|
||||
return _BillingFailed(e)
|
||||
|
||||
|
||||
async def _retry_background_billing(
|
||||
settle: _BillingSettlement, cost_check: _BackgroundCostCheck, retry_delays: Sequence[float]
|
||||
) -> None:
|
||||
for delay in retry_delays:
|
||||
await asyncio.sleep(delay)
|
||||
outcome = await _attempt_billing(settle, cost_check)
|
||||
if not isinstance(outcome, _BillingFailed):
|
||||
return
|
||||
verbose_proxy_logger.warning("Retrying the bill for a finished background response failed: %s", outcome.error)
|
||||
verbose_proxy_logger.error("Gave up billing a finished background response; the cost poll bills it when enabled")
|
||||
|
||||
|
||||
def _schedule_billing_retry(
|
||||
settle: _BillingSettlement, cost_check: _BackgroundCostCheck, retry_delays: Sequence[float]
|
||||
) -> None:
|
||||
retry: Final = asyncio.create_task(_retry_background_billing(settle, cost_check, retry_delays))
|
||||
_billing_retries.add(retry)
|
||||
retry.add_done_callback(_billing_retries.discard)
|
||||
|
||||
|
||||
async def _settle_background_billing(
|
||||
settle: _BillingSettlement, retry_delays: Sequence[float] = _BILLING_RETRY_DELAYS_SECONDS
|
||||
) -> _BilledCharge | None:
|
||||
cost_check: Final = _background_cost_check()
|
||||
if cost_check is None:
|
||||
return None
|
||||
outcome: Final = await _attempt_billing(settle, cost_check)
|
||||
if not isinstance(outcome, _BillingFailed):
|
||||
return outcome
|
||||
verbose_proxy_logger.warning(
|
||||
"Billing a finished background response failed, retrying in background: %s", outcome.error
|
||||
)
|
||||
_schedule_billing_retry(settle, cost_check, retry_delays)
|
||||
return None
|
||||
|
||||
|
||||
def _report_charge(charge: _BilledCharge | None, fastapi_response: Response) -> None:
|
||||
if charge is None or charge.cost is None:
|
||||
return
|
||||
billed_headers: Final = cost_headers(charge.cost, charge.logging_obj)
|
||||
fastapi_response.headers.update({name: value for name, value in billed_headers.items() if value is not None})
|
||||
|
||||
|
||||
def _caller_may_address_response(
|
||||
response_id: str, user_api_key_dict: UserAPIKeyAuth, proxy_logging_obj: "ProxyLogging"
|
||||
) -> bool:
|
||||
security_hook: Final = proxy_logging_obj.get_proxy_hook("responses_id_security")
|
||||
if not isinstance(security_hook, ResponsesIDSecurity):
|
||||
return True
|
||||
try:
|
||||
security_hook.authorize_response_id(response_id, user_api_key_dict)
|
||||
except HTTPException:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _without_managed_background_marker(request_data: Mapping[str, object]) -> Mapping[str, object]:
|
||||
metadata: Final = request_data.get("litellm_metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
return dict(request_data)
|
||||
return {
|
||||
**request_data,
|
||||
"litellm_metadata": {
|
||||
key: value
|
||||
for key, value in _PROXY_SERVER_REQUEST_ADAPTER.validate_python(metadata).items()
|
||||
if key != MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _streamed_background_data_generator(
|
||||
select_data_generator: Callable[..., AsyncIterator[str | bytes]],
|
||||
proxy_logging_obj: "ProxyLogging",
|
||||
response: object,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
request_data: dict[str, object],
|
||||
request: Request | None = None,
|
||||
) -> AsyncIterator[str | bytes]:
|
||||
chunks: Final = select_data_generator(
|
||||
response=response, user_api_key_dict=user_api_key_dict, request_data=request_data, request=request
|
||||
)
|
||||
managed_files_hook: Final = _managed_files_hook(proxy_logging_obj)
|
||||
if managed_files_hook is None:
|
||||
return chunks
|
||||
track_untracked: Final = partial(
|
||||
managed_files_hook.track_untracked_background_response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_tags=_background_response_spend_tags(request_data),
|
||||
)
|
||||
return _bill_streamed_background_response(response, chunks, track_untracked)
|
||||
|
||||
|
||||
async def _bill_streamed_background_response(
|
||||
stream: object,
|
||||
chunks: AsyncIterator[str | bytes],
|
||||
track_untracked: Callable[[str, ResponsesAPIResponse], Awaitable[bool]],
|
||||
) -> AsyncIterator[str | bytes]:
|
||||
async for chunk in chunks:
|
||||
completed: object = ProxyBaseLLMRequestProcessing.extract_completed_responses_response(stream)
|
||||
if not isinstance(completed, ResponsesAPIResponse):
|
||||
yield chunk
|
||||
continue
|
||||
await _bill_finished_stream(completed, track_untracked)
|
||||
yield chunk
|
||||
async for remaining_chunk in chunks:
|
||||
yield remaining_chunk
|
||||
return
|
||||
late_completed: Final = ProxyBaseLLMRequestProcessing.extract_completed_responses_response(stream)
|
||||
if isinstance(late_completed, ResponsesAPIResponse):
|
||||
await _bill_finished_stream(late_completed, track_untracked)
|
||||
|
||||
|
||||
async def _bill_finished_stream(
|
||||
response: ResponsesAPIResponse, track_untracked: Callable[[str, ResponsesAPIResponse], Awaitable[bool]]
|
||||
) -> None:
|
||||
if not is_background_response(response):
|
||||
return
|
||||
await _settle_background_billing(
|
||||
lambda cost_check: cost_check.bill_if_finished(response.id, response, partial(track_untracked, response.id))
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
|
|
@ -874,9 +1150,14 @@ async def delete_response(
|
|||
# Normal provider response flow
|
||||
data: Final = await read_request_body(request=request)
|
||||
data["response_id"] = response_id
|
||||
charge: Final = (
|
||||
await _settle_background_billing(lambda cost_check: cost_check.bill_from_deployment(response_id))
|
||||
if _caller_may_address_response(response_id, user_api_key_dict, proxy_logging_obj)
|
||||
else None
|
||||
)
|
||||
processor: Final = ProxyBaseLLMRequestProcessing(data=data)
|
||||
try:
|
||||
return await processor.base_process_llm_request(
|
||||
deleted: Final = await processor.base_process_llm_request(
|
||||
request=request,
|
||||
fastapi_response=fastapi_response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
|
@ -901,6 +1182,8 @@ async def delete_response(
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
version=version,
|
||||
)
|
||||
_report_charge(charge, fastapi_response)
|
||||
return deleted
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
@ -1287,10 +1570,11 @@ async def cancel_response(
|
|||
|
||||
# Normal provider response flow
|
||||
data: Final = await read_request_body(request=request)
|
||||
read_settles_billing: Final = not carries_client_side_overrides(data)
|
||||
data["response_id"] = response_id
|
||||
processor: Final = ProxyBaseLLMRequestProcessing(data=data)
|
||||
try:
|
||||
return await processor.base_process_llm_request(
|
||||
response: Final[object] = await processor.base_process_llm_request(
|
||||
request=request,
|
||||
fastapi_response=fastapi_response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
|
@ -1315,6 +1599,14 @@ async def cancel_response(
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
version=version,
|
||||
)
|
||||
await _bill_finished_background_response(
|
||||
response=response,
|
||||
response_id=response_id,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
fastapi_response=fastapi_response,
|
||||
read_settles_billing=read_settles_billing,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _resolve_responses_ws_session_limit_seconds() -> float:
|
||||
|
|
@ -1578,7 +1870,7 @@ async def _responses_websocket_session(
|
|||
}
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
except (WebSocketDisconnect, RuntimeError, OSError):
|
||||
pass
|
||||
await websocket.close(code=1008, reason="Pre-call error")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,7 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t
|
|||
org_id String? // creating key's organization at submission time; CheckBatchCost bills org spend against it
|
||||
api_key String?
|
||||
request_tags Json? @default("[]")
|
||||
end_user_id String?
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String?
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm.constants import (
|
||||
CLI_SESSION_KEY_PREFIX,
|
||||
EMPTY_MAPPING,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
LITELLM_PROXY_MASTER_KEY_ALIAS,
|
||||
LITELLM_TRUNCATED_PAYLOAD_FIELD,
|
||||
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE,
|
||||
|
|
@ -24,6 +25,7 @@ from litellm.constants import (
|
|||
LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
|
||||
MAX_SPEND_LOG_MODEL_NAME_LENGTH,
|
||||
MCP_SPEND_LOG_MODEL_PREFIX,
|
||||
NON_INFERENCE_CALL_TYPES,
|
||||
REDACTED_BY_LITELM_STRING,
|
||||
SESSION_ID_OMITTED_METADATA_KEY,
|
||||
UNKNOWN_MODEL_SPEND_LOG_MODEL,
|
||||
|
|
@ -63,6 +65,7 @@ from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
|
|||
from litellm.proxy.utils import PrismaClient, hash_token
|
||||
from litellm.types.router import DeploymentTypedDict, LiteLLM_Params
|
||||
from litellm.types.utils import (
|
||||
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
PROMPT_CARRYING_GUARDRAIL_FIELDS,
|
||||
AzureSpillover,
|
||||
CallTypes,
|
||||
|
|
@ -292,9 +295,23 @@ def _get_spend_logs_metadata(
|
|||
|
||||
|
||||
BATCH_COST_REQUEST_ID_SUFFIX: Final = "_batch_cost"
|
||||
BACKGROUND_RESPONSE_COST_REQUEST_ID_SUFFIX: Final = "_background_cost"
|
||||
|
||||
|
||||
def get_spend_logs_id(call_type: str, response_obj: dict, kwargs: dict) -> str | None:
|
||||
def _cost_row_request_id_suffix(call_type: str, metadata: Mapping[str, object]) -> str:
|
||||
if call_type == CallTypes.aretrieve_batch.value:
|
||||
return BATCH_COST_REQUEST_ID_SUFFIX
|
||||
if (
|
||||
call_type in NON_INFERENCE_CALL_TYPES
|
||||
and metadata.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
):
|
||||
return BACKGROUND_RESPONSE_COST_REQUEST_ID_SUFFIX
|
||||
return ""
|
||||
|
||||
|
||||
def get_spend_logs_id(
|
||||
call_type: str, response_obj: dict, kwargs: dict, metadata: Mapping[str, object] = EMPTY_MAPPING
|
||||
) -> str | None:
|
||||
standard_logging_payload = kwargs.get("standard_logging_object")
|
||||
candidate_ids: Final = (
|
||||
response_obj.get("id"),
|
||||
|
|
@ -304,9 +321,9 @@ def get_spend_logs_id(call_type: str, response_obj: dict, kwargs: dict) -> str |
|
|||
resolved_id: Final = next(
|
||||
(candidate for candidate in candidate_ids if isinstance(candidate, str) and candidate), None
|
||||
)
|
||||
if resolved_id is not None and call_type == CallTypes.aretrieve_batch.value:
|
||||
return f"{resolved_id}{BATCH_COST_REQUEST_ID_SUFFIX}"
|
||||
return resolved_id
|
||||
if resolved_id is None:
|
||||
return None
|
||||
return f"{resolved_id}{_cost_row_request_id_suffix(call_type, metadata)}"
|
||||
|
||||
|
||||
_MISSING_ATTRIBUTE: Final = object()
|
||||
|
|
@ -560,7 +577,7 @@ def get_logging_payload(
|
|||
if not usage and isinstance(_combined_usage, litellm.Usage):
|
||||
usage = _combined_usage.model_dump()
|
||||
|
||||
id = get_spend_logs_id(call_type or "acompletion", response_obj_dict, kwargs)
|
||||
id = get_spend_logs_id(call_type or "acompletion", response_obj_dict, kwargs, metadata)
|
||||
standard_logging_payload: Final = cast(StandardLoggingPayload | None, kwargs.get("standard_logging_object", None))
|
||||
|
||||
end_user_id = get_end_user_id_for_cost_tracking(litellm_params)
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ from litellm.router_utils.cooldown_handlers import (
|
|||
async_get_cooldown_deployments_with_debug_info,
|
||||
get_cooldown_deployments,
|
||||
is_advisor_orchestration_failure,
|
||||
is_background_response_cost_poll_not_found,
|
||||
is_background_response_poll_not_found,
|
||||
is_caller_scoped_auth_failure,
|
||||
is_caller_timeout_408,
|
||||
set_cooldown_deployments,
|
||||
|
|
@ -3617,7 +3617,7 @@ class Router:
|
|||
# fallback_iterator, or any future wrapper). Without this
|
||||
# the proxy's container-ownership hook (which reads
|
||||
# ``getattr(stream_response, "completed_response", None)``
|
||||
# via _extract_completed_responses_response) silently
|
||||
# via extract_completed_responses_response) silently
|
||||
# records nothing on streaming /v1/responses calls — every
|
||||
# follow-up /v1/containers/<id>/files call then 403s for
|
||||
# the very key that created the container (#30210).
|
||||
|
|
@ -8743,10 +8743,10 @@ class Router:
|
|||
litellm_params: Final = kwargs.get("litellm_params", {})
|
||||
_model_info: Final = litellm_params.get("model_info", {})
|
||||
|
||||
if is_background_response_cost_poll_not_found(exception, litellm_params):
|
||||
if is_background_response_poll_not_found(exception, litellm_params):
|
||||
verbose_router_logger.debug(
|
||||
"Router: Exiting 'deployment_callback_on_failure' without cooldown. "
|
||||
"Provider 404 came from the background response cost poll, not the deployment's health."
|
||||
"Provider 404 came from the background response poll, not the deployment's health."
|
||||
)
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
|
|||
from litellm.router_utils.cooldown_cache import ROUTER_COOLDOWNS_TARGET, CooldownCacheValue
|
||||
from litellm.router_utils.cooldown_callbacks import router_cooldown_event_callback
|
||||
from litellm.types.router import Deployment
|
||||
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
from litellm.types.utils import (
|
||||
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN,
|
||||
InternalCallOrigin,
|
||||
)
|
||||
|
||||
from .router_callbacks.track_deployment_metrics import (
|
||||
get_deployment_failures_for_current_minute,
|
||||
|
|
@ -71,11 +75,16 @@ def is_advisor_orchestration_failure(exception: BaseException | None) -> bool:
|
|||
return bool(getattr(exception, _ADVISOR_ORCHESTRATION_FAILURE_ATTR, False))
|
||||
|
||||
|
||||
def is_background_response_cost_poll_not_found(exception: Exception, litellm_params: Mapping[str, object]) -> bool:
|
||||
"""Whether a background response cost poll failed with a provider 404."""
|
||||
_BACKGROUND_RESPONSE_POLL_CALL_ORIGINS: Final[frozenset[InternalCallOrigin]] = frozenset(
|
||||
{BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN, BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN}
|
||||
)
|
||||
|
||||
|
||||
def is_background_response_poll_not_found(exception: Exception, litellm_params: Mapping[str, object]) -> bool:
|
||||
"""Whether a background response poll, a status check or the billed read, failed with a provider 404."""
|
||||
return getattr(exception, "status_code", None) == 404 and any(
|
||||
isinstance(candidate, Mapping)
|
||||
and candidate.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
and candidate.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) in _BACKGROUND_RESPONSE_POLL_CALL_ORIGINS
|
||||
for candidate in (litellm_params.get("metadata"), litellm_params.get("litellm_metadata"))
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3164,6 +3164,7 @@ InternalCallOrigin = Literal[
|
|||
"shadow_eval_judge",
|
||||
"llm_as_a_judge_guardrail",
|
||||
"background_response_cost_poll",
|
||||
"background_response_status_poll",
|
||||
]
|
||||
"""Which internal litellm feature originated a billed sub-call, so a spend log row
|
||||
records that it is not traffic the caller sent."""
|
||||
|
|
@ -3173,6 +3174,7 @@ SHADOW_EVAL_ROUTER_CALL_ORIGIN: Final[InternalCallOrigin] = "shadow_eval_router"
|
|||
SHADOW_EVAL_JUDGE_CALL_ORIGIN: Final[InternalCallOrigin] = "shadow_eval_judge"
|
||||
LLM_AS_A_JUDGE_GUARDRAIL_CALL_ORIGIN: Final[InternalCallOrigin] = "llm_as_a_judge_guardrail"
|
||||
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN: Final[InternalCallOrigin] = "background_response_cost_poll"
|
||||
BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN: Final[InternalCallOrigin] = "background_response_status_poll"
|
||||
|
||||
|
||||
class StandardLoggingHeuristicV2Forecast(TypedDict):
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,7 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t
|
|||
org_id String? // creating key's organization at submission time; CheckBatchCost bills org spend against it
|
||||
api_key String?
|
||||
request_tags Json? @default("[]")
|
||||
end_user_id String?
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String?
|
||||
|
||||
|
|
|
|||
327
tests/integration/spend/_background_responses.py
Normal file
327
tests/integration/spend/_background_responses.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import itertools
|
||||
import json
|
||||
import math
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from integration._support.client import Gateway, Scenario, eventually, held, list_value, object_value, string_value
|
||||
from integration._support.database import read_rows, write_rows
|
||||
from integration._support.upstream import ScenarioHandle, delete_scenario, register_scenario
|
||||
from integration.cost_calculation.cost_tracking_case import JsonResponse, RoutedResponse, SseResponse
|
||||
from pydantic import JsonValue
|
||||
|
||||
INPUT_COST_PER_TOKEN: Final = 0.001
|
||||
OUTPUT_COST_PER_TOKEN: Final = 0.002
|
||||
INPUT_TOKENS: Final = 19
|
||||
OUTPUT_TOKENS: Final = 7
|
||||
PRICE: Final = INPUT_TOKENS * INPUT_COST_PER_TOKEN + OUTPUT_TOKENS * OUTPUT_COST_PER_TOKEN
|
||||
SCHEDULER_PERIOD_CEILING_SECONDS: Final = 31
|
||||
ONE_POLL_SECONDS: Final = 2 * SCHEDULER_PERIOD_CEILING_SECONDS + 8
|
||||
POLL_WINDOW_SECONDS: Final = SCHEDULER_PERIOD_CEILING_SECONDS + 9
|
||||
SPEND_LANDING_SECONDS: Final = 60
|
||||
PROVIDER_ID: Final = "resp_$REQUEST_ID"
|
||||
PROMPT: Final = "bill me once"
|
||||
READS: Final = 3
|
||||
CONCURRENT_READS: Final = 12
|
||||
FINISHED_STATUSES: Final = frozenset({"completed", "incomplete", "cancelled"})
|
||||
MANAGED_READ_MARKER: Final = "_managed_background_response"
|
||||
ATTRIBUTION_COLUMNS: Final = ("api_key", "team_id", "organization_id", "user")
|
||||
|
||||
|
||||
def response_body(status: str, *, background: bool = True, **fields: JsonValue) -> dict[str, JsonValue]:
|
||||
finished: Final = status in FINISHED_STATUSES
|
||||
return {
|
||||
"id": PROVIDER_ID,
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"status": status,
|
||||
"background": background,
|
||||
"store": False,
|
||||
"error": None,
|
||||
"incomplete_details": {"reason": "max_output_tokens"} if status == "incomplete" else None,
|
||||
"model": "gpt-4o-mini",
|
||||
"output": (
|
||||
[
|
||||
{
|
||||
"id": "msg_$REQUEST_ID",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "pong", "annotations": []}],
|
||||
}
|
||||
]
|
||||
if finished
|
||||
else []
|
||||
),
|
||||
"parallel_tool_calls": True,
|
||||
"temperature": 1.0,
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"top_p": 1.0,
|
||||
"truncation": "disabled",
|
||||
"usage": (
|
||||
{
|
||||
"input_tokens": INPUT_TOKENS,
|
||||
"output_tokens": OUTPUT_TOKENS,
|
||||
"total_tokens": INPUT_TOKENS + OUTPUT_TOKENS,
|
||||
"input_tokens_details": {"cached_tokens": 0},
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
}
|
||||
if finished
|
||||
else None
|
||||
),
|
||||
"metadata": {},
|
||||
**fields,
|
||||
}
|
||||
|
||||
|
||||
def json_reply(body: dict[str, JsonValue], status: int = 200) -> JsonResponse:
|
||||
return JsonResponse(content_type="application/json", body=body, status=status)
|
||||
|
||||
|
||||
def scripted_routes(
|
||||
create: JsonResponse, read: JsonResponse, *, provider_id: str = PROVIDER_ID, **extra: JsonResponse
|
||||
) -> RoutedResponse:
|
||||
return RoutedResponse(
|
||||
content_type="application/x-routed",
|
||||
routes={
|
||||
"POST /responses": create,
|
||||
f"GET /responses/{provider_id}": read,
|
||||
**{f"POST /responses/{provider_id}/{action}": response for action, response in extra.items()},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def background_routes(read_status: str, **fields: JsonValue) -> RoutedResponse:
|
||||
return scripted_routes(
|
||||
json_reply(response_body("queued", **fields)), json_reply(response_body(read_status, **fields))
|
||||
)
|
||||
|
||||
|
||||
def sse_frame(event: str, sequence_number: int, response: dict[str, JsonValue]) -> str:
|
||||
return f"event: {event}\ndata: " + json.dumps(
|
||||
{"type": event, "sequence_number": sequence_number, "response": response}, separators=(",", ":")
|
||||
)
|
||||
|
||||
|
||||
def streamed_reply(*, background: bool = True) -> SseResponse:
|
||||
return SseResponse(
|
||||
content_type="text/event-stream",
|
||||
frames=(
|
||||
sse_frame("response.created", 0, response_body("queued", background=background)),
|
||||
sse_frame("response.completed", 1, response_body("completed", background=background)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Upstream:
|
||||
model: str
|
||||
model_id: str
|
||||
handle: ScenarioHandle
|
||||
|
||||
def script(self, response: RoutedResponse | SseResponse) -> None:
|
||||
register_scenario(self.handle.scenario_id, response, control_url=self.handle.control_url)
|
||||
|
||||
|
||||
def scripted_upstream(
|
||||
scenario: Scenario,
|
||||
response: RoutedResponse | SseResponse,
|
||||
*,
|
||||
model_info: Mapping[str, JsonValue] | None = None,
|
||||
**litellm_params: JsonValue,
|
||||
) -> Upstream:
|
||||
scenario_id: Final = f"bgonce-{uuid.uuid4().hex[:12]}"
|
||||
handle: Final = register_scenario(scenario_id, response)
|
||||
scenario.cleanups.callback(delete_scenario, handle)
|
||||
created: Final = scenario.gateway.post(
|
||||
"/model/new",
|
||||
{
|
||||
"model_name": scenario_id,
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"api_key": "sk-scripted-provider",
|
||||
"api_base": handle.api_base(),
|
||||
"input_cost_per_token": INPUT_COST_PER_TOKEN,
|
||||
"output_cost_per_token": OUTPUT_COST_PER_TOKEN,
|
||||
**litellm_params,
|
||||
},
|
||||
**({"model_info": dict(model_info)} if model_info else {}),
|
||||
},
|
||||
)
|
||||
model_id: Final = string_value(object_value(created["model_info"])["id"])
|
||||
scenario.cleanups.callback(scenario.delete_model, model_id)
|
||||
return Upstream(scenario_id, model_id, handle)
|
||||
|
||||
|
||||
def forget_row(response_id: str) -> None:
|
||||
write_rows('DELETE FROM "LiteLLM_ManagedObjectTable" WHERE unified_object_id = %s', (response_id,))
|
||||
|
||||
|
||||
def create_background_response(
|
||||
scenario: Scenario,
|
||||
key: str,
|
||||
model: str,
|
||||
*,
|
||||
body: Mapping[str, JsonValue] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
) -> str:
|
||||
response: Final = scenario.gateway.request(
|
||||
"POST",
|
||||
"/v1/responses",
|
||||
body or {"model": model, "input": PROMPT, "background": True, "store": False},
|
||||
key=key,
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
response_id: Final = string_value(object_value(response.json())["id"])
|
||||
scenario.cleanups.callback(forget_row, response_id)
|
||||
return response_id
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Read:
|
||||
status: str
|
||||
text: str
|
||||
cost: float | None
|
||||
|
||||
|
||||
def reported_cost(headers: httpx.Headers) -> float | None:
|
||||
reported: Final = headers.get("x-litellm-response-cost")
|
||||
return None if reported is None else float(reported)
|
||||
|
||||
|
||||
def read_of(response: httpx.Response) -> Read:
|
||||
assert response.status_code == 200, response.text
|
||||
body: Final = object_value(response.json())
|
||||
output: Final = list_value(body["output"])
|
||||
text: Final = (
|
||||
string_value(object_value(list_value(object_value(output[0])["content"])[0])["text"]) if output else ""
|
||||
)
|
||||
return Read(string_value(body["status"]), text, reported_cost(response.headers))
|
||||
|
||||
|
||||
def read_response(gateway: Gateway, key: str, response_id: str, body: Mapping[str, JsonValue] | None = None) -> Read:
|
||||
return read_of(gateway.request("GET", f"/v1/responses/{response_id}", body, key=key))
|
||||
|
||||
|
||||
def free_reads(status: str, count: int) -> tuple[Read, ...]:
|
||||
return (Read(status, "pong", 0.0),) * count
|
||||
|
||||
|
||||
def settling_reads(status: str, count: int) -> tuple[Read, ...]:
|
||||
return (Read(status, "pong", pytest.approx(PRICE)), *free_reads(status, count - 1))
|
||||
|
||||
|
||||
def hashed(key: str) -> str:
|
||||
return sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
def number(value: JsonValue) -> float:
|
||||
assert isinstance(value, int | float), value
|
||||
return float(value)
|
||||
|
||||
|
||||
def key_spend(key: str) -> float:
|
||||
rows: Final = read_rows('SELECT spend FROM "LiteLLM_VerificationToken" WHERE token = %s', (hashed(key),))
|
||||
return number(rows[0]["spend"])
|
||||
|
||||
|
||||
def team_spend(team_id: str) -> float:
|
||||
rows: Final = read_rows('SELECT spend FROM "LiteLLM_TeamTable" WHERE team_id = %s', (team_id,))
|
||||
return number(rows[0]["spend"])
|
||||
|
||||
|
||||
def user_spend(user_id: str) -> float:
|
||||
rows: Final = read_rows('SELECT spend FROM "LiteLLM_UserTable" WHERE user_id = %s', (user_id,))
|
||||
return number(rows[0]["spend"])
|
||||
|
||||
|
||||
def org_spend(organization_id: str) -> float:
|
||||
rows: Final = read_rows(
|
||||
'SELECT spend FROM "LiteLLM_OrganizationTable" WHERE organization_id = %s', (organization_id,)
|
||||
)
|
||||
return number(rows[0]["spend"])
|
||||
|
||||
|
||||
def tag_spend(tag: str) -> float:
|
||||
rows: Final = read_rows(
|
||||
'SELECT COALESCE(SUM(spend), 0) AS spend FROM "LiteLLM_DailyTagSpend" WHERE tag = %s', (tag,)
|
||||
)
|
||||
return number(rows[0]["spend"])
|
||||
|
||||
|
||||
def costs(expected: float) -> Callable[[float], bool]:
|
||||
return lambda spend: math.isclose(spend, expected, abs_tol=1e-9)
|
||||
|
||||
|
||||
def assert_charged_exactly(read_spend: Callable[[], float], expected: float) -> None:
|
||||
held(read_spend, costs(expected), holding=POLL_WINDOW_SECONDS, seconds=POLL_WINDOW_SECONDS + SPEND_LANDING_SECONDS)
|
||||
|
||||
|
||||
def charged_rows(model_id: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
'SELECT request_id, call_type, api_key, team_id, organization_id, "user", request_tags, spend, '
|
||||
'prompt_tokens, completion_tokens FROM "LiteLLM_SpendLogs" WHERE model_id = %s AND spend > 0',
|
||||
(model_id,),
|
||||
)
|
||||
|
||||
|
||||
def create_rows(model_id: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
'SELECT api_key, team_id, organization_id, "user", request_tags FROM "LiteLLM_SpendLogs" '
|
||||
"WHERE model_id = %s AND call_type = 'aresponses'",
|
||||
(model_id,),
|
||||
)
|
||||
|
||||
|
||||
def one_charged_row(model_id: str) -> dict[str, JsonValue]:
|
||||
rows: Final = eventually(lambda: charged_rows(model_id), lambda found: len(found) >= 1, seconds=30)
|
||||
assert len(rows) == 1, rows
|
||||
return rows[0]
|
||||
|
||||
|
||||
def managed_rows(response_id: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
'SELECT status, api_key, created_by, team_id FROM "LiteLLM_ManagedObjectTable" '
|
||||
"WHERE unified_object_id = %s OR model_object_id = %s",
|
||||
(response_id, response_id),
|
||||
)
|
||||
|
||||
|
||||
def assert_billed_like_its_creator(
|
||||
row: dict[str, JsonValue], key: str, team_id: str | None, user_id: str | None
|
||||
) -> None:
|
||||
assert string_value(row["request_id"]).endswith("_background_cost"), row
|
||||
assert row["call_type"] == "aget_responses"
|
||||
assert row["api_key"] == hashed(key)
|
||||
assert row["team_id"] == team_id
|
||||
assert row["user"] == user_id
|
||||
assert row["prompt_tokens"] == INPUT_TOKENS
|
||||
assert row["completion_tokens"] == OUTPUT_TOKENS
|
||||
assert number(row["spend"]) == pytest.approx(PRICE)
|
||||
|
||||
|
||||
def observed_requests(gateway: Gateway) -> tuple[dict[str, JsonValue], ...]:
|
||||
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream:
|
||||
return tuple(map(object_value, list_value(upstream.get("/__observations").json()["requests"])))
|
||||
|
||||
|
||||
def calls_to(requests: Sequence[dict[str, JsonValue]], handle: ScenarioHandle) -> tuple[dict[str, JsonValue], ...]:
|
||||
return tuple(request for request in requests if string_value(request["path"]).startswith(f"/{handle.scenario_id}/"))
|
||||
|
||||
|
||||
def upstream_log(gateway: Gateway, handle: ScenarioHandle) -> Iterator[tuple[dict[str, JsonValue], ...]]:
|
||||
fresh: Final = (calls_to(observed_requests(gateway), handle) for _ in itertools.count())
|
||||
return itertools.accumulate(fresh, lambda seen, calls: (*seen, *calls))
|
||||
|
||||
|
||||
def submissions(log: Sequence[dict[str, JsonValue]], handle: ScenarioHandle) -> tuple[JsonValue, ...]:
|
||||
return tuple(
|
||||
call["body"] for call in log if call["method"] == "POST" and call["path"] == f"/{handle.scenario_id}/responses"
|
||||
)
|
||||
771
tests/integration/spend/test_background_response_billed_once.py
Normal file
771
tests/integration/spend/test_background_response_billed_once.py
Normal file
|
|
@ -0,0 +1,771 @@
|
|||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable, Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Final
|
||||
|
||||
import openai
|
||||
import pytest
|
||||
from integration._support.client import Gateway, Scenario, eventually, held, list_value, object_value, string_value
|
||||
from integration._support.database import read_rows, write_rows
|
||||
from integration._support.upstream import delete_scenario, register_scenario
|
||||
from integration.caching.response_cache_case import entry_key, redis_store
|
||||
from integration.spend._background_responses import (
|
||||
ATTRIBUTION_COLUMNS,
|
||||
CONCURRENT_READS,
|
||||
INPUT_COST_PER_TOKEN,
|
||||
INPUT_TOKENS,
|
||||
MANAGED_READ_MARKER,
|
||||
ONE_POLL_SECONDS,
|
||||
OUTPUT_COST_PER_TOKEN,
|
||||
OUTPUT_TOKENS,
|
||||
POLL_WINDOW_SECONDS,
|
||||
PRICE,
|
||||
PROMPT,
|
||||
READS,
|
||||
SPEND_LANDING_SECONDS,
|
||||
Read,
|
||||
Upstream,
|
||||
assert_billed_like_its_creator,
|
||||
assert_charged_exactly,
|
||||
background_routes,
|
||||
charged_rows,
|
||||
costs,
|
||||
create_background_response,
|
||||
create_rows,
|
||||
forget_row,
|
||||
free_reads,
|
||||
hashed,
|
||||
json_reply,
|
||||
key_spend,
|
||||
managed_rows,
|
||||
number,
|
||||
one_charged_row,
|
||||
org_spend,
|
||||
read_of,
|
||||
read_response,
|
||||
reported_cost,
|
||||
response_body,
|
||||
scripted_routes,
|
||||
scripted_upstream,
|
||||
settling_reads,
|
||||
streamed_reply,
|
||||
submissions,
|
||||
tag_spend,
|
||||
team_spend,
|
||||
upstream_log,
|
||||
user_spend,
|
||||
)
|
||||
from openai._legacy_response import LegacyAPIResponse
|
||||
from openai.types.responses import Response
|
||||
from pydantic import JsonValue
|
||||
|
||||
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
|
||||
|
||||
def _sdk_read(raw: LegacyAPIResponse[Response]) -> Read:
|
||||
parsed: Final = raw.parse()
|
||||
return Read(parsed.status or "", parsed.output_text, reported_cost(raw.headers))
|
||||
|
||||
|
||||
def _httpx_flow(scenario: Scenario, key: str, model: str) -> tuple[str, tuple[Read, ...]]:
|
||||
response_id: Final = create_background_response(scenario, key, model)
|
||||
return response_id, tuple(read_response(scenario.gateway, key, response_id) for _ in range(READS))
|
||||
|
||||
|
||||
def _forged_metadata_flow(scenario: Scenario, key: str, model: str) -> tuple[str, tuple[Read, ...]]:
|
||||
forged: Final[dict[str, JsonValue]] = {
|
||||
"internal_call_origin": BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
MANAGED_READ_MARKER: False,
|
||||
}
|
||||
response_id: Final = create_background_response(scenario, key, model)
|
||||
return response_id, tuple(
|
||||
read_response(scenario.gateway, key, response_id, {"metadata": forged, "litellm_metadata": forged})
|
||||
for _ in range(READS)
|
||||
)
|
||||
|
||||
|
||||
def _sdk_poll(client: openai.OpenAI, response_id: str, remaining: int) -> tuple[Read, ...]:
|
||||
if remaining == 0:
|
||||
return ()
|
||||
raw: Final = client.responses.with_raw_response.retrieve(response_id)
|
||||
return (_sdk_read(raw), *_sdk_poll(client, raw.parse().id, remaining - 1))
|
||||
|
||||
|
||||
def _sdk_sync_flow(scenario: Scenario, key: str, model: str) -> tuple[str, tuple[Read, ...]]:
|
||||
with openai.OpenAI(base_url=f"{scenario.gateway.client.base_url}/v1", api_key=key, max_retries=0) as client:
|
||||
created: Final = client.responses.create(model=model, input=PROMPT, background=True, store=False)
|
||||
scenario.cleanups.callback(forget_row, created.id)
|
||||
return created.id, _sdk_poll(client, created.id, READS)
|
||||
|
||||
|
||||
async def _sdk_async_poll(client: openai.AsyncOpenAI, response_id: str, remaining: int) -> tuple[Read, ...]:
|
||||
if remaining == 0:
|
||||
return ()
|
||||
raw: Final = await client.responses.with_raw_response.retrieve(response_id)
|
||||
return (_sdk_read(raw), *await _sdk_async_poll(client, raw.parse().id, remaining - 1))
|
||||
|
||||
|
||||
async def _sdk_async_reads(base_url: str, key: str, model: str) -> tuple[str, tuple[Read, ...]]:
|
||||
async with openai.AsyncOpenAI(base_url=base_url, api_key=key, max_retries=0) as client:
|
||||
created: Final = await client.responses.create(model=model, input=PROMPT, background=True, store=False)
|
||||
return created.id, await _sdk_async_poll(client, created.id, READS)
|
||||
|
||||
|
||||
def _sdk_async_flow(scenario: Scenario, key: str, model: str) -> tuple[str, tuple[Read, ...]]:
|
||||
response_id, reads = asyncio.run(_sdk_async_reads(f"{scenario.gateway.client.base_url}/v1", key, model))
|
||||
scenario.cleanups.callback(forget_row, response_id)
|
||||
return response_id, reads
|
||||
|
||||
|
||||
Flow = Callable[[Scenario, str, str], tuple[str, tuple[Read, ...]]]
|
||||
FLOWS: Final[Mapping[str, Flow]] = {
|
||||
"httpx": _httpx_flow,
|
||||
"httpx_forged_metadata": _forged_metadata_flow,
|
||||
"openai_sync": _sdk_sync_flow,
|
||||
"openai_async": _sdk_async_flow,
|
||||
}
|
||||
|
||||
|
||||
def _team_member_key(scenario: Scenario, **fields: JsonValue) -> tuple[str, str, str]:
|
||||
team_id: Final = scenario.team(**fields)
|
||||
user_id: Final = scenario.member(team_id)
|
||||
return scenario.key(team_id=team_id, user_id=user_id), team_id, user_id
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
@pytest.mark.parametrize(
|
||||
("client", "terminal_status"),
|
||||
[
|
||||
("httpx", "completed"),
|
||||
("httpx", "incomplete"),
|
||||
("httpx_forged_metadata", "completed"),
|
||||
("openai_sync", "completed"),
|
||||
("openai_async", "completed"),
|
||||
],
|
||||
)
|
||||
def test_a_finished_background_response_bills_its_creator_once_however_often_it_is_read(
|
||||
gateway: Gateway, client: str, terminal_status: str
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key, team_id, user_id = _team_member_key(scenario)
|
||||
upstream: Final = scripted_upstream(scenario, background_routes(terminal_status))
|
||||
log: Final = upstream_log(gateway, upstream.handle)
|
||||
response_id, reads = FLOWS[client](scenario, key, upstream.model)
|
||||
assert reads in (settling_reads(terminal_status, READS), free_reads(terminal_status, READS))
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
assert_billed_like_its_creator(one_charged_row(upstream.model_id), key, team_id, user_id)
|
||||
assert team_spend(team_id) == pytest.approx(PRICE)
|
||||
assert submissions(next(log), upstream.handle) == (
|
||||
{"model": "gpt-4o-mini", "input": PROMPT, "background": True, "store": False},
|
||||
)
|
||||
assert [row["status"] for row in managed_rows(response_id)] == ["completed"]
|
||||
|
||||
|
||||
def _org_team_member_key(scenario: Scenario) -> tuple[str, str, str, str]:
|
||||
organization_id: Final = scenario.organization()
|
||||
team_id: Final = scenario.team(organization_id=organization_id)
|
||||
user_id: Final = scenario.org_member(organization_id, "internal_user")
|
||||
scenario.gateway.post("/team/member_add", {"team_id": team_id, "member": {"role": "user", "user_id": user_id}})
|
||||
return scenario.key(team_id=team_id, user_id=user_id), team_id, user_id, organization_id
|
||||
|
||||
|
||||
def _billed_by_the_poll_alone(
|
||||
scenario: Scenario, key: str, tags: tuple[str, str]
|
||||
) -> tuple[Upstream, dict[str, JsonValue]]:
|
||||
body_tag, header_tag = tags
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("completed"))
|
||||
response_id: Final = create_background_response(
|
||||
scenario,
|
||||
key,
|
||||
upstream.model,
|
||||
body={
|
||||
"model": upstream.model,
|
||||
"input": PROMPT,
|
||||
"background": True,
|
||||
"store": False,
|
||||
"litellm_metadata": {"tags": [body_tag]},
|
||||
},
|
||||
headers={"x-litellm-tags": header_tag},
|
||||
)
|
||||
eventually(
|
||||
lambda: [row["status"] for row in managed_rows(response_id)],
|
||||
lambda statuses: statuses == ["completed"],
|
||||
seconds=ONE_POLL_SECONDS,
|
||||
)
|
||||
held(lambda: key_spend(key), costs(PRICE), holding=5, seconds=SPEND_LANDING_SECONDS)
|
||||
charged: Final = one_charged_row(upstream.model_id)
|
||||
create_row: Final = eventually(lambda: create_rows(upstream.model_id), lambda rows: len(rows) == 1)[0]
|
||||
assert {column: charged[column] for column in ATTRIBUTION_COLUMNS} == {
|
||||
column: create_row[column] for column in ATTRIBUTION_COLUMNS
|
||||
}
|
||||
assert set(list_value(charged["request_tags"])) == set(list_value(create_row["request_tags"])) >= set(tags)
|
||||
return upstream, charged
|
||||
|
||||
|
||||
def _unique_tags() -> tuple[str, str]:
|
||||
return f"audit-body-{uuid.uuid4().hex[:8]}", f"audit-header-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_a_background_response_nobody_reads_is_billed_once_to_its_creating_key_team_org_and_tags(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key, team_id, user_id, organization_id = _org_team_member_key(scenario)
|
||||
tags: Final = _unique_tags()
|
||||
_, charged = _billed_by_the_poll_alone(scenario, key, tags)
|
||||
assert_billed_like_its_creator(charged, key, team_id, user_id)
|
||||
assert charged["organization_id"] == organization_id
|
||||
eventually(lambda: tuple(map(tag_spend, tags)), lambda spent: all(map(costs(PRICE), spent)))
|
||||
eventually(lambda: org_spend(organization_id), costs(PRICE))
|
||||
listed: Final = gateway.request(
|
||||
"GET", "/spend/logs", params={"request_id": string_value(charged["request_id"])}
|
||||
)
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert [number(object_value(row)["spend"]) for row in list_value(listed.json())] == [pytest.approx(PRICE)]
|
||||
assert number(object_value(gateway.get("/key/info", {"key": key})["info"])["spend"]) == pytest.approx(PRICE)
|
||||
team_info: Final = object_value(gateway.get("/team/info", {"team_id": team_id})["team_info"])
|
||||
assert number(team_info["spend"]) == pytest.approx(PRICE)
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
@pytest.mark.parametrize("in_a_team", [False, True], ids=["no_team", "team"])
|
||||
def test_a_background_response_nobody_reads_on_a_key_with_no_user_is_billed_once_to_the_key(
|
||||
gateway: Gateway, in_a_team: bool
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key(team_id=scenario.team()) if in_a_team else scenario.key()
|
||||
_, charged = _billed_by_the_poll_alone(scenario, key, _unique_tags())
|
||||
assert string_value(charged["request_id"]).endswith("_background_cost"), charged
|
||||
assert charged["api_key"] == hashed(key)
|
||||
assert number(charged["spend"]) == pytest.approx(PRICE)
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_a_background_response_is_free_until_it_finishes_and_a_failed_read_bills_nothing(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("in_progress"))
|
||||
response_id: Final = create_background_response(scenario, key, upstream.model)
|
||||
assert read_response(gateway, key, response_id) == Read("in_progress", "", 0.0)
|
||||
outage: Final = json_reply({"error": {"message": "The server had an error.", "type": "server_error"}}, 500)
|
||||
upstream.script(scripted_routes(json_reply(response_body("queued")), outage))
|
||||
failed: Final = gateway.request("GET", f"/v1/responses/{response_id}", key=key)
|
||||
assert failed.status_code == 500, failed.text
|
||||
assert "The server had an error." in failed.text
|
||||
assert [row["status"] for row in managed_rows(response_id)] == ["queued"]
|
||||
upstream.script(background_routes("completed"))
|
||||
assert tuple(read_response(gateway, key, response_id) for _ in range(2)) == settling_reads("completed", 2)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
assert [row["status"] for row in managed_rows(response_id)] == ["completed"]
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_cancelling_a_background_response_bills_it_once(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
cancelled: Final = json_reply(response_body("cancelled"))
|
||||
upstream: Final = scripted_upstream(
|
||||
scenario, scripted_routes(json_reply(response_body("queued")), cancelled, cancel=cancelled)
|
||||
)
|
||||
response_id: Final = create_background_response(scenario, key, upstream.model)
|
||||
cancel: Final = gateway.request("POST", f"/v1/responses/{response_id}/cancel", key=key)
|
||||
reads: Final = (read_of(cancel), read_response(gateway, key, response_id))
|
||||
assert reads in (settling_reads("cancelled", 2), free_reads("cancelled", 2))
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
assert number(one_charged_row(upstream.model_id)["spend"]) == pytest.approx(PRICE)
|
||||
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
def test_a_foreground_response_bills_its_create_once_and_its_read_is_free(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
foreground: Final = json_reply(response_body("completed", background=False))
|
||||
upstream: Final = scripted_upstream(scenario, scripted_routes(foreground, foreground))
|
||||
created: Final = gateway.request(
|
||||
"POST", "/v1/responses", {"model": upstream.model, "input": PROMPT, "store": False}, key=key
|
||||
)
|
||||
assert read_of(created) == Read("completed", "pong", pytest.approx(PRICE))
|
||||
response_id: Final = string_value(object_value(created.json())["id"])
|
||||
assert read_response(gateway, key, response_id) == Read("completed", "pong", 0.0)
|
||||
held(lambda: key_spend(key), costs(PRICE), holding=10, seconds=SPEND_LANDING_SECONDS)
|
||||
assert managed_rows(response_id) == []
|
||||
|
||||
|
||||
STREAMED_BACKGROUND_FIELDS: Final[Mapping[str, dict[str, JsonValue]]] = {
|
||||
"top_level": {"background": True},
|
||||
"extra_body": {"extra_body": {"background": True}},
|
||||
}
|
||||
|
||||
|
||||
def _streamed_response_id(gateway: Gateway, key: str, model: str, background: Mapping[str, JsonValue]) -> str:
|
||||
with gateway.client.stream(
|
||||
"POST",
|
||||
"/v1/responses",
|
||||
json={"model": model, "input": PROMPT, "store": False, "stream": True, **background},
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
) as stream:
|
||||
assert stream.status_code == 200, stream.read()
|
||||
events: Final = tuple(
|
||||
object_value(json.loads(line.removeprefix("data: ")))
|
||||
for line in stream.iter_lines()
|
||||
if line.startswith("data: {")
|
||||
)
|
||||
completed: Final = [event for event in events if event["type"] == "response.completed"]
|
||||
assert len(completed) == 1, events
|
||||
return string_value(object_value(completed[0]["response"])["id"])
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
@pytest.mark.parametrize("background_in", STREAMED_BACKGROUND_FIELDS.keys())
|
||||
def test_a_streamed_background_response_read_later_bills_its_stream_alone(gateway: Gateway, background_in: str) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
upstream: Final = scripted_upstream(scenario, streamed_reply())
|
||||
response_id: Final = _streamed_response_id(
|
||||
gateway, key, upstream.model, STREAMED_BACKGROUND_FIELDS[background_in]
|
||||
)
|
||||
scenario.cleanups.callback(forget_row, response_id)
|
||||
upstream.script(background_routes("completed"))
|
||||
assert tuple(read_response(gateway, key, response_id) for _ in range(2)) == free_reads("completed", 2)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
|
||||
|
||||
def _finished(scenario: Scenario, key: str) -> tuple[Upstream, str]:
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("completed"))
|
||||
return upstream, create_background_response(scenario, key, upstream.model)
|
||||
|
||||
|
||||
def _lose_the_row_then_finish(scenario: Scenario, key: str) -> tuple[Upstream, str]:
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("queued"))
|
||||
response_id: Final = create_background_response(scenario, key, upstream.model)
|
||||
forget_row(response_id)
|
||||
upstream.script(background_routes("completed"))
|
||||
return upstream, response_id
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_a_finished_response_the_proxy_lost_track_of_bills_its_reader_once(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key, team_id, user_id = _team_member_key(scenario)
|
||||
upstream, response_id = _lose_the_row_then_finish(scenario, key)
|
||||
reads: Final = tuple(read_response(gateway, key, response_id) for _ in range(READS))
|
||||
assert reads == settling_reads("completed", READS)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
assert_billed_like_its_creator(one_charged_row(upstream.model_id), key, team_id, user_id)
|
||||
assert [(row["status"], row["api_key"]) for row in managed_rows(response_id)] == [("completed", hashed(key))]
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
@pytest.mark.parametrize("tracked", [True, False], ids=["tracked", "untracked"])
|
||||
def test_concurrent_reads_of_a_finished_response_bill_it_once(gateway: Gateway, tracked: bool) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
upstream, response_id = _finished(scenario, key) if tracked else _lose_the_row_then_finish(scenario, key)
|
||||
with ThreadPoolExecutor(max_workers=CONCURRENT_READS) as pool:
|
||||
reads: Final = tuple(pool.map(lambda _: read_response(gateway, key, response_id), range(CONCURRENT_READS)))
|
||||
assert [(read.status, read.text) for read in reads] == [("completed", "pong")] * CONCURRENT_READS
|
||||
assert [read.cost for read in reads if read.cost != 0.0] in ([], [pytest.approx(PRICE)])
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
assert len(charged_rows(upstream.model_id)) == 1
|
||||
assert [row["status"] for row in managed_rows(response_id)] == ["completed"]
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_another_key_of_the_same_owner_reads_for_free_and_the_creating_key_pays(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
creator, team_id, user_id = _team_member_key(scenario)
|
||||
reader: Final = scenario.key(team_id=team_id, user_id=user_id)
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("completed"))
|
||||
response_id: Final = create_background_response(scenario, creator, upstream.model)
|
||||
reads: Final = tuple(read_response(gateway, reader, response_id) for _ in range(READS))
|
||||
assert reads in (settling_reads("completed", READS), free_reads("completed", READS))
|
||||
assert_charged_exactly(lambda: key_spend(creator), PRICE)
|
||||
assert key_spend(reader) == 0
|
||||
assert_billed_like_its_creator(one_charged_row(upstream.model_id), creator, team_id, user_id)
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_a_create_that_asks_for_background_inside_extra_body_is_billed_once(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("completed"))
|
||||
log: Final = upstream_log(gateway, upstream.handle)
|
||||
response_id: Final = create_background_response(
|
||||
scenario,
|
||||
key,
|
||||
upstream.model,
|
||||
body={"model": upstream.model, "input": PROMPT, "store": False, "extra_body": {"background": True}},
|
||||
)
|
||||
reads: Final = tuple(read_response(gateway, key, response_id) for _ in range(READS))
|
||||
assert reads in (settling_reads("completed", READS), free_reads("completed", READS))
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
assert [object_value(body).get("background") for body in submissions(next(log), upstream.handle)] == [True]
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_a_response_whose_instructions_carry_a_nul_is_tracked_and_billed_once(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("completed", instructions="be\u0000brief"))
|
||||
response_id: Final = create_background_response(scenario, key, upstream.model)
|
||||
reads: Final = tuple(read_response(gateway, key, response_id) for _ in range(READS))
|
||||
assert reads in (settling_reads("completed", READS), free_reads("completed", READS))
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
stored: Final = read_rows(
|
||||
"SELECT file_object->>'instructions' AS instructions FROM \"LiteLLM_ManagedObjectTable\" "
|
||||
"WHERE unified_object_id = %s",
|
||||
(response_id,),
|
||||
)
|
||||
assert stored == [{"instructions": "bebrief"}]
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_a_request_tag_carrying_a_nul_is_stored_without_it_and_lands_on_the_billed_row(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
suffix: Final = uuid.uuid4().hex[:8]
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("completed"))
|
||||
response_id: Final = create_background_response(
|
||||
scenario,
|
||||
key,
|
||||
upstream.model,
|
||||
body={
|
||||
"model": upstream.model,
|
||||
"input": PROMPT,
|
||||
"background": True,
|
||||
"store": False,
|
||||
"litellm_metadata": {"tags": [f"audit-nul\u0000{suffix}"]},
|
||||
},
|
||||
)
|
||||
reads: Final = tuple(read_response(gateway, key, response_id) for _ in range(2))
|
||||
assert reads in (settling_reads("completed", 2), free_reads("completed", 2))
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
assert f"audit-nul{suffix}" in list_value(one_charged_row(upstream.model_id)["request_tags"])
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
@pytest.mark.parametrize("managed_row", ["tracked", "lost"])
|
||||
def test_a_read_retargeted_with_a_client_side_api_base_bills_the_real_usage_once(
|
||||
gateway: Gateway, managed_row: str
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
upstream: Final = scripted_upstream(
|
||||
scenario, background_routes("queued"), configurable_clientside_auth_params=["api_base"]
|
||||
)
|
||||
provider_id: Final = f"resp_{upstream.model}"
|
||||
cheap_body: Final = {
|
||||
**response_body("completed"),
|
||||
"id": provider_id,
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
decoy: Final = register_scenario(
|
||||
f"bgonce-decoy-{uuid.uuid4().hex[:12]}",
|
||||
scripted_routes(json_reply(cheap_body), json_reply(cheap_body), provider_id=provider_id),
|
||||
)
|
||||
scenario.cleanups.callback(delete_scenario, decoy)
|
||||
decoy_log: Final = upstream_log(gateway, decoy)
|
||||
response_id: Final = create_background_response(scenario, key, upstream.model)
|
||||
if managed_row == "lost":
|
||||
forget_row(response_id)
|
||||
upstream.script(background_routes("completed"))
|
||||
retargeted: Final = read_response(
|
||||
gateway, key, response_id, {"model": upstream.model, "api_base": decoy.api_base()}
|
||||
)
|
||||
assert (retargeted,) in (settling_reads("completed", 1), free_reads("completed", 1))
|
||||
assert [call["method"] for call in next(decoy_log)] == ["GET"]
|
||||
eventually(
|
||||
lambda: [row["status"] for row in managed_rows(response_id)],
|
||||
lambda statuses: statuses == ["completed"],
|
||||
seconds=ONE_POLL_SECONDS,
|
||||
)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
charged: Final = one_charged_row(upstream.model_id)
|
||||
assert (charged["prompt_tokens"], charged["completion_tokens"]) == (INPUT_TOKENS, OUTPUT_TOKENS)
|
||||
|
||||
|
||||
def _id_a_read_returned(gateway: Gateway, key: str, response_id: str) -> str:
|
||||
read: Final = gateway.request("GET", f"/v1/responses/{response_id}", None, key=key)
|
||||
assert read.status_code == 200, read.text
|
||||
return string_value(object_value(read.json())["id"])
|
||||
|
||||
|
||||
@pytest.mark.timeout(240 + ONE_POLL_SECONDS)
|
||||
@pytest.mark.parametrize("read_by", ["create_id", "id_a_read_returned"])
|
||||
def test_a_row_stored_before_the_upgrade_bills_the_creating_team_and_user_once(gateway: Gateway, read_by: str) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key, team_id, user_id = _team_member_key(scenario)
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("queued"))
|
||||
created_id: Final = create_background_response(scenario, key, upstream.model)
|
||||
response_id: Final = created_id if read_by == "create_id" else _id_a_read_returned(gateway, key, created_id)
|
||||
write_rows(
|
||||
'UPDATE "LiteLLM_ManagedObjectTable" SET model_object_id = unified_object_id, api_key = NULL, '
|
||||
"request_tags = NULL WHERE unified_object_id = %s",
|
||||
(created_id,),
|
||||
)
|
||||
upstream.script(background_routes("completed"))
|
||||
reads: Final = tuple(read_response(gateway, key, response_id) for _ in range(READS))
|
||||
assert reads in (settling_reads("completed", READS), free_reads("completed", READS))
|
||||
held(lambda: team_spend(team_id), costs(PRICE), holding=ONE_POLL_SECONDS, seconds=SPEND_LANDING_SECONDS)
|
||||
assert user_spend(user_id) == pytest.approx(PRICE)
|
||||
charged: Final = one_charged_row(upstream.model_id)
|
||||
assert (charged["team_id"], charged["user"]) == (team_id, user_id)
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
@pytest.mark.parametrize("settled_status", ["completed", "stale_expired"])
|
||||
def test_reads_of_a_response_whose_row_already_settled_are_free(gateway: Gateway, settled_status: str) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("queued"))
|
||||
response_id: Final = create_background_response(scenario, key, upstream.model)
|
||||
write_rows(
|
||||
'UPDATE "LiteLLM_ManagedObjectTable" SET status = %s WHERE unified_object_id = %s',
|
||||
(settled_status, response_id),
|
||||
)
|
||||
upstream.script(background_routes("completed"))
|
||||
assert tuple(read_response(gateway, key, response_id) for _ in range(2)) == free_reads("completed", 2)
|
||||
assert_charged_exactly(lambda: key_spend(key), 0.0)
|
||||
assert charged_rows(upstream.model_id) == []
|
||||
|
||||
|
||||
FOREIGN_IDS: Final[Mapping[str, Callable[[Upstream], str]]] = {
|
||||
"unknown": lambda _: f"resp_{uuid.uuid4().hex}",
|
||||
"five_kilobytes": lambda _: "resp_" + "a" * 5120,
|
||||
"raw_vendor_id": lambda upstream: f"resp_{upstream.model}",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
@pytest.mark.parametrize("foreign", FOREIGN_IDS.keys())
|
||||
def test_a_read_of_an_id_the_proxy_never_issued_fails_without_tracking_or_billing_it(
|
||||
gateway: Gateway, foreign: str
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("completed"))
|
||||
response_id: Final = create_background_response(scenario, key, upstream.model)
|
||||
foreign_id: Final = FOREIGN_IDS[foreign](upstream)
|
||||
refused: Final = gateway.request("GET", f"/v1/responses/{foreign_id}", key=key)
|
||||
assert refused.status_code >= 400, refused.text
|
||||
assert managed_rows(foreign_id) == []
|
||||
assert (read_response(gateway, key, response_id),) in (
|
||||
settling_reads("completed", 1),
|
||||
free_reads("completed", 1),
|
||||
)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
assert len(charged_rows(upstream.model_id)) == 1
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_a_background_create_answered_at_once_and_its_cached_twin_bill_the_create_alone(gateway: Gateway) -> None:
|
||||
store: Final = redis_store()
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
answered: Final = json_reply(response_body("completed"))
|
||||
upstream: Final = scripted_upstream(scenario, scripted_routes(answered, answered))
|
||||
log: Final = upstream_log(gateway, upstream.handle)
|
||||
first: Final = create_background_response(scenario, key, upstream.model)
|
||||
eventually(
|
||||
lambda: entry_key(store, f"msg_{upstream.model}"),
|
||||
lambda found: found is not None,
|
||||
)
|
||||
second: Final = create_background_response(scenario, key, upstream.model)
|
||||
assert len(submissions(next(log), upstream.handle)) == 1
|
||||
reads: Final = [read_response(gateway, key, response_id) for response_id in (first, second, first, second)]
|
||||
assert tuple(reads) == free_reads("completed", 4)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
assert len(charged_rows(upstream.model_id)) == 1
|
||||
|
||||
|
||||
def _end_user_spend(end_user: str) -> float:
|
||||
rows: Final = read_rows('SELECT spend FROM "LiteLLM_EndUserTable" WHERE user_id = %s', (end_user,))
|
||||
return number(rows[0]["spend"]) if rows else 0.0
|
||||
|
||||
|
||||
def _charged_end_users(model_id: str) -> list[JsonValue]:
|
||||
rows: Final = read_rows('SELECT end_user FROM "LiteLLM_SpendLogs" WHERE model_id = %s AND spend > 0', (model_id,))
|
||||
return [row["end_user"] for row in rows]
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_a_background_response_bills_the_end_user_who_created_it_once(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
end_user: Final = f"audit-end-user-{uuid.uuid4().hex}"
|
||||
scenario.cleanups.callback(gateway.request, "POST", "/end_user/delete", {"user_ids": [end_user]})
|
||||
upstream: Final = scripted_upstream(scenario, background_routes("completed"))
|
||||
response_id: Final = create_background_response(
|
||||
scenario,
|
||||
key,
|
||||
upstream.model,
|
||||
body={"model": upstream.model, "input": PROMPT, "background": True, "store": False, "user": end_user},
|
||||
)
|
||||
reads: Final = tuple(read_response(gateway, key, response_id) for _ in range(READS))
|
||||
assert reads in (settling_reads("completed", READS), free_reads("completed", READS))
|
||||
assert_charged_exactly(lambda: _end_user_spend(end_user), PRICE)
|
||||
assert key_spend(key) == pytest.approx(PRICE)
|
||||
assert _charged_end_users(upstream.model_id) == [end_user]
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_the_read_that_settles_a_background_response_splits_its_charge_into_input_and_output(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
_, response_id = _lose_the_row_then_finish(scenario, key)
|
||||
settling: Final = gateway.request("GET", f"/v1/responses/{response_id}", key=key)
|
||||
later: Final = tuple(read_response(gateway, key, response_id) for _ in range(READS - 1))
|
||||
assert read_of(settling) == Read("completed", "pong", pytest.approx(PRICE))
|
||||
split: Final = tuple(float(settling.headers[f"x-litellm-response-cost-{part}"]) for part in ("input", "output"))
|
||||
assert split == pytest.approx((INPUT_TOKENS * INPUT_COST_PER_TOKEN, OUTPUT_TOKENS * OUTPUT_COST_PER_TOKEN))
|
||||
assert later == free_reads("completed", READS - 1)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
|
||||
|
||||
def _answered_at_once(scenario: Scenario, key: str) -> tuple[Upstream, str]:
|
||||
answered: Final = json_reply(response_body("completed"))
|
||||
upstream: Final = scripted_upstream(scenario, scripted_routes(answered, answered))
|
||||
return upstream, create_background_response(scenario, key, upstream.model)
|
||||
|
||||
|
||||
def _stored_output(response_id: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
"SELECT file_object->'output' AS output, position('pong' in file_object::text) > 0 AS holds_text "
|
||||
'FROM "LiteLLM_ManagedObjectTable" WHERE unified_object_id = %s',
|
||||
(response_id,),
|
||||
)
|
||||
|
||||
|
||||
STORED_BY: Final[Mapping[str, Callable[[Scenario, str], tuple[Upstream, str]]]] = {
|
||||
"the_create": _answered_at_once,
|
||||
"a_read": _lose_the_row_then_finish,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
@pytest.mark.parametrize("stored_by", STORED_BY.keys())
|
||||
def test_a_stored_background_response_keeps_none_of_the_model_output(gateway: Gateway, stored_by: str) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
_, response_id = STORED_BY[stored_by](scenario, key)
|
||||
assert read_response(gateway, key, response_id).text == "pong"
|
||||
assert _stored_output(response_id) == [{"output": [], "holds_text": False}]
|
||||
|
||||
|
||||
def _model_usage(upstream: Upstream) -> tuple[float, int]:
|
||||
rows: Final = read_rows(
|
||||
"SELECT COALESCE(SUM(spend), 0) AS spend, COALESCE(SUM(request_count), 0)::bigint AS requests "
|
||||
'FROM "LiteLLM_DailyModelUsage" WHERE model_group IN (%s, %s)',
|
||||
(upstream.model, upstream.model_id),
|
||||
)
|
||||
requests: Final = rows[0]["requests"]
|
||||
assert isinstance(requests, int), rows
|
||||
return number(rows[0]["spend"]), requests
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_model_usage_counts_a_background_response_once_in_spend_and_only_client_calls_as_requests(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
upstream, response_id = _finished(scenario, key)
|
||||
reads: Final = tuple(read_response(gateway, key, response_id) for _ in range(READS))
|
||||
assert reads in (settling_reads("completed", READS), free_reads("completed", READS))
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
client_calls: Final = 1 + READS
|
||||
held(
|
||||
lambda: _model_usage(upstream),
|
||||
lambda usage: costs(PRICE)(usage[0]) and usage[1] == client_calls,
|
||||
holding=POLL_WINDOW_SECONDS,
|
||||
seconds=POLL_WINDOW_SECONDS + SPEND_LANDING_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
BASE_MODEL: Final = "gpt-4o-mini"
|
||||
BASE_MODEL_ONLY: Final[Mapping[str, JsonValue]] = {"base_model": BASE_MODEL}
|
||||
BASE_MODEL_IN: Final[Mapping[str, tuple[Mapping[str, JsonValue] | None, Mapping[str, JsonValue]]]] = {
|
||||
"model_info": (BASE_MODEL_ONLY, {}),
|
||||
"litellm_params": (None, BASE_MODEL_ONLY),
|
||||
}
|
||||
|
||||
|
||||
def _unpriced_upstream(
|
||||
scenario: Scenario,
|
||||
unpriced: str,
|
||||
model_info: Mapping[str, JsonValue] | None,
|
||||
litellm_params: Mapping[str, JsonValue],
|
||||
) -> Upstream:
|
||||
foreground: Final = json_reply(response_body("completed", background=False, model=unpriced))
|
||||
return scripted_upstream(
|
||||
scenario,
|
||||
scripted_routes(foreground, foreground),
|
||||
model_info=model_info,
|
||||
model=f"openai/{unpriced}",
|
||||
input_cost_per_token=None,
|
||||
output_cost_per_token=None,
|
||||
**litellm_params,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
@pytest.mark.parametrize("base_model_in", BASE_MODEL_IN.keys())
|
||||
def test_a_background_response_on_a_base_model_deployment_bills_what_a_foreground_response_costs(
|
||||
gateway: Gateway, base_model_in: str
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
foreground_key: Final = scenario.key()
|
||||
background_key: Final = scenario.key()
|
||||
unpriced: Final = f"audit-unpriced-{uuid.uuid4().hex[:12]}"
|
||||
reference: Final = _unpriced_upstream(scenario, unpriced, BASE_MODEL_ONLY, {})
|
||||
created: Final = gateway.request(
|
||||
"POST", "/v1/responses", {"model": reference.model, "input": PROMPT, "store": False}, key=foreground_key
|
||||
)
|
||||
foreground_cost: Final = read_of(created).cost
|
||||
assert foreground_cost is not None and foreground_cost > 0, created.headers
|
||||
upstream: Final = _unpriced_upstream(scenario, unpriced, *BASE_MODEL_IN[base_model_in])
|
||||
upstream.script(background_routes("completed", model=unpriced))
|
||||
response_id: Final = create_background_response(scenario, background_key, upstream.model)
|
||||
reads: Final = tuple(read_response(gateway, background_key, response_id) for _ in range(READS))
|
||||
settled: Final = (
|
||||
Read("completed", "pong", pytest.approx(foreground_cost)),
|
||||
*free_reads("completed", READS - 1),
|
||||
)
|
||||
assert reads in (settled, free_reads("completed", READS))
|
||||
assert_charged_exactly(lambda: key_spend(background_key), foreground_cost)
|
||||
background_rows: Final = [
|
||||
row for row in charged_rows(upstream.model_id) if row["api_key"] == hashed(background_key)
|
||||
]
|
||||
assert [number(row["spend"]) for row in background_rows] == [pytest.approx(foreground_cost)]
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
def test_a_client_cannot_make_a_background_create_free_by_forging_the_managed_marker(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
key: Final = scenario.key()
|
||||
answered: Final = json_reply(response_body("completed"))
|
||||
upstream: Final = scripted_upstream(scenario, scripted_routes(answered, answered))
|
||||
forged: Final[dict[str, JsonValue]] = {MANAGED_READ_MARKER: True}
|
||||
created: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/responses",
|
||||
{
|
||||
"model": upstream.model,
|
||||
"input": PROMPT,
|
||||
"background": True,
|
||||
"store": False,
|
||||
"metadata": forged,
|
||||
"litellm_metadata": forged,
|
||||
},
|
||||
key=key,
|
||||
)
|
||||
assert read_of(created) == Read("completed", "pong", pytest.approx(PRICE))
|
||||
response_id: Final = string_value(object_value(created.json())["id"])
|
||||
scenario.cleanups.callback(forget_row, response_id)
|
||||
assert tuple(read_response(gateway, key, response_id) for _ in range(2)) == free_reads("completed", 2)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
|
|
@ -0,0 +1,569 @@
|
|||
import itertools
|
||||
import uuid
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import Gateway, Scenario, eventually, held, object_value, string_value
|
||||
from integration._support.database import read_rows, scratch_database, write_rows
|
||||
from integration._support.otlp_sink import Span, configure_sink, owned_sinks, recorded_spans
|
||||
from integration._support.process import graceful_stop_seconds, owned_proxy, owned_proxy_process
|
||||
from integration._support.prometheus_series import Sample, scrape
|
||||
from integration._support.timing import assert_not_delayed_by, timed
|
||||
from integration._support.upstream import ScenarioHandle, delete_scenario, register_scenario
|
||||
from integration.cost_calculation.cost_tracking_case import RoutedResponse, SseResponse
|
||||
from integration.spend._background_responses import (
|
||||
INPUT_COST_PER_TOKEN,
|
||||
MANAGED_READ_MARKER,
|
||||
ONE_POLL_SECONDS,
|
||||
OUTPUT_COST_PER_TOKEN,
|
||||
PRICE,
|
||||
PROMPT,
|
||||
PROVIDER_ID,
|
||||
READS,
|
||||
Read,
|
||||
assert_charged_exactly,
|
||||
background_routes,
|
||||
costs,
|
||||
create_background_response,
|
||||
free_reads,
|
||||
hashed,
|
||||
json_reply,
|
||||
key_spend,
|
||||
managed_rows,
|
||||
number,
|
||||
read_response,
|
||||
reported_cost,
|
||||
response_body,
|
||||
scripted_routes,
|
||||
settling_reads,
|
||||
streamed_reply,
|
||||
)
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
CONFIGURATION: Final = TypeAdapter(dict[str, JsonValue])
|
||||
OWNED_PROXY_SECONDS: Final = 2 * graceful_stop_seconds() + 240
|
||||
NO_DATABASE: Final = ("DATABASE_URL", "DATABASE_URL_READ_REPLICA")
|
||||
POLL_OFF: Final = {"PROXY_BATCH_POLLING_ENABLED": "false"}
|
||||
SINK_DELAY_SECONDS: Final = 4.0
|
||||
SPEND_RETRY_SECONDS: Final = 60
|
||||
BURST_RESPONSES: Final = 6
|
||||
RESTART_RESPONSES: Final = 3
|
||||
COOLDOWN_SECONDS: Final = 300
|
||||
CHAT_COMPLETION: Final[dict[str, JsonValue]] = {
|
||||
"id": "chatcmpl-$REQUEST_ID",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "pong"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6},
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _scripted(response: RoutedResponse | SseResponse, count: int = 1) -> Iterator[tuple[ScenarioHandle, ...]]:
|
||||
with ExitStack() as stack:
|
||||
handles: Final = tuple(register_scenario(f"bgonce-{uuid.uuid4().hex[:12]}", response) for _ in range(count))
|
||||
for handle in handles:
|
||||
stack.callback(delete_scenario, handle)
|
||||
yield handles
|
||||
|
||||
|
||||
def _rescript(handles: Sequence[ScenarioHandle], response: RoutedResponse) -> None:
|
||||
for handle in handles:
|
||||
register_scenario(handle.scenario_id, response, control_url=handle.control_url)
|
||||
|
||||
|
||||
def _with_chat(response: RoutedResponse) -> RoutedResponse:
|
||||
return RoutedResponse(
|
||||
content_type="application/x-routed",
|
||||
routes={**response.routes, "POST /chat/completions": json_reply(CHAT_COMPLETION)},
|
||||
)
|
||||
|
||||
|
||||
def _deployment(handle: ScenarioHandle, model_info: Mapping[str, JsonValue] | None = None) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"model_name": handle.scenario_id,
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"api_key": "sk-scripted-provider",
|
||||
"api_base": handle.api_base(),
|
||||
"input_cost_per_token": INPUT_COST_PER_TOKEN,
|
||||
"output_cost_per_token": OUTPUT_COST_PER_TOKEN,
|
||||
},
|
||||
**({"model_info": dict(model_info)} if model_info else {}),
|
||||
}
|
||||
|
||||
|
||||
def _write_config(directory: Path, config: Mapping[str, JsonValue]) -> Path:
|
||||
path: Final = directory / f"background-billing-{uuid.uuid4().hex}.yaml"
|
||||
path.write_text(yaml.safe_dump(dict(config)))
|
||||
return path
|
||||
|
||||
|
||||
def _config(
|
||||
directory: Path,
|
||||
handles: Sequence[ScenarioHandle],
|
||||
*,
|
||||
model_info: Mapping[str, JsonValue] | None = None,
|
||||
**sections: Mapping[str, JsonValue],
|
||||
) -> Path:
|
||||
shared: Final = CONFIGURATION.validate_python(
|
||||
yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
||||
)
|
||||
settings: Final = {
|
||||
name: {**object_value(shared[name]), **sections.get(name, {})}
|
||||
for name in ("general_settings", "litellm_settings", "router_settings")
|
||||
}
|
||||
extra: Final = {name: dict(section) for name, section in sections.items() if name not in settings}
|
||||
return _write_config(
|
||||
directory,
|
||||
{"model_list": [_deployment(handle, model_info) for handle in handles], **settings, **extra},
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _scratch_proxy(
|
||||
gateway: Gateway,
|
||||
directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
config: Path,
|
||||
overrides: Mapping[str, str] | None = None,
|
||||
) -> Iterator[Gateway]:
|
||||
with scratch_database() as database_url:
|
||||
monkeypatch.setenv("DATABASE_URL", database_url)
|
||||
with owned_proxy(
|
||||
gateway, directory, {"DATABASE_URL": database_url, **(overrides or {})}, config=config
|
||||
) as proxy:
|
||||
yield proxy
|
||||
|
||||
|
||||
def _team_member_key(scenario: Scenario) -> str:
|
||||
team_id: Final = scenario.team()
|
||||
return scenario.key(team_id=team_id, user_id=scenario.member(team_id))
|
||||
|
||||
|
||||
def _billed_rows_of(key: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
'SELECT request_id, spend FROM "LiteLLM_SpendLogs" WHERE api_key = %s AND spend > 0', (hashed(key),)
|
||||
)
|
||||
|
||||
|
||||
def _assert_each_billed_once_as_background_cost(key: str, count: int) -> None:
|
||||
rows: Final = _billed_rows_of(key)
|
||||
assert len(rows) == count, rows
|
||||
assert all(string_value(row["request_id"]).endswith("_background_cost") for row in rows), rows
|
||||
assert len({row["request_id"] for row in rows}) == count, rows
|
||||
assert [number(row["spend"]) for row in rows] == pytest.approx([PRICE] * count)
|
||||
|
||||
|
||||
@pytest.mark.timeout(OWNED_PROXY_SECONDS)
|
||||
def test_a_proxy_without_a_database_bills_every_read_of_a_finished_background_response(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
forged: Final[dict[str, JsonValue]] = {
|
||||
"internal_call_origin": "background_response_status_poll",
|
||||
MANAGED_READ_MARKER: True,
|
||||
}
|
||||
with _scripted(background_routes("completed")) as (handle,):
|
||||
config: Final = _write_config(
|
||||
tmp_path,
|
||||
{
|
||||
"model_list": [_deployment(handle)],
|
||||
"general_settings": {"master_key": "os.environ/LITELLM_MASTER_KEY"},
|
||||
},
|
||||
)
|
||||
overrides: Final = {"STORE_MODEL_IN_DB": "False", "DISABLE_SCHEMA_UPDATE": "true"}
|
||||
with owned_proxy(
|
||||
gateway, tmp_path, overrides, config=config, database_setup=(), remove_environment=NO_DATABASE
|
||||
) as proxy:
|
||||
created: Final = proxy.post(
|
||||
"/v1/responses", {"model": handle.scenario_id, "input": PROMPT, "background": True, "store": False}
|
||||
)
|
||||
response_id: Final = string_value(created["id"])
|
||||
reads: Final = (
|
||||
read_response(proxy, proxy.key, response_id),
|
||||
read_response(proxy, proxy.key, response_id),
|
||||
read_response(proxy, proxy.key, response_id, {"metadata": forged, "litellm_metadata": forged}),
|
||||
)
|
||||
assert [(read.status, read.text) for read in reads] == [("completed", "pong")] * 3
|
||||
assert [read.cost for read in reads] == pytest.approx([PRICE] * 3)
|
||||
|
||||
|
||||
def _span_cost(span: Span) -> float:
|
||||
return number(span["attributes"].get("gen_ai.cost.total_cost", 0))
|
||||
|
||||
|
||||
def _spans_of(sink: str, key: str) -> tuple[Span, ...]:
|
||||
_, spans = recorded_spans(sink)
|
||||
return tuple(span for span in spans if span["attributes"].get("metadata.user_api_key_hash") == hashed(key))
|
||||
|
||||
|
||||
@pytest.mark.timeout(OWNED_PROXY_SECONDS + 120)
|
||||
def test_with_the_cost_poll_off_the_first_finished_read_bills_the_creator_once_and_tells_the_destination_once(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
with _scripted(background_routes("queued")) as (handle,), owned_sinks(tmp_path / "sinks") as sinks:
|
||||
config: Final = _config(
|
||||
tmp_path,
|
||||
(handle,),
|
||||
litellm_settings={"callbacks": ["otel"]},
|
||||
callback_settings={
|
||||
"otel": {"exporter": "http/json", "endpoint": sinks.operator, "use_simple_processor": True}
|
||||
},
|
||||
)
|
||||
overrides: Final = {**POLL_OFF, "OTEL_EXPORTER": "http/json", "OTEL_ENDPOINT": sinks.operator}
|
||||
with (
|
||||
_scratch_proxy(gateway, tmp_path, monkeypatch, config, overrides) as proxy,
|
||||
proxy.scenario() as scenario,
|
||||
):
|
||||
key: Final = _team_member_key(scenario)
|
||||
response_id: Final = create_background_response(scenario, key, handle.scenario_id)
|
||||
unfinished: Final = read_response(proxy, key, response_id)
|
||||
_rescript((handle,), background_routes("completed"))
|
||||
finished: Final = tuple(read_response(proxy, key, response_id) for _ in range(READS))
|
||||
assert (unfinished.status, unfinished.cost) == ("queued", 0.0)
|
||||
assert finished == settling_reads("completed", READS)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
_assert_each_billed_once_as_background_cost(key, 1)
|
||||
spans: Final = eventually(
|
||||
lambda: _spans_of(sinks.operator, key), lambda found: len(found) >= READS + 3, seconds=30
|
||||
)
|
||||
assert [_span_cost(span) for span in spans if _span_cost(span) > 0] == pytest.approx([PRICE])
|
||||
|
||||
|
||||
@pytest.mark.timeout(OWNED_PROXY_SECONDS + 60)
|
||||
def test_the_read_that_finishes_a_background_response_does_not_wait_on_a_slow_spend_destination(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
with _scripted(background_routes("queued")) as (handle,), owned_sinks(tmp_path / "sinks") as sinks:
|
||||
config: Final = _config(tmp_path, (handle,), litellm_settings={"callbacks": ["openmeter"]})
|
||||
overrides: Final = {
|
||||
**POLL_OFF,
|
||||
"OPENMETER_API_KEY": "om-scripted-key",
|
||||
"OPENMETER_API_ENDPOINT": sinks.tenant,
|
||||
}
|
||||
with (
|
||||
_scratch_proxy(gateway, tmp_path, monkeypatch, config, overrides) as proxy,
|
||||
proxy.scenario() as scenario,
|
||||
):
|
||||
key: Final = scenario.key()
|
||||
response_id: Final = create_background_response(scenario, key, handle.scenario_id)
|
||||
configure_sink(sinks.tenant, delay_seconds=SINK_DELAY_SECONDS)
|
||||
unfinished: Final = timed(lambda: read_response(proxy, key, response_id))
|
||||
_rescript((handle,), background_routes("completed"))
|
||||
first_finished: Final = timed(lambda: read_response(proxy, key, response_id))
|
||||
later: Final = tuple(timed(lambda: read_response(proxy, key, response_id)) for _ in range(2))
|
||||
assert first_finished.value.status == "completed"
|
||||
assert_not_delayed_by(
|
||||
(first_finished.seconds,),
|
||||
control=(unfinished.seconds, *(read.seconds for read in later)),
|
||||
injected_delay=SINK_DELAY_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _provider_404() -> RoutedResponse:
|
||||
gone: Final = json_reply(
|
||||
{
|
||||
"error": {
|
||||
"message": f"Response with id '{PROVIDER_ID}' not found.",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": None,
|
||||
}
|
||||
},
|
||||
404,
|
||||
)
|
||||
return scripted_routes(json_reply(response_body("queued")), gone)
|
||||
|
||||
|
||||
def _create_status(proxy: Gateway, key: str, model: str) -> int:
|
||||
return proxy.request(
|
||||
"POST", "/v1/responses", {"model": model, "input": PROMPT, "background": True, "store": False}, key=key
|
||||
).status_code
|
||||
|
||||
|
||||
@pytest.mark.timeout(OWNED_PROXY_SECONDS + ONE_POLL_SECONDS)
|
||||
def test_neither_a_poll_404_nor_a_client_read_404_takes_the_deployment_out_of_service(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
with ExitStack() as stack:
|
||||
(handle,) = stack.enter_context(_scripted(_provider_404()))
|
||||
config: Final = _config(
|
||||
tmp_path,
|
||||
(handle,),
|
||||
model_info={"allowed_fails_policy": {"BadRequestErrorAllowedFails": 0, "NotFoundErrorAllowedFails": 0}},
|
||||
router_settings={"disable_cooldowns": False, "cooldown_time": COOLDOWN_SECONDS},
|
||||
)
|
||||
proxy: Final = stack.enter_context(_scratch_proxy(gateway, tmp_path, monkeypatch, config))
|
||||
scenario: Final = stack.enter_context(proxy.scenario())
|
||||
key: Final = scenario.key()
|
||||
polled: Final = create_background_response(scenario, key, handle.scenario_id)
|
||||
eventually(
|
||||
lambda: [row["status"] for row in managed_rows(polled)],
|
||||
lambda statuses: statuses == ["stale_expired"],
|
||||
seconds=ONE_POLL_SECONDS,
|
||||
)
|
||||
after_poll: Final = _create_status(proxy, key, handle.scenario_id)
|
||||
client_read: Final = proxy.request("GET", f"/v1/responses/{polled}", key=key).status_code
|
||||
after_client_read: Final = _create_status(proxy, key, handle.scenario_id)
|
||||
assert (after_poll, client_read, after_client_read) == (200, 404, 200)
|
||||
|
||||
|
||||
def _burst_call(proxy: Gateway, call: tuple[str, str, str]) -> int | None:
|
||||
kind, key, target = call
|
||||
try:
|
||||
if kind == "read":
|
||||
return proxy.request("GET", f"/v1/responses/{target}", key=key).status_code
|
||||
return proxy.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": target, "messages": [{"role": "user", "content": PROMPT}]},
|
||||
key=key,
|
||||
).status_code
|
||||
except httpx.TransportError:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.timeout(OWNED_PROXY_SECONDS + 2 * graceful_stop_seconds() + 60 + ONE_POLL_SECONDS + 100)
|
||||
def test_background_responses_read_across_a_killed_worker_are_each_billed_once_to_their_creator(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
with (
|
||||
_scripted(_with_chat(background_routes("queued")), BURST_RESPONSES) as handles,
|
||||
scratch_database() as database_url,
|
||||
):
|
||||
monkeypatch.setenv("DATABASE_URL", database_url)
|
||||
config: Final = _config(tmp_path, handles)
|
||||
with (
|
||||
owned_proxy_process(gateway, tmp_path, {"DATABASE_URL": database_url}, config=config, workers=2) as owned,
|
||||
owned.gateway.scenario() as scenario,
|
||||
):
|
||||
key: Final = _team_member_key(scenario)
|
||||
chat_key: Final = scenario.key()
|
||||
response_ids: Final = tuple(
|
||||
create_background_response(scenario, key, handle.scenario_id) for handle in handles
|
||||
)
|
||||
reads: Final = tuple(("read", key, response_id) for response_id in response_ids * 3)
|
||||
chats: Final = tuple(("chat", chat_key, handle.scenario_id) for handle in handles * 2)
|
||||
calls: Final = tuple(
|
||||
call for call in itertools.chain.from_iterable(itertools.zip_longest(reads, chats)) if call is not None
|
||||
)
|
||||
with owned.replacing_worker(min(owned.worker_pids())) as kill, ThreadPoolExecutor(max_workers=10) as pool:
|
||||
kill.freeze()
|
||||
futures: Final = tuple(pool.submit(_burst_call, owned.gateway, call) for call in calls)
|
||||
eventually(
|
||||
lambda: sum(future.done() for future in futures),
|
||||
lambda done: done >= len(futures) // 2,
|
||||
seconds=30,
|
||||
)
|
||||
kill()
|
||||
outcomes: Final = tuple(future.result() for future in futures)
|
||||
assert set(outcomes) <= {200, None}, outcomes
|
||||
assert outcomes.count(200) >= len(outcomes) // 2, outcomes
|
||||
_rescript(handles, _with_chat(background_routes("completed")))
|
||||
finished: Final = tuple(read_response(owned.gateway, key, response_id) for response_id in response_ids * 2)
|
||||
assert [read.status for read in finished] == ["completed"] * 2 * BURST_RESPONSES
|
||||
assert [read.cost in (0.0, pytest.approx(PRICE)) for read in finished] == [True] * 2 * BURST_RESPONSES
|
||||
assert sum(read.cost or 0.0 for read in finished) <= BURST_RESPONSES * PRICE + 1e-9
|
||||
assert_charged_exactly(lambda: key_spend(key), BURST_RESPONSES * PRICE)
|
||||
_assert_each_billed_once_as_background_cost(key, BURST_RESPONSES)
|
||||
|
||||
|
||||
def _statuses(response_ids: Sequence[str]) -> list[JsonValue]:
|
||||
rows: Final = itertools.chain.from_iterable(managed_rows(response_id) for response_id in response_ids)
|
||||
return [row["status"] for row in rows]
|
||||
|
||||
|
||||
def _submit(proxy: Gateway, key: str, model: str) -> str:
|
||||
response: Final = proxy.request(
|
||||
"POST", "/v1/responses", {"model": model, "input": PROMPT, "background": True, "store": False}, key=key
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return string_value(object_value(response.json())["id"])
|
||||
|
||||
|
||||
@pytest.mark.timeout(2 * OWNED_PROXY_SECONDS + ONE_POLL_SECONDS + 100)
|
||||
def test_background_responses_left_pending_across_a_proxy_restart_are_billed_once_to_their_creator(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
with _scripted(background_routes("queued"), RESTART_RESPONSES) as handles, scratch_database() as database_url:
|
||||
monkeypatch.setenv("DATABASE_URL", database_url)
|
||||
config: Final = _config(tmp_path, handles)
|
||||
with owned_proxy(
|
||||
gateway, tmp_path / "before", {"DATABASE_URL": database_url, **POLL_OFF}, config=config
|
||||
) as first:
|
||||
key: Final = string_value(first.post("/key/generate", {})["key"])
|
||||
response_ids: Final = tuple(_submit(first, key, handle.scenario_id) for handle in handles)
|
||||
_rescript(handles, background_routes("completed"))
|
||||
with owned_proxy(gateway, tmp_path / "after", {"DATABASE_URL": database_url}, config=config):
|
||||
eventually(
|
||||
lambda: _statuses(response_ids),
|
||||
lambda statuses: statuses == ["completed"] * RESTART_RESPONSES,
|
||||
seconds=ONE_POLL_SECONDS,
|
||||
)
|
||||
assert_charged_exactly(lambda: key_spend(key), RESTART_RESPONSES * PRICE)
|
||||
_assert_each_billed_once_as_background_cost(key, RESTART_RESPONSES)
|
||||
|
||||
|
||||
DELETED: Final[dict[str, JsonValue]] = {"id": PROVIDER_ID, "object": "response.deleted", "deleted": True}
|
||||
REFUSAL_HOLD_SECONDS: Final = 10
|
||||
|
||||
|
||||
def _deletable(read_status: str) -> RoutedResponse:
|
||||
return RoutedResponse(
|
||||
content_type="application/x-routed",
|
||||
routes={**background_routes(read_status).routes, f"DELETE /responses/{PROVIDER_ID}": json_reply(DELETED)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(OWNED_PROXY_SECONDS + 120)
|
||||
def test_a_delete_bills_a_finished_background_response_once_and_only_for_a_caller_that_may_address_it(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
with _scripted(_deletable("queued")) as (handle,):
|
||||
config: Final = _config(tmp_path, (handle,))
|
||||
with (
|
||||
_scratch_proxy(gateway, tmp_path, monkeypatch, config, POLL_OFF) as proxy,
|
||||
proxy.scenario() as scenario,
|
||||
):
|
||||
creator: Final = _team_member_key(scenario)
|
||||
stranger: Final = _team_member_key(scenario)
|
||||
response_id: Final = create_background_response(scenario, creator, handle.scenario_id)
|
||||
_rescript((handle,), _deletable("completed"))
|
||||
refused: Final = proxy.request("DELETE", f"/v1/responses/{response_id}", key=stranger)
|
||||
assert refused.status_code == 403, refused.text
|
||||
held(lambda: key_spend(creator), costs(0.0), holding=REFUSAL_HOLD_SECONDS)
|
||||
assert [row["status"] for row in managed_rows(response_id)] == ["queued"]
|
||||
deleted: Final = proxy.request("DELETE", f"/v1/responses/{response_id}", key=creator)
|
||||
assert deleted.status_code == 200, deleted.text
|
||||
assert object_value(deleted.json())["deleted"] is True
|
||||
assert reported_cost(deleted.headers) == pytest.approx(PRICE)
|
||||
assert_charged_exactly(lambda: key_spend(creator), PRICE)
|
||||
_assert_each_billed_once_as_background_cost(creator, 1)
|
||||
assert key_spend(stranger) == 0
|
||||
assert [row["status"] for row in managed_rows(response_id)] == ["completed"]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _billing_claims_refused() -> Iterator[None]:
|
||||
write_rows(
|
||||
"CREATE FUNCTION audit_refuse_billing_claim() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN "
|
||||
"IF NEW.status = 'completed' AND OLD.status IS DISTINCT FROM 'completed' THEN "
|
||||
"RAISE EXCEPTION 'audit refuses the billing claim'; END IF; RETURN NEW; END $$",
|
||||
(),
|
||||
)
|
||||
write_rows(
|
||||
'CREATE TRIGGER audit_refuse_billing_claim BEFORE UPDATE ON "LiteLLM_ManagedObjectTable" '
|
||||
"FOR EACH ROW EXECUTE FUNCTION audit_refuse_billing_claim()",
|
||||
(),
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
write_rows('DROP TRIGGER audit_refuse_billing_claim ON "LiteLLM_ManagedObjectTable"', ())
|
||||
write_rows("DROP FUNCTION audit_refuse_billing_claim()", ())
|
||||
|
||||
|
||||
@pytest.mark.timeout(OWNED_PROXY_SECONDS + 180)
|
||||
def test_a_finished_read_whose_bill_fails_to_settle_bills_the_creator_once_after_the_database_recovers(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
with _scripted(background_routes("queued")) as (handle,):
|
||||
config: Final = _config(tmp_path, (handle,))
|
||||
with (
|
||||
_scratch_proxy(gateway, tmp_path, monkeypatch, config, POLL_OFF) as proxy,
|
||||
proxy.scenario() as scenario,
|
||||
):
|
||||
key: Final = _team_member_key(scenario)
|
||||
response_id: Final = create_background_response(scenario, key, handle.scenario_id)
|
||||
_rescript((handle,), background_routes("completed"))
|
||||
with _billing_claims_refused():
|
||||
unsettled: Final = read_response(proxy, key, response_id)
|
||||
statuses: Final = [row["status"] for row in managed_rows(response_id)]
|
||||
assert unsettled == Read("completed", "pong", 0.0)
|
||||
assert statuses == ["queued"]
|
||||
eventually(lambda: key_spend(key), costs(PRICE), seconds=SPEND_RETRY_SECONDS)
|
||||
assert tuple(read_response(proxy, key, response_id) for _ in range(READS)) == free_reads("completed", READS)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
_assert_each_billed_once_as_background_cost(key, 1)
|
||||
assert [row["status"] for row in managed_rows(response_id)] == ["completed"]
|
||||
|
||||
|
||||
REMAINING_KEY_BUDGET: Final = "litellm_remaining_api_key_budget_metric"
|
||||
KEY_BUDGET: Final = 10.0
|
||||
|
||||
|
||||
def _remaining_key_budget(samples: Sequence[Sample], key: str) -> list[float]:
|
||||
return [
|
||||
sample.value
|
||||
for sample in samples
|
||||
if sample.name == REMAINING_KEY_BUDGET and sample.labels.get("hashed_api_key") == hashed(key)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.timeout(OWNED_PROXY_SECONDS + ONE_POLL_SECONDS + 60)
|
||||
def test_a_background_response_the_poll_bills_lowers_its_creating_keys_remaining_budget_by_its_price(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
with _scripted(background_routes("completed")) as (handle,):
|
||||
config: Final = _config(tmp_path, (handle,), litellm_settings={"callbacks": ["prometheus"]})
|
||||
with _scratch_proxy(gateway, tmp_path, monkeypatch, config) as proxy, proxy.scenario() as scenario:
|
||||
key: Final = scenario.key(max_budget=KEY_BUDGET)
|
||||
response_id: Final = create_background_response(scenario, key, handle.scenario_id)
|
||||
eventually(
|
||||
lambda: [row["status"] for row in managed_rows(response_id)],
|
||||
lambda statuses: statuses == ["completed"],
|
||||
seconds=ONE_POLL_SECONDS,
|
||||
)
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
eventually(
|
||||
lambda: _remaining_key_budget(scrape(proxy), key),
|
||||
lambda values: values == [pytest.approx(KEY_BUDGET - PRICE)],
|
||||
seconds=30,
|
||||
)
|
||||
|
||||
|
||||
CACHE_POLLING: Final[dict[str, JsonValue]] = {"responses": {"background_mode": {"polling_via_cache": "all"}}}
|
||||
CACHE_POLLED_CREATES: Final[Mapping[str, tuple[dict[str, JsonValue], bool]]] = {
|
||||
"streamed_with_background_in_extra_body": ({"stream": True, "extra_body": {"background": True}}, True),
|
||||
"unstreamed": ({}, False),
|
||||
}
|
||||
|
||||
|
||||
def _cache_polled_create(proxy: Gateway, key: str, model: str, create: Mapping[str, JsonValue]) -> str:
|
||||
created: Final = proxy.request(
|
||||
"POST",
|
||||
"/v1/responses",
|
||||
{"model": model, "input": PROMPT, "background": True, "store": False, **create},
|
||||
key=key,
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
return string_value(object_value(created.json())["id"])
|
||||
|
||||
|
||||
@pytest.mark.timeout(OWNED_PROXY_SECONDS + 120)
|
||||
@pytest.mark.parametrize("create", CACHE_POLLED_CREATES.keys())
|
||||
def test_a_background_response_the_proxy_polls_through_its_cache_bills_its_creator_once(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, create: str
|
||||
) -> None:
|
||||
body, provider_sees_background = CACHE_POLLED_CREATES[create]
|
||||
with _scripted(streamed_reply(background=provider_sees_background)) as (handle,):
|
||||
config: Final = _config(tmp_path, (handle,), litellm_settings=CACHE_POLLING)
|
||||
with _scratch_proxy(gateway, tmp_path, monkeypatch, config, POLL_OFF) as proxy, proxy.scenario() as scenario:
|
||||
key: Final = _team_member_key(scenario)
|
||||
polling_id: Final = _cache_polled_create(proxy, key, handle.scenario_id, body)
|
||||
finished: Final = eventually(
|
||||
lambda: read_response(proxy, key, polling_id),
|
||||
lambda read: read.status == "completed",
|
||||
seconds=SPEND_RETRY_SECONDS,
|
||||
)
|
||||
assert finished.text == "pong"
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
assert [read_response(proxy, key, polling_id).text for _ in range(READS)] == ["pong"] * READS
|
||||
assert_charged_exactly(lambda: key_spend(key), PRICE)
|
||||
|
|
@ -16,7 +16,7 @@ from integration.cost_calculation.cost_tracking_case import JsonResponse, Routed
|
|||
from pydantic import JsonValue
|
||||
|
||||
from litellm.constants import MAX_OBJECTS_PER_POLL_CYCLE
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import BACKGROUND_RESPONSE_COST_REQUEST_ID_SUFFIX
|
||||
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
|
||||
INPUT_COST_PER_TOKEN: Final = 0.001
|
||||
|
|
@ -240,30 +240,21 @@ def test_a_provider_error_other_than_404_keeps_the_row_queued_for_retry(gateway:
|
|||
assert _row_status(unified_id) == [{"status": "queued"}]
|
||||
|
||||
|
||||
def _polled_provider_id(row: dict[str, JsonValue]) -> str:
|
||||
return ResponsesAPIRequestUtils.decode_responses_api_response_id(string_value(row["request_id"]))["response_id"]
|
||||
|
||||
|
||||
def _poll_spend_rows(handle: ScenarioHandle) -> list[dict[str, JsonValue]]:
|
||||
billed: Final = read_rows(
|
||||
'SELECT request_id, call_type, status, prompt_tokens, completion_tokens, spend FROM "LiteLLM_SpendLogs" '
|
||||
"WHERE metadata->>'internal_call_origin' = %s",
|
||||
(BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,),
|
||||
def _poll_spend_rows(unified_id: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
'SELECT call_type, status, prompt_tokens, completion_tokens, spend FROM "LiteLLM_SpendLogs" '
|
||||
"WHERE request_id = %s AND metadata->>'internal_call_origin' = %s",
|
||||
(f"{unified_id}{BACKGROUND_RESPONSE_COST_REQUEST_ID_SUFFIX}", BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN),
|
||||
)
|
||||
return [
|
||||
{column: value for column, value in row.items() if column != "request_id"}
|
||||
for row in billed
|
||||
if _polled_provider_id(row) == f"resp_{handle.scenario_id}"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_a_completed_response_is_marked_completed_and_its_poll_is_billed(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
model, handle = _scripted_deployment(scenario, "done", _routes("queued", _json(_response_body("completed"))))
|
||||
model, _ = _scripted_deployment(scenario, "done", _routes("queued", _json(_response_body("completed"))))
|
||||
unified_id: Final = _submit_background_response(scenario, scenario.key(), model)
|
||||
_await_status(unified_id, "completed", ONE_POLL_SECONDS)
|
||||
spend_rows: Final = eventually(lambda: _poll_spend_rows(handle), lambda rows: len(rows) >= 1, seconds=70)
|
||||
spend_rows: Final = eventually(lambda: _poll_spend_rows(unified_id), lambda rows: len(rows) >= 1, seconds=70)
|
||||
assert spend_rows[0] == {
|
||||
"call_type": "aget_responses",
|
||||
"status": "success",
|
||||
|
|
@ -305,7 +296,7 @@ def test_responses_gone_at_the_provider_do_not_starve_a_newer_response_out_of_co
|
|||
)
|
||||
for index in range(MAX_OBJECTS_PER_POLL_CYCLE)
|
||||
)
|
||||
completable_model, completable = _scripted_deployment(
|
||||
completable_model, _ = _scripted_deployment(
|
||||
scenario, "done", _routes("queued", _json(_response_body("completed")))
|
||||
)
|
||||
completable_id: Final = _submit_background_response(scenario, key, completable_model)
|
||||
|
|
@ -313,7 +304,7 @@ def test_responses_gone_at_the_provider_do_not_starve_a_newer_response_out_of_co
|
|||
assert [_row_status(gone_id) for gone_id in gone_ids] == [
|
||||
[{"status": "stale_expired"}]
|
||||
] * MAX_OBJECTS_PER_POLL_CYCLE
|
||||
spend_rows: Final = eventually(lambda: _poll_spend_rows(completable), lambda rows: len(rows) >= 1, seconds=70)
|
||||
spend_rows: Final = eventually(lambda: _poll_spend_rows(completable_id), lambda rows: len(rows) >= 1, seconds=70)
|
||||
assert spend_rows[0]["spend"] == pytest.approx(
|
||||
INPUT_TOKENS * INPUT_COST_PER_TOKEN + OUTPUT_TOKENS * OUTPUT_COST_PER_TOKEN
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6048,3 +6048,103 @@ async def test_create_from_raw_provider_file_id_skips_managed_ownership_lookup(c
|
|||
|
||||
assert isinstance(result, dict) and result[file_field] == "file-raw-provider-id", result
|
||||
assert not any("unified_file_id" in where for where in table.find_first_calls), table.find_first_calls
|
||||
|
||||
|
||||
_MANAGED_READ_SALT_KEY: Final = "managed-read-unit-test-salt-key"
|
||||
|
||||
|
||||
def _proxy_issued_response_id(encrypted: bool) -> str:
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.hooks.responses_id_security import ResponsesIDSecurity
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
|
||||
routed_id: Final = ResponsesAPIRequestUtils._build_responses_api_response_id(
|
||||
"openai", "deployment-1", "resp_provider_job"
|
||||
)
|
||||
if not encrypted:
|
||||
return routed_id
|
||||
issuer: Final = ResponsesIDSecurity(
|
||||
general_settings_reader=lambda: {}, signing_key_reader=lambda: _MANAGED_READ_SALT_KEY
|
||||
)
|
||||
return issuer._encrypt_response_id(
|
||||
ResponsesAPIResponse(id=routed_id, created_at=1, output=[], status="queued"),
|
||||
UserAPIKeyAuth(user_id="alice"),
|
||||
).id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("call_type", ["aget_responses", "acancel_responses"])
|
||||
@pytest.mark.parametrize("encrypted", [False, True], ids=["after_id_security", "before_id_security"])
|
||||
async def test_a_read_of_a_proxy_issued_response_id_is_marked_managed(
|
||||
call_type: str, encrypted: bool, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The cost poll bills a background response the proxy issued, so a client read of that id is
|
||||
marked managed and priced at zero, whichever order the ID security hook runs in."""
|
||||
from litellm.constants import MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", _MANAGED_READ_SALT_KEY)
|
||||
addressed_id: Final = _proxy_issued_response_id(encrypted)
|
||||
|
||||
result: Final = await PROXY_LiteLLMManagedFiles(DualCache(), prisma_client=MagicMock()).async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="alice"),
|
||||
cache=MagicMock(),
|
||||
data={"response_id": addressed_id, "litellm_metadata": {"user_api_key_user_id": "alice"}},
|
||||
call_type=call_type,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"response_id": addressed_id,
|
||||
"litellm_metadata": {"user_api_key_user_id": "alice", MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_read_of_a_raw_provider_response_id_is_not_marked_managed() -> None:
|
||||
"""A raw provider id names no deployment, so the proxy never stored that response and its read
|
||||
keeps pricing normally."""
|
||||
from litellm.constants import MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
result: Final = await PROXY_LiteLLMManagedFiles(DualCache(), prisma_client=MagicMock()).async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="alice"),
|
||||
cache=MagicMock(),
|
||||
data={"response_id": "resp_raw_provider_job", "metadata": {}},
|
||||
call_type="aget_responses",
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert MANAGED_BACKGROUND_RESPONSE_METADATA_KEY not in result["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"create_fields, marked",
|
||||
[
|
||||
({"stream": True, "background": True}, True),
|
||||
({"stream": True, "extra_body": {"background": True}}, True),
|
||||
({"stream": False, "background": True}, False),
|
||||
({"stream": True}, False),
|
||||
],
|
||||
ids=["streamed_background", "streamed_background_in_extra_body", "not_streamed", "not_background"],
|
||||
)
|
||||
async def test_only_a_streamed_background_create_is_marked_for_the_managed_bill(
|
||||
create_fields: Mapping[str, object], marked: bool
|
||||
) -> None:
|
||||
"""A streamed background create is billed once through its managed row when its stream ends,
|
||||
so only that create is marked free, and only in litellm_metadata, which never reaches the
|
||||
provider."""
|
||||
from litellm.constants import MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
result: Final = await PROXY_LiteLLMManagedFiles(DualCache(), prisma_client=MagicMock()).async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="alice"),
|
||||
cache=MagicMock(),
|
||||
data={"model": "gpt-5.6", "input": "hello", "metadata": {"run": "nightly"}, "litellm_metadata": {}, **create_fields},
|
||||
call_type="aresponses",
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert (MANAGED_BACKGROUND_RESPONSE_METADATA_KEY in result["litellm_metadata"]) is marked
|
||||
assert result["metadata"] == {"run": "nightly"}
|
||||
|
|
|
|||
|
|
@ -553,3 +553,59 @@ async def test_store_unified_object_id_omits_unset_columns():
|
|||
assert "api_key" not in create_data
|
||||
assert "request_tags" not in create_data
|
||||
assert "unified-b" in store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_unified_object_id_drops_nul_characters_postgres_cannot_store():
|
||||
"""Regression (spend loss): Postgres rejects a NUL inside jsonb, and a client can send one
|
||||
in a background response's instructions or tags. The row the cost poll bills from must
|
||||
still be written, with only the NUL characters removed."""
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
|
||||
instance, store = _in_memory_managed_files()
|
||||
creator = UserAPIKeyAuth(user_id="alice", team_id="team-alpha", api_key="hash-alice")
|
||||
response = ResponsesAPIResponse(
|
||||
id="resp_nul",
|
||||
created_at=1,
|
||||
status="queued",
|
||||
model="gpt-5.6",
|
||||
instructions="be brief\x00",
|
||||
metadata={"note\x00": "keep\x00 this"},
|
||||
output=[],
|
||||
)
|
||||
|
||||
await instance.store_unified_object_id(
|
||||
unified_object_id="unified-r",
|
||||
file_object=response,
|
||||
litellm_parent_otel_span=None,
|
||||
model_object_id="resp_nul",
|
||||
file_purpose="response",
|
||||
user_api_key_dict=creator,
|
||||
request_tags=["env\x00:prod"],
|
||||
persist_attribution=True,
|
||||
)
|
||||
|
||||
row = store["unified-r"]
|
||||
assert "\\u0000" not in row["file_object"]
|
||||
stored = json.loads(row["file_object"])
|
||||
assert stored == json.loads(response.model_dump_json().replace("\\u0000", ""))
|
||||
assert stored["instructions"] == "be brief"
|
||||
assert stored["metadata"] == {"note": "keep this"}
|
||||
assert row["request_tags"].data == ["env:prod"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_unified_object_id_keeps_the_provider_json_when_it_has_no_nul():
|
||||
instance, store = _in_memory_managed_files()
|
||||
batch = _build_batch_response(batch_id="b", status="validating")
|
||||
|
||||
await instance.store_unified_object_id(
|
||||
unified_object_id="unified-b",
|
||||
file_object=batch,
|
||||
litellm_parent_otel_span=None,
|
||||
model_object_id="b",
|
||||
file_purpose="batch",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="alice", api_key="hash-alice"),
|
||||
)
|
||||
|
||||
assert store["unified-b"]["file_object"] == batch.model_dump_json()
|
||||
|
|
|
|||
|
|
@ -1499,6 +1499,51 @@ async def test_create_still_upserts_and_claims_attribution():
|
|||
assert created["team_id"] == "team-alpha"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stored_background_response_names_its_creators_end_user_and_holds_none_of_its_output():
|
||||
"""Billing reads only a stored response's status and model, so neither the row nor its cache
|
||||
entry keeps what the model generated, and the row names the end user the create billed to."""
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
|
||||
managed_files, mock_prisma = _make_object_store_instance()
|
||||
generated_text = "the generated answer"
|
||||
response = ResponsesAPIResponse(
|
||||
id="resp_unified",
|
||||
object="response",
|
||||
status="completed",
|
||||
created_at=1,
|
||||
model="gpt-5.6",
|
||||
output=[
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": generated_text, "annotations": []}],
|
||||
}
|
||||
],
|
||||
background=True,
|
||||
)
|
||||
|
||||
await managed_files.store_unified_object_id(
|
||||
unified_object_id="resp_unified",
|
||||
file_object=response,
|
||||
litellm_parent_otel_span=None,
|
||||
model_object_id="resp_provider",
|
||||
file_purpose="response",
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-the-creator", end_user_id="customer-42", parent_otel_span=None),
|
||||
persist_attribution=True,
|
||||
)
|
||||
|
||||
written = mock_prisma.db.litellm_managedobjecttable.upsert.await_args.kwargs["data"]
|
||||
cached = managed_files.internal_usage_cache.async_set_cache.await_args.kwargs["value"]
|
||||
assert generated_text not in json.dumps(written, default=str)
|
||||
assert generated_text not in json.dumps(cached, default=str)
|
||||
assert json.loads(written["create"]["file_object"])["status"] == "completed"
|
||||
assert written["create"]["end_user_id"] == "customer-42"
|
||||
assert generated_text in response.model_dump_json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_callers_still_create_their_rows():
|
||||
"""create_if_missing defaults to True, so the fine-tune, Responses and managed
|
||||
|
|
|
|||
|
|
@ -360,14 +360,14 @@ class TestCheckResponsesCost:
|
|||
mock_job.id = "job-123"
|
||||
mock_job.unified_object_id = "resp_test_id"
|
||||
mock_job.created_by = "test-user"
|
||||
mock_job.file_object = {"model": "gpt-4o", "id": "resp_test_id"}
|
||||
|
||||
# Mock find_many to return the job
|
||||
mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(
|
||||
return_value=[mock_job]
|
||||
)
|
||||
|
||||
# Mock update_many
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock()
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(return_value=1)
|
||||
|
||||
# Create a completed response
|
||||
completed_response = ResponsesAPIResponse(
|
||||
|
|
@ -406,7 +406,10 @@ class TestCheckResponsesCost:
|
|||
if c.kwargs.get("where", {}).get("id") is not None
|
||||
]
|
||||
assert len(completion_calls) == 1
|
||||
assert completion_calls[0].kwargs["where"]["id"]["in"] == ["job-123"]
|
||||
assert completion_calls[0].kwargs["where"] == {
|
||||
"id": "job-123",
|
||||
"status": {"in": ["queued", "in_progress"]},
|
||||
}
|
||||
assert completion_calls[0].kwargs["data"]["status"] == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -423,6 +426,7 @@ class TestCheckResponsesCost:
|
|||
mock_job.id = "job-456"
|
||||
mock_job.unified_object_id = "resp_failed"
|
||||
mock_job.created_by = "test-user"
|
||||
mock_job.file_object = {"model": "gpt-4o", "id": "resp_failed"}
|
||||
|
||||
mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(
|
||||
return_value=[mock_job]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
"""Unit tests for internal-call metadata forwarding: budget-reservation stripping and origin stamping."""
|
||||
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
|
||||
from litellm.litellm_core_utils.internal_call_metadata import (
|
||||
forwarded_internal_call_metadata,
|
||||
is_unbilled_non_inference_call,
|
||||
is_unbilled_non_inference_call_from_params,
|
||||
sanitized_forwardable_call_metadata,
|
||||
)
|
||||
from litellm.types.utils import SHADOW_EVAL_ROUTER_CALL_ORIGIN
|
||||
from litellm.types.utils import (
|
||||
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN,
|
||||
SHADOW_EVAL_ROUTER_CALL_ORIGIN,
|
||||
)
|
||||
|
||||
PARENT = {
|
||||
"user_api_key": "sk-hash",
|
||||
|
|
@ -119,3 +125,69 @@ class TestSubCallMetadataSanitization:
|
|||
assert sanitized_auth.team_id == "team-1"
|
||||
assert sanitized_auth.api_key == auth.api_key
|
||||
assert auth.budget_reservation == {"reserved_cost": 1.0}
|
||||
|
||||
|
||||
class TestNonInferenceCallBilling:
|
||||
"""A read of a stored object replays the usage of the call that created it, so it is free. A
|
||||
background create returns no usage, so its reads are where the job's spend shows up: the cost
|
||||
poll's stamped read bills it, and any unmarked read (the SDK, a proxy with no database) bills
|
||||
it too, because nothing else ever will."""
|
||||
|
||||
BACKGROUND = {"id": "resp_1", "background": True, "status": "completed"}
|
||||
FOREGROUND = {"id": "resp_2", "background": False, "status": "completed"}
|
||||
COST_POLL = {INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN}
|
||||
STATUS_POLL = {INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN}
|
||||
MANAGED_READ = {"user_api_key": "sk-hash", MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: True}
|
||||
|
||||
def test_unmarked_read_of_a_background_response_is_billed(self):
|
||||
assert is_unbilled_non_inference_call("aget_responses", None, self.BACKGROUND) is False
|
||||
assert is_unbilled_non_inference_call("aget_responses", {"user_api_key": "sk-hash"}, self.BACKGROUND) is False
|
||||
assert is_unbilled_non_inference_call_from_params("aget_responses", {}, self.BACKGROUND) is False
|
||||
|
||||
def test_managed_read_and_status_poll_of_a_background_response_are_free(self):
|
||||
assert is_unbilled_non_inference_call("aget_responses", self.MANAGED_READ, self.BACKGROUND) is True
|
||||
assert is_unbilled_non_inference_call("acancel_responses", self.MANAGED_READ, self.BACKGROUND) is True
|
||||
assert is_unbilled_non_inference_call("aget_responses", self.STATUS_POLL, self.BACKGROUND) is True
|
||||
assert is_unbilled_non_inference_call_from_params(
|
||||
"aget_responses", {"metadata": self.MANAGED_READ}, self.BACKGROUND
|
||||
) is True
|
||||
assert is_unbilled_non_inference_call_from_params(
|
||||
"aget_responses", {"litellm_metadata": self.STATUS_POLL}, self.BACKGROUND
|
||||
) is True
|
||||
|
||||
def test_a_marker_that_is_not_true_does_not_waive_the_charge(self):
|
||||
not_true = {MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: "true"}
|
||||
assert is_unbilled_non_inference_call("aget_responses", not_true, self.BACKGROUND) is False
|
||||
|
||||
def test_cost_poll_read_is_billed_even_when_marked(self):
|
||||
assert is_unbilled_non_inference_call("aget_responses", self.COST_POLL, self.BACKGROUND) is False
|
||||
assert is_unbilled_non_inference_call(
|
||||
"aget_responses", {**self.MANAGED_READ, **self.COST_POLL}, self.BACKGROUND
|
||||
) is False
|
||||
assert is_unbilled_non_inference_call_from_params(
|
||||
"aget_responses", {"litellm_metadata": self.COST_POLL}, self.FOREGROUND
|
||||
) is False
|
||||
|
||||
def test_read_of_a_foreground_response_is_free_whatever_it_carries(self):
|
||||
assert is_unbilled_non_inference_call("aget_responses", None, self.FOREGROUND) is True
|
||||
assert is_unbilled_non_inference_call("aget_responses", {"user_api_key": "sk-hash"}, self.FOREGROUND) is True
|
||||
assert is_unbilled_non_inference_call(
|
||||
"aget_responses", {INTERNAL_CALL_ORIGIN_METADATA_KEY: SHADOW_EVAL_ROUTER_CALL_ORIGIN}, self.FOREGROUND
|
||||
) is True
|
||||
|
||||
def test_inference_calls_are_billed_unless_the_proxy_tracks_their_background_response(self):
|
||||
assert is_unbilled_non_inference_call("aresponses", None, self.BACKGROUND) is False
|
||||
assert is_unbilled_non_inference_call("aresponses", self.MANAGED_READ, self.FOREGROUND) is False
|
||||
assert is_unbilled_non_inference_call_from_params("aresponses", {}, self.BACKGROUND) is False
|
||||
assert is_unbilled_non_inference_call_from_params("acompletion", {"metadata": self.MANAGED_READ}, None) is False
|
||||
|
||||
def test_a_tracked_streamed_background_create_is_free_in_every_shape_its_stream_logs(self):
|
||||
from litellm.types.llms.openai import ResponseCompletedEvent, ResponsesAPIResponse
|
||||
|
||||
finished = ResponsesAPIResponse(**{**self.BACKGROUND, "created_at": 1, "output": []})
|
||||
completed_event = ResponseCompletedEvent(type="response.completed", response=finished)
|
||||
for logged in (finished, completed_event, completed_event.model_dump(), self.BACKGROUND):
|
||||
assert is_unbilled_non_inference_call("aresponses", self.MANAGED_READ, logged) is True
|
||||
assert is_unbilled_non_inference_call_from_params(
|
||||
"aresponses", {"litellm_metadata": self.MANAGED_READ}, logged
|
||||
) is True
|
||||
|
|
|
|||
|
|
@ -7071,6 +7071,21 @@ class TestNonInferenceCallTypesAreNotBilled:
|
|||
assert payload is not None
|
||||
assert payload["total_tokens"] == 6000
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"litellm_metadata",
|
||||
[
|
||||
{"_managed_background_response": True},
|
||||
{"internal_call_origin": "background_response_status_poll"},
|
||||
],
|
||||
ids=["managed_read", "status_poll"],
|
||||
)
|
||||
def test_managed_read_of_a_background_response_is_free(self, litellm_metadata):
|
||||
"""The proxy's cost poll bills a background response it tracks once, so a read the managed files
|
||||
hook marks, or the poll's own status check, must not bill the same tokens again."""
|
||||
logging_obj = self._logging_obj("aget_responses", litellm_metadata=litellm_metadata)
|
||||
cost = logging_obj.response_cost_calculator(result=self._retrieved_response(background=True))
|
||||
assert cost == 0.0
|
||||
|
||||
def test_reading_a_foreground_response_is_still_free(self):
|
||||
"""Guards the test above against a blanket exemption: an explicit background=false read was
|
||||
already billed by its create and must stay at zero."""
|
||||
|
|
|
|||
|
|
@ -2366,17 +2366,21 @@ class TestBatchCostAttribution:
|
|||
"""CheckBatchCost rebuilds the creator's spend metadata from the managed-object row so
|
||||
the batch-cost log is attributed like a non-batch request."""
|
||||
|
||||
def _instance(self, key_row=None, team_row=None, user_row=None):
|
||||
from litellm_enterprise.proxy.common_utils.check_batch_cost import CheckBatchCost
|
||||
|
||||
def _prisma(self, key_row=None, team_row=None, user_row=None):
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=key_row)
|
||||
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
|
||||
prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row)
|
||||
return CheckBatchCost(
|
||||
proxy_logging_obj=MagicMock(),
|
||||
prisma_client=prisma,
|
||||
llm_router=MagicMock(),
|
||||
return prisma
|
||||
|
||||
def _instance(self, key_row=None, team_row=None, user_row=None, prisma=None):
|
||||
from litellm_enterprise.proxy.common_utils.managed_object_attribution import (
|
||||
ManagedObjectCreatorAttribution,
|
||||
)
|
||||
|
||||
return ManagedObjectCreatorAttribution(
|
||||
prisma_client=prisma if prisma is not None else self._prisma(key_row, team_row, user_row),
|
||||
poller_name="CheckBatchCost",
|
||||
)
|
||||
|
||||
def _job(self, **overrides):
|
||||
|
|
@ -2402,7 +2406,7 @@ class TestBatchCostAttribution:
|
|||
user_row=SimpleNamespace(user_email="alice@example.com", user_alias=None),
|
||||
)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(), "batch-1")
|
||||
|
||||
assert metadata["user_api_key"] == "hash-alice"
|
||||
assert metadata["user_api_key_hash"] == "hash-alice"
|
||||
|
|
@ -2419,7 +2423,7 @@ class TestBatchCostAttribution:
|
|||
instance = self._instance()
|
||||
job = self._job(api_key=None, request_tags=None)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(job, "batch-1")
|
||||
metadata = await instance.batch_metadata(job, "batch-1")
|
||||
|
||||
assert metadata["user_api_key"] is None
|
||||
assert metadata["user_api_key_user_id"] == "alice"
|
||||
|
|
@ -2432,15 +2436,32 @@ class TestBatchCostAttribution:
|
|||
a None user_id) and the key hash still drives key-level attribution."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
instance = self._instance(key_row=SimpleNamespace(key_alias="svc-key"))
|
||||
prisma = self._prisma(key_row=SimpleNamespace(key_alias="svc-key"))
|
||||
instance = self._instance(prisma=prisma)
|
||||
job = self._job(created_by=None)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(job, "batch-1")
|
||||
metadata = await instance.batch_metadata(job, "batch-1")
|
||||
|
||||
assert metadata["user_api_key"] == "hash-alice"
|
||||
assert metadata["user_api_key_user_id"] is None
|
||||
assert metadata["user_api_key_alias"] == "svc-key"
|
||||
instance.prisma_client.db.litellm_usertable.find_unique.assert_not_called()
|
||||
prisma.db.litellm_usertable.find_unique.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_metadata_keeps_the_creator_column_and_adds_no_budgets(self):
|
||||
"""The batch cost event keeps the user id and fields the batch poll has always written,
|
||||
even though background responses read budgets and drop key-scoped creators off the same rows."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
instance = self._instance(
|
||||
key_row=SimpleNamespace(key_alias="svc-key", spend=4.0, max_budget=5.0),
|
||||
team_row=SimpleNamespace(team_alias="Team Alpha", organization_id=None, spend=40.0, max_budget=50.0),
|
||||
)
|
||||
|
||||
metadata = await instance.batch_metadata(self._job(created_by="key:hash-alice"), "batch-1")
|
||||
|
||||
assert metadata["user_api_key_user_id"] == "key:hash-alice"
|
||||
assert [field for field in metadata if field.endswith(("_spend", "_max_budget"))] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_drops_non_string_tags(self):
|
||||
|
|
@ -2449,7 +2470,7 @@ class TestBatchCostAttribution:
|
|||
instance = self._instance()
|
||||
job = self._job(request_tags=["env:prod", 7, None, "team:ml"])
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(job, "batch-1")
|
||||
metadata = await instance.batch_metadata(job, "batch-1")
|
||||
|
||||
assert metadata["tags"] == ["env:prod", "team:ml"]
|
||||
|
||||
|
|
@ -2457,10 +2478,11 @@ class TestBatchCostAttribution:
|
|||
async def test_key_alias_lookup_failure_does_not_break_attribution(self):
|
||||
"""An alias lookup failure must not lose the spend row; the key hash and team still
|
||||
attribute it."""
|
||||
instance = self._instance()
|
||||
instance.prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(side_effect=Exception("db down"))
|
||||
prisma = self._prisma()
|
||||
prisma.db.litellm_verificationtoken.find_unique = AsyncMock(side_effect=Exception("db down"))
|
||||
instance = self._instance(prisma=prisma)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(), "batch-1")
|
||||
|
||||
assert metadata["user_api_key"] == "hash-alice"
|
||||
assert metadata.get("user_api_key_alias") is None
|
||||
|
|
@ -2469,7 +2491,7 @@ class TestBatchCostAttribution:
|
|||
async def test_cli_session_batch_keeps_its_alias_without_a_key_row(self):
|
||||
instance = self._instance(key_row=None)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(api_key="cli-session-alice"), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(api_key="cli-session-alice"), "batch-1")
|
||||
|
||||
assert metadata["user_api_key"] == "cli-session-alice"
|
||||
assert metadata["user_api_key_alias"] == "cli-session-alice"
|
||||
|
|
@ -2478,7 +2500,7 @@ class TestBatchCostAttribution:
|
|||
async def test_raw_cli_session_token_on_a_legacy_batch_row_is_not_treated_as_the_alias(self):
|
||||
instance = self._instance(key_row=None)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(
|
||||
metadata = await instance.batch_metadata(
|
||||
self._job(api_key="cli-session-Qm7xJ2kP9sLw4vT1nR8yAa"), "batch-1"
|
||||
)
|
||||
|
||||
|
|
@ -2496,7 +2518,7 @@ class TestBatchCostAttribution:
|
|||
user_row=SimpleNamespace(user_email="alice@example.com", user_alias="Alice Chen"),
|
||||
)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(), "batch-1")
|
||||
|
||||
assert metadata["user_api_key_alias"] == "Alice Chen"
|
||||
assert metadata["user_api_key"] == "hash-alice"
|
||||
|
|
@ -2512,7 +2534,7 @@ class TestBatchCostAttribution:
|
|||
user_row=SimpleNamespace(user_email="alice@example.com", user_alias="Alice Chen"),
|
||||
)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(), "batch-1")
|
||||
|
||||
assert metadata["user_api_key_alias"] == "Alice Chen"
|
||||
|
||||
|
|
@ -2527,7 +2549,7 @@ class TestBatchCostAttribution:
|
|||
user_row=SimpleNamespace(user_email="alice@example.com", user_alias="Alice Chen"),
|
||||
)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(), "batch-1")
|
||||
|
||||
assert metadata["user_api_key_alias"] == "prod-key"
|
||||
|
||||
|
|
@ -2542,7 +2564,7 @@ class TestBatchCostAttribution:
|
|||
team_row=SimpleNamespace(team_alias="Team Alpha", organization_id="org-team"),
|
||||
)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(org_id="org-at-creation"), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(org_id="org-at-creation"), "batch-1")
|
||||
|
||||
assert metadata["user_api_key_org_id"] == "org-at-creation"
|
||||
|
||||
|
|
@ -2557,7 +2579,7 @@ class TestBatchCostAttribution:
|
|||
team_row=SimpleNamespace(team_alias="Team Alpha", organization_id="org-team"),
|
||||
)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(), "batch-1")
|
||||
|
||||
assert metadata["user_api_key_org_id"] == "org-42"
|
||||
|
||||
|
|
@ -2572,7 +2594,7 @@ class TestBatchCostAttribution:
|
|||
team_row=SimpleNamespace(team_alias="Team Alpha", organization_id="org-team"),
|
||||
)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(), "batch-1")
|
||||
|
||||
assert metadata["user_api_key_org_id"] == "org-team"
|
||||
|
||||
|
|
@ -2582,12 +2604,11 @@ class TestBatchCostAttribution:
|
|||
organization: the two lookups fail independently, so org spend still lands."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
instance = self._instance(
|
||||
team_row=SimpleNamespace(team_alias="Team Alpha", organization_id="org-team"),
|
||||
)
|
||||
instance.prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(side_effect=Exception("db down"))
|
||||
prisma = self._prisma(team_row=SimpleNamespace(team_alias="Team Alpha", organization_id="org-team"))
|
||||
prisma.db.litellm_verificationtoken.find_unique = AsyncMock(side_effect=Exception("db down"))
|
||||
instance = self._instance(prisma=prisma)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(), "batch-1")
|
||||
|
||||
assert metadata["user_api_key_org_id"] == "org-team"
|
||||
|
||||
|
|
@ -2602,7 +2623,7 @@ class TestBatchCostAttribution:
|
|||
team_row=SimpleNamespace(team_alias="Team Alpha", organization_id=None),
|
||||
)
|
||||
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(), "batch-1")
|
||||
|
||||
assert "user_api_key_org_id" not in metadata
|
||||
|
||||
|
|
@ -2625,7 +2646,7 @@ class TestBatchCostAttribution:
|
|||
key_row=SimpleNamespace(key_alias="prod-key"),
|
||||
user_row=SimpleNamespace(user_email="alice@example.com", user_alias=None),
|
||||
)
|
||||
metadata = await instance._build_creator_attribution_metadata(self._job(api_key=token_hash), "batch-1")
|
||||
metadata = await instance.batch_metadata(self._job(api_key=token_hash), "batch-1")
|
||||
|
||||
assert metadata["user_api_key"] == token_hash
|
||||
assert metadata["user_api_key_hash"] == token_hash
|
||||
|
|
|
|||
|
|
@ -3,15 +3,48 @@ Unit tests for CheckResponsesCost class
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from prisma.errors import UniqueViolationError
|
||||
from litellm_enterprise.proxy.common_utils.check_responses_cost import BilledBackgroundResponse, BilledCharge
|
||||
|
||||
from litellm.constants import MAX_OBJECTS_PER_POLL_CYCLE
|
||||
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
|
||||
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
|
||||
|
||||
|
||||
class _RecordingBilledLogger:
|
||||
"""Stands in for the spend logger so a test reads the billing events a poller wrote, and can
|
||||
make the next writes raise."""
|
||||
|
||||
def __init__(self, failures: tuple[BaseException, ...] = (), cost: float = 0.25) -> None:
|
||||
self.billed: tuple[BilledBackgroundResponse, ...] = ()
|
||||
self._failures = failures
|
||||
self.charge: Final = BilledCharge(cost=cost, logging_obj=MagicMock())
|
||||
|
||||
async def __call__(self, billed: BilledBackgroundResponse) -> BilledCharge:
|
||||
await asyncio.sleep(0)
|
||||
if self._failures:
|
||||
failure, *remaining = self._failures
|
||||
self._failures = tuple(remaining)
|
||||
raise failure
|
||||
self.billed = (*self.billed, billed)
|
||||
return self.charge
|
||||
|
||||
|
||||
def _deployment(model_id: str) -> Deployment:
|
||||
return Deployment(
|
||||
model_name="gpt-5.6",
|
||||
litellm_params=LiteLLM_Params(model="openai/gpt-5.6"),
|
||||
model_info=ModelInfo(id=model_id),
|
||||
)
|
||||
|
||||
|
||||
class _RecordingManagedObjectTable:
|
||||
|
|
@ -27,6 +60,114 @@ class _RecordingManagedObjectTable:
|
|||
return len(self.rows)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StoredResponse:
|
||||
id: str
|
||||
unified_object_id: str
|
||||
status: str
|
||||
model_object_id: str = ""
|
||||
file_purpose: str = "response"
|
||||
file_object: Mapping[str, object] = dataclasses.field(default_factory=lambda: {"model": "gpt-5"})
|
||||
created_by: str | None = None
|
||||
api_key: str | None = None
|
||||
team_id: str | None = None
|
||||
org_id: str | None = None
|
||||
request_tags: object = None
|
||||
end_user_id: str | None = None
|
||||
|
||||
|
||||
class _InMemoryManagedObjectTable:
|
||||
"""Applies where filters and status writes the way Postgres does, so two pollers sharing it
|
||||
race over the same rows."""
|
||||
|
||||
def __init__(self, rows: tuple[_StoredResponse, ...]) -> None:
|
||||
self._rows: Final = rows
|
||||
self.statuses: Final = {row.id: row.status for row in rows}
|
||||
self.model_object_ids: Final = {row.id: row.model_object_id for row in rows}
|
||||
|
||||
def _matches(self, row: _StoredResponse, where: Mapping[str, object]) -> bool:
|
||||
def value_of(field: str) -> object:
|
||||
if field == "status":
|
||||
return self.statuses[row.id]
|
||||
if field == "model_object_id":
|
||||
return self.model_object_ids[row.id]
|
||||
return getattr(row, field)
|
||||
|
||||
def holds(field: str, condition: object) -> bool:
|
||||
if field == "OR" and isinstance(condition, list):
|
||||
return any(self._matches(row, alternative) for alternative in condition)
|
||||
if isinstance(condition, dict):
|
||||
return value_of(field) in condition["in"]
|
||||
return value_of(field) == condition
|
||||
|
||||
return all(holds(field, condition) for field, condition in where.items())
|
||||
|
||||
def _current(self, where: Mapping[str, object]) -> list[_StoredResponse]:
|
||||
return [
|
||||
dataclasses.replace(row, status=self.statuses[row.id], model_object_id=self.model_object_ids[row.id])
|
||||
for row in self._rows
|
||||
if self._matches(row, where)
|
||||
]
|
||||
|
||||
async def find_many(self, where: Mapping[str, object], **_: object) -> list[_StoredResponse]:
|
||||
return self._current(where)
|
||||
|
||||
async def find_first(self, where: Mapping[str, object]) -> _StoredResponse | None:
|
||||
return next(iter(self._current(where)), None)
|
||||
|
||||
async def update_many(self, where: Mapping[str, object], data: Mapping[str, str]) -> int:
|
||||
matched: Final = self._current(where)
|
||||
if "model_object_id" in data and any(
|
||||
other.id not in {row.id for row in matched} and value == data["model_object_id"]
|
||||
for other, value in ((other, self.model_object_ids[other.id]) for other in self._rows)
|
||||
):
|
||||
raise UniqueViolationError({"user_facing_error": {"error_code": "P2002"}})
|
||||
self.statuses.update({row.id: data["status"] for row in matched if "status" in data})
|
||||
self.model_object_ids.update({row.id: data["model_object_id"] for row in matched if "model_object_id" in data})
|
||||
return len(matched)
|
||||
|
||||
|
||||
def _poller_over(table: _InMemoryManagedObjectTable, billed_logger: _RecordingBilledLogger):
|
||||
from litellm_enterprise.proxy.common_utils.check_responses_cost import CheckResponsesCost
|
||||
|
||||
prisma_client: Final = MagicMock()
|
||||
prisma_client.db.litellm_managedobjecttable = table
|
||||
poller: Final = CheckResponsesCost(
|
||||
proxy_logging_obj=MagicMock(),
|
||||
prisma_client=prisma_client,
|
||||
llm_router=MagicMock(),
|
||||
log_billed_response=billed_logger,
|
||||
)
|
||||
poller._expire_stale_rows = AsyncMock(return_value=0)
|
||||
return poller
|
||||
|
||||
|
||||
def _terminal_response(status: str = "completed") -> ResponsesAPIResponse:
|
||||
return ResponsesAPIResponse(
|
||||
id="resp_upstream",
|
||||
object="response",
|
||||
status=status,
|
||||
created_at=int(datetime.now().timestamp()),
|
||||
output=[],
|
||||
usage=ResponseAPIUsage(input_tokens=100, output_tokens=50, total_tokens=150),
|
||||
)
|
||||
|
||||
|
||||
def _claim(job_id: str) -> tuple[Mapping[str, object], Mapping[str, object]]:
|
||||
return ({"id": job_id, "status": {"in": ["queued", "in_progress"]}}, {"status": "completed"})
|
||||
|
||||
|
||||
_READ_OF_A_BACKGROUND_RESPONSE: Final = MappingProxyType({"background": True})
|
||||
|
||||
|
||||
def _is_billed(litellm_metadata: Mapping[str, object]) -> bool:
|
||||
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call
|
||||
|
||||
return not is_unbilled_non_inference_call(
|
||||
"aget_responses", dict(litellm_metadata), _READ_OF_A_BACKGROUND_RESPONSE
|
||||
)
|
||||
|
||||
|
||||
class TestCheckResponsesCost:
|
||||
"""Test suite for CheckResponsesCost class"""
|
||||
|
||||
|
|
@ -50,12 +191,16 @@ class TestCheckResponsesCost:
|
|||
"""Create a mock LLM Router"""
|
||||
router = MagicMock()
|
||||
router.aget_responses = AsyncMock()
|
||||
router.get_deployment = MagicMock()
|
||||
router.get_deployment = MagicMock(side_effect=lambda model_id: _deployment(model_id))
|
||||
return router
|
||||
|
||||
@pytest.fixture
|
||||
def billed_logger(self) -> _RecordingBilledLogger:
|
||||
return _RecordingBilledLogger()
|
||||
|
||||
@pytest.fixture
|
||||
def check_responses_cost_instance(
|
||||
self, mock_proxy_logging_obj, mock_prisma_client, mock_llm_router
|
||||
self, mock_proxy_logging_obj, mock_prisma_client, mock_llm_router, billed_logger
|
||||
):
|
||||
"""Create a CheckResponsesCost instance with mocked dependencies"""
|
||||
from litellm_enterprise.proxy.common_utils.check_responses_cost import (
|
||||
|
|
@ -66,6 +211,7 @@ class TestCheckResponsesCost:
|
|||
proxy_logging_obj=mock_proxy_logging_obj,
|
||||
prisma_client=mock_prisma_client,
|
||||
llm_router=mock_llm_router,
|
||||
log_billed_response=billed_logger,
|
||||
)
|
||||
# Mock _expire_stale_rows (raw SQL) so _cleanup_stale_managed_objects
|
||||
# succeeds without a real DB. Individual tests can override this.
|
||||
|
|
@ -121,7 +267,7 @@ class TestCheckResponsesCost:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_responses_cost_with_completed_response(
|
||||
self, check_responses_cost_instance, mock_prisma_client, mock_llm_router
|
||||
self, check_responses_cost_instance, mock_prisma_client, mock_llm_router, billed_logger
|
||||
):
|
||||
"""Test check_responses_cost with a completed response"""
|
||||
# Mock job with response ID
|
||||
|
|
@ -150,23 +296,22 @@ class TestCheckResponsesCost:
|
|||
)
|
||||
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(
|
||||
return_value=0
|
||||
return_value=1
|
||||
)
|
||||
|
||||
# Run the check with mocked litellm.aget_responses
|
||||
with patch("litellm.aget_responses", new_callable=AsyncMock) as mock_aget:
|
||||
mock_aget.return_value = mock_response
|
||||
|
||||
await check_responses_cost_instance.check_responses_cost()
|
||||
|
||||
# update_many should only contain the job completion call
|
||||
calls = (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args_list
|
||||
)
|
||||
assert len(calls) == 1
|
||||
completion_call = calls[0]
|
||||
assert completion_call[1]["data"]["status"] == "completed"
|
||||
assert completion_call[1]["where"]["id"]["in"] == ["job-123"]
|
||||
assert [(call[1]["where"], call[1]["data"]) for call in calls] == [_claim("job-123")]
|
||||
assert [_is_billed(call[1]["litellm_metadata"]) for call in mock_aget.call_args_list] == [False]
|
||||
assert [(billed.response.id, billed.response.usage) for billed in billed_logger.billed] == [
|
||||
("resp_test_123", mock_response.usage)
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_responses_cost_with_failed_response(
|
||||
|
|
@ -399,7 +544,8 @@ class TestCheckResponsesCost:
|
|||
mock_job.id = "job-404"
|
||||
mock_job.file_object = {"model": "gpt-4o", "id": encoded_response_id}
|
||||
|
||||
mock_llm_router.get_deployment.return_value = {"model_id": "deployment-404"}
|
||||
mock_llm_router.get_deployment.side_effect = None
|
||||
mock_llm_router.get_deployment.return_value = _deployment("deployment-404")
|
||||
mock_llm_router.aget_responses = AsyncMock(
|
||||
side_effect=litellm.NotFoundError(
|
||||
message="Response with id 'resp_upstream_404' not found.", model="gpt-5", llm_provider="openai"
|
||||
|
|
@ -461,7 +607,8 @@ class TestCheckResponsesCost:
|
|||
mock_job.id = "job-404-mapped"
|
||||
mock_job.file_object = {"model": "gpt-5.5", "id": encoded_response_id}
|
||||
|
||||
mock_llm_router.get_deployment.return_value = {"model_id": "deployment-404-mapped"}
|
||||
mock_llm_router.get_deployment.side_effect = None
|
||||
mock_llm_router.get_deployment.return_value = _deployment("deployment-404-mapped")
|
||||
mock_llm_router.aget_responses = AsyncMock(side_effect=mapped.value)
|
||||
|
||||
table = _RecordingManagedObjectTable(rows=(mock_job,))
|
||||
|
|
@ -493,6 +640,7 @@ class TestCheckResponsesCost:
|
|||
mock_job.id = "job-404-fallback"
|
||||
mock_job.file_object = {"model": "gpt-4o", "id": encoded_response_id}
|
||||
|
||||
mock_llm_router.get_deployment.side_effect = None
|
||||
mock_llm_router.get_deployment.return_value = None
|
||||
|
||||
table = _RecordingManagedObjectTable(rows=(mock_job,))
|
||||
|
|
@ -531,7 +679,8 @@ class TestCheckResponsesCost:
|
|||
mock_job.id = "job-404-other"
|
||||
mock_job.file_object = {"model": "azure-gpt-5", "id": encoded_response_id}
|
||||
|
||||
mock_llm_router.get_deployment.return_value = {"model_id": "deployment-renamed"}
|
||||
mock_llm_router.get_deployment.side_effect = None
|
||||
mock_llm_router.get_deployment.return_value = _deployment("deployment-renamed")
|
||||
mock_llm_router.aget_responses = AsyncMock(
|
||||
side_effect=litellm.NotFoundError(
|
||||
message="Resource not found", model="gpt-5", llm_provider="azure"
|
||||
|
|
@ -550,7 +699,7 @@ class TestCheckResponsesCost:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_responses_cost_deployment_lookup_error_skips_only_that_job(
|
||||
self, check_responses_cost_instance, mock_prisma_client, mock_llm_router
|
||||
self, check_responses_cost_instance, mock_prisma_client, mock_llm_router, billed_logger
|
||||
):
|
||||
"""A deployment lookup that raises skips its own job and the cycle still records the next job."""
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
|
|
@ -576,7 +725,7 @@ class TestCheckResponsesCost:
|
|||
|
||||
mock_llm_router.get_deployment.side_effect = [
|
||||
Exception("Model invalid format - <class 'str'>"),
|
||||
{"model_id": "deployment-healthy"},
|
||||
_deployment("deployment-healthy"),
|
||||
]
|
||||
mock_llm_router.aget_responses = AsyncMock(
|
||||
return_value=ResponsesAPIResponse(
|
||||
|
|
@ -596,9 +745,11 @@ class TestCheckResponsesCost:
|
|||
await check_responses_cost_instance.check_responses_cost()
|
||||
|
||||
mock_sdk_aget.assert_not_called()
|
||||
mock_llm_router.aget_responses.assert_awaited_once()
|
||||
assert mock_llm_router.aget_responses.call_args.kwargs["response_id"] == healthy_response_id
|
||||
assert table.updates == (({"id": {"in": ["job-healthy"]}}, {"status": "completed"}),)
|
||||
assert [call.kwargs["response_id"] for call in mock_llm_router.aget_responses.call_args_list] == [
|
||||
healthy_response_id,
|
||||
]
|
||||
assert table.updates == (_claim("job-healthy"),)
|
||||
assert [billed.response.id for billed in billed_logger.billed] == [healthy_response_id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_responses_cost_non_404_error_keeps_row_for_retry(
|
||||
|
|
@ -620,7 +771,8 @@ class TestCheckResponsesCost:
|
|||
mock_job.id = "job-500"
|
||||
mock_job.file_object = {"model": "gpt-4o", "id": encoded_response_id}
|
||||
|
||||
mock_llm_router.get_deployment.return_value = {"model_id": "deployment-500"}
|
||||
mock_llm_router.get_deployment.side_effect = None
|
||||
mock_llm_router.get_deployment.return_value = _deployment("deployment-500")
|
||||
mock_llm_router.aget_responses = AsyncMock(
|
||||
side_effect=litellm.InternalServerError(
|
||||
message="boom", model="gpt-5", llm_provider="openai"
|
||||
|
|
@ -639,7 +791,7 @@ class TestCheckResponsesCost:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_responses_cost_multiple_jobs(
|
||||
self, check_responses_cost_instance, mock_prisma_client
|
||||
self, check_responses_cost_instance, mock_prisma_client, billed_logger
|
||||
):
|
||||
"""Test check_responses_cost with multiple jobs"""
|
||||
# Mock multiple jobs
|
||||
|
|
@ -702,25 +854,23 @@ class TestCheckResponsesCost:
|
|||
)
|
||||
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(
|
||||
return_value=0
|
||||
return_value=1
|
||||
)
|
||||
|
||||
# Run the check
|
||||
with patch("litellm.aget_responses", new_callable=AsyncMock) as mock_aget:
|
||||
mock_aget.side_effect = [mock_response1, mock_response2, mock_response3]
|
||||
|
||||
await check_responses_cost_instance.check_responses_cost()
|
||||
|
||||
# update_many should only contain the job completion call
|
||||
calls = (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args_list
|
||||
)
|
||||
assert len(calls) == 1
|
||||
completion_call = calls[0]
|
||||
assert len(completion_call[1]["where"]["id"]["in"]) == 2
|
||||
assert "job-1" in completion_call[1]["where"]["id"]["in"]
|
||||
assert "job-3" in completion_call[1]["where"]["id"]["in"]
|
||||
assert "job-2" not in completion_call[1]["where"]["id"]["in"]
|
||||
assert [(call[1]["where"], call[1]["data"]) for call in calls] == [_claim("job-1"), _claim("job-3")]
|
||||
assert [_is_billed(call[1]["litellm_metadata"]) for call in mock_aget.call_args_list] == [False, False, False]
|
||||
assert [(billed.response.id, billed.response.usage) for billed in billed_logger.billed] == [
|
||||
("resp_test_1", mock_response1.usage),
|
||||
("resp_test_3", mock_response3.usage),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encoded_response_id_is_fetched_through_router(
|
||||
|
|
@ -787,9 +937,7 @@ class TestCheckResponsesCost:
|
|||
calls = (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args_list
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1]["data"]["status"] == "completed"
|
||||
assert calls[0][1]["where"]["id"]["in"] == ["job-router"]
|
||||
assert [(call[1]["where"], call[1]["data"]) for call in calls] == [_claim("job-router")]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encrypted_response_id_is_fetched_through_router(
|
||||
|
|
@ -860,8 +1008,7 @@ class TestCheckResponsesCost:
|
|||
calls = (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args_list
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1]["where"]["id"]["in"] == ["job-encrypted"]
|
||||
assert [(call[1]["where"], call[1]["data"]) for call in calls] == [_claim("job-encrypted")]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_id_without_model_id_uses_sdk(
|
||||
|
|
@ -955,9 +1102,7 @@ class TestCheckResponsesCost:
|
|||
calls = (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args_list
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1]["data"]["status"] == "completed"
|
||||
assert calls[0][1]["where"]["id"]["in"] == ["job-missing-deployment"]
|
||||
assert [(call[1]["where"], call[1]["data"]) for call in calls] == [_claim("job-missing-deployment")]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_responses_cost_with_incomplete_response(
|
||||
|
|
@ -993,9 +1138,7 @@ class TestCheckResponsesCost:
|
|||
calls = (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args_list
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1]["data"]["status"] == "completed"
|
||||
assert calls[0][1]["where"]["id"]["in"] == ["job-incomplete"]
|
||||
assert [(call[1]["where"], call[1]["data"]) for call in calls] == [_claim("job-incomplete")]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_responses_cost_no_model_in_file_object(
|
||||
|
|
@ -1012,7 +1155,7 @@ class TestCheckResponsesCost:
|
|||
return_value=[mock_job]
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(
|
||||
return_value=0
|
||||
return_value=1
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
|
|
@ -1028,12 +1171,12 @@ class TestCheckResponsesCost:
|
|||
assert "model_group" not in call_kwargs.get("litellm_metadata", {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_stamps_internal_call_origin_so_the_read_is_billed(
|
||||
self, check_responses_cost_instance, mock_prisma_client
|
||||
async def test_poll_stamps_the_billing_event_so_it_is_priced_and_its_status_read_is_free(
|
||||
self, check_responses_cost_instance, mock_prisma_client, billed_logger
|
||||
):
|
||||
"""A background create returns queued with no usage, so this poll's retrieval is the only
|
||||
place the job's spend is ever seen. Without the origin stamp it is priced at zero like a
|
||||
user-facing read (LIT-5602) and the job is never billed."""
|
||||
"""A background create returns queued with no usage, so the event the poll logs from the
|
||||
finished response is the only place the job's spend is ever seen. Without the origin stamp
|
||||
it is priced at zero like a user-facing read (LIT-5602) and the job is never billed."""
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
||||
from litellm.litellm_core_utils.internal_call_metadata import (
|
||||
is_unbilled_non_inference_call,
|
||||
|
|
@ -1049,7 +1192,7 @@ class TestCheckResponsesCost:
|
|||
return_value=[mock_job]
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(
|
||||
return_value=0
|
||||
return_value=1
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
|
|
@ -1059,8 +1202,549 @@ class TestCheckResponsesCost:
|
|||
mock_aget.return_value = mock_response
|
||||
await check_responses_cost_instance.check_responses_cost()
|
||||
|
||||
metadata = mock_aget.call_args[1]["litellm_metadata"]
|
||||
foreground_read = {"background": False}
|
||||
assert metadata[INTERNAL_CALL_ORIGIN_METADATA_KEY] == "background_response_cost_poll"
|
||||
assert is_unbilled_non_inference_call("aget_responses", metadata, foreground_read) is False
|
||||
assert is_unbilled_non_inference_call("aget_responses", None, foreground_read) is True
|
||||
assert [_is_billed(call[1]["litellm_metadata"]) for call in mock_aget.call_args_list] == [False]
|
||||
(billed,) = billed_logger.billed
|
||||
assert billed.metadata[INTERNAL_CALL_ORIGIN_METADATA_KEY] == "background_response_cost_poll"
|
||||
assert (
|
||||
is_unbilled_non_inference_call("aget_responses", dict(billed.metadata), _READ_OF_A_BACKGROUND_RESPONSE)
|
||||
is False
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_bills_the_response_to_the_creating_key_team_org_and_tags(
|
||||
self, check_responses_cost_instance, mock_prisma_client, billed_logger
|
||||
):
|
||||
"""The poll's billing event is the one billed event of a background job, so its spend row must name
|
||||
the key, team, org, user, and tags of the create it settles, read off the managed object row,
|
||||
never the poller itself."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_job = MagicMock()
|
||||
mock_job.unified_object_id = "resp_test_attributed"
|
||||
mock_job.id = "job-attributed"
|
||||
mock_job.created_by = "alice"
|
||||
mock_job.api_key = "hash-alice"
|
||||
mock_job.team_id = "team-alpha"
|
||||
mock_job.org_id = "org-1"
|
||||
mock_job.request_tags = ["env:prod"]
|
||||
mock_job.file_object = {"model": "gpt-5", "id": "resp_test_attributed"}
|
||||
|
||||
mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(return_value=[mock_job])
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(return_value=1)
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
|
||||
return_value=SimpleNamespace(key_alias="prod-key", organization_id=None, spend=4.0, max_budget=5.0)
|
||||
)
|
||||
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
|
||||
return_value=SimpleNamespace(team_alias="Team Alpha", organization_id=None, spend=40.0, max_budget=50.0)
|
||||
)
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=SimpleNamespace(user_email="alice@example.com", user_alias=None, spend=1.0, max_budget=2.0)
|
||||
)
|
||||
|
||||
with patch("litellm.aget_responses", new_callable=AsyncMock) as mock_aget:
|
||||
mock_aget.return_value = MagicMock(status="completed")
|
||||
await check_responses_cost_instance.check_responses_cost()
|
||||
|
||||
(billed,) = billed_logger.billed
|
||||
metadata = billed.metadata
|
||||
assert metadata["user_api_key"] == "hash-alice"
|
||||
assert metadata["user_api_key_hash"] == "hash-alice"
|
||||
assert metadata["user_api_key_user_id"] == "alice"
|
||||
assert metadata["user_api_key_user_email"] == "alice@example.com"
|
||||
assert metadata["user_api_key_team_id"] == "team-alpha"
|
||||
assert metadata["user_api_key_org_id"] == "org-1"
|
||||
assert metadata["user_api_key_alias"] == "prod-key"
|
||||
assert metadata["user_api_key_team_alias"] == "Team Alpha"
|
||||
assert (metadata["user_api_key_spend"], metadata["user_api_key_max_budget"]) == (4.0, 5.0)
|
||||
assert (metadata["user_api_key_team_spend"], metadata["user_api_key_team_max_budget"]) == (40.0, 50.0)
|
||||
assert (metadata["user_api_key_user_spend"], metadata["user_api_key_user_max_budget"]) == (1.0, 2.0)
|
||||
assert metadata["tags"] == ["env:prod"]
|
||||
assert metadata["model_group"] == "gpt-5"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_pollers_racing_over_one_completed_response_bill_it_once():
|
||||
"""Every worker and pod runs its own poll, so two of them can pick up the same completed
|
||||
response in one window; only the one that moves the row out of a pending status bills it."""
|
||||
table: Final = _InMemoryManagedObjectTable(
|
||||
rows=(_StoredResponse(id="job-race", unified_object_id="resp_race", status="in_progress"),)
|
||||
)
|
||||
reads: Final[list[Mapping[str, object]]] = []
|
||||
billed_logger: Final = _RecordingBilledLogger()
|
||||
|
||||
async def provider_read(response_id: str, litellm_metadata: Mapping[str, object]) -> ResponsesAPIResponse:
|
||||
reads.append(litellm_metadata)
|
||||
await asyncio.sleep(0)
|
||||
return _terminal_response()
|
||||
|
||||
with patch("litellm.aget_responses", side_effect=provider_read):
|
||||
await asyncio.gather(
|
||||
_poller_over(table, billed_logger).check_responses_cost(),
|
||||
_poller_over(table, billed_logger).check_responses_cost(),
|
||||
)
|
||||
|
||||
assert [_is_billed(metadata) for metadata in reads] == [False, False]
|
||||
assert len(billed_logger.billed) == 1
|
||||
assert table.statuses == {"job-race": "completed"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failed_billing_event_puts_the_response_back_for_the_next_poll():
|
||||
table: Final = _InMemoryManagedObjectTable(
|
||||
rows=(_StoredResponse(id="job-retry", unified_object_id="resp_retry", status="queued"),)
|
||||
)
|
||||
billed_logger: Final = _RecordingBilledLogger(failures=(RuntimeError("spend log unavailable"),))
|
||||
poller: Final = _poller_over(table, billed_logger)
|
||||
with patch("litellm.aget_responses", new_callable=AsyncMock, return_value=_terminal_response()):
|
||||
await poller.check_responses_cost()
|
||||
assert table.statuses == {"job-retry": "queued"}
|
||||
assert billed_logger.billed == ()
|
||||
await poller.check_responses_cost()
|
||||
|
||||
assert len(billed_logger.billed) == 1
|
||||
assert table.statuses == {"job-retry": "completed"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_the_first_read_that_sees_a_response_finished_bills_it():
|
||||
"""A client polling its background response bills it on the first read that sees it finished,
|
||||
under the creating key, so it is billed even if the provider forgets it before the poll runs;
|
||||
reads before and after that one stay free."""
|
||||
table: Final = _InMemoryManagedObjectTable(
|
||||
rows=(
|
||||
_StoredResponse(
|
||||
id="job-read",
|
||||
unified_object_id="resp_read",
|
||||
status="queued",
|
||||
created_by="alice",
|
||||
api_key="hash-alice",
|
||||
team_id="team-alpha",
|
||||
),
|
||||
)
|
||||
)
|
||||
billed_logger: Final = _RecordingBilledLogger()
|
||||
poller: Final = _poller_over(table, billed_logger)
|
||||
poller.prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
|
||||
poller.prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
|
||||
poller.prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
|
||||
finished: Final = _terminal_response("completed")
|
||||
|
||||
with patch("litellm.aget_responses", side_effect=AssertionError("billing must not read the provider again")):
|
||||
costs: Final = (
|
||||
await poller.bill_if_finished(response_id="resp_read", response=_terminal_response("in_progress")),
|
||||
await poller.bill_if_finished(response_id="resp_read", response=finished),
|
||||
await poller.bill_if_finished(response_id="resp_read", response=finished),
|
||||
)
|
||||
await poller.check_responses_cost()
|
||||
|
||||
assert costs == (None, billed_logger.charge, None)
|
||||
(billed,) = billed_logger.billed
|
||||
assert billed.response.usage == finished.usage
|
||||
assert billed.response.id == "resp_read"
|
||||
assert billed.metadata["user_api_key_hash"] == "hash-alice"
|
||||
assert billed.metadata["user_api_key_team_id"] == "team-alpha"
|
||||
assert table.statuses == {"job-read": "completed"}
|
||||
|
||||
|
||||
_ROTATION_SALT_KEY: Final = "check-responses-cost-unit-test-salt"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_read_through_a_reissued_response_id_bills_the_stored_response(monkeypatch: pytest.MonkeyPatch):
|
||||
"""The proxy re-encrypts a response id on every read, so a client polling with the id its last
|
||||
read returned still bills the row stored under the id the create returned."""
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.hooks.responses_id_security import ResponsesIDSecurity
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", _ROTATION_SALT_KEY)
|
||||
issuer: Final = ResponsesIDSecurity(general_settings_reader=lambda: {}, signing_key_reader=lambda: _ROTATION_SALT_KEY)
|
||||
|
||||
def issued_id() -> str:
|
||||
return issuer._encrypt_response_id(
|
||||
ResponsesAPIResponse(id="resp_provider_job", created_at=1, output=[], status="queued"),
|
||||
UserAPIKeyAuth(user_id="alice"),
|
||||
).id
|
||||
|
||||
created_id: Final = issued_id()
|
||||
reissued_id: Final = issued_id()
|
||||
assert created_id != reissued_id
|
||||
table: Final = _InMemoryManagedObjectTable(
|
||||
rows=(
|
||||
_StoredResponse(
|
||||
id="job-rotated",
|
||||
unified_object_id=created_id,
|
||||
model_object_id="resp_provider_job",
|
||||
status="queued",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
billed_logger: Final = _RecordingBilledLogger()
|
||||
with patch("litellm.aget_responses", side_effect=AssertionError("billing must not read the provider again")):
|
||||
await _poller_over(table, billed_logger).bill_if_finished(response_id=reissued_id, response=_terminal_response())
|
||||
|
||||
assert len(billed_logger.billed) == 1
|
||||
assert table.statuses == {"job-rotated": "completed"}
|
||||
|
||||
|
||||
def _issued_ids(monkeypatch: pytest.MonkeyPatch, count: int) -> tuple[str, ...]:
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.hooks.responses_id_security import ResponsesIDSecurity
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", _ROTATION_SALT_KEY)
|
||||
issuer: Final = ResponsesIDSecurity(general_settings_reader=lambda: {}, signing_key_reader=lambda: _ROTATION_SALT_KEY)
|
||||
return tuple(
|
||||
issuer._encrypt_response_id(
|
||||
ResponsesAPIResponse(id="resp_provider_job", created_at=1, output=[], status="queued"),
|
||||
UserAPIKeyAuth(user_id="alice"),
|
||||
).id
|
||||
for _ in range(count)
|
||||
)
|
||||
|
||||
|
||||
def _stored_before_the_upgrade(created_id: str) -> _StoredResponse:
|
||||
return _StoredResponse(
|
||||
id="job-legacy", unified_object_id=created_id, model_object_id=created_id, status="queued"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_response_stored_before_the_upgrade_bills_once_whichever_issued_id_reads_it(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""A row the old proxy stored keys the response by the encrypted id the create returned, so the
|
||||
read that sends that id rekeys it by the provider id, and a read through any later id finds it
|
||||
already billed."""
|
||||
created_id, reissued_id = _issued_ids(monkeypatch, 2)
|
||||
table: Final = _InMemoryManagedObjectTable(rows=(_stored_before_the_upgrade(created_id),))
|
||||
billed_logger: Final = _RecordingBilledLogger()
|
||||
poller: Final = _poller_over(table, billed_logger)
|
||||
track_untracked: Final = AsyncMock(return_value=False)
|
||||
|
||||
with patch("litellm.aget_responses", side_effect=AssertionError("billing must not read the provider again")):
|
||||
await poller.bill_if_finished(response_id=created_id, response=_terminal_response())
|
||||
await poller.bill_if_finished(
|
||||
response_id=reissued_id, response=_terminal_response(), track_untracked=track_untracked
|
||||
)
|
||||
|
||||
assert len(billed_logger.billed) == 1
|
||||
assert table.model_object_ids == {"job-legacy": "resp_provider_job"}
|
||||
track_untracked.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"read_rows, billed_by_the_poll",
|
||||
[((), 1), ((_StoredResponse(id="job-read", unified_object_id="read-id", model_object_id="resp_provider_job", status="completed"),), 0)],
|
||||
ids=["no_read_yet", "a_read_already_billed_it"],
|
||||
)
|
||||
async def test_the_poll_bills_a_response_stored_before_the_upgrade_only_when_no_read_billed_it_first(
|
||||
monkeypatch: pytest.MonkeyPatch, read_rows: tuple[_StoredResponse, ...], billed_by_the_poll: int
|
||||
):
|
||||
"""A read through a later id misses the old row and tracks the response under its provider id,
|
||||
so the poll retires the old row unbilled when that row exists and bills it once otherwise."""
|
||||
(created_id,) = _issued_ids(monkeypatch, 1)
|
||||
table: Final = _InMemoryManagedObjectTable(rows=(_stored_before_the_upgrade(created_id), *read_rows))
|
||||
billed_logger: Final = _RecordingBilledLogger()
|
||||
|
||||
with patch("litellm.aget_responses", new_callable=AsyncMock, return_value=_terminal_response()):
|
||||
await _poller_over(table, billed_logger).check_responses_cost()
|
||||
await _poller_over(table, billed_logger).check_responses_cost()
|
||||
|
||||
assert len(billed_logger.billed) == billed_by_the_poll
|
||||
assert table.statuses["job-legacy"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_cancelled_billing_event_puts_the_response_back_for_the_next_poll():
|
||||
"""A client that disconnects mid-read cancels the billing event, so the claim is released and
|
||||
the next poll still bills the response."""
|
||||
table: Final = _InMemoryManagedObjectTable(
|
||||
rows=(_StoredResponse(id="job-cancelled", unified_object_id="resp_cancelled", status="in_progress"),)
|
||||
)
|
||||
billed_logger: Final = _RecordingBilledLogger(failures=(asyncio.CancelledError(),))
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await _poller_over(table, billed_logger).bill_if_finished(
|
||||
response_id="resp_cancelled", response=_terminal_response()
|
||||
)
|
||||
|
||||
assert billed_logger.billed == ()
|
||||
assert table.statuses == {"job-cancelled": "in_progress"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_billing_event_carries_the_creators_end_user():
|
||||
"""The charge belongs to the end user the create named, whoever reads the response later."""
|
||||
table: Final = _InMemoryManagedObjectTable(
|
||||
rows=(
|
||||
_StoredResponse(
|
||||
id="job-end-user", unified_object_id="resp_end_user", status="queued", end_user_id="customer-42"
|
||||
),
|
||||
)
|
||||
)
|
||||
billed_logger: Final = _RecordingBilledLogger()
|
||||
|
||||
await _poller_over(table, billed_logger).bill_if_finished(
|
||||
response_id="resp_end_user", response=_terminal_response()
|
||||
)
|
||||
|
||||
assert table.statuses == {"job-end-user": "completed"}
|
||||
(billed,) = billed_logger.billed
|
||||
assert billed.metadata["user_api_key_end_user_id"] == "customer-42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("created_by", "billed_user_id"),
|
||||
[("alice", "alice"), ("key:hashed-token", None)],
|
||||
ids=["key_with_a_user", "key_with_no_user_or_team"],
|
||||
)
|
||||
async def test_the_billing_event_bills_the_creating_user_and_never_a_keys_owner_scope(
|
||||
created_by: str, billed_user_id: str | None
|
||||
):
|
||||
table: Final = _InMemoryManagedObjectTable(
|
||||
rows=(
|
||||
_StoredResponse(
|
||||
id="job-owner",
|
||||
unified_object_id="resp_owner",
|
||||
status="queued",
|
||||
created_by=created_by,
|
||||
api_key="hashed-token",
|
||||
),
|
||||
)
|
||||
)
|
||||
billed_logger: Final = _RecordingBilledLogger()
|
||||
|
||||
await _poller_over(table, billed_logger).bill_if_finished(response_id="resp_owner", response=_terminal_response())
|
||||
|
||||
(billed,) = billed_logger.billed
|
||||
assert billed.metadata["user_api_key_user_id"] == billed_user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settling_from_the_deployment_bills_a_finished_response_once_from_a_free_fetch():
|
||||
"""A read the caller retargets, or a delete, bills from a fetch through the configured
|
||||
deployment, since the client controls the body it would otherwise hand in."""
|
||||
table: Final = _InMemoryManagedObjectTable(
|
||||
rows=(_StoredResponse(id="job-settle", unified_object_id="resp_settle", status="in_progress"),)
|
||||
)
|
||||
billed_logger: Final = _RecordingBilledLogger()
|
||||
poller: Final = _poller_over(table, billed_logger)
|
||||
fetched: Final = _terminal_response()
|
||||
|
||||
with patch("litellm.aget_responses", new_callable=AsyncMock, return_value=fetched) as fetch:
|
||||
charge: Final = await poller.bill_from_deployment("resp_settle")
|
||||
with patch("litellm.aget_responses", side_effect=AssertionError("a billed response is never fetched again")):
|
||||
assert await poller.bill_from_deployment("resp_settle") is None
|
||||
|
||||
assert charge == billed_logger.charge
|
||||
assert [_is_billed(call.kwargs["litellm_metadata"]) for call in fetch.call_args_list] == [False]
|
||||
(billed,) = billed_logger.billed
|
||||
assert billed.response.usage == fetched.usage
|
||||
assert table.statuses == {"job-settle": "completed"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settling_from_the_deployment_leaves_a_running_response_for_the_poll():
|
||||
table: Final = _InMemoryManagedObjectTable(
|
||||
rows=(_StoredResponse(id="job-running", unified_object_id="resp_running", status="queued"),)
|
||||
)
|
||||
billed_logger: Final = _RecordingBilledLogger()
|
||||
|
||||
with patch("litellm.aget_responses", new_callable=AsyncMock, return_value=_terminal_response("in_progress")):
|
||||
assert await _poller_over(table, billed_logger).bill_from_deployment("resp_running") is None
|
||||
|
||||
assert billed_logger.billed == ()
|
||||
assert table.statuses == {"job-running": "queued"}
|
||||
|
||||
|
||||
class _SpendCapture:
|
||||
def __init__(self) -> None:
|
||||
self.payloads: tuple[Mapping[str, object], ...] = ()
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
payload = kwargs["standard_logging_object"]
|
||||
assert isinstance(payload, Mapping)
|
||||
self.payloads = (*self.payloads, payload)
|
||||
|
||||
|
||||
def _spend_capture_logger():
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
class _CapturingLogger(_SpendCapture, CustomLogger):
|
||||
pass
|
||||
|
||||
return _CapturingLogger()
|
||||
|
||||
|
||||
_PRICED_COST: Final = 100 * 0.001 + 50 * 0.002
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PricedBilling:
|
||||
table: _InMemoryManagedObjectTable
|
||||
response_id: str
|
||||
cost: float | None
|
||||
|
||||
|
||||
_PRICED_LITELLM_PARAMS: Final[Mapping[str, object]] = MappingProxyType(
|
||||
{"model": "openai/gpt-5.6", "api_key": "sk-unused", "input_cost_per_token": 0.001, "output_cost_per_token": 0.002}
|
||||
)
|
||||
|
||||
|
||||
async def _bill_through_a_priced_deployment(
|
||||
read: ResponsesAPIResponse,
|
||||
litellm_params: Mapping[str, object] = _PRICED_LITELLM_PARAMS,
|
||||
model_info: Mapping[str, object] = MappingProxyType({}),
|
||||
) -> _PricedBilling:
|
||||
import litellm
|
||||
from litellm_enterprise.proxy.common_utils.check_responses_cost import CheckResponsesCost
|
||||
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
|
||||
response_id: Final = ResponsesAPIRequestUtils._build_responses_api_response_id(
|
||||
custom_llm_provider="openai", model_id="deployment-custom", response_id="resp_upstream"
|
||||
)
|
||||
table: Final = _InMemoryManagedObjectTable(
|
||||
rows=(_StoredResponse(id="job-priced", unified_object_id=response_id, status="queued", api_key="hash-alice"),)
|
||||
)
|
||||
prisma_client: Final = MagicMock()
|
||||
prisma_client.db.litellm_managedobjecttable = table
|
||||
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
|
||||
prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
|
||||
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "priced-gpt",
|
||||
"litellm_params": dict(litellm_params),
|
||||
"model_info": {"id": "deployment-custom", **model_info},
|
||||
}
|
||||
]
|
||||
)
|
||||
charge: Final = await CheckResponsesCost(
|
||||
proxy_logging_obj=MagicMock(), prisma_client=prisma_client, llm_router=router
|
||||
).bill_if_finished(response_id=response_id, response=read)
|
||||
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10)
|
||||
return _PricedBilling(table=table, response_id=response_id, cost=charge.cost if charge is not None else None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_billing_event_prices_the_fetched_usage_at_the_deployment_rates(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Billing logs the body the read already fetched instead of reading it again, so the event must
|
||||
carry the deployment's own pricing (100 input tokens at 0.001 plus 50 output tokens at 0.002 is
|
||||
0.2), and neither take the read's zero price nor change the response the reader gets back."""
|
||||
import litellm
|
||||
|
||||
capture: Final = _spend_capture_logger()
|
||||
monkeypatch.setattr(litellm, "_async_success_callback", [capture])
|
||||
read: Final = _terminal_response()
|
||||
read.hidden_params = {"response_cost": 0.0}
|
||||
|
||||
billing: Final = await _bill_through_a_priced_deployment(read)
|
||||
|
||||
(payload,) = capture.payloads
|
||||
assert payload["response_cost"] == pytest.approx(_PRICED_COST)
|
||||
assert billing.cost == pytest.approx(_PRICED_COST)
|
||||
assert payload["model_id"] == "deployment-custom"
|
||||
assert payload["model_group"] == "priced-gpt"
|
||||
assert payload["metadata"]["user_api_key_hash"] == "hash-alice"
|
||||
assert read.hidden_params == {"response_cost": 0.0}
|
||||
assert billing.table.statuses == {"job-priced": "completed"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("base_model_in", ["litellm_params", "model_info"])
|
||||
async def test_the_billing_event_prices_the_usage_at_the_deployments_base_model(
|
||||
monkeypatch: pytest.MonkeyPatch, base_model_in: str
|
||||
):
|
||||
"""A deployment that names a base_model for cost tracking bills its background responses at that
|
||||
model's price, whether the base_model sits in litellm_params or model_info."""
|
||||
import litellm
|
||||
|
||||
capture: Final = _spend_capture_logger()
|
||||
monkeypatch.setattr(litellm, "_async_success_callback", [capture])
|
||||
monkeypatch.setitem(
|
||||
litellm.model_cost,
|
||||
"qa-priced-base-model",
|
||||
{
|
||||
"litellm_provider": "openai",
|
||||
"mode": "responses",
|
||||
"input_cost_per_token": 0.001,
|
||||
"output_cost_per_token": 0.002,
|
||||
},
|
||||
)
|
||||
base_model: Final = {"base_model": "qa-priced-base-model"}
|
||||
|
||||
billing: Final = await _bill_through_a_priced_deployment(
|
||||
_terminal_response(),
|
||||
litellm_params={
|
||||
"model": "openai/gpt-5.6",
|
||||
"api_key": "sk-unused",
|
||||
**(base_model if base_model_in == "litellm_params" else {}),
|
||||
},
|
||||
model_info=base_model if base_model_in == "model_info" else {},
|
||||
)
|
||||
|
||||
(payload,) = capture.payloads
|
||||
assert payload["response_cost"] == pytest.approx(_PRICED_COST)
|
||||
assert billing.cost == pytest.approx(_PRICED_COST)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_billing_event_reaches_sync_only_success_callbacks(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Sync-only success callbacks (legacy string integrations, plain functions) get every async
|
||||
call's event from a worker thread, so they must get the billed background response too."""
|
||||
import queue
|
||||
|
||||
import litellm
|
||||
|
||||
priced_costs: Final[queue.Queue[object]] = queue.Queue()
|
||||
|
||||
def record_cost(kwargs: Mapping[str, object], *_: object) -> None:
|
||||
priced_costs.put(kwargs.get("response_cost"))
|
||||
|
||||
monkeypatch.setattr(litellm, "success_callback", [record_cost])
|
||||
monkeypatch.setattr(litellm, "_async_success_callback", [])
|
||||
|
||||
await _bill_through_a_priced_deployment(_terminal_response())
|
||||
|
||||
assert priced_costs.get(timeout=10) == pytest.approx(_PRICED_COST)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_billing_event_keys_its_spend_row_apart_from_the_read_it_bills(monkeypatch: pytest.MonkeyPatch):
|
||||
"""The billing event logs the body the read returned, under the same response id, and spend logs
|
||||
keep one row per request id. Its row must take its own request id, or the read's free row takes
|
||||
the slot and the charge reaches the key with no spend row for an admin to see."""
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
|
||||
|
||||
class _RequestIdCapture(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.request_ids: tuple[str, ...] = ()
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
||||
) -> None:
|
||||
self.request_ids = (
|
||||
*self.request_ids,
|
||||
get_logging_payload(kwargs, response_obj, start_time, end_time)["request_id"],
|
||||
)
|
||||
|
||||
capture: Final = _RequestIdCapture()
|
||||
monkeypatch.setattr(litellm, "_async_success_callback", [capture])
|
||||
read: Final = _terminal_response()
|
||||
|
||||
billing: Final = await _bill_through_a_priced_deployment(read)
|
||||
|
||||
(billed_request_id,) = capture.request_ids
|
||||
assert billed_request_id != billing.response_id
|
||||
assert billed_request_id.startswith(billing.response_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
|
@ -6,6 +7,7 @@ from unittest.mock import MagicMock
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
||||
from litellm.proxy.db import rollup_lock_timeout
|
||||
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
|
||||
from litellm.proxy.db.model_usage_rollup import (
|
||||
|
|
@ -16,6 +18,7 @@ from litellm.proxy.db.model_usage_rollup import (
|
|||
model_usage_task_type,
|
||||
)
|
||||
from litellm.proxy.db.rollup_lock_timeout import ROLLUP_LOCK_TIMEOUT_SQL
|
||||
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
|
||||
|
||||
class _FakeBatcher:
|
||||
|
|
@ -155,6 +158,27 @@ async def test_flush_aggregates_each_rollup_row_into_one_upsert() -> None:
|
|||
assert upserts["claude"]["create"]["request_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_background_response_billing_row_adds_spend_but_no_request() -> None:
|
||||
"""A background response is billed by a later event, not by the create the client sent, so
|
||||
model insights counts its spend and tokens once and its request once, from the create."""
|
||||
batcher = _FakeBatcher()
|
||||
prisma = _prisma(MagicMock(return_value=batcher))
|
||||
create = build_model_usage_transaction(_payload(spend=0.0))
|
||||
billing = build_model_usage_transaction(
|
||||
_payload(spend=0.5, metadata=json.dumps({INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN}))
|
||||
)
|
||||
assert create is not None and billing is not None
|
||||
|
||||
await flush_model_usage_transactions(prisma_client=prisma, transactions=[create, billing])
|
||||
|
||||
(upsert,) = batcher.litellm_dailymodelusage.upsert.call_args_list
|
||||
created = upsert.kwargs["data"]["create"]
|
||||
assert created["spend"] == 0.5
|
||||
assert created["prompt_tokens"] == 20
|
||||
assert (created["request_count"], created["successful_requests"], created["failed_requests"]) == (1, 1, 0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_with_no_transactions_touches_nothing() -> None:
|
||||
prisma = _prisma(MagicMock())
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,7 @@ import litellm
|
|||
import litellm.constants as litellm_constants
|
||||
import litellm.proxy.spend_tracking.spend_tracking_utils as spend_tracking_utils
|
||||
from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, LITTELM_CLI_SERVICE_ACCOUNT_NAME, LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, MAX_SPEND_LOG_MODEL_NAME_LENGTH, REDACTED_BY_LITELM_STRING, SESSION_ID_OMITTED_METADATA_KEY, UNKNOWN_MODEL_SPEND_LOG_MODEL
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
from litellm.llms.base_llm.ocr.transformation import OCRResponse, OCRUsageInfo
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
|
|
@ -48,6 +49,7 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import (
|
|||
from litellm.proxy.utils import hash_token
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import (
|
||||
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
StandardLoggingHiddenParams,
|
||||
StandardLoggingMetadata,
|
||||
StandardLoggingModelInformation,
|
||||
|
|
@ -4400,7 +4402,10 @@ def test_user_traffic_carries_no_internal_call_origin():
|
|||
|
||||
|
||||
def _spend_log_for_call_type(
|
||||
call_type: str, internal_call_origin: str | None = None, background: bool | None = None
|
||||
call_type: str,
|
||||
internal_call_origin: str | None = None,
|
||||
background: bool | None = None,
|
||||
managed_read: bool = False,
|
||||
) -> dict:
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
|
||||
|
|
@ -4415,6 +4420,7 @@ def _spend_log_for_call_type(
|
|||
"metadata": {
|
||||
"user_api_key": "test-key",
|
||||
"internal_call_origin": internal_call_origin,
|
||||
**({"_managed_background_response": True} if managed_read else {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -4460,6 +4466,15 @@ def test_spend_log_for_background_response_retrieval_counts_tokens():
|
|||
assert payload["total_tokens"] == 6000
|
||||
|
||||
|
||||
def test_spend_log_for_managed_background_response_read_counts_nothing():
|
||||
"""A proxy that tracks a background response bills it once through the cost poll, so a client
|
||||
read the managed files hook marked must not count the job's tokens a second time."""
|
||||
payload = _spend_log_for_call_type("aget_responses", background=True, managed_read=True)
|
||||
|
||||
assert payload["total_tokens"] == 0
|
||||
assert payload["spend"] == 0.0
|
||||
|
||||
|
||||
def test_spend_log_for_foreground_response_retrieval_still_counts_nothing():
|
||||
"""Guards the test above against a blanket exemption: an explicit background=false read was
|
||||
already billed by its create and must stay at zero."""
|
||||
|
|
@ -4573,6 +4588,34 @@ def test_get_spend_logs_id_separates_distinct_batches_whose_bodies_were_both_red
|
|||
assert ids == ["batch_first_batch_cost", "batch_second_batch_cost"]
|
||||
|
||||
|
||||
def test_a_background_response_billing_event_writes_a_row_apart_from_the_read_that_fetched_it():
|
||||
"""The one billing event of a background response logs the body a read just fetched, so both
|
||||
carry the same response id. Spend logs keep one row per request id, so the billing event must
|
||||
key its row apart from the read's, or the charge reaches the key and no row ever shows it."""
|
||||
response: Final = {"id": "resp_shared", "object": "response", "status": "completed", "background": True}
|
||||
read_metadata: Final = {"user_api_key": "test-key", MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: True}
|
||||
billing_metadata: Final = {
|
||||
"user_api_key": "test-key",
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
|
||||
}
|
||||
request_ids: Final = tuple(
|
||||
get_logging_payload(
|
||||
kwargs={
|
||||
"call_type": "aget_responses",
|
||||
"model": "gpt-5.6",
|
||||
"litellm_call_id": call_id,
|
||||
"litellm_params": {"metadata": metadata},
|
||||
},
|
||||
response_obj=dict(response),
|
||||
start_time=datetime.datetime.now(timezone.utc),
|
||||
end_time=datetime.datetime.now(timezone.utc),
|
||||
)["request_id"]
|
||||
for call_id, metadata in (("call-read", read_metadata), ("call-bill", billing_metadata))
|
||||
)
|
||||
|
||||
assert request_ids == ("resp_shared", "resp_shared_background_cost")
|
||||
|
||||
|
||||
def test_get_spend_logs_id_prefers_the_response_id_over_the_standard_logging_id():
|
||||
"""An unredacted response keeps deciding its own row key, so cache-hit ids and every
|
||||
other call type behave exactly as they did before."""
|
||||
|
|
|
|||
|
|
@ -1189,6 +1189,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
|
|||
"litellm_gateway_injected_cache": "forged-deployment-id",
|
||||
"_session_deployment_affinity_ttl": 999999,
|
||||
"internal_call_origin": "autorouter_classifier",
|
||||
"_managed_background_response": True,
|
||||
"_guardrail_pipelines": [{"name": "spoofed"}],
|
||||
"_pipeline_managed_guardrails": ["evaded"],
|
||||
"safe_user_metadata": "kept",
|
||||
|
|
@ -1246,6 +1247,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
|
|||
"litellm_gateway_injected_cache",
|
||||
"_session_deployment_affinity_ttl",
|
||||
"internal_call_origin",
|
||||
"_managed_background_response",
|
||||
"_guardrail_pipelines",
|
||||
"_pipeline_managed_guardrails",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ which returns ``FallbackResponsesStreamWrapper``. That wrapper set
|
|||
``self.completed_response = None`` in __init__ and never updated it,
|
||||
so the proxy's container-ownership hook (which reads
|
||||
``getattr(stream_response, "completed_response", None)`` via
|
||||
``ProxyBaseLLMRequestProcessing._extract_completed_responses_response``)
|
||||
``ProxyBaseLLMRequestProcessing.extract_completed_responses_response``)
|
||||
saw None on every streaming call and silently recorded nothing —
|
||||
follow-up ``GET /v1/containers/<id>/files`` then 403'd for the very
|
||||
key that created the container.
|
||||
|
|
@ -194,7 +194,7 @@ class TestProxyOwnershipHookReadsCompletedResponse:
|
|||
wrapper = wrapper_cls(gen())
|
||||
asyncio.run(_drain(wrapper))
|
||||
|
||||
extracted = ProxyBaseLLMRequestProcessing._extract_completed_responses_response(
|
||||
extracted = ProxyBaseLLMRequestProcessing.extract_completed_responses_response(
|
||||
wrapper
|
||||
)
|
||||
assert extracted is not None
|
||||
|
|
|
|||
|
|
@ -10410,14 +10410,16 @@ class TestBackgroundResponseCostPollCooldown:
|
|||
)
|
||||
assert "dep-1" in self._cooled_down_ids(router)
|
||||
|
||||
def test_cost_poll_not_found_does_not_cool_down_deployment(self):
|
||||
@pytest.mark.parametrize("poll_origin", ["background_response_cost_poll", "background_response_status_poll"])
|
||||
def test_background_response_poll_not_found_does_not_cool_down_deployment(self, poll_origin: str):
|
||||
"""A client can delete its background response before the poll reads it, so a 404 on either
|
||||
poll read says nothing about the deployment's health."""
|
||||
from datetime import datetime
|
||||
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
||||
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
|
||||
get_deployment_failures_for_current_minute,
|
||||
)
|
||||
from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
|
||||
router = self._router()
|
||||
now = datetime.now()
|
||||
|
|
@ -10427,9 +10429,7 @@ class TestBackgroundResponseCostPollCooldown:
|
|||
"exception": self._not_found(),
|
||||
"litellm_params": {
|
||||
"model_info": {"id": "dep-1"},
|
||||
"litellm_metadata": {
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
|
||||
},
|
||||
"litellm_metadata": {INTERNAL_CALL_ORIGIN_METADATA_KEY: poll_origin},
|
||||
},
|
||||
},
|
||||
None,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue