This commit is contained in:
todayim 2026-08-27 19:23:14 -05:00 • committed by GitHub
commit 5bf3c1b0a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 761 additions and 0 deletions

View file

@ -675,6 +675,7 @@ class LiteLLMRoutes(enum.Enum):
"/jwt/key/mapping/delete",
"/jwt/key/mapping/list",
"/jwt/key/mapping/info",
"/spend/usage",
]
+ key_management_routes
+ mcp_management_routes

View file

@ -610,6 +610,9 @@ from litellm.proxy.spend_tracking.spend_management_endpoints import (
router as spend_management_router,
)
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from litellm.proxy.spend_tracking.usage_ingestion_endpoints import (
router as usage_ingestion_router,
)
from litellm.proxy.types_utils.utils import get_instance_fn
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
router as ui_crud_endpoints_router,
@ -17681,6 +17684,7 @@ app.include_router(organization_router)
app.include_router(customer_router)
app.include_router(management_v1_router)
app.include_router(spend_management_router)
app.include_router(usage_ingestion_router)
app.include_router(caching_router)
app.include_router(analytics_router)
app.include_router(callback_management_endpoints_router)

View file

@ -0,0 +1,351 @@
import asyncio
import uuid
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
Annotated,
Final,
Literal,
NamedTuple,
TypeAlias,
cast, # noqa: TID251 # untyped tx boundary needs cast for the shim
)
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, model_validator
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import LitellmUserRoles, ProxyException, SpendLogsPayload, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from litellm.proxy.utils import hash_token
router: Final = APIRouter()
MAX_RECORDS_PER_REQUEST: Final = 1000
class ExternalUsageRecord(BaseModel):
api_key: str | None = Field(
default=None, min_length=1, description="Raw virtual key (sk-...) to attribute usage to. Never logged."
)
api_key_hash: str | None = Field(
default=None,
min_length=1,
description="SHA-256 hash of the virtual key. Use instead of api_key to avoid submitting raw keys.",
)
model: str = Field(min_length=1)
prompt_tokens: int = Field(ge=0)
completion_tokens: int = Field(ge=0)
start_time: datetime
end_time: datetime | None = None
cost: float | None = Field(
default=None, ge=0, description="Explicit cost in USD. Computed from litellm pricing when omitted."
)
idempotency_key: str | None = Field(
default=None, max_length=255, description="Becomes the spend-log request_id for dedup on retries."
)
tags: list[str] | None = None # mutable-ok: serialized as a JSON array by spend logs
end_user_id: str | None = None
@model_validator(mode="after")
def end_time_not_before_start_time(self) -> "ExternalUsageRecord":
if self.end_time is not None and self.end_time < self.start_time:
raise ValueError("end_time must not be before start_time")
return self
@model_validator(mode="after")
def exactly_one_key_identifier(self) -> "ExternalUsageRecord":
if (self.api_key is None) == (self.api_key_hash is None):
raise ValueError("exactly one of api_key or api_key_hash is required")
return self
class UsageIngestRequest(BaseModel):
records: tuple[ExternalUsageRecord, ...] = Field(min_length=1, max_length=MAX_RECORDS_PER_REQUEST)
class UsageIngestRecordResult(BaseModel):
request_id: str
status: Literal["recorded", "duplicate", "error"]
spend: float | None = None
error: str | None = None
class UsageIngestResponse(BaseModel):
results: tuple[UsageIngestRecordResult, ...]
class KeyAttribution(NamedTuple):
user_id: str | None
team_id: str | None
organization_id: str | None
ReservationOutcome: TypeAlias = Literal["reserved", "duplicate", "disabled"]
@dataclass(frozen=True, slots=True)
class UsageIngestionDeps:
lookup_key: Callable[[str], Awaitable[KeyAttribution | None]]
reserve_spend_log: Callable[[ExternalUsageRecord, str, str, KeyAttribution, float], Awaitable[ReservationOutcome]]
record_spend: Callable[..., Awaitable[None]]
compute_cost: Callable[[litellm.ModelResponse, str], float]
generate_request_id: Callable[[], str]
def _build_usage_kwargs(record: ExternalUsageRecord, hashed_token: str) -> Mapping[str, object]:
tags: Final = list(record.tags) if record.tags else [] # mutable-ok: real list required by json serializer
metadata: Final = MappingProxyType(
{
"user_api_key": hashed_token,
"user_api_key_end_user_id": record.end_user_id,
"tags": tags,
}
)
return MappingProxyType(
{
"model": record.model,
"call_type": "ingest_external_usage",
"litellm_params": MappingProxyType({"model": record.model, "metadata": metadata}),
}
)
def _build_completion_response(record: ExternalUsageRecord, request_id: str) -> litellm.ModelResponse:
total_tokens: Final = record.prompt_tokens + record.completion_tokens
usage: Final = litellm.Usage(
prompt_tokens=record.prompt_tokens,
completion_tokens=record.completion_tokens,
total_tokens=total_tokens,
)
return litellm.ModelResponse(
id=request_id,
model=record.model,
created=int(record.start_time.timestamp()),
usage=usage,
)
def build_spend_log_payload(
record: ExternalUsageRecord,
request_id: str,
hashed_token: str,
key: KeyAttribution,
cost: float,
) -> SpendLogsPayload:
payload: Final[SpendLogsPayload] = get_logging_payload(
kwargs=_build_usage_kwargs(record, hashed_token),
response_obj=_build_completion_response(record, request_id),
start_time=record.start_time,
end_time=record.end_time or record.start_time,
)
payload["spend"] = cost
if isinstance(payload["startTime"], datetime):
payload["startTime"] = payload["startTime"].isoformat()
if isinstance(payload["endTime"], datetime):
payload["endTime"] = payload["endTime"].isoformat()
if key.organization_id is not None and key.organization_id != "":
payload["organization_id"] = key.organization_id
if key.team_id is not None and key.team_id != "":
payload["team_id"] = key.team_id
return payload
def _resolve_cost(deps: UsageIngestionDeps, record: ExternalUsageRecord, response: litellm.ModelResponse) -> float:
if record.cost is not None:
return record.cost
return deps.compute_cost(response, record.model)
async def process_external_usage_record(
record: ExternalUsageRecord, deps: UsageIngestionDeps
) -> UsageIngestRecordResult:
request_id: Final = record.idempotency_key or deps.generate_request_id()
hashed_token: Final = record.api_key_hash if record.api_key_hash is not None else hash_token(record.api_key or "")
key: Final = await deps.lookup_key(hashed_token)
if key is None:
return UsageIngestRecordResult(request_id=request_id, status="error", error="api key not found")
response: Final = _build_completion_response(record, request_id)
try:
cost: Final = _resolve_cost(deps, record, response)
except Exception as e: # noqa: BLE001 # pricing lookup raises arbitrary provider-specific errors; any failure means the model is unpriceable and the record must carry an explicit cost
verbose_proxy_logger.info("ingest usage: cost computation failed for model %s: %s", record.model, e)
return UsageIngestRecordResult(
request_id=request_id,
status="error",
error="could not compute cost for this model, pass an explicit cost",
)
if record.idempotency_key is not None:
try:
reservation: Final = await deps.reserve_spend_log(record, request_id, hashed_token, key, cost)
except Exception as e: # noqa: BLE001 # booking raises arbitrary persistence errors; an aborted transaction means nothing was booked, so telling the caller to retry is safe
verbose_proxy_logger.info("ingest usage: transactional booking failed for %s: %s", request_id, e)
return UsageIngestRecordResult(
request_id=request_id,
status="error",
error="booking failed transactionally, nothing was recorded, safe to retry",
)
if reservation == "duplicate":
return UsageIngestRecordResult(request_id=request_id, status="duplicate")
if reservation == "disabled":
return UsageIngestRecordResult(
request_id=request_id,
status="error",
error="spend updates are disabled on this proxy, nothing was recorded",
)
return UsageIngestRecordResult(request_id=request_id, status="recorded", spend=cost)
await deps.record_spend(
token=hashed_token,
user_id=key.user_id,
end_user_id=record.end_user_id,
team_id=key.team_id,
kwargs=_build_usage_kwargs(record, hashed_token),
completion_response=response,
start_time=record.start_time,
end_time=record.end_time or record.start_time,
response_cost=cost,
org_id=key.organization_id,
)
return UsageIngestRecordResult(request_id=request_id, status="recorded", spend=cost)
def _attribution_of(key_row: object) -> KeyAttribution:
return KeyAttribution(
user_id=getattr(key_row, "user_id", None),
team_id=getattr(key_row, "team_id", None),
organization_id=getattr(key_row, "organization_id", None),
)
if TYPE_CHECKING:
from prisma.client import TransactionManager
class _TransactionClientShim:
def __init__(self, tx: "TransactionManager") -> None:
self.db: Final = tx
async def reserve_spend_log_atomic(
record: ExternalUsageRecord,
request_id: str,
hashed_token: str,
key: KeyAttribution,
cost: float,
) -> ReservationOutcome:
from litellm.proxy.proxy_server import litellm_proxy_budget_name, prisma_client, proxy_logging_obj
from litellm.proxy.utils import PrismaClient, ProxyUpdateSpend
from litellm.repositories.table_repositories import SpendLogsRepository
if ProxyUpdateSpend.disable_spend_updates() is True:
return "disabled"
spend_payload: Final = build_spend_log_payload(record, request_id, hashed_token, key, cost)
payload: Final = prisma_client.jsonify_object(spend_payload)
request_tags: Final = spend_payload.get("request_tags")
from prisma.errors import UniqueViolationError
writer: Final = proxy_logging_obj.db_spend_update_writer
try:
async with prisma_client.tx() as tx:
shim: Final = cast(PrismaClient, _TransactionClientShim(tx)) # cast-ok: helper uses only .db (untyped)
await SpendLogsRepository(shim).table.create(data=payload)
await writer._update_key_db(response_cost=cost, hashed_token=hashed_token, prisma_client=shim)
await writer._update_user_db(
response_cost=cost,
user_id=key.user_id,
prisma_client=shim,
litellm_proxy_budget_name=litellm_proxy_budget_name,
end_user_id=record.end_user_id,
)
await writer._update_team_db(
response_cost=cost, team_id=key.team_id, user_id=key.user_id, prisma_client=shim
)
await writer._update_org_db(response_cost=cost, org_id=key.organization_id, prisma_client=shim)
await writer._update_tag_db(response_cost=cost, request_tags=request_tags, prisma_client=shim)
except UniqueViolationError:
return "duplicate"
return "reserved"
def default_ingestion_deps() -> UsageIngestionDeps:
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
if prisma_client is None:
raise ProxyException(
message="Prisma Client is not initialized",
type="internal_error",
param="None",
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
async def lookup_key(hashed_token: str) -> KeyAttribution | None:
from litellm.proxy.auth.auth_checks import get_key_object
try:
key_row: Final = await get_key_object(
hashed_token=hashed_token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
except ProxyException:
return None
return _attribution_of(key_row)
return UsageIngestionDeps(
lookup_key=lookup_key,
reserve_spend_log=reserve_spend_log_atomic,
record_spend=proxy_logging_obj.db_spend_update_writer.update_database,
compute_cost=lambda resp, model: litellm.completion_cost(completion_response=resp, model=model),
generate_request_id=lambda: str(uuid.uuid4()),
)
@router.post(
"/spend/usage",
tags=["Budget & Spend Tracking"], # mutable-ok: fastapi decorator contract takes a list
dependencies=[Depends(user_api_key_auth)], # mutable-ok: fastapi decorator contract takes a list
response_model=UsageIngestResponse,
)
async def ingest_external_usage(
request: UsageIngestRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> UsageIngestResponse:
"""
PROXY_ADMIN ONLY: record externally measured usage into the same spend pipeline as proxy-routed traffic.
For inference traffic that legitimately bypasses the proxy (for example async batch processors
dispatching directly to model gateways), so budgets and spend stay coherent in litellm as the
single metering system.
Attribution (user/team/org) is derived from the given virtual key, submitted either raw
(api_key) or pre-hashed (api_key_hash) to keep raw keys out of request bodies. Records accept an optional
idempotency_key, stored as the spend-log request_id: the reservation insert, counter updates and
dedup are checked atomically at the database primary key, so overlapping retries are safe. The
reservation row is always written (even when disable_spend_logs is set), because it is both the
dedup anchor and the audit record for the booked usage. When cost is omitted it is computed from
litellm pricing; records whose model cannot be priced are rejected with an error instead of
being booked as zero spend.
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only proxy admins ingest spend records here. Use a key with the proxy_admin role.",
)
deps: Final = default_ingestion_deps()
results: Final = tuple(
await asyncio.gather(*(process_external_usage_record(record, deps) for record in request.records))
)
return UsageIngestResponse(results=results)

View file

@ -0,0 +1,275 @@
import asyncio
import json
import os
import sys
from datetime import datetime, timezone
from typing import Any
import pytest
from pydantic import ValidationError
sys.path.insert(0, os.path.abspath("../../.."))
from litellm.proxy.spend_tracking.usage_ingestion_endpoints import (
ExternalUsageRecord,
KeyAttribution,
UsageIngestionDeps,
build_spend_log_payload,
process_external_usage_record,
)
from litellm.proxy.utils import hash_token
RAW_KEY = "sk-test-batch-dispatch-key"
GENERATED_ID = "generated-uuid-1"
DEFAULT_KEY = KeyAttribution(user_id="u-1", team_id="t-1", organization_id="o-1")
class RecordingDeps:
def __init__(
self,
key: KeyAttribution | None = DEFAULT_KEY,
existing_ids: frozenset[str] = frozenset(),
compute_cost_result: float = 0.05,
compute_cost_error: Exception | None = None,
reserve_outcome: str = "reserved",
reserve_raises: Exception | None = None,
):
self._key = key
self._existing_ids = existing_ids
self._compute_cost_result = compute_cost_result
self._compute_cost_error = compute_cost_error
self._reserve_outcome = reserve_outcome
self._reserve_raises = reserve_raises
self.spend_calls: list[dict[str, Any]] = []
self.reserve_calls: list[dict[str, Any]] = []
self.compute_cost_calls: list[tuple[Any, str]] = []
def as_deps(self) -> UsageIngestionDeps:
async def lookup_key(hashed: str) -> KeyAttribution | None:
self.looked_up_hashed = hashed
return self._key
async def reserve_spend_log(
record: ExternalUsageRecord,
request_id: str,
hashed_token: str,
key: KeyAttribution,
cost: float,
) -> Any:
self.reserve_calls.append(
{
"record": record,
"request_id": request_id,
"hashed_token": hashed_token,
"key": key,
"cost": cost,
}
)
if self._reserve_raises is not None:
raise self._reserve_raises
if self._reserve_outcome == "disabled":
return "disabled"
return "duplicate" if request_id in self._existing_ids else "reserved"
async def record_spend(**kwargs: Any) -> None:
self.spend_calls.append(kwargs)
def compute_cost(response: Any, model: str) -> float:
self.compute_cost_calls.append((response, model))
if self._compute_cost_error is not None:
raise self._compute_cost_error
return self._compute_cost_result
return UsageIngestionDeps(
lookup_key=lookup_key,
reserve_spend_log=reserve_spend_log,
record_spend=record_spend,
compute_cost=compute_cost,
generate_request_id=lambda: GENERATED_ID,
)
def make_record(**overrides: Any) -> ExternalUsageRecord:
base: dict[str, Any] = {
"api_key": RAW_KEY,
"model": "gpt-4o-mini",
"prompt_tokens": 100,
"completion_tokens": 50,
"start_time": datetime(2026, 8, 5, 12, 0, 0, tzinfo=timezone.utc),
}
base.update(overrides)
return ExternalUsageRecord(**base)
def run(coro: Any) -> Any:
return asyncio.run(coro)
def test_spend_log_payload_matches_funnel_shape():
record = make_record(idempotency_key="batch-1-line-1", tags=["batch:job-42"], end_user_id="tenant-a")
payload = build_spend_log_payload(
record=record,
request_id="batch-1-line-1",
hashed_token=hash_token(RAW_KEY),
key=DEFAULT_KEY,
cost=0.123,
)
assert payload["request_id"] == "batch-1-line-1"
assert payload["spend"] == 0.123
assert payload["total_tokens"] == 150
assert payload["prompt_tokens"] == 100
assert payload["completion_tokens"] == 50
assert payload["api_key"] == hash_token(RAW_KEY)
assert payload["api_key"] != RAW_KEY
assert payload["team_id"] == "t-1"
assert payload["organization_id"] == "o-1"
assert payload["end_user"] == "tenant-a"
metadata = json.loads(payload["metadata"]) if isinstance(payload["metadata"], str) else payload["metadata"]
assert metadata["user_api_key"] == hash_token(RAW_KEY)
request_tags = (
json.loads(payload["request_tags"]) if isinstance(payload["request_tags"], str) else payload["request_tags"]
)
assert request_tags == ["batch:job-42"]
def test_idempotent_record_books_through_atomic_reserve_not_funnel():
deps = RecordingDeps(compute_cost_error=RuntimeError("pricing must not be consulted"))
result = run(
process_external_usage_record(make_record(cost=0.123, idempotency_key="batch-1-line-1"), deps.as_deps())
)
assert result.status == "recorded"
assert result.spend == 0.123
assert result.request_id == "batch-1-line-1"
assert len(deps.reserve_calls) == 1
reserve_call = deps.reserve_calls[0]
assert reserve_call["request_id"] == "batch-1-line-1"
assert reserve_call["hashed_token"] == hash_token(RAW_KEY)
assert reserve_call["cost"] == 0.123
assert reserve_call["key"] == DEFAULT_KEY
assert deps.spend_calls == []
assert deps.compute_cost_calls == []
def test_computed_cost_is_resolved_before_reserving():
deps = RecordingDeps(compute_cost_result=0.07)
result = run(process_external_usage_record(make_record(idempotency_key="k-2"), deps.as_deps()))
assert result.status == "recorded"
assert result.spend == 0.07
assert len(deps.compute_cost_calls) == 1
assert deps.compute_cost_calls[0][1] == "gpt-4o-mini"
assert deps.reserve_calls[0]["cost"] == 0.07
def test_unpriceable_model_without_explicit_cost_is_error_and_books_nothing():
deps = RecordingDeps(compute_cost_error=ValueError("unknown model"))
result = run(process_external_usage_record(make_record(idempotency_key="k-3"), deps.as_deps()))
assert result.status == "error"
assert result.spend is None
assert "explicit cost" in (result.error or "")
assert deps.reserve_calls == []
assert deps.spend_calls == []
def test_unknown_key_is_rejected_and_books_nothing():
deps = RecordingDeps(key=None)
result = run(process_external_usage_record(make_record(idempotency_key="k-4"), deps.as_deps()))
assert result.status == "error"
assert result.error == "api key not found"
assert deps.reserve_calls == []
assert deps.spend_calls == []
def test_duplicate_idempotency_key_is_skipped_and_never_rebooks():
deps = RecordingDeps(existing_ids=frozenset({"k-5"}))
result = run(process_external_usage_record(make_record(idempotency_key="k-5"), deps.as_deps()))
assert result.status == "duplicate"
assert result.request_id == "k-5"
assert len(deps.reserve_calls) == 1
assert deps.spend_calls == []
def test_missing_idempotency_key_uses_funnel_with_generated_request_id():
deps = RecordingDeps()
result = run(process_external_usage_record(make_record(cost=0.01), deps.as_deps()))
assert result.status == "recorded"
assert result.request_id == GENERATED_ID
assert deps.reserve_calls == []
assert len(deps.spend_calls) == 1
call = deps.spend_calls[0]
assert call["token"] == hash_token(RAW_KEY)
assert call["user_id"] == "u-1"
assert call["team_id"] == "t-1"
assert call["org_id"] == "o-1"
assert call["response_cost"] == 0.01
response = call["completion_response"]
assert response.id == GENERATED_ID
assert response.usage.total_tokens == 150
metadata = call["kwargs"]["litellm_params"]["metadata"]
assert metadata["user_api_key"] == hash_token(RAW_KEY)
assert metadata["user_api_key"] != RAW_KEY
def test_end_time_defaults_to_start_time_and_end_before_start_rejected():
deps = RecordingDeps()
start = datetime(2026, 8, 5, 12, 0, 0, tzinfo=timezone.utc)
run(process_external_usage_record(make_record(cost=0.01, start_time=start), deps.as_deps()))
assert deps.spend_calls[0]["start_time"] == start
assert deps.spend_calls[0]["end_time"] == start
earlier = datetime(2026, 8, 5, 11, 0, 0, tzinfo=timezone.utc)
with pytest.raises(ValidationError):
make_record(start_time=start, end_time=earlier)
def test_record_without_idempotency_key_still_flows_tags_to_funnel_kwargs():
deps = RecordingDeps()
result = run(
process_external_usage_record(
make_record(cost=0.01, tags=["batch:job-42"], end_user_id="tenant-a"), deps.as_deps()
)
)
assert result.status == "recorded"
metadata = deps.spend_calls[0]["kwargs"]["litellm_params"]["metadata"]
assert metadata["tags"] == ["batch:job-42"]
assert metadata["user_api_key_end_user_id"] == "tenant-a"
assert deps.spend_calls[0]["end_user_id"] == "tenant-a"
def test_disabled_spend_updates_reports_error_instead_of_fake_recorded():
deps = RecordingDeps(reserve_outcome="disabled")
result = run(process_external_usage_record(make_record(cost=0.01, idempotency_key="k-9"), deps.as_deps()))
assert result.status == "error"
assert "disabled" in (result.error or "")
assert result.spend is None
assert deps.spend_calls == []
def test_failed_booking_is_retry_safe_error_not_permanent_duplicate():
deps = RecordingDeps(reserve_raises=RuntimeError("db gone mid-tx"))
result = run(process_external_usage_record(make_record(cost=0.01, idempotency_key="k-10"), deps.as_deps()))
assert result.status == "error"
assert "safe to retry" in (result.error or "")
assert result.spend is None
assert deps.spend_calls == []
def test_key_hash_resolves_without_raw_key_in_body():
deps = RecordingDeps()
record = make_record(cost=0.01, idempotency_key="k-11")
record = ExternalUsageRecord(**{**record.model_dump(), "api_key": None, "api_key_hash": hash_token(RAW_KEY)})
result = run(process_external_usage_record(record, deps.as_deps()))
assert result.status == "recorded"
assert deps.looked_up_hashed == hash_token(RAW_KEY)
assert deps.reserve_calls[0]["hashed_token"] == hash_token(RAW_KEY)
def test_exactly_one_key_identifier_required():
with pytest.raises(ValidationError):
make_record(api_key=None)
with pytest.raises(ValidationError):
make_record(api_key_hash=hash_token(RAW_KEY))

View file

@ -13812,6 +13812,39 @@ export interface paths {
patch?: never;
trace?: never;
};
"/spend/usage": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Ingest External Usage
* @description PROXY_ADMIN ONLY: record externally measured usage into the same spend pipeline as proxy-routed traffic.
*
* For inference traffic that legitimately bypasses the proxy (for example async batch processors
* dispatching directly to model gateways), so budgets and spend stay coherent in litellm as the
* single metering system.
*
* Attribution (user/team/org) is derived from the given virtual key, submitted either raw
* (api_key) or pre-hashed (api_key_hash) to keep raw keys out of request bodies. Records accept an optional
* idempotency_key, stored as the spend-log request_id: the reservation insert, counter updates and
* dedup are checked atomically at the database primary key, so overlapping retries are safe. The
* reservation row is always written (even when disable_spend_logs is set), because it is both the
* dedup anchor and the audit record for the booked usage. When cost is omitted it is computed from
* litellm pricing; records whose model cannot be priced are rejected with an error instead of
* being booked as zero spend.
*/
post: operations["ingest_external_usage_spend_usage_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/spend/users": {
parameters: {
query?: never;
@ -26272,6 +26305,46 @@ export interface components {
/** Updated At */
updated_at?: number | null;
};
/** ExternalUsageRecord */
ExternalUsageRecord: {
/**
* Api Key
* @description Raw virtual key (sk-...) to attribute usage to. Never logged.
*/
api_key?: string | null;
/**
* Api Key Hash
* @description SHA-256 hash of the virtual key. Use instead of api_key to avoid submitting raw keys.
*/
api_key_hash?: string | null;
/** Completion Tokens */
completion_tokens: number;
/**
* Cost
* @description Explicit cost in USD. Computed from litellm pricing when omitted.
*/
cost?: number | null;
/** End Time */
end_time?: string | null;
/** End User Id */
end_user_id?: string | null;
/**
* Idempotency Key
* @description Becomes the spend-log request_id for dedup on retries.
*/
idempotency_key?: string | null;
/** Model */
model: string;
/** Prompt Tokens */
prompt_tokens: number;
/**
* Start Time
* Format: date-time
*/
start_time: string;
/** Tags */
tags?: string[] | null;
};
/**
* FacetListResponse
* @description The distinct values one column takes over a filtered query. `data` holds bare values, not entity rows.
@ -37060,6 +37133,30 @@ export interface components {
/** Usage Units Daily */
usage_units_daily: components["schemas"]["UsageUnitsDailyPoint"][];
};
/** UsageIngestRecordResult */
UsageIngestRecordResult: {
/** Error */
error?: string | null;
/** Request Id */
request_id: string;
/** Spend */
spend?: number | null;
/**
* Status
* @enum {string}
*/
status: "recorded" | "duplicate" | "error";
};
/** UsageIngestRequest */
UsageIngestRequest: {
/** Records */
records: components["schemas"]["ExternalUsageRecord"][];
};
/** UsageIngestResponse */
UsageIngestResponse: {
/** Results */
results: components["schemas"]["UsageIngestRecordResult"][];
};
/** UsageLogEntry */
UsageLogEntry: {
/** Action */
@ -55252,6 +55349,39 @@ export interface operations {
};
};
};
ingest_external_usage_spend_usage_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UsageIngestRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UsageIngestResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
spend_user_fn_spend_users_get: {
parameters: {
query?: {