mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
feat(ui): show prompt caching requests and net savings
This commit is contained in:
parent
f49fd22875
commit
94b2fd827b
14 changed files with 1195 additions and 28 deletions
|
|
@ -51,6 +51,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
|
|||
"/cache_settings",
|
||||
"/coordination_redis/",
|
||||
"/cost_tracking",
|
||||
"/cost_optimization/",
|
||||
"/cost/",
|
||||
"/credentials",
|
||||
"/credential",
|
||||
|
|
|
|||
184
litellm/proxy/management_endpoints/prompt_caching_requests.py
Normal file
184
litellm/proxy/management_endpoints/prompt_caching_requests.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Annotated, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Json, TypeAdapter
|
||||
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth, user_api_key_has_admin_view
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.spend_tracking.savings import (
|
||||
extract_cache_creation_tokens,
|
||||
extract_cache_read_tokens,
|
||||
marks_gateway_injection,
|
||||
prompt_caching_savings_for_request,
|
||||
)
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import (
|
||||
_query_raw_rows, # pyright: ignore[reportPrivateUsage] # existing typed spend-query adapter; rows validated below
|
||||
)
|
||||
from litellm.types.integrations.anthropic_cache_control_hook import GATEWAY_INJECTED_CACHE_METADATA_KEY
|
||||
from litellm.types.management_endpoints.prompt_caching_requests import (
|
||||
PromptCachingRequest,
|
||||
PromptCachingRequestCursor,
|
||||
PromptCachingRequestFilter,
|
||||
PromptCachingRequestsResponse,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
||||
|
||||
def _numeric_token_sql(path: str) -> str:
|
||||
value: Final = f"metadata #> '{{usage_object,{path}}}'"
|
||||
return (
|
||||
f"CASE WHEN jsonb_typeof({value}) = 'number' THEN ({value} #>> '{{}}')::numeric "
|
||||
f"WHEN {value} = 'true'::jsonb THEN 1 WHEN {value} = 'false'::jsonb THEN 0 END"
|
||||
)
|
||||
|
||||
|
||||
def _cache_tokens_sql(*paths: str) -> str:
|
||||
candidates: Final = ", ".join(f"NULLIF(({_numeric_token_sql(path)}), 0)" for path in paths)
|
||||
return f"TRUNC(COALESCE({candidates}, 0))"
|
||||
|
||||
|
||||
_CACHE_READ_SQL: Final = _cache_tokens_sql("cache_read_input_tokens", "prompt_tokens_details,cached_tokens")
|
||||
_CACHE_CREATION_SQL: Final = _cache_tokens_sql(
|
||||
"cache_creation_input_tokens",
|
||||
"prompt_tokens_details,cache_write_tokens",
|
||||
"prompt_tokens_details,cache_creation_tokens",
|
||||
)
|
||||
_GATEWAY_INJECTED_SQL: Final = (
|
||||
f"(jsonb_typeof(metadata->'{GATEWAY_INJECTED_CACHE_METADATA_KEY}') = 'string' "
|
||||
f"AND (metadata->>'{GATEWAY_INJECTED_CACHE_METADATA_KEY}' = '' "
|
||||
f"OR metadata->>'{GATEWAY_INJECTED_CACHE_METADATA_KEY}' = model_id))"
|
||||
)
|
||||
_FILTER_SQL: Final = MappingProxyType(
|
||||
{
|
||||
"all": f"({_GATEWAY_INJECTED_SQL} OR {_CACHE_READ_SQL} > 0 OR {_CACHE_CREATION_SQL} > 0)",
|
||||
"injected": _GATEWAY_INJECTED_SQL,
|
||||
"hits": f"{_CACHE_READ_SQL} > 0",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def prompt_caching_requests_sql(filter: PromptCachingRequestFilter) -> str:
|
||||
return f"""
|
||||
SELECT request_id, "startTime" AS start_time, "endTime" AS end_time,
|
||||
model, model_id, custom_llm_provider, spend,
|
||||
CASE WHEN jsonb_typeof(metadata->'usage_object') = 'object'
|
||||
THEN metadata->'usage_object' END AS usage_object,
|
||||
CASE WHEN jsonb_typeof(metadata->'cost_breakdown') = 'object'
|
||||
THEN metadata->'cost_breakdown' END AS cost_breakdown,
|
||||
CASE WHEN jsonb_typeof(metadata->'{GATEWAY_INJECTED_CACHE_METADATA_KEY}') = 'string'
|
||||
THEN metadata->>'{GATEWAY_INJECTED_CACHE_METADATA_KEY}' END AS gateway_marker
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE "startTime" >= ($1::text::timestamptz AT TIME ZONE 'UTC')
|
||||
AND "startTime" <= ($2::text::timestamptz AT TIME ZONE 'UTC')
|
||||
AND COALESCE(LOWER(cache_hit), 'false') != 'true'
|
||||
AND {_FILTER_SQL[filter]}
|
||||
AND ($4::text::timestamptz IS NULL OR
|
||||
("startTime", request_id) < (($4::text::timestamptz AT TIME ZONE 'UTC'), $5::text))
|
||||
ORDER BY "startTime" DESC, request_id DESC
|
||||
LIMIT $3::integer
|
||||
"""
|
||||
|
||||
|
||||
class _PromptCachingRow(BaseModel):
|
||||
request_id: str
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
model: str
|
||||
model_id: str | None
|
||||
custom_llm_provider: str | None
|
||||
spend: float
|
||||
usage_object: Json[Mapping[str, object]] | Mapping[str, object] | None
|
||||
cost_breakdown: Json[Mapping[str, object]] | Mapping[str, object] | None
|
||||
gateway_marker: str | None
|
||||
|
||||
|
||||
_REQUEST_ROWS: Final = TypeAdapter(tuple[_PromptCachingRow, ...])
|
||||
|
||||
|
||||
def _request_result(row: _PromptCachingRow, llm_router: "Callable[[], Router | None]") -> PromptCachingRequest:
|
||||
return PromptCachingRequest(
|
||||
request_id=row.request_id,
|
||||
start_time=row.start_time.replace(tzinfo=timezone.utc) if row.start_time.tzinfo is None else row.start_time,
|
||||
model=row.model,
|
||||
gateway_injected=marks_gateway_injection(
|
||||
MappingProxyType({GATEWAY_INJECTED_CACHE_METADATA_KEY: row.gateway_marker}), row.model_id
|
||||
),
|
||||
cache_read_tokens=extract_cache_read_tokens(row.usage_object),
|
||||
cache_creation_tokens=extract_cache_creation_tokens(row.usage_object),
|
||||
spend=row.spend,
|
||||
net_savings=prompt_caching_savings_for_request(
|
||||
model=row.model,
|
||||
custom_llm_provider=row.custom_llm_provider,
|
||||
usage_object=row.usage_object,
|
||||
model_id=row.model_id,
|
||||
llm_router=llm_router,
|
||||
cost_breakdown=row.cost_breakdown,
|
||||
billed_at=row.end_time,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/cost_optimization/prompt_caching/requests",
|
||||
tags=["Cost Optimization"], # mutable-ok: FastAPI's route API requires a list
|
||||
response_model=PromptCachingRequestsResponse,
|
||||
)
|
||||
async def get_prompt_caching_requests(
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
page_size: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
filter: PromptCachingRequestFilter = "all",
|
||||
cursor_start_time: datetime | None = None,
|
||||
cursor_request_id: Annotated[str | None, Query(min_length=1)] = None,
|
||||
) -> PromptCachingRequestsResponse:
|
||||
from litellm.proxy.proxy_server import llm_router, prisma_client
|
||||
|
||||
if not user_api_key_has_admin_view(user_api_key_dict):
|
||||
raise HTTPException(status_code=403, detail="Only proxy admin roles can view prompt caching requests")
|
||||
if (cursor_start_time is None) != (cursor_request_id is None):
|
||||
raise HTTPException(status_code=400, detail="cursor_start_time and cursor_request_id must be provided together")
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
|
||||
start: Final = start_date.replace(tzinfo=timezone.utc) if start_date.tzinfo is None else start_date
|
||||
end: Final = end_date.replace(tzinfo=timezone.utc) if end_date.tzinfo is None else end_date
|
||||
if end < start:
|
||||
raise HTTPException(status_code=400, detail="end_date must not be earlier than start_date")
|
||||
cursor_time: Final = (
|
||||
cursor_start_time.replace(tzinfo=timezone.utc)
|
||||
if cursor_start_time is not None and cursor_start_time.tzinfo is None
|
||||
else cursor_start_time
|
||||
)
|
||||
rows: Final = _REQUEST_ROWS.validate_python(
|
||||
await _query_raw_rows(
|
||||
prisma_client,
|
||||
prompt_caching_requests_sql(filter),
|
||||
start.isoformat(),
|
||||
end.isoformat(),
|
||||
page_size + 1,
|
||||
cursor_time.isoformat() if cursor_time is not None else None,
|
||||
cursor_request_id,
|
||||
)
|
||||
or ()
|
||||
)
|
||||
|
||||
def current_router() -> "Router | None":
|
||||
return llm_router
|
||||
|
||||
requests: Final = tuple(_request_result(row, current_router) for row in rows[:page_size])
|
||||
has_more: Final = len(rows) > page_size
|
||||
return PromptCachingRequestsResponse(
|
||||
requests=requests,
|
||||
page_size=page_size,
|
||||
has_more=has_more,
|
||||
next_cursor=PromptCachingRequestCursor(start_time=requests[-1].start_time, request_id=requests[-1].request_id)
|
||||
if has_more
|
||||
else None,
|
||||
)
|
||||
|
|
@ -587,6 +587,9 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
|
|||
from litellm.proxy.management_endpoints.organization_endpoints import (
|
||||
router as organization_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.prompt_caching_requests import (
|
||||
router as prompt_caching_requests_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.router_settings_endpoints import (
|
||||
router as router_settings_router,
|
||||
)
|
||||
|
|
@ -19183,6 +19186,7 @@ app.include_router(workflow_management_router)
|
|||
app.include_router(memory_router)
|
||||
app.include_router(plugin_router)
|
||||
app.include_router(cost_tracking_settings_router)
|
||||
app.include_router(prompt_caching_requests_router)
|
||||
app.include_router(router_settings_router)
|
||||
app.include_router(fallback_management_router)
|
||||
app.include_router(cache_settings_router)
|
||||
|
|
|
|||
|
|
@ -578,6 +578,56 @@ def autorouter_savings_for_logging_payload(
|
|||
)
|
||||
|
||||
|
||||
def _request_savings_pricing(
|
||||
model: str | None,
|
||||
custom_llm_provider: str | None,
|
||||
model_id: str | None,
|
||||
llm_router: "Callable[[], Router | None] | None",
|
||||
) -> tuple[str | None, ModelInfo | None]:
|
||||
router_instance: Final = llm_router() if llm_router else None
|
||||
identity: Final = _resolve_model(model, custom_llm_provider)
|
||||
pricing: Final = _effective_model_info(router_instance, model_id, model or "") or (
|
||||
_model_info(identity) if identity else None
|
||||
)
|
||||
return identity.provider if identity else custom_llm_provider, pricing
|
||||
|
||||
|
||||
def _prompt_caching_savings(
|
||||
pricing: ModelInfo | None,
|
||||
provider: str | None,
|
||||
usage_object: Mapping[str, object] | None,
|
||||
cost_breakdown: Mapping[str, object] | None,
|
||||
billed_at: datetime | str | None,
|
||||
) -> float | None:
|
||||
usage: Final = _usage_from_spend_log(usage_object)
|
||||
if pricing is None or usage is None:
|
||||
return None
|
||||
basis: Final = _pricing_basis(cost_breakdown)
|
||||
result: Final = calculate_prompt_caching_savings(
|
||||
model_info=pricing,
|
||||
usage=usage,
|
||||
custom_llm_provider=provider,
|
||||
service_tier=basis.service_tier,
|
||||
data_residency=basis.data_residency,
|
||||
vertex_location=basis.vertex_location,
|
||||
billed_at=_coerce_billed_at(billed_at),
|
||||
)
|
||||
return result if isfinite(result) else None
|
||||
|
||||
|
||||
def prompt_caching_savings_for_request(
|
||||
model: str | None,
|
||||
custom_llm_provider: str | None,
|
||||
usage_object: Mapping[str, object] | None,
|
||||
model_id: str | None = None,
|
||||
llm_router: "Callable[[], Router | None] | None" = None,
|
||||
cost_breakdown: Mapping[str, object] | None = None,
|
||||
billed_at: datetime | str | None = None,
|
||||
) -> float | None:
|
||||
request_pricing: Final = _request_savings_pricing(model, custom_llm_provider, model_id, llm_router)
|
||||
return _prompt_caching_savings(request_pricing[1], request_pricing[0], usage_object, cost_breakdown, billed_at)
|
||||
|
||||
|
||||
def compute_savings_spend(
|
||||
model: str | None,
|
||||
custom_llm_provider: str | None,
|
||||
|
|
@ -639,29 +689,12 @@ def compute_savings_spend(
|
|||
# Deployment rates when the request came through one, public rates otherwise --
|
||||
# `_effective_model_info` merges a deployment's configured prices over the built-in
|
||||
# map, so a negotiated price is not silently replaced by the list rate.
|
||||
router_instance: Router | None = llm_router() if llm_router else None
|
||||
identity: Final = _resolve_model(model, custom_llm_provider)
|
||||
pricing: Final = _effective_model_info(router_instance, model_id, model or "") or (
|
||||
_model_info(identity) if identity else None
|
||||
)
|
||||
request_pricing: Final = _request_savings_pricing(model, custom_llm_provider, model_id, llm_router)
|
||||
provider: Final = request_pricing[0]
|
||||
pricing: Final = request_pricing[1]
|
||||
input_cost: Final = (_get_cost_per_unit(pricing, "input_cost_per_token") or 0.0) if pricing else 0.0
|
||||
compression: Final = max(compression_saved_tokens, 0) * input_cost
|
||||
usage: Final = _usage_from_spend_log(usage_object)
|
||||
basis: Final = _pricing_basis(cost_breakdown)
|
||||
billed_at_datetime: Final = _coerce_billed_at(billed_at)
|
||||
prompt_caching: Final = (
|
||||
calculate_prompt_caching_savings(
|
||||
model_info=pricing,
|
||||
usage=usage,
|
||||
custom_llm_provider=identity.provider if identity else custom_llm_provider,
|
||||
service_tier=basis.service_tier,
|
||||
data_residency=basis.data_residency,
|
||||
vertex_location=basis.vertex_location,
|
||||
billed_at=billed_at_datetime,
|
||||
)
|
||||
if pricing is not None and usage is not None
|
||||
else 0.0
|
||||
)
|
||||
prompt_caching: Final = _prompt_caching_savings(pricing, provider, usage_object, cost_breakdown, billed_at) or 0.0
|
||||
gateway_injected_caching: Final = prompt_caching if gateway_injected_cache else 0.0
|
||||
|
||||
# The figure the logging path recorded wins, before the usage gate on purpose: a row
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
from datetime import datetime
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
PromptCachingRequestFilter: TypeAlias = Literal["all", "injected", "hits"]
|
||||
|
||||
|
||||
class PromptCachingRequest(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
request_id: str
|
||||
start_time: datetime
|
||||
model: str
|
||||
gateway_injected: bool
|
||||
cache_read_tokens: int
|
||||
cache_creation_tokens: int
|
||||
spend: float
|
||||
net_savings: float | None
|
||||
|
||||
|
||||
class PromptCachingRequestCursor(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
start_time: datetime
|
||||
request_id: str
|
||||
|
||||
|
||||
class PromptCachingRequestsResponse(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
requests: tuple[PromptCachingRequest, ...]
|
||||
page_size: int
|
||||
has_more: bool
|
||||
next_cursor: PromptCachingRequestCursor | None
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
import json
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import psycopg
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from prisma import Prisma
|
||||
from pydantic import TypeAdapter
|
||||
from pytest_postgresql import factories
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.prompt_caching_requests import router
|
||||
from litellm.proxy.spend_tracking.savings import (
|
||||
extract_cache_creation_tokens,
|
||||
extract_cache_read_tokens,
|
||||
marks_gateway_injection,
|
||||
)
|
||||
from litellm.types.management_endpoints.prompt_caching_requests import (
|
||||
PromptCachingRequestFilter,
|
||||
PromptCachingRequestsResponse,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("local_model_cost_map")
|
||||
|
||||
_cache_postgresql_proc: Final = factories.postgresql_proc() # pyright: ignore[reportUnknownMemberType] # third-party fixture factory has incomplete callable types
|
||||
_cache_postgresql: Final = factories.postgresql("_cache_postgresql_proc")
|
||||
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object])
|
||||
_JSON_ROWS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
|
||||
_START: Final = "2026-09-01T00:00:00Z"
|
||||
_END: Final = "2026-09-02T00:00:00Z"
|
||||
_URL: Final = "/cost_optimization/prompt_caching/requests"
|
||||
_MODEL: Final = "claude-sonnet-5"
|
||||
_MARKER: Final = "litellm_gateway_injected_cache"
|
||||
_DDL: Final = """
|
||||
CREATE TABLE "LiteLLM_SpendLogs" (
|
||||
request_id TEXT PRIMARY KEY, "startTime" TIMESTAMP, "endTime" TIMESTAMP,
|
||||
model TEXT, model_id TEXT, custom_llm_provider TEXT, spend DOUBLE PRECISION,
|
||||
metadata JSONB, cache_hit TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Case:
|
||||
request_id: str
|
||||
metadata: Mapping[str, object]
|
||||
cache_hit: str | None = None
|
||||
start_time: datetime = datetime(2026, 9, 1, 12, 0, 0, 123456)
|
||||
|
||||
def matches(self, filter: PromptCachingRequestFilter) -> bool:
|
||||
if self.cache_hit is not None and self.cache_hit.lower() == "true":
|
||||
return False
|
||||
if not datetime(2026, 9, 1) <= self.start_time <= datetime(2026, 9, 2):
|
||||
return False
|
||||
usage: Final = self.metadata.get("usage_object")
|
||||
normalized: Final = _JSON_OBJECT.validate_python(usage) if isinstance(usage, Mapping) else None
|
||||
injected: Final = marks_gateway_injection(self.metadata, "dep-a")
|
||||
reads: Final = extract_cache_read_tokens(normalized)
|
||||
writes: Final = extract_cache_creation_tokens(normalized)
|
||||
match filter:
|
||||
case "injected":
|
||||
return injected
|
||||
case "hits":
|
||||
return reads > 0
|
||||
case "all":
|
||||
return injected or reads > 0 or writes > 0
|
||||
|
||||
|
||||
_CASES: Final = (
|
||||
_Case("injected-empty", {_MARKER: ""}),
|
||||
_Case("injected-deployment", {_MARKER: "dep-a"}),
|
||||
_Case("wrong-deployment", {_MARKER: "dep-b"}),
|
||||
_Case("legacy-read", {"usage_object": {"cache_read_input_tokens": 100}}),
|
||||
_Case("nested-read", {"usage_object": {"prompt_tokens_details": {"cached_tokens": 100}}}),
|
||||
_Case("write", {"usage_object": {"cache_creation_input_tokens": 100}}),
|
||||
_Case("nested-write", {"usage_object": {"prompt_tokens_details": {"cache_write_tokens": 100}}}),
|
||||
_Case("nested-creation", {"usage_object": {"prompt_tokens_details": {"cache_creation_tokens": 100}}}),
|
||||
_Case(
|
||||
"top-precedence",
|
||||
{"usage_object": {"cache_read_input_tokens": -2, "prompt_tokens_details": {"cached_tokens": 100}}},
|
||||
),
|
||||
_Case(
|
||||
"zero-fallback",
|
||||
{"usage_object": {"cache_read_input_tokens": 0, "prompt_tokens_details": {"cached_tokens": 100}}},
|
||||
),
|
||||
_Case(
|
||||
"fractional-precedence",
|
||||
{"usage_object": {"cache_read_input_tokens": 0.5, "prompt_tokens_details": {"cached_tokens": 100}}},
|
||||
),
|
||||
_Case("malformed-number", {"usage_object": {"cache_read_input_tokens": "100"}}),
|
||||
_Case("malformed-container", {"usage_object": [100]}),
|
||||
_Case("boolean-number", {"usage_object": {"cache_read_input_tokens": True}}),
|
||||
_Case("boolean-marker", {_MARKER: True}),
|
||||
_Case("response-cache", {_MARKER: "", "usage_object": {"cache_read_input_tokens": 100}}, "True"),
|
||||
_Case("outside-before", {_MARKER: ""}, start_time=datetime(2026, 8, 31, 23, 59, 59)),
|
||||
_Case(
|
||||
"outside-after", {"usage_object": {"cache_read_input_tokens": 100}}, start_time=datetime(2026, 9, 2, 0, 0, 1)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="function")
|
||||
async def _cache_prisma(
|
||||
_cache_postgresql: psycopg.Connection[tuple[object, ...]],
|
||||
) -> AsyncIterator[Prisma]:
|
||||
info: Final = _cache_postgresql.info
|
||||
database: Final = Prisma(datasource={
|
||||
"url": f"postgresql://{info.user}@{info.host}:{info.port}/{info.dbname}?connection_limit=1",
|
||||
})
|
||||
await database.connect()
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
await database.disconnect()
|
||||
|
||||
|
||||
def _seed(connection: psycopg.Connection[tuple[object, ...]], cases: tuple[_Case, ...] = _CASES) -> None:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(_DDL)
|
||||
cursor.executemany(
|
||||
"""INSERT INTO "LiteLLM_SpendLogs"
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s)""",
|
||||
tuple(
|
||||
(
|
||||
case.request_id,
|
||||
case.start_time,
|
||||
datetime(2026, 9, 1, 12, 0, 1),
|
||||
_MODEL,
|
||||
"dep-a",
|
||||
"anthropic",
|
||||
0.01,
|
||||
json.dumps(dict(case.metadata)),
|
||||
case.cache_hit,
|
||||
)
|
||||
for case in cases
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _app(role: LitellmUserRoles | None) -> FastAPI:
|
||||
application: Final = FastAPI()
|
||||
application.include_router(router)
|
||||
|
||||
def caller() -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(user_role=role)
|
||||
|
||||
application.dependency_overrides[user_api_key_auth] = caller
|
||||
return application
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("filter", ["all", "injected", "hits"])
|
||||
@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
|
||||
async def test_request_filters_match_accounting_and_paginate_before_projection(
|
||||
_cache_postgresql: psycopg.Connection[tuple[object, ...]],
|
||||
_cache_prisma: Prisma,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
filter: PromptCachingRequestFilter,
|
||||
role: LitellmUserRoles,
|
||||
) -> None:
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
_seed(_cache_postgresql)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=_cache_prisma))
|
||||
monkeypatch.setattr(proxy_server, "llm_router", None)
|
||||
expected: Final = tuple(sorted((case.request_id for case in _CASES if case.matches(filter)), reverse=True))
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_app(role)), base_url="http://test") as client:
|
||||
first: Final = await client.get(
|
||||
_URL, params={"start_date": _START, "end_date": _END, "filter": filter, "page_size": 2}
|
||||
)
|
||||
assert first.status_code == 200
|
||||
first_page: Final = PromptCachingRequestsResponse.model_validate_json(first.content)
|
||||
assert tuple(row.request_id for row in first_page.requests) == expected[:2]
|
||||
assert first_page.has_more is (len(expected) > 2)
|
||||
assert (first_page.next_cursor is not None) is first_page.has_more
|
||||
if first_page.next_cursor is not None:
|
||||
assert first_page.next_cursor.request_id == expected[1]
|
||||
assert first_page.next_cursor.start_time == first_page.requests[-1].start_time
|
||||
next_response: Final = await client.get(
|
||||
_URL, params={
|
||||
"start_date": _START, "end_date": _END, "filter": filter, "page_size": 2,
|
||||
"cursor_start_time": first_page.next_cursor.start_time.astimezone(
|
||||
timezone(timedelta(hours=-7))
|
||||
).isoformat(),
|
||||
"cursor_request_id": first_page.next_cursor.request_id,
|
||||
}
|
||||
)
|
||||
assert next_response.status_code == 200
|
||||
next_page: Final = PromptCachingRequestsResponse.model_validate_json(next_response.content)
|
||||
assert tuple(row.request_id for row in next_page.requests) == expected[2:4]
|
||||
assert next_page.has_more is (len(expected) > 4)
|
||||
assert (next_page.next_cursor is not None) is next_page.has_more
|
||||
second: Final = await client.get(
|
||||
_URL, params={"start_date": _START, "end_date": _END, "filter": filter, "page_size": 100}
|
||||
)
|
||||
assert second.status_code == 200
|
||||
complete: Final = PromptCachingRequestsResponse.model_validate_json(second.content)
|
||||
assert tuple(row.request_id for row in complete.requests) == expected
|
||||
assert complete.has_more is False
|
||||
assert complete.next_cursor is None
|
||||
assert all(row.start_time.tzinfo == timezone.utc for row in complete.requests)
|
||||
payload: Final = _JSON_OBJECT.validate_json(second.content)
|
||||
assert set(payload) == {"requests", "page_size", "has_more", "next_cursor"}
|
||||
serialized_rows: Final = _JSON_ROWS.validate_python(payload["requests"])
|
||||
assert set(serialized_rows[0]) == {
|
||||
"request_id",
|
||||
"start_time",
|
||||
"model",
|
||||
"gateway_injected",
|
||||
"cache_read_tokens",
|
||||
"cache_creation_tokens",
|
||||
"spend",
|
||||
"net_savings",
|
||||
}
|
||||
by_id: Final = {row.request_id: row for row in complete.requests}
|
||||
if filter == "all":
|
||||
assert by_id["injected-empty"].gateway_injected is True
|
||||
assert by_id["injected-empty"].net_savings is None
|
||||
assert by_id["legacy-read"].gateway_injected is False
|
||||
assert by_id["legacy-read"].net_savings is not None and by_id["legacy-read"].net_savings > 0
|
||||
assert by_id["write"].net_savings is not None and by_id["write"].net_savings < 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("role", [None, LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY])
|
||||
async def test_non_admin_is_denied_before_database_access(
|
||||
role: LitellmUserRoles | None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_app(role)), base_url="http://test") as client:
|
||||
response: Final = await client.get(_URL, params={"start_date": _START, "end_date": _END})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("params", [
|
||||
{"filter": "savings"}, {"page_size": 0}, {"page_size": 101}, {"start_date": "invalid"},
|
||||
{"cursor_start_time": "invalid", "cursor_request_id": "request"},
|
||||
{"cursor_start_time": _START, "cursor_request_id": ""},
|
||||
])
|
||||
async def test_invalid_request_is_rejected(params: Mapping[str, str | int]) -> None:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(LitellmUserRoles.PROXY_ADMIN)), base_url="http://test"
|
||||
) as client:
|
||||
response: Final = await client.get(_URL, params={"start_date": _START, "end_date": _END, **params})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("params", [{"cursor_start_time": _START}, {"cursor_request_id": "request"}])
|
||||
async def test_incomplete_cursor_is_rejected(
|
||||
params: Mapping[str, str], monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(LitellmUserRoles.PROXY_ADMIN)), base_url="http://test"
|
||||
) as client:
|
||||
response: Final = await client.get(_URL, params={"start_date": _START, "end_date": _END, **params})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("delete_before_cursor", [False, True])
|
||||
async def test_cursor_keeps_remaining_requests_once_during_insertions_and_deletions(
|
||||
_cache_postgresql: psycopg.Connection[tuple[object, ...]],
|
||||
_cache_prisma: Prisma,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
delete_before_cursor: bool,
|
||||
) -> None:
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
cases: Final = (*_CASES, _Case(
|
||||
"older-cache-read", {"usage_object": {"cache_read_input_tokens": 100}}, start_time=datetime(2026, 9, 1, 11),
|
||||
))
|
||||
_seed(_cache_postgresql, cases)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=_cache_prisma))
|
||||
monkeypatch.setattr(proxy_server, "llm_router", None)
|
||||
expected: Final = (*sorted((case.request_id for case in _CASES if case.matches("all")), reverse=True), "older-cache-read")
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(LitellmUserRoles.PROXY_ADMIN)), base_url="http://test"
|
||||
) as client:
|
||||
first: Final = await client.get(_URL, params={"start_date": _START, "end_date": _END, "page_size": 2})
|
||||
assert first.status_code == 200
|
||||
first_page: Final = PromptCachingRequestsResponse.model_validate_json(first.content)
|
||||
assert tuple(row.request_id for row in first_page.requests) == expected[:2]
|
||||
assert first_page.next_cursor is not None
|
||||
with _cache_postgresql.cursor() as cursor:
|
||||
cursor.executemany(
|
||||
"""INSERT INTO "LiteLLM_SpendLogs"
|
||||
SELECT %s, %s, "endTime", model, model_id, custom_llm_provider, spend, metadata, cache_hit
|
||||
FROM "LiteLLM_SpendLogs" WHERE request_id = %s""",
|
||||
(
|
||||
("newer-request", datetime(2026, 9, 1, 13), expected[0]),
|
||||
("zz-higher-id", cases[0].start_time, expected[0]),
|
||||
),
|
||||
)
|
||||
if delete_before_cursor:
|
||||
cursor.execute('DELETE FROM "LiteLLM_SpendLogs" WHERE request_id = %s', (expected[0],))
|
||||
_cache_postgresql.commit()
|
||||
following: Final = await client.get(_URL, params={
|
||||
"start_date": _START, "end_date": _END, "page_size": 100,
|
||||
"cursor_start_time": first_page.next_cursor.start_time.isoformat(),
|
||||
"cursor_request_id": first_page.next_cursor.request_id,
|
||||
})
|
||||
assert following.status_code == 200
|
||||
following_page: Final = PromptCachingRequestsResponse.model_validate_json(following.content)
|
||||
assert tuple(row.request_id for row in following_page.requests) == expected[2:]
|
||||
assert following_page.has_more is False
|
||||
assert following_page.next_cursor is None
|
||||
|
|
@ -11,6 +11,7 @@ from litellm.proxy.spend_tracking.savings import (
|
|||
compute_autorouter_savings,
|
||||
compute_savings_spend,
|
||||
marks_gateway_injection,
|
||||
prompt_caching_savings_for_request,
|
||||
)
|
||||
from litellm.router import Router
|
||||
from litellm.types.utils import Usage
|
||||
|
|
@ -18,6 +19,42 @@ from litellm.types.utils import Usage
|
|||
pytestmark = pytest.mark.usefixtures("local_model_cost_map")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model,usage", [
|
||||
(None, {"cache_read_input_tokens": 100}),
|
||||
("claude-sonnet-5", None),
|
||||
("claude-sonnet-5", {"prompt_tokens": "invalid"}),
|
||||
])
|
||||
def test_prompt_cache_estimate_distinguishes_unknown_from_zero(model: str | None, usage: dict[str, object] | None) -> None:
|
||||
assert prompt_caching_savings_for_request(model, "anthropic", usage) is None
|
||||
assert compute_savings_spend(model, "anthropic", 0, False, usage_object=usage).prompt_caching == 0
|
||||
assert prompt_caching_savings_for_request("claude-sonnet-5", "anthropic", {"prompt_tokens": 100}) == 0
|
||||
|
||||
|
||||
def test_prompt_cache_estimate_uses_the_rollup_pricing_and_retains_write_premiums() -> None:
|
||||
router: Final = Router(model_list=[{
|
||||
"model_name": "negotiated",
|
||||
"litellm_params": {
|
||||
"model": "anthropic/claude-sonnet-5", "input_cost_per_token": 1e-6,
|
||||
"cache_creation_input_token_cost": 1.25e-6, "cache_read_input_token_cost": 1e-7,
|
||||
},
|
||||
"model_info": {"id": "negotiated-cache-prices"},
|
||||
}])
|
||||
|
||||
def current_router() -> Router:
|
||||
return router
|
||||
|
||||
usage: Final = {"cache_read_input_tokens": 1000, "cache_creation_input_tokens": 20000}
|
||||
estimate: Final = prompt_caching_savings_for_request(
|
||||
"claude-sonnet-5", "anthropic", usage, model_id="negotiated-cache-prices", llm_router=current_router,
|
||||
)
|
||||
rollup: Final = compute_savings_spend(
|
||||
"claude-sonnet-5", "anthropic", 0, True, usage_object=usage,
|
||||
model_id="negotiated-cache-prices", llm_router=current_router,
|
||||
)
|
||||
assert estimate == pytest.approx(1000 * (1e-6 - 1e-7) - 20000 * (1.25e-6 - 1e-6))
|
||||
assert estimate == rollup.prompt_caching == rollup.gateway_injected_caching
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modifier", [{"speed": "fast"}, {"inference_geo": "us"}])
|
||||
@pytest.mark.parametrize("continuing", [False, True])
|
||||
def test_baseline_preserves_anthropic_pricing_fields(modifier: dict[str, str], continuing: bool) -> None:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Info } from "lucide-react";
|
||||
|
||||
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -81,7 +80,7 @@ const SortableHead = ({
|
|||
};
|
||||
|
||||
const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
|
||||
const { dateValue, onDateChange, results, loading, isFetchingMore, apiKeyTruncation } = activity;
|
||||
const { results, loading, isFetchingMore, apiKeyTruncation } = activity;
|
||||
const [dimension, setDimension] = useState<CacheLeakageDimension>("key");
|
||||
const [sort, setSort] = useState<SortState>({ column: "potentialSavings", dir: "desc" });
|
||||
const leakage = useMemo(() => computeCacheLeakage(results, dimension), [results, dimension]);
|
||||
|
|
@ -111,9 +110,6 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
|
|||
cached token, after cache-write premiums.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs value={dimension} onValueChange={(value) => setDimension(value === "model" ? "model" : "key")}>
|
||||
<TabsList>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () =>
|
|||
}));
|
||||
|
||||
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div /> }));
|
||||
vi.mock("./PromptCachingRequestsTable", () => ({ default: () => <div /> }));
|
||||
|
||||
import CostOptimizationView from "./CostOptimizationView";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,248 @@
|
|||
import { Profiler } from "react";
|
||||
import { act, fireEvent, renderWithProviders, screen, testQueryClient, waitFor, within } from "@/../tests/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import PromptCachingRequestsTable from "./PromptCachingRequestsTable";
|
||||
import type { DateRange } from "./useDailyActivityRange";
|
||||
|
||||
type CacheRequest = components["schemas"]["PromptCachingRequest"];
|
||||
type RequestsResponse = components["schemas"]["PromptCachingRequestsResponse"];
|
||||
const firstCursor = { start_time: "2026-09-01T11:59:59.123456Z", request_id: "first-boundary?&" };
|
||||
const secondCursor = { start_time: firstCursor.start_time, request_id: "second-boundary" };
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
const dates = { from: new Date(2026, 8, 1, 12), to: new Date(2026, 8, 2, 12) };
|
||||
const request = (overrides: Partial<CacheRequest> = {}): CacheRequest => ({
|
||||
request_id: "request-default",
|
||||
start_time: "2026-09-01T12:00:00Z",
|
||||
model: "cache-test-model",
|
||||
gateway_injected: true,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 1000,
|
||||
spend: 0.0375,
|
||||
net_savings: -0.0075,
|
||||
...overrides,
|
||||
});
|
||||
const response = (requests: CacheRequest[], nextCursor: RequestsResponse["next_cursor"] = null) => {
|
||||
const body: RequestsResponse = { requests, has_more: nextCursor !== null, next_cursor: nextCursor, page_size: 50 };
|
||||
return Response.json(body);
|
||||
};
|
||||
const lastQuery = () => new URL(String(fetchMock.mock.calls.at(-1)?.[0]), "http://localhost").searchParams;
|
||||
|
||||
describe("PromptCachingRequestsTable", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testQueryClient.clear();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("separates recorded injection from cache hits, retains write premiums and unknown savings, and links each request", async () => {
|
||||
const clientHit = {
|
||||
request_id: "client-hit",
|
||||
gateway_injected: false,
|
||||
cache_read_tokens: 10000,
|
||||
cache_creation_tokens: 0,
|
||||
net_savings: 0.27,
|
||||
};
|
||||
fetchMock.mockResolvedValue(
|
||||
response([
|
||||
request({ request_id: "injected/write?&", net_savings: -0.0075 }),
|
||||
request(clientHit),
|
||||
request({ request_id: "unknown-price", net_savings: null }),
|
||||
request({ request_id: "no-benefit", net_savings: 0 }),
|
||||
]),
|
||||
);
|
||||
renderWithProviders(<PromptCachingRequestsTable accessToken="token-a" dateValue={dates} />);
|
||||
|
||||
const table = await screen.findByRole("table", { name: "Prompt caching requests" });
|
||||
const write = within(table).getByRole("row", { name: /injected\/write/ });
|
||||
expect(within(write).getByText("Recorded")).toBeInTheDocument();
|
||||
expect(within(write).getByText("1,000")).toBeInTheDocument();
|
||||
expect(within(write).getByText("$0.0375")).toBeInTheDocument();
|
||||
expect(within(write).getByText("-$0.0075")).toBeInTheDocument();
|
||||
expect(within(write).getByText(new Date("2026-09-01T12:00:00Z").toLocaleString())).toBeInTheDocument();
|
||||
expect(within(write).getByText("cache-test-model")).toHaveAttribute("title", "cache-test-model");
|
||||
expect(within(write).getByRole("link")).toHaveAttribute("href", "/ui/logs?log_id=injected%2Fwrite%3F%26");
|
||||
|
||||
const hit = within(table).getByRole("row", { name: /client-hit/ });
|
||||
expect(within(hit).getByText("Not recorded")).toBeInTheDocument();
|
||||
expect(within(hit).getByText("10,000")).toBeInTheDocument();
|
||||
expect(within(hit).getByText("$0.2700")).toBeInTheDocument();
|
||||
expect(within(table).getByRole("row", { name: /unknown-price/ })).toHaveTextContent("Unavailable");
|
||||
expect(within(table).getByRole("row", { name: /no-benefit/ })).toHaveTextContent("$0.00");
|
||||
expect(screen.getByText(/after cache-write premiums/)).toBeInTheDocument();
|
||||
expect(lastQuery().get("start_date")).toBe("2026-09-01T00:00:00.000Z");
|
||||
expect(lastQuery().get("end_date")).toBe("2026-09-02T23:59:59.999Z");
|
||||
expect(fetchMock.mock.calls[0][1]?.headers).toEqual(expect.objectContaining({ Authorization: "Bearer token-a" }));
|
||||
});
|
||||
|
||||
it("forwards complete server cursors, goes back to prior cursors, and clears them for each caching filter", async () => {
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const query = new URL(String(input), "http://localhost").searchParams;
|
||||
const pages = new Map([
|
||||
[null, 1],
|
||||
[firstCursor.request_id, 2],
|
||||
[secondCursor.request_id, 3],
|
||||
]);
|
||||
const page = pages.get(query.get("cursor_request_id"));
|
||||
const nextCursor =
|
||||
new Map([
|
||||
[1, firstCursor],
|
||||
[2, secondCursor],
|
||||
]).get(page ?? 0) ?? null;
|
||||
return response([request({ request_id: `${query.get("filter")}-${page}` })], nextCursor);
|
||||
});
|
||||
renderWithProviders(<PromptCachingRequestsTable accessToken="token-a" dateValue={dates} />);
|
||||
await screen.findByRole("link", { name: "all-1" });
|
||||
expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
|
||||
expect(lastQuery().has("page")).toBe(false);
|
||||
expect(lastQuery().has("cursor_request_id")).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await screen.findByRole("link", { name: "all-2" });
|
||||
expect(screen.getByText("Page 2")).toBeInTheDocument();
|
||||
expect(lastQuery().get("cursor_start_time")).toBe(firstCursor.start_time);
|
||||
expect(lastQuery().get("cursor_request_id")).toBe(firstCursor.request_id);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await screen.findByRole("link", { name: "all-3" });
|
||||
expect(screen.getByText("Page 3")).toBeInTheDocument();
|
||||
expect(lastQuery().get("cursor_start_time")).toBe(secondCursor.start_time);
|
||||
expect(lastQuery().get("cursor_request_id")).toBe(secondCursor.request_id);
|
||||
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
|
||||
|
||||
await testQueryClient.invalidateQueries({ refetchType: "none" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous" }));
|
||||
await screen.findByRole("link", { name: "all-2" });
|
||||
await waitFor(() => expect(lastQuery().get("cursor_request_id")).toBe(firstCursor.request_id));
|
||||
expect(lastQuery().get("cursor_start_time")).toBe(firstCursor.start_time);
|
||||
expect(screen.getByText("Page 2")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous" }));
|
||||
await screen.findByRole("link", { name: "all-1" });
|
||||
await waitFor(() => expect(lastQuery().has("cursor_request_id")).toBe(false));
|
||||
expect(lastQuery().has("cursor_start_time")).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await screen.findByRole("link", { name: "all-2" });
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "LiteLLM injected" }));
|
||||
await screen.findByRole("link", { name: "injected-1" });
|
||||
expect(screen.queryByRole("link", { name: "all-2" })).not.toBeInTheDocument();
|
||||
expect(lastQuery().get("filter")).toBe("injected");
|
||||
expect(lastQuery().has("cursor_request_id")).toBe(false);
|
||||
expect(lastQuery().has("cursor_start_time")).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await screen.findByRole("link", { name: "injected-2" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Cache hits" }));
|
||||
await screen.findByRole("link", { name: "hits-1" });
|
||||
expect(lastQuery().get("filter")).toBe("hits");
|
||||
expect(lastQuery().get("page_size")).toBe("50");
|
||||
expect(screen.getByText("Page 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes the current UTC day for a range ending today, matching the activity totals", async () => {
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
vi.setSystemTime(new Date("2026-09-20T03:00:00Z"));
|
||||
fetchMock.mockResolvedValue(response([]));
|
||||
const today = { from: new Date(2026, 8, 19), to: new Date() };
|
||||
renderWithProviders(<PromptCachingRequestsTable accessToken="token-a" dateValue={today} />);
|
||||
|
||||
await screen.findByText("No matching prompt caching requests in this range");
|
||||
expect(lastQuery().get("start_date")).toBe("2026-09-19T00:00:00.000Z");
|
||||
expect(lastQuery().get("end_date")).toBe("2026-09-20T23:59:59.999Z");
|
||||
});
|
||||
|
||||
it.each(["date", "authentication"])(
|
||||
"hides every old-scope frame and resets pagination when %s changes",
|
||||
async (change) => {
|
||||
fetchMock.mockResolvedValueOnce(response([request({ request_id: "old-first" })], firstCursor));
|
||||
fetchMock.mockResolvedValueOnce(response([request({ request_id: "old-second" })]));
|
||||
const committedOldRows: boolean[] = [];
|
||||
const snapshot = () => {
|
||||
committedOldRows.push(screen.queryByRole("link", { name: "old-second" }) !== null);
|
||||
};
|
||||
const tree = (accessToken: string, dateValue: DateRange) => (
|
||||
<Profiler id="request-scope" onRender={snapshot}>
|
||||
<PromptCachingRequestsTable accessToken={accessToken} dateValue={dateValue} />
|
||||
</Profiler>
|
||||
);
|
||||
const { rerender } = renderWithProviders(tree("token-a", dates));
|
||||
await screen.findByRole("link", { name: "old-first" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await screen.findByRole("link", { name: "old-second" });
|
||||
|
||||
const pending = Promise.withResolvers<Response>();
|
||||
fetchMock.mockReturnValueOnce(pending.promise);
|
||||
committedOldRows.length = 0;
|
||||
rerender(
|
||||
tree(
|
||||
change === "authentication" ? "token-b" : "token-a",
|
||||
change === "date" ? { ...dates, to: new Date(2026, 8, 3) } : dates,
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Loading requests");
|
||||
expect(committedOldRows.length).toBeGreaterThan(0);
|
||||
expect(committedOldRows.every((visible) => !visible)).toBe(true);
|
||||
expect(lastQuery().has("cursor_request_id")).toBe(false);
|
||||
expect(lastQuery().has("cursor_start_time")).toBe(false);
|
||||
if (change === "date") {
|
||||
expect(lastQuery().get("end_date")).toBe("2026-09-03T23:59:59.999Z");
|
||||
} else {
|
||||
expect(fetchMock.mock.calls.at(-1)?.[1]?.headers).toEqual(
|
||||
expect.objectContaining({ Authorization: "Bearer token-b" }),
|
||||
);
|
||||
}
|
||||
|
||||
pending.resolve(response([request({ request_id: "new-first" })]));
|
||||
await screen.findByRole("link", { name: "new-first" });
|
||||
expect(screen.getByText("Page 1")).toBeInTheDocument();
|
||||
expect(committedOldRows.every((visible) => !visible)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores a delayed response from the previous caching filter", async () => {
|
||||
const stale = Promise.withResolvers<Response>();
|
||||
const current = Promise.withResolvers<Response>();
|
||||
fetchMock.mockReturnValueOnce(stale.promise).mockReturnValueOnce(current.promise);
|
||||
renderWithProviders(<PromptCachingRequestsTable accessToken="token-a" dateValue={dates} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Cache hits" }));
|
||||
expect(lastQuery().get("filter")).toBe("hits");
|
||||
|
||||
current.resolve(response([request({ request_id: "current-hit" })]));
|
||||
await screen.findByRole("link", { name: "current-hit" });
|
||||
await act(async () => {
|
||||
stale.resolve(response([request({ request_id: "stale-all" })], firstCursor));
|
||||
await stale.promise;
|
||||
});
|
||||
|
||||
expect(screen.getByRole("link", { name: "current-hit" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "stale-all" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("offers retry after a failed read and shows the empty state after it succeeds", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
fetchMock.mockResolvedValueOnce(response([]));
|
||||
renderWithProviders(<PromptCachingRequestsTable accessToken="token-a" dateValue={dates} />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load prompt caching requests");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByText("No matching prompt caching requests in this range")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not request data for an incomplete date range", async () => {
|
||||
renderWithProviders(<PromptCachingRequestsTable accessToken="token-a" dateValue={{ from: dates.from }} />);
|
||||
expect(screen.getByText("Select a date range to view requests")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LOG_ID_QUERY_PARAM } from "@/components/view_logs/logDetailRouting";
|
||||
import type { paths } from "@/lib/http/schema";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { usd } from "./costOptimizationUtils";
|
||||
import { benchmarksWindow as activityWindow } from "./useAutoRouterBenchmarks";
|
||||
import type { DateRange } from "./useDailyActivityRange";
|
||||
|
||||
const REQUESTS_PATH = "/cost_optimization/prompt_caching/requests";
|
||||
type RequestsEndpoint = paths[typeof REQUESTS_PATH]["get"];
|
||||
type RequestsResponse = RequestsEndpoint["responses"][200]["content"]["application/json"];
|
||||
type RequestsQuery = NonNullable<RequestsEndpoint["parameters"]["query"]>;
|
||||
type RequestFilter = NonNullable<RequestsQuery["filter"]>;
|
||||
type RequestCursor = RequestsResponse["next_cursor"];
|
||||
|
||||
interface PromptCachingRequestsTableProps {
|
||||
accessToken: string;
|
||||
dateValue: DateRange;
|
||||
}
|
||||
|
||||
export default function PromptCachingRequestsTable({ accessToken, dateValue }: PromptCachingRequestsTableProps) {
|
||||
const [filter, setFilter] = useState<RequestFilter>("all");
|
||||
const window = activityWindow(dateValue, new Date());
|
||||
const startDate = window.start_date ? `${window.start_date}T00:00:00.000Z` : "";
|
||||
const endDate = window.end_date ? `${window.end_date}T23:59:59.999Z` : "";
|
||||
const scope = JSON.stringify([accessToken, startDate, endDate, filter]);
|
||||
const [pagination, setPagination] = useState<{ scope: string; cursors: readonly RequestCursor[] }>({
|
||||
scope,
|
||||
cursors: [null],
|
||||
});
|
||||
const cursors = pagination.scope === scope ? pagination.cursors : [null];
|
||||
const cursor = cursors.at(-1);
|
||||
const page = cursors.length;
|
||||
|
||||
if (pagination.scope !== scope) {
|
||||
setPagination({ scope, cursors: [null] });
|
||||
}
|
||||
|
||||
const enabled = Boolean(accessToken && startDate && endDate);
|
||||
const query: RequestsQuery = {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
filter,
|
||||
page_size: 50,
|
||||
cursor_start_time: cursor?.start_time,
|
||||
cursor_request_id: cursor?.request_id,
|
||||
};
|
||||
const queryOptions: UseQueryOptions<RequestsResponse> = {
|
||||
queryKey: [REQUESTS_PATH, accessToken, query],
|
||||
queryFn: ({ signal }) => apiClient.get<RequestsResponse>(REQUESTS_PATH, { accessToken, query, signal }),
|
||||
enabled,
|
||||
retry: false,
|
||||
};
|
||||
const requests = useQuery(queryOptions);
|
||||
const nextCursor = requests.data?.next_cursor;
|
||||
|
||||
const changeFilter = (value: unknown) => {
|
||||
if (value === "all" || value === "injected" || value === "hits") {
|
||||
setFilter(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<div>
|
||||
<CardTitle>Prompt caching requests</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Requests with recorded LiteLLM injection or provider cache reads or writes. A cache hit alone does not
|
||||
establish LiteLLM injection; older logs may not record it.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Net savings are estimated from logged usage and current configured pricing, after cache-write premiums.
|
||||
Negative values mean caching cost more; unavailable means the request could not be priced.
|
||||
</p>
|
||||
</div>
|
||||
<Tabs value={filter} onValueChange={changeFilter}>
|
||||
<TabsList aria-label="Prompt caching request filters">
|
||||
<TabsTrigger value="all">All caching</TabsTrigger>
|
||||
<TabsTrigger value="injected">LiteLLM injected</TabsTrigger>
|
||||
<TabsTrigger value="hits">Cache hits</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!enabled && <p className="py-8 text-center text-muted-foreground">Select a date range to view requests</p>}
|
||||
{enabled && requests.isPending && (
|
||||
<p role="status" className="py-8 text-center text-muted-foreground">
|
||||
Loading requests...
|
||||
</p>
|
||||
)}
|
||||
{enabled && requests.isError && (
|
||||
<div role="alert" className="flex items-center justify-center gap-3 py-8">
|
||||
<p>Could not load prompt caching requests</p>
|
||||
<Button variant="outline" onClick={() => void requests.refetch()} disabled={requests.isFetching}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{enabled && requests.isSuccess && (
|
||||
<>
|
||||
{requests.data.requests.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No matching prompt caching requests in this range
|
||||
</p>
|
||||
) : (
|
||||
<Table aria-label="Prompt caching requests">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Request</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>LiteLLM injection</TableHead>
|
||||
<TableHead className="text-right">Cache reads</TableHead>
|
||||
<TableHead className="text-right">Cache writes</TableHead>
|
||||
<TableHead className="text-right">Actual cost</TableHead>
|
||||
<TableHead className="text-right">Net savings</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requests.data.requests.map((request) => (
|
||||
<TableRow key={request.request_id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={uiHref(`logs?${new URLSearchParams({ [LOG_ID_QUERY_PARAM]: request.request_id })}`)}
|
||||
className="block max-w-40 truncate text-primary underline underline-offset-2"
|
||||
title={request.request_id}
|
||||
>
|
||||
{request.request_id}
|
||||
</Link>
|
||||
<time dateTime={request.start_time} className="mt-1 block text-xs text-muted-foreground">
|
||||
{new Date(request.start_time).toLocaleString()}
|
||||
</time>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="block max-w-36 truncate" title={request.model}>
|
||||
{request.model}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{request.gateway_injected ? "Recorded" : "Not recorded"}</TableCell>
|
||||
<TableCell className="text-right">{formatNumberWithCommas(request.cache_read_tokens)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumberWithCommas(request.cache_creation_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{usd(request.spend)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{request.net_savings === null ? "Unavailable" : usd(request.net_savings)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPagination({ scope, cursors: cursors.slice(0, -1) })}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {page}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!requests.data.has_more || !nextCursor}
|
||||
onClick={() => nextCursor && setPagination({ scope, cursors: [...cursors, nextCursor] })}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, waitFor, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, waitFor, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockGetGeneralSettingsCall = vi.fn();
|
||||
|
|
@ -12,6 +12,21 @@ vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () =>
|
|||
}));
|
||||
|
||||
const mockCacheLeakageCard = vi.fn();
|
||||
const mockRequestsTable = vi.fn();
|
||||
const nextDateRange = { from: new Date(2026, 8, 1), to: new Date(2026, 8, 2) };
|
||||
|
||||
vi.mock("./PromptCachingRequestsTable", () => ({
|
||||
default: (props: unknown) => {
|
||||
mockRequestsTable(props);
|
||||
return <div data-testid="caching-requests" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/advanced_date_picker", () => ({
|
||||
default: ({ onValueChange }: { onValueChange: (range: typeof nextDateRange) => void }) => (
|
||||
<button onClick={() => onValueChange(nextDateRange)}>Change caching dates</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./CacheLeakageCard", () => ({
|
||||
__esModule: true,
|
||||
|
|
@ -24,7 +39,7 @@ vi.mock("./CacheLeakageCard", () => ({
|
|||
import PromptCachingTab from "./PromptCachingTab";
|
||||
|
||||
describe("PromptCachingTab", () => {
|
||||
it("renders the cache leakage table alongside the caching settings", async () => {
|
||||
it("shares the selected dates between requests and cache leakage alongside caching settings", async () => {
|
||||
mockGetGeneralSettingsCall.mockResolvedValue([]);
|
||||
|
||||
const activity = {
|
||||
|
|
@ -42,6 +57,10 @@ describe("PromptCachingTab", () => {
|
|||
|
||||
expect(screen.getByTestId("caching-settings")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cache-leakage-card")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("caching-requests")).toBeInTheDocument();
|
||||
expect(mockRequestsTable).toHaveBeenCalledWith({ accessToken: "test-token", dateValue: activity.dateValue });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change caching dates" }));
|
||||
expect(activity.onDateChange).toHaveBeenCalledWith(nextDateRange);
|
||||
await waitFor(() => expect(mockCacheLeakageCard).toHaveBeenCalledWith(expect.objectContaining({ activity })));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getGeneralSettingsCall } from "@/components/networking";
|
||||
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
PromptCachingPanel,
|
||||
generalSettingsItem,
|
||||
} from "@/app/(dashboard)/router-settings/_components/general_settings";
|
||||
import CacheLeakageCard from "./CacheLeakageCard";
|
||||
import PromptCachingRequestsTable from "./PromptCachingRequestsTable";
|
||||
import { DailyActivityRange } from "./useDailyActivityRange";
|
||||
|
||||
interface PromptCachingTabProps {
|
||||
|
|
@ -48,6 +50,11 @@ const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken, activi
|
|||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<PromptCachingPanel accessToken={accessToken} settings={settings} onChange={handleChange} />
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">Date range for requests and cache leakage</p>
|
||||
<AdvancedDatePicker value={activity.dateValue} onValueChange={activity.onDateChange} />
|
||||
</div>
|
||||
<PromptCachingRequestsTable accessToken={accessToken} dateValue={activity.dateValue} />
|
||||
<CacheLeakageCard activity={activity} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
95
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
95
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -3534,6 +3534,23 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/cost_optimization/prompt_caching/requests": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Prompt Caching Requests */
|
||||
get: operations["get_prompt_caching_requests_cost_optimization_prompt_caching_requests_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/credentials": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -35814,6 +35831,48 @@ export interface components {
|
|||
prompt_id: string;
|
||||
prompt_info?: components["schemas"]["PromptInfo"] | null;
|
||||
};
|
||||
/** PromptCachingRequest */
|
||||
PromptCachingRequest: {
|
||||
/** Cache Creation Tokens */
|
||||
cache_creation_tokens: number;
|
||||
/** Cache Read Tokens */
|
||||
cache_read_tokens: number;
|
||||
/** Gateway Injected */
|
||||
gateway_injected: boolean;
|
||||
/** Model */
|
||||
model: string;
|
||||
/** Net Savings */
|
||||
net_savings: number | null;
|
||||
/** Request Id */
|
||||
request_id: string;
|
||||
/** Spend */
|
||||
spend: number;
|
||||
/**
|
||||
* Start Time
|
||||
* Format: date-time
|
||||
*/
|
||||
start_time: string;
|
||||
};
|
||||
/** PromptCachingRequestCursor */
|
||||
PromptCachingRequestCursor: {
|
||||
/** Request Id */
|
||||
request_id: string;
|
||||
/**
|
||||
* Start Time
|
||||
* Format: date-time
|
||||
*/
|
||||
start_time: string;
|
||||
};
|
||||
/** PromptCachingRequestsResponse */
|
||||
PromptCachingRequestsResponse: {
|
||||
/** Has More */
|
||||
has_more: boolean;
|
||||
next_cursor: components["schemas"]["PromptCachingRequestCursor"] | null;
|
||||
/** Page Size */
|
||||
page_size: number;
|
||||
/** Requests */
|
||||
requests: components["schemas"]["PromptCachingRequest"][];
|
||||
};
|
||||
/** PromptInfo */
|
||||
PromptInfo: {
|
||||
/**
|
||||
|
|
@ -47238,6 +47297,42 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_prompt_caching_requests_cost_optimization_prompt_caching_requests_get: {
|
||||
parameters: {
|
||||
query: {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
page_size?: number;
|
||||
filter?: "all" | "injected" | "hits";
|
||||
cursor_start_time?: string | null;
|
||||
cursor_request_id?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PromptCachingRequestsResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_credentials_credentials_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue