Merge branch 'litellm_internal_staging' into fix/async-stream-finalization

This commit is contained in:
Dixing Xu 2026-07-25 23:18:08 +00:00
commit 339fbe83a5
24 changed files with 1095 additions and 166 deletions

View file

@ -29,7 +29,15 @@ jobs:
id: changed
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# base.sha is the base branch tip from when the PR was opened, while
# actions/checkout leaves HEAD on a merge of the PR into the *current*
# base tip. "$BASE_SHA"...HEAD therefore spans every base-branch commit
# landed since, so a PR that touches no UI file still gets linted
# against hundreds of other people's files. Diff the PR head against its
# own merge base instead, which is exactly what this PR changed.
merge_base=$(git merge-base "$BASE_SHA" "$HEAD_SHA")
: > "$RUNNER_TEMP/prettier_files.txt"
: > "$RUNNER_TEMP/eslint_files.txt"
while IFS= read -r f; do
@ -41,7 +49,7 @@ jobs:
*.json | *.css | *.scss | *.md | *.mdx | *.yml | *.yaml | *.html)
printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt" ;;
esac
done < <(git diff --name-only --diff-filter=ACMR --relative "$BASE_SHA"...HEAD -- .)
done < <(git diff --name-only --diff-filter=ACMR --relative "$merge_base" "$HEAD_SHA" -- .)
if [ -s "$RUNNER_TEMP/prettier_files.txt" ] || [ -s "$RUNNER_TEMP/eslint_files.txt" ]; then
echo "has_files=true" >> "$GITHUB_OUTPUT"
else

View file

@ -316,26 +316,34 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
where_clause: Dict[str, Any] = {"file_purpose": "batch", **owner_filter}
if after:
where_clause["id"] = {"gt": after}
cursor_row = (
await self.prisma_client.db.litellm_managedobjecttable.find_first(
where={**where_clause, "unified_object_id": after}
)
)
if cursor_row is None:
raise HTTPException(
status_code=400,
detail=f"Invalid 'after' cursor: no batch found with id '{after}'.",
)
fetch_limit = limit or 20
if target_model_names:
# Oversample so post-fetch model-name filtering still has enough rows.
fetch_limit = max(fetch_limit * 3, 100)
page_size = limit or 20
cursor_args: Dict[str, Any] = (
{"cursor": {"unified_object_id": after}, "skip": 1} if after else {}
)
batches = await self.prisma_client.db.litellm_managedobjecttable.find_many(
where=where_clause,
take=fetch_limit,
order={"created_at": "desc"},
take=page_size + 1,
order=[{"created_at": "desc"}, {"unified_object_id": "desc"}],
**cursor_args,
)
batch_objects: List[LiteLLMBatch] = []
for batch in batches:
try:
# Stop once we have enough after filtering
if len(batch_objects) >= (limit or 20):
break
has_more = len(batches) > page_size
batch_objects: List[LiteLLMBatch] = []
for batch in batches[:page_size]:
try:
batch_data = (
json.loads(batch.file_object)
if isinstance(batch.file_object, str)
@ -351,9 +359,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
)
continue
return build_list_page(
batch_objects, has_more=len(batch_objects) == (limit or 20)
)
return build_list_page(batch_objects, has_more=has_more)
async def get_user_created_file_ids(
self, user_api_key_dict: UserAPIKeyAuth, model_object_ids: List[str]

View file

@ -2727,7 +2727,7 @@ if MCP_AVAILABLE:
):
raise HTTPException(
status_code=403,
detail=f"User not allowed to call this tool. Allowed MCP servers: {allowed_mcp_servers}",
detail="User not allowed to call this tool.",
)
standard_logging_mcp_tool_call: StandardLoggingMCPToolCall = _get_standard_logging_mcp_tool_call(

View file

@ -163,6 +163,15 @@ class MCPServer(BaseModel):
allow_elicitation: bool = False
model_config = ConfigDict(arbitrary_types_allowed=True)
def __repr__(self) -> str:
return (
f"MCPServer(server_id={self.server_id!r}, name={self.name!r}, "
f"transport={self.transport!r}, auth_type={self.auth_type!r})"
)
def __str__(self) -> str:
return self.__repr__()
@property
def has_client_credentials(self) -> bool:
"""True if this server should use the OAuth2 client_credentials (M2M) flow.

View file

@ -32,10 +32,17 @@ from e2e_config import unique_marker
from e2e_http import Result, UnknownApiError, unwrap
from lifecycle import ResourceManager
# No api_key: litellm resolves ANTHROPIC_API_KEY from the proxy's own environment
# for this provider, which is what the agent-owner flow relies on. Pinning
# "os.environ/ANTHROPIC_API_KEY" here (#34512) was worse than redundant. The a2a
# bridge forwards litellm_params to acompletion() without expanding "os.environ/"
# indirection, so that literal string was sent upstream as x-api-key and every
# message/send failed with `AnthropicException - invalid x-api-key`. Omitting the
# key lets the normal provider resolution apply. Verified against a live proxy:
# omitted -> 200, "os.environ/..." -> 500 invalid x-api-key, literal key -> 200.
BRIDGE = A2ABridgeParams(
custom_llm_provider="anthropic",
model="claude-haiku-4-5",
api_key="os.environ/ANTHROPIC_API_KEY",
)
MOVEHOME_AGENT_CARD_URL = "https://movehome.org/.well-known/agent.json"

View file

@ -524,6 +524,17 @@ class TestOpenAIFiles:
"llm.files.openai.list.nonstream.works",
exercised_on=["files"],
)
@pytest.mark.skip(
reason=(
"LIT-4820 (https://linear.app/litellm-ai/issue/LIT-4820): GET /v1/files omits "
"newly uploaded files. The upload succeeds and "
"GET /v1/files/{id} returns the file, but it never appears in the listing; the "
"returned set is stable with its newest entry ~10h old, on both the managed "
"(/v1/files?model=) and provider-scoped (/openai/v1/files) routes. Skipped rather "
"than weakened because the assertion below is the correct contract. Remove this "
"marker when LIT-4820 is fixed; do not relax the assertion to make it pass."
)
)
def test_uploaded_file_appears_in_list(
self, client: BatchClient, resources: ResourceManager, batch_deployments: None
) -> None:

View file

@ -58,7 +58,7 @@
- {id: mgmt.budget.delete.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "budget_management_endpoints.py:280", rationale: "Clears limits"}
- {id: mgmt.budget.list.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "budget_management_endpoints.py:215", rationale: "Budget enumeration"}
- {id: mgmt.callback.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "callback_management_endpoints.py", rationale: "Callback config (smoke)"}
- {id: mgmt.cache_settings.update.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cache_settings_endpoints.py", rationale: "Cache config (smoke)"}
- {id: mgmt.cache_settings.update.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cache_settings_endpoints.py", rationale: "Cache config (smoke). Deliberately uncovered: the previous test read the live settings and wrote them back, which proves nothing (identical values in, so a no-op POST still passes) while being able to break the deployment. /cache/settings persists what it receives and that row outranks YAML cache_params, re-applied on a timer, so a write that omits ssl or redis_startup_nodes turns a TLS cluster into a plaintext standalone node and every later Redis call hangs. That took out 60 of 72 tests on 2026-07-25. GET cannot round-trip it either: it resolves the stored row overlaid with REDIS_* env and never reads YAML, so on a fresh deploy it cannot see YAML ssl to echo back. A safe test needs an isolated proxy, or LIT-4816 fixed so a partial write cannot downgrade transport. Do not re-add a read-then-write-back test against a shared proxy."}
- {id: mgmt.cost_tracking.estimate.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cost_tracking_settings.py", rationale: "Cost estimate (smoke)"}
- {id: mgmt.router_settings.update.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "router_settings_endpoints.py", rationale: "Router config (smoke)"}
- {id: mgmt.jwt_key_mapping.new.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "jwt_key_mapping_endpoints.py", rationale: "JWT->key mapping (smoke)"}

View file

@ -77,10 +77,16 @@ REQUEST_TIMEOUT = float(os.environ.get("E2E_REQUEST_TIMEOUT", "60"))
EXPECT_RUST = os.environ.get("E2E_EXPECT_RUST", "").strip().lower() in ("1", "true", "yes")
LOAD_USERS = int(os.environ.get("E2E_LOAD_USERS", "750"))
LOAD_SPAWN_RATE = float(os.environ.get("E2E_LOAD_SPAWN_RATE", "50"))
# Deliberately modest concurrency. The suite shares its proxy with every other
# suite in the run, and 750 users at spawn rate 50 saturated the request path hard
# enough to distort latency-sensitive neighbours (and to spend real provider money
# fast). The SLO is scaled with the user count to keep the same per-user throughput
# expectation (~0.47 RPS/user), so this still catches a request-path regression
# rather than becoming a formality. Raise all four via env for a real load run.
LOAD_USERS = int(os.environ.get("E2E_LOAD_USERS", "200"))
LOAD_SPAWN_RATE = float(os.environ.get("E2E_LOAD_SPAWN_RATE", "20"))
LOAD_DURATION_SECONDS = float(os.environ.get("E2E_LOAD_DURATION_SECONDS", "60"))
LOAD_MIN_RPS = float(os.environ.get("E2E_LOAD_MIN_RPS", "355"))
LOAD_MIN_RPS = float(os.environ.get("E2E_LOAD_MIN_RPS", "90"))
LOAD_MAX_FAILURE_RATIO = float(os.environ.get("E2E_LOAD_MAX_FAILURE_RATIO", "0.01"))
WEEKLY_ANOMALY_OPT_IN_ENV = "E2E_WEEKLY_ANOMALY"

View file

@ -140,7 +140,17 @@ class GuardrailsClient:
*,
identifier: str,
version: str,
default_on: bool = False,
) -> str:
"""Register a Bedrock guardrail, opted out of `default_on` by default.
`default_on=True` applies the guardrail to every request the proxy serves,
not just this test's. When the upstream ApplyGuardrail call fails (a missing
bedrock:ApplyGuardrail permission answers 403), that failure is returned to
unrelated traffic as `403 Bedrock guardrail request failed`, so one guardrail
test takes out whatever else is running. Callers select the guardrail
per-request instead, which keeps the blast radius to the test that wants it.
"""
return unwrap(
self.proxy.transport.post(
"/guardrails",
@ -150,7 +160,7 @@ class GuardrailsClient:
guardrail_name=name,
litellm_params=BedrockGuardrailParamsBody(
mode="pre_call",
default_on=True,
default_on=default_on,
guardrailIdentifier=identifier,
guardrailVersion=version,
),

View file

@ -47,7 +47,10 @@ class TestBedrockGuardrail:
)
resources.defer(lambda: client.delete_guardrail(guardrail_id))
result = client.chat(scoped_key, MODEL, BLOCKED_PROMPT)
# Selected per request rather than registered default_on, so an upstream
# ApplyGuardrail failure surfaces here instead of 403ing every other suite
# running against this proxy.
result = client.chat(scoped_key, MODEL, BLOCKED_PROMPT, guardrails=[name])
match result:
case UnknownApiError(status_code=status, body=body):

View file

@ -105,7 +105,12 @@ class AnthropicMessageBody(BaseModel):
class OpenAIChatBody(BaseModel):
model: str
messages: list[ChatMessage]
max_tokens: int = 64
# Passthrough sends this body to OpenAI untranslated, so it has to satisfy
# OpenAI's current contract directly: newer models reject `max_tokens` with
# "Unsupported parameter: 'max_tokens' is not supported with this model. Use
# 'max_completion_tokens' instead." litellm's drop_params/translation does not
# apply on this route.
max_completion_tokens: int = 64
class VllmChatBody(BaseModel):
@ -198,14 +203,14 @@ class PassthroughClient:
)
def openai_chat(
self, key: str, model: str, text: str, *, max_tokens: int = 64
self, key: str, model: str, text: str, *, max_completion_tokens: int = 64
) -> StreamingResponse:
return self.proxy.transport.send(
"/openai/v1/chat/completions",
headers=self.proxy.transport.bearer(key),
json=OpenAIChatBody(
model=model,
max_tokens=max_tokens,
max_completion_tokens=max_completion_tokens,
messages=[ChatMessage(role="user", content=text)],
),
)

View file

@ -535,13 +535,25 @@ class TestOpenAIChatCompletions:
ChatBody(
model=model,
messages=[
# The prompt and effort have to make the model actually think,
# otherwise this asserts something the model is free not to do:
# at reasoning_effort="low" a one-step arithmetic question comes
# back with reasoning_tokens=0, which is correct behavior and not
# a reporting bug. Verified against the live model:
# low + "60 miles in 1.5 hours" -> reasoning_tokens=0,
# high + the prompt below -> reasoning_tokens=90.
# What is under test is that litellm surfaces the field, so the
# request has to be one where the field is populated.
ChatMessage(
role="user",
content="A train travels 60 miles in 1.5 hours. What is its average speed in mph?",
content=(
"Prove that the sum of two odd integers is even, then find the "
"smallest prime p greater than 100 such that p+2 is also prime."
),
)
],
reasoning_effort="low",
max_tokens=2048,
reasoning_effort="high",
max_tokens=3000,
),
)
)

View file

@ -3,9 +3,13 @@
One method per registry cell, each asserting the real contract against a live
proxy: read-only inventory routes return their documented shape, stateless
validators compute their verdict from the request, and the write routes persist
so a read-back reflects the change. The two routes that mutate global proxy state
(cache settings and router settings, both driven from the admin UI) are exercised
with a benign, self-restoring change so a shared proxy is left as it was found.
so a read-back reflects the change. Router settings, which mutate global proxy
state, are exercised with a benign, self-restoring change so a shared proxy is left
as it was found.
Cache settings are deliberately not covered here; see the rationale on
mgmt.cache_settings.update.happy_path in coverage_registry/mgmt.yaml before adding
a test for that route.
"""
from __future__ import annotations
@ -124,34 +128,6 @@ class ComplianceResponse(BaseModel):
checks: list[ComplianceCheck]
# ---- cache settings --------------------------------------------------------
class CacheSettingsValue(BaseModel):
type: str
host: str = ""
port: str = ""
class CacheSettingsUpdateBody(BaseModel):
cache_settings: CacheSettingsValue
class CacheCurrentValues(BaseModel):
type: str | None = None
host: str | None = None
port: str | None = None
class CacheGetResponse(BaseModel):
current_values: CacheCurrentValues
class CacheUpdateResponse(BaseModel):
status: str
settings: CacheSettingsValue
# ---- fallback management ---------------------------------------------------
@ -369,70 +345,6 @@ class TestComplianceRoutes:
)
class TestCacheSettings:
@pytest.mark.covers("mgmt.cache_settings.update.happy_path")
def test_update_persists_cache_backend_to_get(
self, client: ManagementClient, resources: ResourceManager
) -> None:
"""Exercise the update route without changing global state: capture the live
cache backend and write exactly that back, so the config the proxy ends on is
byte-for-byte the one it started with. A teardown restore of the same captured
settings is the safety net if the body fails partway. The update route is only
meaningful against a configured cache, so an unconfigured proxy fails loudly
here rather than being silently switched to redis."""
before = self._read_settings(client)
assert before.type is not None, (
"GET /cache/settings reported no cache type; refusing to invent one and mutate the shared proxy"
)
captured = CacheSettingsValue(type=before.type, host=before.host or "", port=before.port or "")
resources.defer(lambda: self._write_settings(client, captured))
updated = unwrap(
client.proxy.transport.post(
"/cache/settings",
headers=client.proxy.transport.master,
json=CacheSettingsUpdateBody(cache_settings=captured),
response_type=CacheUpdateResponse,
)
)
assert updated.status == "success", f"/cache/settings update status {updated.status!r}, expected 'success'"
assert updated.settings.type == captured.type, (
f"/cache/settings echoed type {updated.settings.type!r}, wrote {captured.type!r}"
)
def reflected() -> CacheCurrentValues | None:
current = self._read_settings(client)
return current if current.type == captured.type else None
after = _poll(client, reflected, f"/cache/settings never reported type {captured.type!r} after the update")
assert after.host == captured.host and after.port == captured.port, (
f"/cache/settings persisted host/port {after.host!r}/{after.port!r}, "
f"wrote {captured.host!r}/{captured.port!r}"
)
@staticmethod
def _read_settings(client: ManagementClient) -> CacheCurrentValues:
return unwrap(
client.proxy.transport.get(
"/cache/settings",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=CacheGetResponse,
)
).current_values
@staticmethod
def _write_settings(client: ManagementClient, settings: CacheSettingsValue) -> None:
_ = unwrap(
client.proxy.transport.post(
"/cache/settings",
headers=client.proxy.transport.master,
json=CacheSettingsUpdateBody(cache_settings=settings),
response_type=CacheUpdateResponse,
)
)
class TestFallbackManagement:
@pytest.mark.covers("mgmt.fallback_management.update.happy_path")
def test_create_persists_and_is_read_back(self, client: ManagementClient, resources: ResourceManager) -> None:

View file

@ -1771,8 +1771,8 @@ async def test_list_batches_from_managed_objects_table():
# Should filter by user_id (created_by)
prisma_client.db.litellm_managedobjecttable.find_many.assert_called_once_with(
where={"file_purpose": "batch", "created_by": "test-user"},
take=10,
order={"created_at": "desc"},
take=11,
order=[{"created_at": "desc"}, {"unified_object_id": "desc"}],
)
@ -1801,8 +1801,8 @@ async def test_list_batches_from_managed_objects_table_empty_list():
# Default take is 20 when no limit is provided
prisma_client.db.litellm_managedobjecttable.find_many.assert_called_once_with(
where={"file_purpose": "batch", "created_by": "test-user"},
take=20,
order={"created_at": "desc"},
take=21,
order=[{"created_at": "desc"}, {"unified_object_id": "desc"}],
)
@ -1918,8 +1918,8 @@ async def test_list_batches_from_managed_objects_table_filters_by_created_by():
assert result_user1["data"][0].id == "unified-batch-user1"
prisma_client.db.litellm_managedobjecttable.find_many.assert_called_with(
where={"file_purpose": "batch", "created_by": "user1"},
take=10,
order={"created_at": "desc"},
take=11,
order=[{"created_at": "desc"}, {"unified_object_id": "desc"}],
)
# Query with user2's API key - should only return user2's batch
@ -1933,11 +1933,505 @@ async def test_list_batches_from_managed_objects_table_filters_by_created_by():
assert result_user2["data"][0].id == "unified-batch-user2"
prisma_client.db.litellm_managedobjecttable.find_many.assert_called_with(
where={"file_purpose": "batch", "created_by": "user2"},
take=10,
order={"created_at": "desc"},
take=11,
order=[{"created_at": "desc"}, {"unified_object_id": "desc"}],
)
@pytest.mark.asyncio
async def test_list_batches_pagination_uses_unified_object_id_cursor():
"""Regression for LIT-4678.
The ``after`` cursor a client sends back is a batch's ``unified_object_id``
(that is what is returned as ``.id`` / ``last_id``). Paginating must use a
Prisma cursor on the unique ``unified_object_id`` column, not a
``where id > after`` filter against the random-uuid primary key.
"""
from litellm.proxy._types import UserAPIKeyAuth
prisma_client = AsyncMock()
prisma_client.db.litellm_managedobjecttable.find_first.return_value = MagicMock()
prisma_client.db.litellm_managedobjecttable.find_many.return_value = []
proxy_managed_files = _PROXY_LiteLLMManagedFiles(
DualCache(), prisma_client=prisma_client
)
await proxy_managed_files.list_user_batches(
user_api_key_dict=UserAPIKeyAuth(user_id="test-user"),
limit=5,
after="unified-batch-id-7",
)
prisma_client.db.litellm_managedobjecttable.find_many.assert_called_once_with(
where={"file_purpose": "batch", "created_by": "test-user"},
take=6,
order=[{"created_at": "desc"}, {"unified_object_id": "desc"}],
cursor={"unified_object_id": "unified-batch-id-7"},
skip=1,
)
_, call_kwargs = prisma_client.db.litellm_managedobjecttable.find_many.call_args
assert "id" not in call_kwargs["where"]
@pytest.mark.asyncio
async def test_list_batches_pagination_walks_all_pages_without_loops_or_gaps():
"""Regression for LIT-4678.
Simulates the managed-objects table (random-uuid ``id`` primary key,
base64 ``unified_object_id``, reverse-chronological ``created_at``) and
walks every page the way a client would, feeding ``last_id`` back as
``after``. With the old ``where id > after`` cursor this loops and drops
batches; the fixed cursor returns each batch exactly once, newest first.
"""
import uuid as _uuid
from litellm.proxy._types import UserAPIKeyAuth
def _unified_id(i: int) -> str:
raw = f"litellm_proxy;model_id:gpt-4o-batch;llm_batch_id:batch_{i:03d}"
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
total = 10
rows = []
for i in range(total):
row = MagicMock()
row.id = str(_uuid.uuid4())
row.unified_object_id = _unified_id(i)
row.created_at = 1_000_000 + i
row.file_object = json.dumps(
{
"id": f"batch_provider_{i:03d}",
"object": "batch",
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
"status": "completed",
"created_at": 1_000_000 + i,
"input_file_id": f"file-input-{i:03d}",
"request_counts": {"total": 1, "completed": 1, "failed": 0},
}
)
rows.append(row)
async def fake_find_many(where, take, order, cursor=None, skip=0):
result = list(rows)
id_filter = where.get("id")
if isinstance(id_filter, dict) and "gt" in id_filter:
result = [r for r in result if r.id > id_filter["gt"]]
order_keys = order if isinstance(order, list) else [order]
for clause in reversed(order_keys):
(order_field, direction), = clause.items()
result.sort(
key=lambda r: getattr(r, order_field), reverse=(direction == "desc")
)
if cursor is not None:
(cur_field, cur_val), = cursor.items()
idx = next(
(i for i, r in enumerate(result) if getattr(r, cur_field) == cur_val),
None,
)
if idx is None:
return []
result = result[idx + skip:]
return result[:take]
async def fake_find_first(where):
return next(
(r for r in rows if r.unified_object_id == where.get("unified_object_id")),
None,
)
prisma_client = AsyncMock()
prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(
side_effect=fake_find_many
)
prisma_client.db.litellm_managedobjecttable.find_first = AsyncMock(
side_effect=fake_find_first
)
proxy_managed_files = _PROXY_LiteLLMManagedFiles(
DualCache(), prisma_client=prisma_client
)
user = UserAPIKeyAuth(user_id="test-user")
seen: list = []
after = None
for _ in range(total + 5):
resp = await proxy_managed_files.list_user_batches(
user_api_key_dict=user, limit=3, after=after
)
page_ids = [b.id for b in resp["data"]]
seen.extend(page_ids)
if not resp["has_more"]:
break
assert page_ids, "has_more was true but the page was empty"
assert resp["last_id"] != after, "cursor did not advance (pagination loop)"
after = resp["last_id"]
expected = [_unified_id(i) for i in reversed(range(total))]
assert seen == expected
assert len(seen) == len(set(seen))
@pytest.mark.asyncio
async def test_list_batches_pagination_stable_when_created_at_ties():
"""Regression for LIT-4678.
Cursor pagination is only well-defined when the ``order`` fully determines
row order. If listing ordered by non-unique ``created_at`` alone, batches
sharing a timestamp come back in an arbitrary order that can shift between
page requests, so a cursor row's neighbours change and batches get skipped
or duplicated. Listing must add the unique ``unified_object_id`` as a
tie-breaker so the order is total and pagination is stable.
"""
import itertools
import uuid as _uuid
from litellm.proxy._types import UserAPIKeyAuth
def _unified_id(i: int) -> str:
raw = f"litellm_proxy;model_id:gpt-4o-batch;llm_batch_id:batch_{i:03d}"
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
total = 6
shared_created_at = 1_000_000
rows = []
for i in range(total):
row = MagicMock()
row.id = str(_uuid.uuid4())
row.unified_object_id = _unified_id(i)
row.created_at = shared_created_at
row.file_object = json.dumps(
{
"id": f"batch_provider_{i:03d}",
"object": "batch",
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
"status": "completed",
"created_at": shared_created_at,
"input_file_id": f"file-input-{i:03d}",
"request_counts": {"total": 1, "completed": 1, "failed": 0},
}
)
rows.append(row)
call_counter = itertools.count()
async def fake_find_many(where, take, order, cursor=None, skip=0):
call = next(call_counter)
order_keys = order if isinstance(order, list) else [order]
fields = [next(iter(clause)) for clause in order_keys]
result = list(rows)
for clause in reversed(order_keys):
field, direction = next(iter(clause.items()))
result.sort(
key=lambda r: getattr(r, field), reverse=(direction == "desc")
)
def order_key(r):
return tuple(getattr(r, f) for f in fields)
stabilized = []
i = 0
while i < len(result):
j = i
while j < len(result) and order_key(result[j]) == order_key(result[i]):
j += 1
group = result[i:j]
if len(group) > 1:
rot = call % len(group)
group = group[rot:] + group[:rot]
stabilized.extend(group)
i = j
result = stabilized
if cursor is not None:
cur_field, cur_val = next(iter(cursor.items()))
idx = next(
(k for k, r in enumerate(result) if getattr(r, cur_field) == cur_val),
None,
)
if idx is None:
return []
result = result[idx + skip:]
return result[:take]
async def fake_find_first(where):
return next(
(r for r in rows if r.unified_object_id == where.get("unified_object_id")),
None,
)
prisma_client = AsyncMock()
prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(
side_effect=fake_find_many
)
prisma_client.db.litellm_managedobjecttable.find_first = AsyncMock(
side_effect=fake_find_first
)
proxy_managed_files = _PROXY_LiteLLMManagedFiles(
DualCache(), prisma_client=prisma_client
)
user = UserAPIKeyAuth(user_id="test-user")
seen: list = []
after = None
for _ in range(total + 5):
resp = await proxy_managed_files.list_user_batches(
user_api_key_dict=user, limit=2, after=after
)
page_ids = [b.id for b in resp["data"]]
seen.extend(page_ids)
if not resp["has_more"]:
break
assert page_ids, "has_more was true but the page was empty"
assert resp["last_id"] != after, "cursor did not advance (pagination loop)"
after = resp["last_id"]
assert sorted(seen) == sorted(_unified_id(i) for i in range(total))
assert len(seen) == len(set(seen)), "a tied batch was returned more than once"
def _managed_batch_row(index, file_object=None):
row = MagicMock()
row.id = f"pk-{index:03d}"
raw = f"litellm_proxy;model_id:gpt-4o-batch;llm_batch_id:batch_{index:03d}"
row.unified_object_id = base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
row.created_at = 1_000_000 + index
row.file_object = (
file_object
if file_object is not None
else json.dumps(
{
"id": f"batch_provider_{index:03d}",
"object": "batch",
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
"status": "completed",
"created_at": 1_000_000 + index,
"input_file_id": f"file-input-{index:03d}",
"request_counts": {"total": 1, "completed": 1, "failed": 0},
}
)
)
return row
def _fake_managed_object_table(rows):
async def find_many(where, take, order, cursor=None, skip=0):
result = sorted(
rows, key=lambda r: (r.created_at, r.unified_object_id), reverse=True
)
if cursor is not None:
(cur_field, cur_val), = cursor.items()
idx = next(
(i for i, r in enumerate(result) if getattr(r, cur_field) == cur_val),
None,
)
if idx is None:
return []
result = result[idx + skip:]
return result[:take]
async def find_first(where):
return next(
(r for r in rows if r.unified_object_id == where.get("unified_object_id")),
None,
)
prisma_client = AsyncMock()
prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(
side_effect=find_many
)
prisma_client.db.litellm_managedobjecttable.find_first = AsyncMock(
side_effect=find_first
)
return prisma_client
async def _walk_batch_pages(proxy_managed_files, user, limit, max_pages=20):
pages = []
after = None
for _ in range(max_pages):
resp = await proxy_managed_files.list_user_batches(
user_api_key_dict=user, limit=limit, after=after
)
pages.append(resp)
if not resp["has_more"]:
break
assert resp["last_id"] is not None, "has_more was true but there is no cursor"
after = resp["last_id"]
return pages
@pytest.mark.asyncio
async def test_list_batches_rejects_unknown_after_cursor():
"""An ``after`` that does not resolve to a batch the caller can see is a
client error, not an empty page.
Returning ``[]`` for an unresolvable cursor is indistinguishable from
"you have reached the end of the list", so a client walking pages with a
stale or malformed cursor silently sees a truncated batch list instead of
an error it can act on.
"""
from fastapi import HTTPException
from litellm.proxy._types import UserAPIKeyAuth
prisma_client = AsyncMock()
prisma_client.db.litellm_managedobjecttable.find_first = AsyncMock(
return_value=None
)
prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(return_value=[])
proxy_managed_files = _PROXY_LiteLLMManagedFiles(
DualCache(), prisma_client=prisma_client
)
with pytest.raises(HTTPException) as exc_info:
await proxy_managed_files.list_user_batches(
user_api_key_dict=UserAPIKeyAuth(user_id="test-user"),
limit=3,
after="does-not-exist-xyz",
)
assert exc_info.value.status_code == 400
assert "does-not-exist-xyz" in str(exc_info.value.detail)
prisma_client.db.litellm_managedobjecttable.find_many.assert_not_called()
prisma_client.db.litellm_managedobjecttable.find_first.assert_called_once_with(
where={
"file_purpose": "batch",
"created_by": "test-user",
"unified_object_id": "does-not-exist-xyz",
}
)
@pytest.mark.asyncio
async def test_list_batches_treats_empty_after_as_no_cursor():
"""``?after=`` means "start from the beginning", as it always has.
Only a cursor the client actually sent is validated, so an SDK that always
emits the query parameter does not get a 400 on its first page.
"""
from litellm.proxy._types import UserAPIKeyAuth
rows = [_managed_batch_row(i) for i in range(2)]
prisma_client = _fake_managed_object_table(rows)
proxy_managed_files = _PROXY_LiteLLMManagedFiles(
DualCache(), prisma_client=prisma_client
)
page = await proxy_managed_files.list_user_batches(
user_api_key_dict=UserAPIKeyAuth(user_id="test-user"), limit=2, after=""
)
assert [batch.id for batch in page["data"]] == [
rows[1].unified_object_id,
rows[0].unified_object_id,
]
prisma_client.db.litellm_managedobjecttable.find_first.assert_not_called()
_, call_kwargs = prisma_client.db.litellm_managedobjecttable.find_many.call_args
assert "cursor" not in call_kwargs
@pytest.mark.asyncio
async def test_list_batches_rejects_after_cursor_owned_by_another_user():
"""The cursor lookup must be scoped to the rows the caller can list.
A Prisma cursor resolves by unique column regardless of the ``where``
filter, so an unscoped cursor would let one user anchor their page window
to another user's batch and learn when it was created.
"""
from fastapi import HTTPException
from litellm.proxy._types import UserAPIKeyAuth
other_users_batch = _managed_batch_row(0)
async def find_first(where):
if where.get("created_by") != "user-b":
return None
return other_users_batch
prisma_client = AsyncMock()
prisma_client.db.litellm_managedobjecttable.find_first = AsyncMock(
side_effect=find_first
)
prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(return_value=[])
proxy_managed_files = _PROXY_LiteLLMManagedFiles(
DualCache(), prisma_client=prisma_client
)
with pytest.raises(HTTPException) as exc_info:
await proxy_managed_files.list_user_batches(
user_api_key_dict=UserAPIKeyAuth(user_id="user-a"),
limit=3,
after=other_users_batch.unified_object_id,
)
assert exc_info.value.status_code == 400
prisma_client.db.litellm_managedobjecttable.find_many.assert_not_called()
@pytest.mark.asyncio
async def test_list_batches_has_more_false_on_exactly_full_final_page():
"""``has_more`` must mean "another row exists", not "this page is full".
With a batch count that is an exact multiple of ``limit``, reporting
``has_more`` off page fullness makes every client fetch one extra empty
page before it can stop.
"""
from litellm.proxy._types import UserAPIKeyAuth
rows = [_managed_batch_row(i) for i in range(4)]
prisma_client = _fake_managed_object_table(rows)
proxy_managed_files = _PROXY_LiteLLMManagedFiles(
DualCache(), prisma_client=prisma_client
)
pages = await _walk_batch_pages(
proxy_managed_files, UserAPIKeyAuth(user_id="test-user"), limit=2
)
seen = [batch.id for page in pages for batch in page["data"]]
assert seen == [r.unified_object_id for r in reversed(rows)]
assert [page["has_more"] for page in pages] == [True, False]
assert prisma_client.db.litellm_managedobjecttable.find_many.call_count == 2
@pytest.mark.asyncio
async def test_list_batches_unparseable_row_does_not_truncate_pagination():
"""A row that fails to parse must not end pagination early.
Skipping a corrupt row shortens the page, so deriving ``has_more`` from
the number of returned batches reports "no more results" while older
batches are still unread, silently hiding them from the caller.
"""
from litellm.proxy._types import UserAPIKeyAuth
rows = [_managed_batch_row(i) for i in range(4)]
rows[2].file_object = "{ not valid json"
prisma_client = _fake_managed_object_table(rows)
proxy_managed_files = _PROXY_LiteLLMManagedFiles(
DualCache(), prisma_client=prisma_client
)
pages = await _walk_batch_pages(
proxy_managed_files, UserAPIKeyAuth(user_id="test-user"), limit=2
)
seen = [batch.id for page in pages for batch in page["data"]]
assert seen == [rows[3].unified_object_id, rows[1].unified_object_id, rows[0].unified_object_id]
assert len(seen) == len(set(seen))
@pytest.mark.asyncio
async def test_return_unified_file_id_includes_expires_at():
from litellm.types.llms.openai import OpenAIFileObject
@ -2312,8 +2806,8 @@ async def test_list_batches_only_returns_user_own_batches():
# Verify the database query filtered by user_id
prisma_client.db.litellm_managedobjecttable.find_many.assert_called_once_with(
where={"file_purpose": "batch", "created_by": "user_a_id"},
take=10,
order={"created_at": "desc"},
take=11,
order=[{"created_at": "desc"}, {"unified_object_id": "desc"}],
)

View file

@ -3355,6 +3355,121 @@ async def test_call_mcp_tool_user_unauthorized_access():
assert "User not allowed to call this tool" in exc_info.value.detail
@pytest.mark.asyncio
async def test_call_mcp_tool_unauthorized_403_does_not_leak_server_credentials():
"""Regression for LIT-4703 / GH #29936.
Calling a tool on a server the key is not scoped to must 403 with a bare
message. The prior code interpolated the caller's allowed ``List[MCPServer]``
config objects into the 403 detail, dumping every upstream credential
(authentication_token, client_secret, AWS keys, private keys, env,
static_headers) in cleartext to the caller.
"""
from fastapi import HTTPException
from litellm.proxy._experimental.mcp_server.server import call_mcp_tool
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
mock_user_auth = UserAPIKeyAuth(
api_key="test-key",
user_id="test-user",
team_id="team-basic",
object_permission_id="key-permission-123",
)
secret_fields = {
"authentication_token": "sk-LEAK-authtok",
"client_id": "LEAK-clientid",
"client_secret": "sk-LEAK-clientsecret",
"aws_access_key_id": "LEAK-akid",
"aws_secret_access_key": "LEAK-awssecret",
"aws_session_token": "LEAK-awssess",
"client_private_key": "LEAK-privkey",
}
allowed_server_obj = MCPServer(
server_id="allowed_server",
name="allowed_server",
server_name="allowed_server",
alias="allowed_server",
transport="http",
auth_type=MCPAuth.bearer_token,
env={"UPSTREAM_API_KEY": "LEAK-env"},
static_headers={"X-Upstream-Auth": "LEAK-header"},
**secret_fields,
)
def mock_get_server_by_id(server_id):
if server_id == "allowed_server":
return allowed_server_obj
return None
with (
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.get_allowed_mcp_servers",
AsyncMock(return_value=["allowed_server"]),
),
patch(
"litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_id",
side_effect=mock_get_server_by_id,
),
):
with pytest.raises(HTTPException) as exc_info:
await call_mcp_tool(
name="restricted_server-send_email",
arguments={"to": "test@example.com"},
user_api_key_auth=mock_user_auth,
mcp_auth_header="Bearer test_token",
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == "User not allowed to call this tool."
all_secret_values = list(secret_fields.values()) + ["LEAK-env", "LEAK-header"]
detail_text = str(exc_info.value.detail)
leaked = [value for value in all_secret_values if value in detail_text]
assert leaked == [], f"403 body leaked upstream credentials: {leaked}"
def test_mcpserver_repr_and_str_mask_credentials():
"""Regression for LIT-4703 / GH #29936.
``MCPServer.__repr__``/``__str__`` must never render credential fields, so a
stray f-string, log line, or list interpolation cannot leak them. Only
display is masked; ``model_dump`` serialization is unchanged.
"""
from litellm.types.mcp_server.mcp_server_manager import MCPServer
secret_fields = {
"authentication_token": "sk-SENTINEL-authtok",
"client_id": "SENTINEL-clientid",
"client_secret": "sk-SENTINEL-clientsecret",
"aws_access_key_id": "SENTINEL-akid",
"aws_secret_access_key": "SENTINEL-awssecret",
"aws_session_token": "SENTINEL-awssess",
"client_private_key": "SENTINEL-privkey",
"client_private_key_id": "SENTINEL-privkeyid",
}
server = MCPServer(
server_id="srv1",
name="srv1",
transport="http",
auth_type=MCPAuth.bearer_token,
env={"UPSTREAM_API_KEY": "SENTINEL-env"},
static_headers={"X-Upstream-Auth": "SENTINEL-header"},
env_vars=[{"name": "K", "value": "SENTINEL-envvar"}],
**secret_fields,
)
all_secrets = list(secret_fields.values()) + ["SENTINEL-env", "SENTINEL-header", "SENTINEL-envvar"]
for text in (repr(server), str(server), repr([server]), f"{server}", f"{[server]}"):
leaked = [secret for secret in all_secrets if secret in text]
assert leaked == [], f"MCPServer rendering leaked credentials {leaked} in {text!r}"
assert "srv1" in repr(server)
assert server.model_dump()["authentication_token"] == "sk-SENTINEL-authtok"
assert server.model_dump()["client_secret"] == "sk-SENTINEL-clientsecret"
@pytest.mark.asyncio
async def test_list_tools_filters_by_key_team_permissions():
"""Test that list_tools filters tools based on key/team mcp_tool_permissions"""

View file

@ -18,6 +18,7 @@ vi.mock("@/components/shared/charts", () => ({
AreaChart: () => <div />,
DonutChart: () => <div />,
BarChart: () => <div />,
CustomLegend: () => <div />,
DEFAULT_COLOR_CYCLE: ["emerald"],
}));

View file

@ -1,5 +1,6 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolSpendResponse } from "@/components/networking";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
@ -25,6 +26,9 @@ vi.mock("@/components/shared/charts", () => ({
BarChart: ({ data, categories }: { data: unknown; categories: string[] }) => (
<div data-testid="bar-chart" data-categories={categories.join(",")} data-series={JSON.stringify(data)} />
),
CustomLegend: ({ categories }: { categories: readonly string[] }) => (
<div data-testid="chart-legend">{categories.join(",")}</div>
),
DEFAULT_COLOR_CYCLE: ["emerald", "blue", "violet", "amber"],
}));
@ -58,13 +62,20 @@ const day = (date: string, metrics: Partial<SpendMetrics>): DailyData => ({
},
});
const renderWith = (results: DailyData[], toolSpend = emptyToolSpend) => {
interface RenderOptions {
toolSpend?: ToolSpendResponse;
from?: Date;
to?: Date;
}
const renderWith = (results: DailyData[], options: RenderOptions = {}) => {
const { toolSpend = emptyToolSpend, from = new Date(2026, 6, 1), to = new Date(2026, 6, 14) } = options;
mockGetToolSpend.mockResolvedValue(toolSpend);
return render(
<UsageTab
accessToken="test-token"
activity={{
dateValue: { from: new Date("2026-07-01"), to: new Date("2026-07-14") },
dateValue: { from, to },
onDateChange: vi.fn(),
results,
loading: false,
@ -74,7 +85,13 @@ const renderWith = (results: DailyData[], toolSpend = emptyToolSpend) => {
);
};
const readSeries = (element: HTMLElement) => JSON.parse(element.getAttribute("data-series") ?? "[]");
describe("UsageTab", () => {
beforeEach(() => {
mockGetToolSpend.mockReset();
});
it("sums compression and caching dollars across days into the summary cards", () => {
const { getByText } = renderWith([
day("2026-07-12", {
@ -95,16 +112,88 @@ describe("UsageTab", () => {
expect(getByText("140,000 tokens compressed")).toBeInTheDocument();
});
it("builds a per-day time series and per-driver donut from the daily rows", () => {
const { getByTestId } = renderWith([
day("2026-07-12", { compression_savings_spend: 0.04, prompt_caching_savings_spend: 0.006 }),
day("2026-07-13", { compression_savings_spend: 0.1, prompt_caching_savings_spend: 0.01 }),
]);
const twoDays = () => [
day("2026-07-12", { compression_savings_spend: 0.04, prompt_caching_savings_spend: 0.006 }),
day("2026-07-13", { compression_savings_spend: 0.1, prompt_caching_savings_spend: 0.01 }),
];
const series = JSON.parse(getByTestId("area-chart").getAttribute("data-series") ?? "[]");
it("opens on a running total anchored at $0 at the start of the range", () => {
const { getByTestId } = renderWith(twoDays());
// Cumulative prepends a synthetic $0 point at the range start (Jul 1) so the
// line rises from zero rather than floating; the daily running totals follow.
const series = readSeries(getByTestId("area-chart"));
expect(series).toHaveLength(3);
expect(series[0]).toMatchObject({ date: "Jul 1", Compression: 0, "Prompt caching": 0 });
expect(series[1]).toMatchObject({ Compression: 0.04, "Prompt caching": 0.006 });
expect(series[2].Compression).toBeCloseTo(0.14, 5);
expect(series[2]["Prompt caching"]).toBeCloseTo(0.016, 5);
});
it("rises from $0 to the day's cumulative total for a single-day range", () => {
// The original complaint: a one-day range plotted a single floating dot. The
// synthetic start anchor gives the line a zero origin to climb from.
const oneDay = new Date(2026, 6, 24);
const { getByTestId } = renderWith(
[day("2026-07-24", { compression_savings_spend: 0.2, prompt_caching_savings_spend: 0.05 })],
{ from: oneDay, to: oneDay },
);
const series = readSeries(getByTestId("area-chart"));
expect(series).toHaveLength(2);
expect(series[0]).toMatchObject({ date: "Jul 24", Compression: 0, "Prompt caching": 0 });
expect(series[1]).toMatchObject({ date: "Jul 24", Compression: 0.2, "Prompt caching": 0.05 });
});
it("plots the daily series oldest first even though the rollup arrives newest first", async () => {
// The daily activity endpoint returns days newest first; the chart must
// still read left to right in time, and the running total must climb toward
// the newest day, not fall away from it.
const newestFirst = [
day("2026-07-13", { prompt_caching_savings_spend: 0.1 }),
day("2026-07-12", { prompt_caching_savings_spend: 0.04 }),
];
const { getByTestId, getByRole } = renderWith(newestFirst);
// The $0 anchor leads, then the days climb oldest to newest.
const cumulative = readSeries(getByTestId("area-chart"));
expect(cumulative.map((p: { date: string }) => p.date)).toEqual(["Jul 1", "Jul 12", "Jul 13"]);
expect(cumulative[1]["Prompt caching"]).toBeCloseTo(0.04, 5);
expect(cumulative[2]["Prompt caching"]).toBeCloseTo(0.14, 5);
expect(cumulative[2]["Prompt caching"]).toBeGreaterThan(cumulative[1]["Prompt caching"]);
await userEvent.click(getByRole("tab", { name: "Per day" }));
const perDay = readSeries(getByTestId("bar-chart"));
expect(perDay.map((p: { date: string }) => p.date)).toEqual(["Jul 12", "Jul 13"]);
});
it("draws bars of the raw per-interval readings on the other tab", async () => {
const { getByRole, getByTestId, queryByTestId } = renderWith(twoDays());
// Cumulative opens on the area line.
expect(getByTestId("area-chart")).toBeInTheDocument();
await userEvent.click(getByRole("tab", { name: "Per day" }));
// Per day switches to a bar chart of the unaccumulated daily savings, with no
// synthetic anchor prepended.
expect(queryByTestId("area-chart")).toBeNull();
const series = readSeries(getByTestId("bar-chart"));
expect(series).toHaveLength(2);
expect(series[0]).toMatchObject({ Compression: 0.04, "Prompt caching": 0.006 });
expect(series[1]).toMatchObject({ Compression: 0.1, "Prompt caching": 0.01 });
});
it("says what the line means and over what range", async () => {
const { getByText, getByRole } = renderWith(twoDays());
expect(getByText("Running total saved · Jul 1 – Jul 14")).toBeInTheDocument();
await userEvent.click(getByRole("tab", { name: "Per day" }));
expect(getByText("Saved per day · Jul 1 – Jul 14")).toBeInTheDocument();
});
it("builds the per-driver donut from the range totals, not the running total", () => {
const { getByTestId } = renderWith(twoDays());
const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]");
expect(slices).toEqual([
@ -131,7 +220,7 @@ describe("UsageTab", () => {
start_date: "2026-07-12",
end_date: "2026-07-12",
};
const { findAllByTestId } = renderWith([day("2026-07-12", {})], toolSpend);
const { findAllByTestId } = renderWith([day("2026-07-12", {})], { toolSpend });
const bars = await findAllByTestId("bar-chart");
const series = JSON.parse(bars[0].getAttribute("data-series") ?? "[]");
@ -146,7 +235,7 @@ describe("UsageTab", () => {
start_date: "2026-07-05",
end_date: "2026-07-14",
};
const { findByText } = renderWith([day("2026-07-12", {})], toolSpend);
const { findByText } = renderWith([day("2026-07-12", {})], { toolSpend });
expect(await findByText(/capped at 30 days before the end of the selected range/)).toBeInTheDocument();
});
@ -159,7 +248,7 @@ describe("UsageTab", () => {
start_date: "2026-07-01",
end_date: "2026-07-14",
};
const { findAllByTestId, queryByText } = renderWith([day("2026-07-12", {})], toolSpend);
const { findAllByTestId, queryByText } = renderWith([day("2026-07-12", {})], { toolSpend });
await findAllByTestId("bar-chart");
expect(queryByText(/capped at 30 days before the end of the selected range/)).not.toBeInTheDocument();

View file

@ -3,14 +3,27 @@
import React, { useEffect, useMemo, useState } from "react";
import { Info } from "lucide-react";
import { AreaChart, BarChart, DonutChart, DEFAULT_COLOR_CYCLE } from "@/components/shared/charts";
import { AreaChart, BarChart, CustomLegend, DonutChart, DEFAULT_COLOR_CYCLE } from "@/components/shared/charts";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getToolSpend, ToolSpendResponse } from "@/components/networking";
import { SpendMetrics } from "@/components/UsagePage/types";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { buildDailyToolSeries, topToolsBySpend, usd } from "./costOptimizationUtils";
import {
buildDailyToolSeries,
formatRangeLabel,
localIsoDay,
MAX_POINTS_WITH_DOTS,
SAVINGS_SERIES,
SavingsAccumulation,
SavingsPoint,
toCumulative,
topToolsBySpend,
usd,
withStartAnchor,
} from "./costOptimizationUtils";
import { DailyActivityRange } from "./useDailyActivityRange";
interface UsageTabProps {
@ -26,6 +39,8 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = {
end_date: null,
};
const SAVINGS_COLORS = ["emerald", "blue"] as const;
const shortDate = (iso: string): string =>
new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });
@ -95,16 +110,41 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
const savedTokensTotal = useMemo(() => results.reduce((sum, d) => sum + savedTokensOf(d.metrics), 0), [results]);
const totalSaved = compressionTotal + cachingTotal;
const overTime = useMemo(
const [accumulation, setAccumulation] = useState<SavingsAccumulation>("cumulative");
// The daily rollup arrives newest first; sort on the raw ISO date so the axis
// reads oldest to newest and the running total accumulates forward in time
// rather than backward. Sort here, before shortDate() drops the year and makes
// the labels unsortable.
const perInterval = useMemo<SavingsPoint[]>(
() =>
results.map((d) => ({
date: shortDate(d.date),
Compression: compressionOf(d.metrics),
"Prompt caching": cachingOf(d.metrics),
})),
[...results]
.sort((a, b) => a.date.localeCompare(b.date))
.map((d) => ({
date: shortDate(d.date),
Compression: compressionOf(d.metrics),
"Prompt caching": cachingOf(d.metrics),
})),
[results],
);
// Cumulative anchors on a synthetic $0 point at the range start so a short
// range (down to a single day) rises from zero instead of floating as one dot.
const overTime = useMemo(() => {
if (accumulation !== "cumulative") return perInterval;
const startLabel = startTime ? shortDate(localIsoDay(startTime)) : "";
return withStartAnchor(toCumulative(perInterval), startLabel);
}, [accumulation, perInterval, startTime]);
const intervalLabel = "Per day";
const rangeLabel = formatRangeLabel(startTime ?? undefined, endTime ?? undefined);
const savingsSubtitle = [
accumulation === "cumulative" ? "Running total saved" : `Saved ${intervalLabel.toLowerCase()}`,
rangeLabel,
]
.filter(Boolean)
.join(" \u00b7 ");
const byDriver = useMemo(
() =>
[
@ -159,16 +199,44 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Savings over time</CardTitle>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Savings</CardTitle>
<p className="text-sm text-muted-foreground">{savingsSubtitle}</p>
</div>
<div className="flex items-center gap-4">
<CustomLegend categories={SAVINGS_SERIES} colors={SAVINGS_COLORS} />
<Tabs value={accumulation} onValueChange={(value) => setAccumulation(value as SavingsAccumulation)}>
<TabsList>
<TabsTrigger value="cumulative">Cumulative</TabsTrigger>
<TabsTrigger value="per-interval">{intervalLabel}</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
</CardHeader>
<CardContent>
<AreaChart
data={overTime}
index="date"
categories={["Compression", "Prompt caching"]}
colors={["emerald", "blue"]}
valueFormatter={usd}
/>
{accumulation === "cumulative" ? (
<AreaChart
data={overTime}
index="date"
categories={SAVINGS_SERIES}
colors={SAVINGS_COLORS}
valueFormatter={usd}
showLegend={false}
showDots={overTime.length <= MAX_POINTS_WITH_DOTS}
/>
) : (
<BarChart
data={overTime}
index="date"
categories={SAVINGS_SERIES}
colors={SAVINGS_COLORS}
stack
valueFormatter={usd}
showLegend={false}
/>
)}
</CardContent>
</Card>
<Card>

View file

@ -2,7 +2,16 @@ import { describe, expect, it } from "vitest";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import type { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking";
import { buildDailyToolSeries, computeCacheLeakage, isAnthropicModel, topToolsBySpend } from "./costOptimizationUtils";
import {
buildDailyToolSeries,
computeCacheLeakage,
formatRangeLabel,
isAnthropicModel,
localIsoDay,
toCumulative,
topToolsBySpend,
withStartAnchor,
} from "./costOptimizationUtils";
const metrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
spend: 0,
@ -221,3 +230,79 @@ describe("topToolsBySpend", () => {
expect(topToolsBySpend(byTool, 2).map((t) => t.tool_name)).toEqual(["b", "c"]);
});
});
describe("localIsoDay", () => {
it("reads the date off the viewer's clock rather than shifting it to UTC", () => {
expect(localIsoDay(new Date(2026, 6, 23, 23, 30))).toBe("2026-07-23");
expect(localIsoDay(new Date(2026, 0, 5, 0, 30))).toBe("2026-01-05");
});
});
describe("toCumulative", () => {
const point = (date: string, compression: number, caching: number) => ({
date,
Compression: compression,
"Prompt caching": caching,
});
it("turns each reading into everything saved up to that point", () => {
const running = toCumulative([point("Jul 1", 1, 10), point("Jul 2", 2, 20), point("Jul 3", 3, 30)]);
expect(running.map((p) => p.Compression)).toEqual([1, 3, 6]);
expect(running.map((p) => p["Prompt caching"])).toEqual([10, 30, 60]);
});
it("accumulates each driver on its own, so one flat series cannot lift the other", () => {
const running = toCumulative([point("Jul 1", 0, 5), point("Jul 2", 0, 5)]);
expect(running.map((p) => p.Compression)).toEqual([0, 0]);
expect(running.map((p) => p["Prompt caching"])).toEqual([5, 10]);
});
it("never falls, even across a quiet interval", () => {
const running = toCumulative([point("Jul 1", 4, 0), point("Jul 2", 0, 0), point("Jul 3", 1, 0)]);
expect(running.map((p) => p.Compression)).toEqual([4, 4, 5]);
});
it("keeps the labels and length of the readings it was given", () => {
const running = toCumulative([point("9am", 1, 1), point("10am", 1, 1)]);
expect(running.map((p) => p.date)).toEqual(["9am", "10am"]);
expect(toCumulative([])).toEqual([]);
});
});
describe("withStartAnchor", () => {
const point = (date: string, compression: number, caching: number) => ({
date,
Compression: compression,
"Prompt caching": caching,
});
it("lifts a single-day cumulative off a floating dot by prepending a $0 origin", () => {
const anchored = withStartAnchor([point("Jul 24", 12, 30)], "Jul 24");
expect(anchored).toEqual([point("Jul 24", 0, 0), point("Jul 24", 12, 30)]);
});
it("starts the range at zero without disturbing the running totals that follow", () => {
const anchored = withStartAnchor([point("Jul 16", 5, 1), point("Jul 17", 9, 4)], "Jul 16");
expect(anchored.map((p) => p.Compression)).toEqual([0, 5, 9]);
expect(anchored.map((p) => p["Prompt caching"])).toEqual([0, 1, 4]);
});
it("leaves an empty series alone so the chart's own no-data state can show", () => {
expect(withStartAnchor([], "Jul 24")).toEqual([]);
});
});
describe("formatRangeLabel", () => {
it("reads as a range across days", () => {
expect(formatRangeLabel(new Date(2026, 6, 16), new Date(2026, 6, 23))).toBe("Jul 16 \u2013 Jul 23");
});
it("collapses to one date when both ends are the same day", () => {
expect(formatRangeLabel(new Date(2026, 6, 23), new Date(2026, 6, 23))).toBe("Jul 23");
});
it("is empty until both ends are picked", () => {
expect(formatRangeLabel(undefined, new Date(2026, 6, 23))).toBe("");
expect(formatRangeLabel(new Date(2026, 6, 23), undefined)).toBe("");
});
});

View file

@ -149,3 +149,63 @@ const seedPoint = (date: string, toolNames: readonly string[]): DailyToolSpendPo
export const topToolsBySpend = (byTool: readonly ToolSpendEntry[], limit = 8): ToolSpendEntry[] =>
[...byTool].sort((a, b) => b.spend - a.spend).slice(0, limit);
export const localIsoDay = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
export type SavingsAccumulation = "cumulative" | "per-interval";
// A type alias, not an interface: only aliases get the implicit index signature
// that the chart wrappers' `Record<string, unknown>` datum bound requires.
export type SavingsPoint = {
date: string;
Compression: number;
"Prompt caching": number;
};
export const SAVINGS_SERIES = ["Compression", "Prompt caching"] as const;
/**
* Running total of each series across the selected window. The total restarts
* at the beginning of the range rather than carrying in earlier spend, which is
* what "running total saved, <range>" claims on the card.
*/
export const toCumulative = (points: readonly SavingsPoint[]): SavingsPoint[] =>
points.reduce<SavingsPoint[]>((acc, point) => {
const previous = acc[acc.length - 1];
return [
...acc,
{
date: point.date,
Compression: (previous?.Compression ?? 0) + point.Compression,
"Prompt caching": (previous?.["Prompt caching"] ?? 0) + point["Prompt caching"],
},
];
}, []);
/**
* Prepend a synthetic $0 point at the start of the range so the cumulative line
* rises from zero instead of floating as a single dot. The daily rollup only
* resolves whole days, so a one-day range would otherwise be one point; with the
* anchor it reads as "start of range $0 climbing to the range's running total".
* An empty series is left untouched so the chart's own "No data" state shows.
*/
export const withStartAnchor = (cumulative: readonly SavingsPoint[], startLabel: string): SavingsPoint[] =>
cumulative.length === 0
? [...cumulative]
: [{ date: startLabel, Compression: 0, "Prompt caching": 0 }, ...cumulative];
/** "Jul 16 – Jul 23", collapsing to a single date when the range is one day. */
export const formatRangeLabel = (from: Date | undefined, to: Date | undefined): string => {
if (!from || !to) return "";
const short = (d: Date) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const start = short(from);
const end = short(to);
return start === end ? start : `${start} – ${end}`;
};
/**
* Dots mark each reading, as in the design. Past this many readings they crowd
* into a solid band and stop being readable, so the line carries it alone.
*/
export const MAX_POINTS_WITH_DOTS = 31;

View file

@ -40,4 +40,12 @@ describe("AreaChart", () => {
expect(area.getAttribute("fill")).toMatch(/^url\(#fill-/);
}
});
it("marks each reading with a dot only when asked", () => {
const withoutDots = render(<AreaChart data={data} index="date" categories={["tokens"]} />);
expect(withoutDots.container.querySelectorAll("circle.recharts-dot")).toHaveLength(0);
const withDots = render(<AreaChart data={data} index="date" categories={["tokens"]} showDots />);
expect(withDots.container.querySelectorAll("circle.recharts-dot").length).toBeGreaterThanOrEqual(data.length);
});
});

View file

@ -17,6 +17,7 @@ export type AreaChartProps<TDatum extends Record<string, unknown>> = {
showLegend?: boolean;
showGridLines?: boolean;
showTooltip?: boolean;
showDots?: boolean;
customTooltip?: ChartTooltipComponent;
className?: string;
style?: React.CSSProperties;
@ -32,6 +33,7 @@ export function AreaChart<TDatum extends Record<string, unknown>>({
showLegend = true,
showGridLines = true,
showTooltip = true,
showDots = false,
customTooltip,
className,
style,
@ -94,7 +96,7 @@ export function AreaChart<TDatum extends Record<string, unknown>>({
strokeWidth={2}
fill={`url(#fill-${gradientId}-${i})`}
fillOpacity={1}
dot={false}
dot={showDots ? { r: 3.5, strokeWidth: 2, stroke: fills[i], fill: "var(--background, #fff)" } : false}
isAnimationActive={false}
/>
))}

View file

@ -20,6 +20,13 @@ describe("BarChart", () => {
expect(fills).toEqual(new Set(["var(--color-green-500, #22c55e)", "var(--color-red-500, #ef4444)"]));
});
it("renders the No data placeholder instead of a chart when data is empty", () => {
const { container, getByText } = render(<BarChart data={[]} index="date" categories={["passed"]} />);
expect(getByText("No data")).toBeTruthy();
expect(container.querySelector('[data-slot="chart"]')).toBeNull();
});
it("falls back to the tremor default color cycle when no colors are passed", () => {
const { container } = render(<BarChart data={data} index="date" categories={["passed", "blocked"]} />);

View file

@ -46,6 +46,17 @@ export function BarChart<TDatum extends Record<string, unknown>>({
className,
style,
}: BarChartProps<TDatum>) {
if (data.length === 0) {
return (
<div
className={cn("flex h-80 w-full items-center justify-center rounded-lg border border-dashed", className)}
style={style}
>
<p className="text-sm text-muted-foreground">No data</p>
</div>
);
}
const fills = categoryFills(categories.length, colors);
const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }]));
const vertical = layout === "vertical";