Merge remote-tracking branch 'upstream/main' into fix-44211-deepseek-tool-images

This commit is contained in:
Darsh Joshi 2026-10-23 16:35:42 -04:00
commit d620e29f54
125 changed files with 16693 additions and 3539 deletions

View file

@ -0,0 +1,91 @@
name: Interactions spec canary
on:
schedule:
- cron: "17 7 * * *"
pull_request:
paths:
- .github/workflows/interactions-spec-canary.yml
- tests/unit/interactions/**
- litellm/types/interactions/**
workflow_dispatch:
permissions: {}
concurrency: interactions-spec-canary
jobs:
interactions-spec-canary:
name: interactions-spec-canary
if: github.repository == 'BerriAI/litellm'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
outputs:
compliance: ${{ steps.compliance.outcome }}
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up uv
uses: ./.github/actions/setup-uv-with-retries
with:
version: "0.10.9"
- name: Install dependencies
run: .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra proxy --no-install-project
- name: Run the compliance tests against Google's live spec
id: compliance
run: |
uv run --no-sync pytest tests/unit/interactions/test_openapi_compliance.py \
--interactions-spec-url=https://ai.google.dev/static/api/interactions.openapi.json \
--tb=short -vv -p no:rerunfailures -p no:pytest-retry \
--allow-hosts=127.0.0.1,::1,localhost,ai.google.dev
tracking-issue:
name: tracking-issue
needs: interactions-spec-canary
if: always() && github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch && github.repository == 'BerriAI/litellm'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
steps:
- name: File the tracking issue unless the compliance step passed, close it when it did
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
COMPLIANCE_OUTCOME: ${{ needs.interactions-spec-canary.outputs.compliance }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const title = "Interactions spec canary is red";
const outcome = process.env.COMPLIANCE_OUTCOME;
const runUrl = process.env.RUN_URL;
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open in:title "${title}"`;
const { data: found } = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 20 });
const open = found.items.find((issue) => issue.title === title);
if (outcome === "success") {
if (open === undefined) return;
await github.rest.issues.createComment({ ...context.repo, issue_number: open.number, body: `Green again: ${runUrl}` });
await github.rest.issues.update({ ...context.repo, issue_number: open.number, state: "closed" });
return;
}
const state = outcome === "failure" ? "red" : `not run (compliance step outcome: ${outcome || "none"})`;
if (open !== undefined) {
await github.rest.issues.createComment({ ...context.repo, issue_number: open.number, body: `Still ${state}: ${runUrl}` });
return;
}
await github.rest.issues.create({
...context.repo,
title,
assignees: ["mateo-berri"],
body: `The scheduled run of tests/unit/interactions/test_openapi_compliance.py against Google's live Interactions spec is ${state}: ${runUrl}\n\nlitellm/types/interactions/README.md says how to refresh the snapshot and regenerate the types.`,
});

View file

@ -425,6 +425,7 @@ jobs:
tests/unit/realtime_api
tests/unit/rerank_api
tests/unit/search
tests/unit/text_completions
tests/unit/vector_stores
tests/unit/videos
python-version: "3.12"

View file

@ -61,6 +61,7 @@ RUN apk add --no-cache \
python-3.13-dev \
rust \
openssl \
libssl3 \
openssl-dev \
nodejs \
npm \

View file

@ -43,7 +43,7 @@ COPY --from=uvbin /uv /uvx /usr/local/bin/
# instead of nodeenv downloading one whose dynamic deps may not be in Wolfi
# (e.g. Node 26.2.0 needs libatomic). Retry for transient apk.cgr.dev flakes.
RUN for i in 1 2 3; do \
apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl openssl-dev libsndfile nodejs npm && break; \
apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl libssl3 openssl-dev libsndfile nodejs npm && break; \
[ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \
sleep 5; \
done

View file

@ -59,6 +59,7 @@ RUN apk add --no-cache \
python-3.13 \
python-3.13-dev \
openssl \
libssl3 \
openssl-dev \
nodejs \
npm \

View file

@ -60,6 +60,7 @@ RUN for i in 1 2 3; do \
coreutils \
curl \
openssl \
libssl3 \
libsndfile \
nodejs \
npm && break || sleep 5; \

View file

@ -30,7 +30,7 @@ RUN uv venv --python python && \
"google-cloud-aiplatform==1.133.0" \
"google-genai==1.37.0" \
"anthropic[vertex]==0.84.0" \
"grpcio==1.78.0" \
"grpcio==1.84.1" \
"prometheus-client==0.20.0" \
"langfuse==2.59.7" \
"opentelemetry-api==1.28.0" \

View file

@ -59,7 +59,7 @@ COPY --from=uvbin /uv /uvx /usr/local/bin/
# instead of nodeenv downloading one whose dynamic deps may not be in Wolfi
# (e.g. Node 26.2.0 needs libatomic). Retry for transient apk.cgr.dev flakes.
RUN for i in 1 2 3; do \
apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl openssl-dev libsndfile nodejs npm && break; \
apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl libssl3 openssl-dev libsndfile nodejs npm && break; \
[ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \
sleep 5; \
done

View file

@ -102,6 +102,7 @@ class ExceptionCheckers:
"`inputs` tokens + `max_new_tokens` must be",
"exceeds the available context size", # llama.cpp/Lemonade
"exceeds the maximum number of tokens allowed", # Gemini
'"code": "context_length_exceeded"',
]
for substring in known_exception_substrings:
if substring in _error_str_lowercase:

View file

@ -75,6 +75,12 @@ def messages_carry_output_config(messages: Sequence[object]) -> bool:
_messages_carry_output_config = messages_carry_output_config
def _ensure_stop_sequence(response: AnthropicMessagesResponse) -> AnthropicMessagesResponse:
if "stop_sequence" not in response:
response["stop_sequence"] = None # rebind-ok: normalize the request-owned response in place
return response
class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
_workload_identity_eligible: ClassVar[bool] = True
@ -715,14 +721,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> AnthropicMessagesResponse:
"""
No transformation is needed for Anthropic messages, since we want the response in the Anthropic /v1/messages API spec
"""
try:
raw_response_json: Final = raw_response.json()
except Exception:
raise AnthropicError(message=raw_response.text, status_code=raw_response.status_code)
return AnthropicMessagesResponse(**raw_response_json)
response: Final = AnthropicMessagesResponse(**raw_response_json)
return _ensure_stop_sequence(response)
def get_async_streaming_response_iterator(
self,

View file

@ -143,6 +143,7 @@ from litellm.router_utils.reasoning_effort_capability import (
resolve_chat_reasoning,
)
from litellm.secret_managers.main import get_secret_bool, get_secret_str
from litellm.text_completions.streaming_iterator import TextCompletionStreamWrapper
from litellm.types.completion import (
CompletionDispatchContext,
_CompletionDispatchResult,
@ -169,7 +170,6 @@ from litellm.utils import (
ProviderConfigManager,
TextChoices,
TextCompletionResponse,
TextCompletionStreamWrapper,
TranscriptionResponse,
Usage,
add_provider_specific_params_to_optional_params,

View file

@ -36861,17 +36861,17 @@
},
"gpt-6-luna": {
"cache_creation_input_token_cost": 1.25e-07,
"cache_creation_input_token_cost_decisions": 1e-07,
"cache_creation_input_token_cost_decisions": 1.25e-07,
"cache_creation_input_token_cost_above_272k_tokens": 2.5e-07,
"cache_creation_input_token_cost_above_272k_tokens_decisions": 2e-07,
"cache_creation_input_token_cost_above_272k_tokens_decisions": 2.5e-07,
"cache_creation_input_token_cost_above_272k_tokens_flex": 1.25e-07,
"cache_creation_input_token_cost_above_272k_tokens_priority": 5e-07,
"cache_creation_input_token_cost_flex": 6.25e-08,
"cache_creation_input_token_cost_priority": 2.5e-07,
"cache_read_input_token_cost": 1e-08,
"cache_read_input_token_cost_decisions": 1e-07,
"cache_read_input_token_cost_decisions": 1e-08,
"cache_read_input_token_cost_above_272k_tokens": 2e-08,
"cache_read_input_token_cost_above_272k_tokens_decisions": 2e-07,
"cache_read_input_token_cost_above_272k_tokens_decisions": 2e-08,
"cache_read_input_token_cost_above_272k_tokens_flex": 1e-08,
"cache_read_input_token_cost_above_272k_tokens_priority": 4e-08,
"cache_read_input_token_cost_batches": 5e-09,
@ -73330,6 +73330,7 @@
]
},
"vertex_ai/veo-2.0-generate-001": {
"deprecation_date": "2026-06-30",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

View file

@ -0,0 +1,27 @@
<!--
Source: https://github.com/lobehub/lobe-icons/blob/master/packages/static-svg/icons/claude-color.svg
Retrieved: 2026-10-22
MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

View file

@ -299,11 +299,6 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
module_path="litellm.proxy.spend_tracking.vantage_endpoints",
path_prefixes=("/vantage",),
),
LazyFeature(
name="usage_ai",
module_path="litellm.proxy.management_endpoints.usage_endpoints",
path_prefixes=("/usage/ai",),
),
LazyFeature(
name="prompts",
module_path="litellm.proxy.prompts.prompt_endpoints",

View file

@ -11407,7 +11407,7 @@
},
"/systemone": {
"post": {
"description": "Judge a `state` against 1 to 128 named `questions` and get one calibrated answer per question name.\n\nQuestion types are `noul` (probability the answer is yes), `choice` (one label out of `criteria`, with\n`confidence` and `probabilities`), `score` (an index into the `criteria` levels, with `confidence`,\n`legend` and `probabilities`) and `box` (the image region the `instructions` describe, with `confidence`).\nTypeSafe models also take images in `assets`, each one referenced by its key in `state`. Requests that break\nthese rules are rejected with a 400 before any provider is called. `model` is any decision model in the proxy model list. OpenAI decision models accept this format\ntoo, LiteLLM translates it and keeps the answers keyed by question name. Streaming is not supported.\n\n[Docs](https://docs.litellm.ai/docs/decisions)",
"description": "Judge a `state` against one or more named `questions` and get one calibrated answer per question name.\n\nQuestion types are `noul` (probability the answer is yes), `choice` (one label out of `criteria`, with\n`confidence` and `probabilities`), `score` (an index into the `criteria` levels, with `confidence`,\n`legend` and `probabilities`) and `box` (the image region the `instructions` describe, with `confidence`).\nTypeSafe models also take images in `assets`, each one referenced by its key in `state`. Requests that break\nthese rules are rejected with a 400 before any provider is called. `model` is any decision model in the proxy model list. OpenAI decision models accept this format\ntoo, LiteLLM translates it and keeps the answers keyed by question name. Streaming is not supported.\n\n[Docs](https://docs.litellm.ai/docs/decisions)",
"operationId": "systemone_systemone_post",
"requestBody": {
"content": {
@ -11813,7 +11813,6 @@
]
},
"description": "Named noul, choice and score questions. Each key becomes a key in the answers",
"maxProperties": 128,
"minProperties": 1,
"propertyNames": {
"minLength": 1
@ -12814,7 +12813,7 @@
},
"/v1/systemone": {
"post": {
"description": "Judge a `state` against 1 to 128 named `questions` and get one calibrated answer per question name.\n\nQuestion types are `noul` (probability the answer is yes), `choice` (one label out of `criteria`, with\n`confidence` and `probabilities`), `score` (an index into the `criteria` levels, with `confidence`,\n`legend` and `probabilities`) and `box` (the image region the `instructions` describe, with `confidence`).\nTypeSafe models also take images in `assets`, each one referenced by its key in `state`. Requests that break\nthese rules are rejected with a 400 before any provider is called. `model` is any decision model in the proxy model list. OpenAI decision models accept this format\ntoo, LiteLLM translates it and keeps the answers keyed by question name. Streaming is not supported.\n\n[Docs](https://docs.litellm.ai/docs/decisions)",
"description": "Judge a `state` against one or more named `questions` and get one calibrated answer per question name.\n\nQuestion types are `noul` (probability the answer is yes), `choice` (one label out of `criteria`, with\n`confidence` and `probabilities`), `score` (an index into the `criteria` levels, with `confidence`,\n`legend` and `probabilities`) and `box` (the image region the `instructions` describe, with `confidence`).\nTypeSafe models also take images in `assets`, each one referenced by its key in `state`. Requests that break\nthese rules are rejected with a 400 before any provider is called. `model` is any decision model in the proxy model list. OpenAI decision models accept this format\ntoo, LiteLLM translates it and keeps the answers keyed by question name. Streaming is not supported.\n\n[Docs](https://docs.litellm.ai/docs/decisions)",
"operationId": "systemone_v1_systemone_post",
"requestBody": {
"content": {
@ -13220,7 +13219,6 @@
]
},
"description": "Named noul, choice and score questions. Each key becomes a key in the answers",
"maxProperties": 128,
"minProperties": 1,
"propertyNames": {
"minLength": 1
@ -57900,163 +57898,6 @@
}
}
},
"usage_ai": {
"components": {
"schemas": {
"ChatMessage": {
"properties": {
"content": {
"title": "Content",
"type": "string"
},
"role": {
"enum": [
"user",
"assistant"
],
"title": "Role",
"type": "string"
}
},
"required": [
"role",
"content"
],
"title": "ChatMessage",
"type": "object"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"title": "Detail",
"type": "array"
}
},
"title": "HTTPValidationError",
"type": "object"
},
"UsageAIChatRequest": {
"properties": {
"messages": {
"description": "Chat messages (user/assistant history)",
"items": {
"$ref": "#/components/schemas/ChatMessage"
},
"title": "Messages",
"type": "array"
},
"model": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Model to use for AI chat",
"title": "Model"
}
},
"required": [
"messages"
],
"title": "UsageAIChatRequest",
"type": "object"
},
"ValidationError": {
"properties": {
"ctx": {
"title": "Context",
"type": "object"
},
"input": {
"title": "Input"
},
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"title": "Location",
"type": "array"
},
"msg": {
"title": "Message",
"type": "string"
},
"type": {
"title": "Error Type",
"type": "string"
}
},
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError",
"type": "object"
}
}
},
"paths": {
"/usage/ai/chat": {
"post": {
"description": "AI chat about usage data. Streams SSE events with the AI response.\nThe AI agent has access to tools that query aggregated daily activity data.",
"operationId": "usage_ai_chat_usage_ai_chat_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UsageAIChatRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Usage Ai Chat",
"tags": [
"usage_ai"
]
}
}
}
},
"vantage": {
"components": {
"schemas": {

View file

@ -2717,6 +2717,13 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"hidden model can still be called."
),
)
return_wildcard_routes: bool | None = Field(
None,
description=(
"When true, `/v1/models` lists wildcard routes like `openai/*` unless the request passes "
"`return_wildcard_routes=false`."
),
)
alerting: list | None = Field(
None,
description="List of alerting integrations - e.g. `alerting: ['slack', 'webhook', 'email']`. 'slack' posts Slack-format messages to any Slack-compatible webhook (Slack, Rocket.Chat, Mattermost); 'webhook' posts structured JSON budget alerts to WEBHOOK_URL",

View file

@ -228,7 +228,7 @@ async def systemone(
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Judge a `state` against 1 to 128 named `questions` and get one calibrated answer per question name.
Judge a `state` against one or more named `questions` and get one calibrated answer per question name.
Question types are `noul` (probability the answer is yes), `choice` (one label out of `criteria`, with
`confidence` and `probabilities`), `score` (an index into the `criteria` levels, with `confidence`,

View file

@ -1,9 +0,0 @@
"""
Usage endpoints package.
Re-exports the router from endpoints module.
"""
from litellm.proxy.management_endpoints.usage_endpoints.endpoints import ( # noqa: F401
router,
)

View file

@ -1,627 +0,0 @@
"""
AI Usage Chat - uses LLM tool calling to answer questions about
usage/spend data by querying the aggregated daily activity endpoints.
"""
import json
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Sequence
from datetime import date
from typing import Final, Literal, NamedTuple, Protocol, cast, overload
from fastapi import HTTPException
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import DEFAULT_COMPETITOR_DISCOVERY_MODEL
from litellm.proxy._types import CommonProxyErrors
from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
)
from litellm.types.utils import ChatCompletionMessageToolCall
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
USAGE_AI_TEMPERATURE: Final = 0.2
TABLE_DAILY_USER_SPEND: Final = "litellm_dailyuserspend"
TABLE_DAILY_TEAM_SPEND: Final = "litellm_dailyteamspend"
TABLE_DAILY_TAG_SPEND: Final = "litellm_dailytagspend"
ENTITY_FIELD_USER: Final = "user_id"
ENTITY_FIELD_TEAM: Final = "team_id"
ENTITY_FIELD_TAG: Final = "tag"
PAGINATED_PAGE_SIZE: Final = 200
MAX_CHAT_MESSAGES: Final = 20
TOP_N_MODELS: Final = 15
TOP_N_PROVIDERS: Final = 10
TOP_N_KEYS: Final = 10
# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------
class SSEStatusEvent(TypedDict):
type: Literal["status"]
message: str
class SSEToolCallEvent(TypedDict, total=False):
type: Literal["tool_call"]
tool_name: str
tool_label: str
arguments: dict[str, str]
status: Literal["running", "complete", "error"]
error: str
class SSEChunkEvent(TypedDict):
type: Literal["chunk"]
content: str
class SSEDoneEvent(TypedDict):
type: Literal["done"]
class SSEErrorEvent(TypedDict):
type: Literal["error"]
message: str
SSEEvent = SSEStatusEvent | SSEToolCallEvent | SSEChunkEvent | SSEDoneEvent | SSEErrorEvent
class _EntityEntry(TypedDict, total=False):
metrics: ReadOnly[Mapping[str, float]]
metadata: ReadOnly[Mapping[str, str]]
class _DayDump(TypedDict, total=False):
breakdown: ReadOnly[Mapping[str, Mapping[str, _EntityEntry]]]
class _EntityTotal(NamedTuple):
"""Running per-entity totals accumulated while summarising a usage dump."""
alias: str
spend: float
requests: float
tokens: float
class _UsageDump(Protocol):
@overload
def get(self, key: Literal["metadata"], default: Mapping[str, float], /) -> Mapping[str, float]: ...
@overload
def get(self, key: Literal["results"], default: Sequence[_DayDump], /) -> Sequence[_DayDump]: ...
class _ToolFunctionDef(TypedDict):
name: ReadOnly[str]
description: ReadOnly[str]
parameters: ReadOnly[Mapping[str, object]]
class _ToolDef(TypedDict):
type: ReadOnly[str]
function: ReadOnly[_ToolFunctionDef]
class ToolHandler(TypedDict):
fetch: Callable[..., Awaitable[_UsageDump]]
summarise: Callable[[_UsageDump], str]
label: str
# ---------------------------------------------------------------------------
# Tool definitions (OpenAI function-calling schema)
# ---------------------------------------------------------------------------
_DATE_PARAMS: Final = {
"start_date": {"type": "string", "description": "Start date in YYYY-MM-DD format"},
"end_date": {"type": "string", "description": "End date in YYYY-MM-DD format"},
}
_TOOL_USAGE: Final[_ToolDef] = {
"type": "function",
"function": {
"name": "get_usage_data",
"description": (
"Fetch aggregated global usage/spend data. Returns daily spend, "
"token counts, request counts, and breakdowns by model, provider, "
"and API key. Use for overall spend, top models, top providers."
),
"parameters": {
"type": "object",
"properties": {
**_DATE_PARAMS,
"user_id": {
"type": "string",
"description": "Optional user ID filter. Omit for global view.",
},
},
"required": ["start_date", "end_date"],
},
},
}
_TOOL_TEAM: Final[_ToolDef] = {
"type": "function",
"function": {
"name": "get_team_usage_data",
"description": (
"Fetch usage/spend data broken down by team. Use for questions "
"like 'which team spends the most' or 'show me team X usage'."
),
"parameters": {
"type": "object",
"properties": {
**_DATE_PARAMS,
"team_ids": {
"type": "string",
"description": "Optional comma-separated team IDs. Omit for all teams.",
},
},
"required": ["start_date", "end_date"],
},
},
}
_TOOL_TAG: Final[_ToolDef] = {
"type": "function",
"function": {
"name": "get_tag_usage_data",
"description": (
"Fetch usage/spend data broken down by tag. Tags are labels "
"attached to requests (features, environments, credentials)."
),
"parameters": {
"type": "object",
"properties": {
**_DATE_PARAMS,
"tags": {
"type": "string",
"description": "Optional comma-separated tag names. Omit for all tags.",
},
},
"required": ["start_date", "end_date"],
},
},
}
TOOLS_BASE: Final = [_TOOL_USAGE]
TOOLS_ADMIN: Final = [_TOOL_USAGE, _TOOL_TEAM, _TOOL_TAG]
def get_tools_for_role(is_admin: bool) -> list[_ToolDef]:
"""Return the tool list appropriate for the user's role."""
return TOOLS_ADMIN if is_admin else TOOLS_BASE
_SYSTEM_PROMPT_BASE: Final = (
"You are an AI assistant embedded in the LiteLLM Usage dashboard. "
"You help users understand their LLM API spend and usage data.\n\n"
"ALWAYS call the appropriate tool(s) first to fetch data before answering. "
"You may call multiple tools if the question spans different dimensions.\n\n"
"Guidelines:\n"
"- Be concise and specific. Use exact numbers from the data.\n"
"- Format costs as dollar amounts (e.g. $12.34).\n"
"- When comparing entities, show a ranked list.\n"
"- If data is empty or no results found, say so clearly.\n"
"- Do not hallucinate data — only use what the tools return.\n"
"- Today's date will be provided below. Use it to interpret relative dates "
"like 'this week', 'this month', 'last 7 days', etc."
)
_TOOL_DESCRIPTIONS_ADMIN: Final = (
"You have access to these tools:\n"
"- `get_usage_data`: Global/user-level usage (spend, models, providers, API keys)\n"
"- `get_team_usage_data`: Team-level usage breakdown\n"
"- `get_tag_usage_data`: Tag-level usage breakdown\n\n"
)
_TOOL_DESCRIPTIONS_BASE: Final = (
"You have access to this tool:\n- `get_usage_data`: Your usage data (spend, models, providers, API keys)\n\n"
)
def _build_system_prompt(is_admin: bool) -> str:
"""Build role-appropriate system prompt with today's date."""
tool_desc: Final = _TOOL_DESCRIPTIONS_ADMIN if is_admin else _TOOL_DESCRIPTIONS_BASE
return f"{_SYSTEM_PROMPT_BASE}\n\n{tool_desc}Today's date: {date.today().isoformat()}"
# keep a public reference for test assertions
SYSTEM_PROMPT: Final = _SYSTEM_PROMPT_BASE
# ---------------------------------------------------------------------------
# Data fetchers
# ---------------------------------------------------------------------------
def _parse_csv_ids(raw: str | None) -> list[str] | None:
if not raw:
return None
return [t.strip() for t in raw.split(",") if t.strip()]
async def _query_activity(
table_name: str,
entity_id_field: str,
entity_id: str | list[str] | None,
start_date: str,
end_date: str,
*,
use_aggregated: bool = False,
) -> SpendAnalyticsPaginatedResponse:
"""Shared helper that calls the daily activity query layer."""
from litellm.proxy.management_endpoints.common_daily_activity import (
daily_activity_repository,
daily_activity_scope,
get_daily_activity,
get_daily_activity_aggregated,
)
from litellm.proxy.proxy_server import prisma_client
if use_aggregated:
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
repository: Final = daily_activity_repository(prisma_client)
scope: Final = daily_activity_scope(
table_name,
entity_id_field,
entity_id,
None,
None,
start_date,
end_date,
None,
None,
)
return await get_daily_activity_aggregated(
repository,
scope,
)
return await get_daily_activity(
prisma_client=prisma_client,
table_name=table_name,
entity_id_field=entity_id_field,
entity_id=entity_id,
entity_metadata_field=None,
start_date=start_date,
end_date=end_date,
model=None,
api_key=None,
page=1,
page_size=PAGINATED_PAGE_SIZE,
)
async def _fetch_usage_data(start_date: str, end_date: str, user_id: str | None = None) -> _UsageDump:
resp: Final = await _query_activity(
TABLE_DAILY_USER_SPEND,
ENTITY_FIELD_USER,
user_id,
start_date,
end_date,
use_aggregated=True,
)
return resp.model_dump(mode="json")
async def _fetch_team_usage_data(start_date: str, end_date: str, team_ids: str | None = None) -> _UsageDump:
resp: Final = await _query_activity(
TABLE_DAILY_TEAM_SPEND,
ENTITY_FIELD_TEAM,
_parse_csv_ids(team_ids),
start_date,
end_date,
)
return resp.model_dump(mode="json")
async def _fetch_tag_usage_data(start_date: str, end_date: str, tags: str | None = None) -> _UsageDump:
resp: Final = await _query_activity(
TABLE_DAILY_TAG_SPEND,
ENTITY_FIELD_TAG,
_parse_csv_ids(tags),
start_date,
end_date,
)
return resp.model_dump(mode="json")
# ---------------------------------------------------------------------------
# Summarisers — convert raw JSON to concise text the LLM can reason over
# ---------------------------------------------------------------------------
def _accumulate_breakdown(
results: Sequence[_DayDump], dimension: str, fields: Sequence[str]
) -> dict[str, dict[str, float]]:
"""Aggregate a single breakdown dimension across days."""
totals: Final[dict[str, dict[str, float]]] = {}
for day in results:
for key, entry in day.get("breakdown", {}).get(dimension, {}).items():
if key not in totals:
totals[key] = {f: 0.0 for f in fields}
m = entry.get("metrics", {})
for f in fields:
totals[key][f] += m.get(f, 0)
return totals
def _ranked_lines(
totals: dict[str, dict[str, float]],
fmt: Callable[[str, dict[str, float]], str],
limit: int,
) -> list[str]:
"""Sort by spend descending, format each entry, and truncate."""
return [fmt(name, vals) for name, vals in sorted(totals.items(), key=lambda x: -x[1].get("spend", 0))[:limit]]
def _summarise_usage_data(data: _UsageDump) -> str:
meta: Final = data.get("metadata", {})
results: Final = data.get("results", [])
header: Final = (
f"Total Spend: ${meta.get('total_spend', 0):.4f}\n"
f"Total Requests: {meta.get('total_api_requests', 0)}\n"
f"Successful: {meta.get('total_successful_requests', 0)} | "
f"Failed: {meta.get('total_failed_requests', 0)}\n"
f"Total Tokens: {meta.get('total_tokens', 0)}"
)
models: Final = _accumulate_breakdown(results, "models", ["spend", "api_requests", "total_tokens"])
providers: Final = _accumulate_breakdown(results, "providers", ["spend", "api_requests"])
model_lines: Final = _ranked_lines(
models,
lambda n, d: f" - {n}: ${d['spend']:.4f} ({int(d['api_requests'])} reqs, {int(d['total_tokens'])} tokens)",
TOP_N_MODELS,
)
provider_lines: Final = _ranked_lines(
providers,
lambda n, d: f" - {n}: ${d['spend']:.4f} ({int(d['api_requests'])} reqs)",
TOP_N_PROVIDERS,
)
sections = [header, ""]
sections += ["Top Models by Spend:"] + (model_lines or [" (no data)"]) + [""]
sections += ["Top Providers by Spend:"] + (provider_lines or [" (no data)"])
return "\n".join(sections)
def _summarise_entity_data(data: _UsageDump, entity_label: str) -> str:
"""Summarise team/tag entity usage data."""
results: Final = data.get("results", [])
if not results:
return f"No {entity_label} usage data found for the given date range."
totals: Final[dict[str, _EntityTotal]] = {}
for day in results:
for eid, entry in day.get("breakdown", {}).get("entities", {}).items():
previous = totals.get(eid)
m = entry.get("metrics", {})
totals[eid] = _EntityTotal(
alias=previous.alias if previous is not None else entry.get("metadata", {}).get("alias", eid),
spend=(previous.spend if previous is not None else 0.0) + m.get("spend", 0),
requests=(previous.requests if previous is not None else 0) + m.get("api_requests", 0),
tokens=(previous.tokens if previous is not None else 0) + m.get("total_tokens", 0),
)
lines: Final = [f"{entity_label} Usage ({len(totals)} {entity_label.lower()}s):", ""]
for eid, d in sorted(totals.items(), key=lambda x: -x[1].spend):
label = d.alias if d.alias != eid else eid
lines.append(f"- {label} (ID: {eid}): ${d.spend:.4f} | {int(d.requests)} reqs | {int(d.tokens)} tokens")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Tool dispatch registry
# ---------------------------------------------------------------------------
TOOL_HANDLERS: Final[dict[str, ToolHandler]] = {
"get_usage_data": ToolHandler(
fetch=_fetch_usage_data,
summarise=_summarise_usage_data,
label="global usage data",
),
"get_team_usage_data": ToolHandler(
fetch=_fetch_team_usage_data,
summarise=lambda data: _summarise_entity_data(data, "Team"),
label="team usage data",
),
"get_tag_usage_data": ToolHandler(
fetch=_fetch_tag_usage_data,
summarise=lambda data: _summarise_entity_data(data, "Tag"),
label="tag usage data",
),
}
# ---------------------------------------------------------------------------
# SSE streaming
# ---------------------------------------------------------------------------
def _sse(event: SSEEvent) -> str:
return f"data: {json.dumps(event)}\n\n"
def _resolve_fetch_kwargs(
fn_name: str,
fn_args: Mapping[str, str],
user_id: str | None,
is_admin: bool,
) -> dict[str, str]:
"""Build keyword arguments for a tool's fetch function."""
start_date: Final = fn_args.get("start_date", "")
end_date: Final = fn_args.get("end_date", "")
if not start_date or not end_date:
raise ValueError("Missing required start_date or end_date from tool arguments")
kwargs: Final[dict[str, str]] = {"start_date": start_date, "end_date": end_date}
if fn_name == "get_usage_data":
if not is_admin:
if user_id is None:
# Defense-in-depth: the endpoint guard in usage_endpoints/endpoints.py
# should have already rejected this. If we ever reach here it means
# a future caller invoked the helper without scoping — fail loudly
# rather than issuing an unfiltered global query.
raise ValueError(
"Non-admin caller has user_id=None; refusing to issue an "
"unscoped query. Endpoint-level guard missing."
)
kwargs["user_id"] = user_id
elif fn_args.get("user_id"):
kwargs["user_id"] = fn_args["user_id"]
elif fn_name == "get_team_usage_data" and fn_args.get("team_ids"):
kwargs["team_ids"] = fn_args["team_ids"]
elif fn_name == "get_tag_usage_data" and fn_args.get("tags"):
kwargs["tags"] = fn_args["tags"]
return kwargs
async def _execute_tool_call(
handler: ToolHandler,
fn_name: str,
fn_args: Mapping[str, str],
user_id: str | None,
is_admin: bool,
) -> str:
"""Run a single tool and return the summarised result text, or the reason the query
layer rejected its dates so the model can correct them."""
kwargs: Final = _resolve_fetch_kwargs(fn_name, fn_args, user_id, is_admin)
try:
raw_data: Final = await handler["fetch"](**kwargs)
except HTTPException as e:
reason: Final = _invalid_date_reason(e)
if reason is None:
raise
return f"Invalid date range: {reason}"
return handler["summarise"](raw_data)
def _invalid_date_reason(error: HTTPException) -> str | None:
"""The reason in the daily activity query layer's 400 for invalid or reversed dates."""
detail: Final = cast(object, error.detail) # cast-ok: starlette types detail as str; the query layer passes a dict
match detail:
case {"error": str() as reason} if error.status_code == 400:
return reason
case _:
return None
async def _process_tool_call(
tc: ChatCompletionMessageToolCall,
chat_messages: list[Mapping[str, object]],
user_id: str | None,
is_admin: bool,
) -> AsyncIterator[str]:
"""Execute a single tool call, yielding SSE events for status."""
fn_name: Final = tc.function.name
fn_args: Final[Mapping[str, str]] = json.loads(tc.function.arguments)
allowed_names: Final = {t["function"]["name"] for t in get_tools_for_role(is_admin)}
handler: Final = TOOL_HANDLERS.get(fn_name) if fn_name is not None else None
if fn_name is None or fn_name not in allowed_names or not handler:
chat_messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": f"Tool not available: {fn_name}",
}
)
return
tool_event_base: Final = {
"type": "tool_call",
"tool_name": fn_name,
"tool_label": handler["label"],
"arguments": fn_args,
}
yield _sse(cast(SSEToolCallEvent, {**tool_event_base, "status": "running"}))
try:
tool_result = await _execute_tool_call(handler, fn_name, fn_args, user_id, is_admin)
yield _sse(cast(SSEToolCallEvent, {**tool_event_base, "status": "complete"}))
except Exception as e:
verbose_proxy_logger.error("Tool %s failed: %s", fn_name, e)
tool_result = f"Error fetching {handler['label']}. Please try again."
yield _sse(cast(SSEToolCallEvent, {**tool_event_base, "status": "error"}))
chat_messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_result})
async def _stream_final_response(model: str, chat_messages: list[Mapping[str, object]]) -> AsyncIterator[str]:
"""Stream the final LLM response after tool results are appended."""
yield _sse({"type": "status", "message": "Analyzing results..."})
response: Final = await litellm.acompletion(
model=model,
messages=chat_messages,
stream=True,
temperature=USAGE_AI_TEMPERATURE,
)
async for chunk in response:
delta = chunk.choices[0].delta.content
if delta:
yield _sse({"type": "chunk", "content": delta})
async def stream_usage_ai_chat(
messages: list[dict[str, str]],
model: str | None = None,
user_id: str | None = None,
is_admin: bool = False,
) -> AsyncGenerator[str, None]:
"""Stream SSE events: status → tool_call → chunk → done."""
resolved_model: Final = (model or "").strip() or DEFAULT_COMPETITOR_DISCOVERY_MODEL
truncated: Final = messages[-MAX_CHAT_MESSAGES:] if len(messages) > MAX_CHAT_MESSAGES else messages
chat_messages: Final[list[Mapping[str, object]]] = [
{"role": "system", "content": _build_system_prompt(is_admin)},
*truncated,
]
try:
yield _sse({"type": "status", "message": "Thinking..."})
tools: Final = get_tools_for_role(is_admin)
response: Final = await litellm.acompletion(
model=resolved_model,
messages=chat_messages,
tools=tools,
temperature=USAGE_AI_TEMPERATURE,
)
choice: Final = response.choices[0]
if not choice.message.tool_calls:
if choice.message.content:
yield _sse({"type": "chunk", "content": choice.message.content})
yield _sse({"type": "done"})
return
chat_messages.append(choice.message.model_dump())
for tc in choice.message.tool_calls:
async for event in _process_tool_call(tc, chat_messages, user_id, is_admin):
yield event
async for event in _stream_final_response(resolved_model, chat_messages):
yield event
yield _sse({"type": "done"})
except Exception as e:
verbose_proxy_logger.error("AI usage chat failed: %s", e)
yield _sse(
{
"type": "error",
"message": "An internal error occurred. Please try again.",
}
)

View file

@ -1,77 +0,0 @@
"""
USAGE AI CHAT ENDPOINTS
/usage/ai/chat - Stream AI chat responses about usage data
"""
from typing import Final, Literal
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from pydantic import Field
import litellm
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.sse_keepalive import (
SSE_COMMENT_PING,
wrap_sse_stream_with_keepalive_pings,
)
from litellm.types.llms.base import LiteLLMBaseModel
router: Final = APIRouter()
class ChatMessage(LiteLLMBaseModel):
role: Literal["user", "assistant"]
content: str
class UsageAIChatRequest(LiteLLMBaseModel):
messages: list[ChatMessage] = Field(..., description="Chat messages (user/assistant history)")
model: str | None = Field(default=None, description="Model to use for AI chat")
@router.post(
"/usage/ai/chat",
tags=["Budget & Spend Tracking"],
dependencies=[Depends(user_api_key_auth)],
)
async def usage_ai_chat(
data: UsageAIChatRequest,
request: Request,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
AI chat about usage data. Streams SSE events with the AI response.
The AI agent has access to tools that query aggregated daily activity data.
"""
from litellm.proxy.management_endpoints.common_utils import (
require_caller_user_id_for_non_admin,
user_api_key_has_admin_view,
)
from litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat import (
stream_usage_ai_chat,
)
is_admin: Final = user_api_key_has_admin_view(user_api_key_dict)
if is_admin:
user_id = user_api_key_dict.user_id
else:
user_id = require_caller_user_id_for_non_admin(user_api_key_dict)
messages: Final = [{"role": m.role, "content": m.content} for m in data.messages]
return StreamingResponse(
wrap_sse_stream_with_keepalive_pings(
stream_usage_ai_chat(
messages=messages,
model=data.model,
user_id=user_id,
is_admin=is_admin,
),
ping_interval_seconds=litellm.sse_keepalive_ping_interval_seconds,
ping_chunk=SSE_COMMENT_PING,
),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)

View file

@ -12210,7 +12210,7 @@ async def _deployment_hidden_by_listing_callbacks(deployment: Deployment, user_a
async def model_list(
request: Request = None, # pyright: ignore[reportArgumentType] # FastAPI always injects the Request; the None default only serves direct in-process callers
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
return_wildcard_routes: bool | None = False,
return_wildcard_routes: bool | None = None,
team_id: str | None = None,
include_model_access_groups: bool | None = False,
only_model_access_groups: bool | None = False,
@ -12226,6 +12226,9 @@ async def model_list(
This is just for compatibility with openai projects like aider.
Query Parameters:
- return_wildcard_routes: When true, also list wildcard routes such as `openai/*`.
Defaults to `general_settings.return_wildcard_routes`, and an explicit
value on the request wins over it
- client_version: Sent by Codex CLI (`?client_version=0.159.3`) when it fetches a
provider's model catalog. When present, the response is Codex's own
catalog shape (`{"models": [...]}`) built from the same listing, with
@ -12254,6 +12257,9 @@ async def model_list(
global llm_model_list, general_settings, llm_router, prisma_client, user_api_key_cache, proxy_logging_obj
settings: Final = cast(dict[str, object], general_settings) # any-ok: legacy settings
include_wildcard_routes: Final = (
return_wildcard_routes if return_wildcard_routes is not None else settings.get("return_wildcard_routes") is True
)
from litellm.llms.anthropic.common_utils import (
create_anthropic_model_list_response,
@ -12343,7 +12349,7 @@ async def model_list(
proxy_model_list=proxy_model_list,
user_model=None,
infer_model_from_keys=False,
return_wildcard_routes=return_wildcard_routes or False,
return_wildcard_routes=include_wildcard_routes,
llm_router=llm_router,
model_access_groups=model_access_groups,
include_model_access_groups=include_model_access_groups or False,
@ -12411,7 +12417,7 @@ async def model_list(
team_id=team_id,
include_model_access_groups=include_model_access_groups or False,
only_model_access_groups=only_model_access_groups or False,
return_wildcard_routes=return_wildcard_routes or False,
return_wildcard_routes=include_wildcard_routes,
user_api_key_cache=user_api_key_cache,
)
@ -19657,6 +19663,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
"proxy_config_reload_interval_seconds": "Integer",
"pass_through_endpoints": "PydanticModel",
"store_model_in_db": "Boolean",
"return_wildcard_routes": "Boolean",
"store_prompts_in_spend_logs": "Boolean",
"disable_requester_ip_address_logging": "Boolean",
"disable_fallbacks_on_per_model_rate_limits": "Boolean",

View file

@ -1,5 +1,7 @@
import asyncio
import os
import re
import xml.etree.ElementTree as ET
from collections import Counter
from collections.abc import Awaitable, Mapping
from dataclasses import dataclass
@ -17,7 +19,8 @@ from litellm.types.llms.base import LiteLLMBaseModel
router: Final = APIRouter()
LATEST_RELEASE_URL: Final = "https://api.github.com/repos/BerriAI/litellm/releases/latest"
CHANGELOG_URL: Final = "https://docs.litellm.ai/release_notes/"
CHANGELOG_FEED_URL: Final = f"{CHANGELOG_URL}rss.xml"
RELEASE_BY_TAG_URL: Final = "https://api.github.com/repos/BerriAI/litellm/releases/tags/{tag}"
LATEST_RELEASE_FETCH_TIMEOUT_SECONDS: Final = 5
LATEST_RELEASE_CACHE_TTL_SECONDS: Final = 60 * 60
@ -26,7 +29,7 @@ LATEST_RELEASE_CACHE_KEY: Final = "latest_release_info"
_RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+(?:([A-Za-z]+)(?:\([^)]*\))?!?:\s)?\S")
_NEW_CONTRIBUTOR_PATTERN: Final = re.compile(r"^\*\s+@\S+ made their first contribution\b")
_STABLE_TAG_PATTERN: Final = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
_CHANGELOG_PAGE_PATTERN: Final = re.compile(rf"{re.escape(CHANGELOG_URL)}v(\d+)\.(\d+)\.(\d+)/")
_Bucket: TypeAlias = Literal["new_features", "bug_fixes", "other_updates"]
_PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"})
@ -45,6 +48,16 @@ class LatestReleaseUnavailable:
reason: str
@dataclass(frozen=True, slots=True)
class ChangelogEntry:
version: tuple[int, int, int]
url: str
@property
def tag(self) -> str:
return "v" + ".".join(str(part) for part in self.version)
class _GitHubRelease(LiteLLMBaseModel):
tag_name: str
html_url: str
@ -76,6 +89,10 @@ def _default_fetch_lock() -> asyncio.Lock:
return _latest_release_fetch_lock
def _default_release_check_enabled() -> bool:
return os.getenv("LITELLM_DISABLE_LATEST_RELEASE_CHECK", "").strip().lower() != "true"
def _bucket_for(line: str) -> _Bucket | None:
if _NEW_CONTRIBUTOR_PATTERN.match(line) is not None:
return None
@ -91,9 +108,18 @@ def count_release_bullets(body: str) -> Mapping[_Bucket, int]:
return MappingProxyType(Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None))
def _stable_version(tag_name: str) -> tuple[int, int, int] | None:
match: Final = _STABLE_TAG_PATTERN.match(tag_name)
return None if match is None else (int(match.group(1)), int(match.group(2)), int(match.group(3)))
def _changelog_entry(link: str) -> ChangelogEntry | None:
match: Final = _CHANGELOG_PAGE_PATTERN.match(link)
if match is None:
return None
return ChangelogEntry(version=(int(match.group(1)), int(match.group(2)), int(match.group(3))), url=link)
def latest_changelog_entry(feed: bytes) -> ChangelogEntry | None:
"""The highest stable release with a page in the changelog feed. Release candidate pages don't count."""
links: Final = (link.text or "" for link in ET.fromstring(feed).iterfind("channel/item/link"))
entries: Final = (entry for link in links if (entry := _changelog_entry(link)) is not None)
return max(entries, key=lambda entry: entry.version, default=None)
def earlier_patch_tags(version: tuple[int, int, int]) -> tuple[str, ...]:
@ -102,7 +128,9 @@ def earlier_patch_tags(version: tuple[int, int, int]) -> tuple[str, ...]:
return tuple(f"v{major}.{minor}.{earlier}" for earlier in range(patch))
def summarize_release_line(latest: _GitHubRelease, earlier: tuple[_GitHubRelease, ...]) -> LatestReleaseInfo:
def summarize_release_line(
entry: ChangelogEntry, latest: _GitHubRelease, earlier: tuple[_GitHubRelease, ...]
) -> LatestReleaseInfo:
"""A patch release's notes only list its backports, so count the whole minor line it belongs to."""
counts: Final = sum(
(Counter[_Bucket](count_release_bullets(release.body or "")) for release in (latest, *earlier)),
@ -113,7 +141,7 @@ def summarize_release_line(latest: _GitHubRelease, earlier: tuple[_GitHubRelease
new_features=counts.get("new_features", 0),
bug_fixes=counts.get("bug_fixes", 0),
other_updates=counts.get("other_updates", 0),
release_url=latest.html_url,
release_url=entry.url,
)
@ -137,25 +165,38 @@ async def _fetch_earlier_release(client: _AsyncGetClient, tag: str) -> _GitHubRe
return release
async def _fetch_changelog_entry(client: _AsyncGetClient) -> ChangelogEntry | LatestReleaseUnavailable:
response: Final = await client.get(CHANGELOG_FEED_URL, timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS)
if response.status_code != 200:
return LatestReleaseUnavailable(reason=f"Changelog feed responded with status {response.status_code}")
try:
entry: Final = latest_changelog_entry(response.content)
except ET.ParseError as e:
return LatestReleaseUnavailable(reason=f"Changelog feed was not valid XML: {e}")
return LatestReleaseUnavailable(reason="Changelog feed lists no stable release") if entry is None else entry
async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | LatestReleaseUnavailable:
try:
entry: Final = await _fetch_changelog_entry(client)
if isinstance(entry, LatestReleaseUnavailable):
return entry
latest: Final = parse_github_release(
await client.get(LATEST_RELEASE_URL, timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS)
await client.get(RELEASE_BY_TAG_URL.format(tag=entry.tag), timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS)
)
if isinstance(latest, LatestReleaseUnavailable):
return latest
version: Final = _stable_version(latest.tag_name)
if version is None:
return summarize_release_line(latest, ())
earlier: Final = await asyncio.gather(
*(_fetch_earlier_release(client, tag) for tag in earlier_patch_tags(version))
*(_fetch_earlier_release(client, tag) for tag in earlier_patch_tags(entry.version))
)
except httpx.HTTPError as e:
return LatestReleaseUnavailable(reason=f"{type(e).__name__}: {e}")
unavailable: Final = next((release for release in earlier if isinstance(release, LatestReleaseUnavailable)), None)
if unavailable is not None:
return unavailable
return summarize_release_line(latest, tuple(release for release in earlier if isinstance(release, _GitHubRelease)))
return summarize_release_line(
entry, latest, tuple(release for release in earlier if isinstance(release, _GitHubRelease))
)
async def get_latest_release_info(
@ -188,12 +229,16 @@ async def latest_release_info(
client: Annotated[_AsyncGetClient, Depends(_default_client)],
cache: Annotated[InMemoryCache, Depends(_default_cache)],
fetch_lock: Annotated[asyncio.Lock, Depends(_default_fetch_lock)],
release_check_enabled: Annotated[bool, Depends(_default_release_check_enabled)],
) -> LatestReleaseInfo | None:
"""
Latest stable LiteLLM GitHub release with the PR count of its whole minor line (e.g. v1.105.0 + v1.105.1)
split into new features, bug fixes and other updates.
Returns null when GitHub can't be reached so the dashboard upgrade banner simply doesn't render.
Newest stable release on the LiteLLM changelog, linked to its changelog page, with the PR count of its whole
minor line (e.g. v1.105.0 + v1.105.1) from GitHub split into new features, bug fixes and other updates.
Returns null when the changelog or GitHub can't be reached so the dashboard upgrade banner simply doesn't render.
Set LITELLM_DISABLE_LATEST_RELEASE_CHECK=true to return null without making any outbound request.
"""
if not release_check_enabled:
return None
result: Final = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
if isinstance(result, LatestReleaseUnavailable):
verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason)

View file

View file

@ -0,0 +1,88 @@
from typing import Final
from litellm.types.utils import Choices, ModelResponse, TextChoices, TextCompletionResponse
class TextCompletionStreamWrapper:
def __init__(
self,
completion_stream,
model,
stream_options: dict | None = None,
custom_llm_provider: str | None = None,
):
self.completion_stream = completion_stream
self.model = model
self.stream_options = stream_options
self.custom_llm_provider = custom_llm_provider
def __iter__(self):
return self
def __aiter__(self):
return self
def convert_to_text_completion_object(self, chunk: ModelResponse):
try:
response: Final = TextCompletionResponse()
response["id"] = chunk.get("id", None)
response["object"] = "text_completion"
response["created"] = chunk.get("created", None)
response["model"] = chunk.get("model", None)
text_choices: Final = TextChoices()
if isinstance(chunk, Choices): # chunk should always be of type StreamingChoices
raise Exception
if chunk["choices"]:
delta: Final = chunk["choices"][0]["delta"]
text_choices["text"] = delta["content"]
text_choices["reasoning_content"] = delta.get("reasoning_content")
text_choices["index"] = chunk["choices"][0]["index"]
text_choices["finish_reason"] = chunk["choices"][0]["finish_reason"]
response["choices"] = [text_choices]
else:
response["choices"] = []
# only pass usage when stream_options["include_usage"] is True
if self.stream_options and self.stream_options.get("include_usage", False) is True:
response.usage = chunk.get("usage", None)
else:
response.usage = None
return response
except Exception as e:
raise Exception(f"Error occurred converting to text completion object - chunk: {chunk}; Error: {e}")
def __next__(self):
# model_response = ModelResponse(stream=True, model=self.model)
TextCompletionResponse()
try:
for chunk in self.completion_stream:
if chunk == "None" or chunk is None:
raise Exception
processed_chunk = self.convert_to_text_completion_object(chunk=chunk)
return processed_chunk
raise StopIteration
except StopIteration:
raise StopIteration
except Exception as e:
from litellm.litellm_core_utils.exception_mapping_utils import exception_type
raise exception_type(
model=self.model,
custom_llm_provider=self.custom_llm_provider or "",
original_exception=e,
completion_kwargs={},
extra_kwargs={},
)
async def __anext__(self):
try:
async for chunk in self.completion_stream:
if chunk == "None" or chunk is None:
raise Exception
processed_chunk = self.convert_to_text_completion_object(chunk=chunk)
return processed_chunk
raise StopIteration
except StopIteration:
raise StopAsyncIteration

View file

@ -73,7 +73,7 @@ DecisionQuestion: TypeAlias = Annotated[
DecisionQuestionMap: TypeAlias = Annotated[
Mapping[Annotated[str, Field(min_length=1)], DecisionQuestion],
Field(min_length=1, max_length=MAX_DECISION_QUESTIONS),
Field(min_length=1),
]

View file

@ -9,16 +9,38 @@ https://ai.google.dev/static/api/interactions.openapi.json
### How to Regenerate
When the API spec changes, regenerate the types with:
The compliance tests in `tests/unit/interactions/test_openapi_compliance.py` read the snapshot at
`tests/unit/interactions/interactions.openapi.json`, so a PR never depends on Google's live spec. The scheduled
`interactions-spec-canary` workflow runs those tests against the live spec once a day (`--interactions-spec-url`),
files the issue "Interactions spec canary is red" on a red run and closes it once a run is green again. A red run
means the spec moved in a way these types no longer cover
When the red run names one new status, the small fix is a hand edit adding that member to the enum the failing
schema's model carries, the way `queued` was added in #34135. When the spec moved further than that, refresh the
snapshot and regenerate the types from it:
```bash
uv tool run --from datamodel-code-generator datamodel-codegen \
--url "https://ai.google.dev/static/api/interactions.openapi.json" \
curl -fsSL https://ai.google.dev/static/api/interactions.openapi.json \
-o tests/unit/interactions/interactions.openapi.json
uv tool run --from datamodel-code-generator==0.66.0 datamodel-codegen \
--input tests/unit/interactions/interactions.openapi.json \
--input-file-type openapi \
--output litellm/types/interactions/generated.py \
--output-model-type pydantic_v2.BaseModel \
--target-python-version 3.9
--target-python-version 3.10 \
--base-class litellm.types.llms.base.LiteLLMBaseModel
```
The pin is the same `datamodel-code-generator` version `scripts/generate_trace_types.py` uses. The generator names
inline enums by order of appearance (`Status`, `Status1`, `Status2`, ...), so a regeneration can rename them. The
compliance tests resolve each status enum through its model's `status` field, but
`tests/unit/interactions/test_background_cost_polling.py` and
`tests/unit/proxy/hooks/test_proxy_track_cost_callback.py` import `Status1` by name and need the new name, and
`litellm/types/interactions/__init__.py` exports the interaction status enum as `InteractionStatus`, which has to
point at the enum the regenerated `Interaction.status` carries (a compliance test checks their members match). The
spec has grown a lot since the 2025-12-16 generation (agents, triggers, credentials, environments), so a
regeneration today is a review-sized change rather than a refresh
Then add the LiteLLM-specific types at the bottom of the generated file:
- `InteractionsAPIResponse`
- `InteractionsAPIStreamingResponse`

View file

@ -218,6 +218,7 @@ def _get_cached_audio_utils():
return _audio_utils_module
from litellm.text_completions.streaming_iterator import TextCompletionStreamWrapper as _TextCompletionStreamWrapper
from litellm.types.llms.openai import (
AllMessageValues,
AllPromptValues,
@ -262,13 +263,17 @@ from litellm.types.utils import (
SearchProviders,
SelectTokenizerResponse,
StreamingChoices,
TextChoices,
TextCompletionResponse,
TranscriptionResponse,
Usage,
credential_api_key_secret_reference,
is_litellm_owned_kwarg,
)
from litellm.types.utils import (
TextChoices as _TextChoices,
)
from litellm.types.utils import (
TextCompletionResponse as _TextCompletionResponse,
)
_CALL_TYPE_ENUM_MAP: Final[dict] = {ct.value: ct for ct in CallTypes}
_BACKFILL_MODES: Final = frozenset({"chat", "responses"})
@ -299,10 +304,14 @@ except (ImportError, AttributeError, TypeError):
claude_json_str = json.dumps(json_data)
import importlib.metadata
from collections.abc import AsyncIterator, Callable, Collection, Iterable, Iterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, cast, runtime_checkable
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeAlias, TypeVar, cast, runtime_checkable
from typing_extensions import assert_never
TextChoices: TypeAlias = _TextChoices
TextCompletionResponse: TypeAlias = _TextCompletionResponse
TextCompletionStreamWrapper: TypeAlias = _TextCompletionStreamWrapper
from litellm import utils as litellm_utils
# These are lazy loaded via __getattr__
@ -340,7 +349,8 @@ if TYPE_CHECKING:
# Type stubs for lazy-loaded functions to help mypy understand their types
# These imports allow mypy to understand the types when these are accessed via __getattr__
from litellm.litellm_core_utils.exception_mapping_utils import exception_type
exception_type: Callable[..., Exception]
from litellm.litellm_core_utils.get_litellm_params import (
get_base_model_from_litellm_call_metadata,
get_litellm_params,
@ -7945,88 +7955,6 @@ def register_prompt_template(
return litellm.custom_prompt_dict
class TextCompletionStreamWrapper:
def __init__(
self,
completion_stream,
model,
stream_options: dict | None = None,
custom_llm_provider: str | None = None,
):
self.completion_stream = completion_stream
self.model = model
self.stream_options = stream_options
self.custom_llm_provider = custom_llm_provider
def __iter__(self):
return self
def __aiter__(self):
return self
def convert_to_text_completion_object(self, chunk: ModelResponse):
try:
response: Final = TextCompletionResponse()
response["id"] = chunk.get("id", None)
response["object"] = "text_completion"
response["created"] = chunk.get("created", None)
response["model"] = chunk.get("model", None)
text_choices: Final = TextChoices()
if isinstance(chunk, Choices): # chunk should always be of type StreamingChoices
raise Exception
if chunk["choices"]:
delta: Final = chunk["choices"][0]["delta"]
text_choices["text"] = delta["content"]
text_choices["reasoning_content"] = delta.get("reasoning_content")
text_choices["index"] = chunk["choices"][0]["index"]
text_choices["finish_reason"] = chunk["choices"][0]["finish_reason"]
response["choices"] = [text_choices]
else:
response["choices"] = []
# only pass usage when stream_options["include_usage"] is True
if self.stream_options and self.stream_options.get("include_usage", False) is True:
response["usage"] = chunk.get("usage", None)
return response
except Exception as e:
raise Exception(f"Error occurred converting to text completion object - chunk: {chunk}; Error: {e}")
def __next__(self):
# model_response = ModelResponse(stream=True, model=self.model)
TextCompletionResponse()
try:
for chunk in self.completion_stream:
if chunk == "None" or chunk is None:
raise Exception
processed_chunk = self.convert_to_text_completion_object(chunk=chunk)
return processed_chunk
raise StopIteration
except StopIteration:
raise StopIteration
except Exception as e:
exception_type: Final = litellm_utils.exception_type
raise exception_type(
model=self.model,
custom_llm_provider=self.custom_llm_provider or "",
original_exception=e,
completion_kwargs={},
extra_kwargs={},
)
async def __anext__(self):
try:
async for chunk in self.completion_stream:
if chunk == "None" or chunk is None:
raise Exception
processed_chunk = self.convert_to_text_completion_object(chunk=chunk)
return processed_chunk
raise StopIteration
except StopIteration:
raise StopAsyncIteration
def mock_stream_usage_chunk(model_response: ModelResponseStream, model: str, prompt_tokens: int) -> ModelResponseStream:
return ModelResponseStream(
id=model_response.id,

View file

@ -61,7 +61,7 @@ COPY --from=uvbin /uv /uvx /usr/local/bin/
# instead of nodeenv downloading one whose dynamic deps may not be in Wolfi
# (e.g. Node 26.2.0 needs libatomic). Retry for transient apk.cgr.dev flakes.
RUN for i in 1 2 3; do \
apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl openssl-dev libsndfile nodejs npm && break; \
apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl libssl3 openssl-dev libsndfile nodejs npm && break; \
[ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \
sleep 5; \
done

View file

@ -36861,17 +36861,17 @@
},
"gpt-6-luna": {
"cache_creation_input_token_cost": 1.25e-07,
"cache_creation_input_token_cost_decisions": 1e-07,
"cache_creation_input_token_cost_decisions": 1.25e-07,
"cache_creation_input_token_cost_above_272k_tokens": 2.5e-07,
"cache_creation_input_token_cost_above_272k_tokens_decisions": 2e-07,
"cache_creation_input_token_cost_above_272k_tokens_decisions": 2.5e-07,
"cache_creation_input_token_cost_above_272k_tokens_flex": 1.25e-07,
"cache_creation_input_token_cost_above_272k_tokens_priority": 5e-07,
"cache_creation_input_token_cost_flex": 6.25e-08,
"cache_creation_input_token_cost_priority": 2.5e-07,
"cache_read_input_token_cost": 1e-08,
"cache_read_input_token_cost_decisions": 1e-07,
"cache_read_input_token_cost_decisions": 1e-08,
"cache_read_input_token_cost_above_272k_tokens": 2e-08,
"cache_read_input_token_cost_above_272k_tokens_decisions": 2e-07,
"cache_read_input_token_cost_above_272k_tokens_decisions": 2e-08,
"cache_read_input_token_cost_above_272k_tokens_flex": 1e-08,
"cache_read_input_token_cost_above_272k_tokens_priority": 4e-08,
"cache_read_input_token_cost_batches": 5e-09,
@ -73330,6 +73330,7 @@
]
},
"vertex_ai/veo-2.0-generate-001": {
"deprecation_date": "2026-06-30",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,

View file

@ -35,7 +35,7 @@ dependencies = [
"pydantic>=2.12.0,<3.0.0; python_version >= '3.14'",
"pydantic-settings>=2.14.1,<3.0",
"jsonschema>=4.0.0,<5.0",
"boto3>=1.43.1,<2.0",
"boto3>=1.43.117,<2.0",
"certifi>=2026.10.19",
"typing-extensions>=4.13.0,<5.0",
]
@ -73,7 +73,7 @@ proxy = [
"cryptography>=49.0.0,<51.0",
"pynacl>=1.6.2,<2.0",
"websockets>=15.0.1,<16.0",
"boto3>=1.43.1,<2.0",
"boto3>=1.43.117,<2.0",
"azure-identity>=1.25.2,<2.0",
"azure-storage-blob>=12.28.0,<13.0",
"mcp>=2.3.0,<3",
@ -139,8 +139,8 @@ semantic-router = [
]
mlflow = ["mlflow>=3.11.1,<4.0"]
grpc = [
# Newest non-yanked release older than the 30-day cutoff.
"grpcio==1.78.0",
# First release whose bundled roots.pem drops the compromised eMudhra roots, ahead of the 30-day cutoff
"grpcio==1.84.1",
]
stt-vertex-chirp = [
# Google Cloud Speech-to-Text v2 streaming (gRPC) for Chirp models on
@ -174,7 +174,7 @@ proxy-runtime = [
"google-cloud-speech>=2.40.0,<3.0",
"google-genai>=1.37.0,<2.0",
"anthropic[vertex]>=0.84.0,<1.0",
"grpcio==1.78.0",
"grpcio==1.84.1",
"prometheus-client>=0.20.0,<1.0",
"langfuse>=4.7,<5.0",
"opentelemetry-api==1.33.1",
@ -371,7 +371,9 @@ default-groups = ["dev"]
required-version = ">=0.10.9"
exclude-newer = "3 days"
# TODO: ryan, remove after 2026-10-23, once certifi 2026.10.19 (drops the compromised eMudhra root) clears the 3-day window
exclude-newer-package = { certifi = "2026-10-20T03:00:00Z" }
# TODO(shrey): remove after 2026-10-25, once grpcio 1.84.1 (drops the compromised eMudhra roots from roots.pem) clears the 3-day window
# TODO(shrey): remove after 2026-10-24, once boto3/botocore 1.43.117 (drops the compromised eMudhra roots from cacert.pem) clear the 3-day window
exclude-newer-package = { certifi = "2026-10-20T03:00:00Z", grpcio = "2026-10-22T15:00:00Z", boto3 = "2026-10-21T19:31:00Z", botocore = "2026-10-21T19:31:00Z" }
[tool.uv.sources]
litellm-proxy-extras = { workspace = true }

View file

@ -1,125 +0,0 @@
"""The /usage/ai/chat tools hand the model the reason the daily activity query rejected its dates, the same check
the daily activity routes answer 400 with, instead of a generic fetch error."""
from __future__ import annotations
import json
import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Final
import pytest
from integration._support.client import JSON_OBJECT, Gateway, object_value
from integration._support.upstream import delete_scenario, register_scenario
from integration.compatibility._status_code_audit import Upstream, json_response, owned_gateway
from pydantic import JsonValue
pytestmark: Final = pytest.mark.timeout(180)
_TOOLS: Final = ("get_usage_data", "get_team_usage_data", "get_tag_usage_data")
@dataclass(frozen=True, slots=True)
class _UsageChat:
gateway: Gateway
identity: str
@pytest.fixture(scope="module")
def usage_chat(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_UsageChat]:
"""A proxy whose usage chat model (`openai/...`, called straight through litellm) is the scripted upstream."""
identity: Final = f"audit-usage-chat-{uuid.uuid4().hex}"
handle: Final = register_scenario(identity, json_response({}))
try:
with owned_gateway(
tmp_path_factory.mktemp("audit-usage-chat"),
{"model_list": [], "router_settings": {"num_retries": 0}},
environment={"OPENAI_API_BASE": handle.api_base(), "OPENAI_API_KEY": identity},
) as owned:
yield _UsageChat(owned, identity)
finally:
delete_scenario(handle)
def _tool_calls(start_date: str, end_date: str) -> dict[str, JsonValue]:
arguments: Final = json.dumps({"start_date": start_date, "end_date": end_date})
return {
"id": "chatcmpl-usage",
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{"id": f"call-{tool}", "type": "function", "function": {"name": tool, "arguments": arguments}}
for tool in _TOOLS
],
},
"finish_reason": "tool_calls",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
def _tool_results(usage_chat: _UsageChat, start_date: str, end_date: str) -> tuple[dict[str, str], tuple[str, ...]]:
"""Runs one chat turn whose model calls every tool with these dates; returns the tool messages the follow-up
model call carried, keyed by tool, and the tool statuses the SSE stream reported."""
register_scenario(usage_chat.identity, json_response(_tool_calls(start_date, end_date)))
Upstream(usage_chat.gateway.upstream_url).reset()
with usage_chat.gateway.client.stream(
"POST",
"/usage/ai/chat",
json={"model": "openai/gpt-4o-mini", "messages": [{"role": "user", "content": "How much did we spend?"}]},
headers={"Authorization": f"Bearer {usage_chat.gateway.key}"},
timeout=60,
) as response:
assert response.status_code == 200
events: Final = tuple(
object_value(JSON_OBJECT.validate_json(line.removeprefix("data: ")))
for line in response.iter_lines()
if line.startswith("data: ")
)
calls: Final = Upstream(usage_chat.gateway.upstream_url).calls(usage_chat.identity)
assert len(calls) == 2, calls
messages: Final = object_value(calls[1]["body"]).get("messages")
assert isinstance(messages, list), calls[1]
tool_messages: Final = {
str(message["tool_call_id"]).removeprefix("call-"): str(message["content"])
for message in messages
if isinstance(message, dict) and message.get("role") == "tool"
}
statuses: Final = tuple(str(event.get("status")) for event in events if event.get("type") == "tool_call")
return tool_messages, statuses
_REVERSED: Final = "Invalid date range: end_date must be on or after start_date"
_NOT_CANONICAL: Final = "Invalid date range: start_date and end_date must be valid YYYY-MM-DD dates"
_NO_TEAM_DATA: Final = "No Team usage data found for the given date range."
_NO_TAG_DATA: Final = "No Tag usage data found for the given date range."
@pytest.mark.parametrize(
("start_date", "end_date", "team_message", "tag_message"),
(
pytest.param("2026-10-09", "2026-10-01", _REVERSED, _REVERSED, id="reversed"),
pytest.param("2026-13-01", "2026-10-09", _NOT_CANONICAL, _NOT_CANONICAL, id="invalid-month"),
pytest.param("2001/01/01", "2001-01-09", _NOT_CANONICAL, _NOT_CANONICAL, id="slashes"),
pytest.param("2001-01-01", "2001-01-09", _NO_TEAM_DATA, _NO_TAG_DATA, id="valid"),
),
)
def test_usage_chat_tools_answer_with_the_query_layers_date_check(
usage_chat: _UsageChat, start_date: str, end_date: str, team_message: str, tag_message: str
) -> None:
"""The team and tag tools give the model the daily activity routes' 400 reason; the global tool, whose query
takes these dates without that check, still answers with its usage summary."""
tool_messages, statuses = _tool_results(usage_chat, start_date, end_date)
assert tool_messages["get_usage_data"].startswith("Total Spend: $"), tool_messages
assert tool_messages["get_team_usage_data"] == team_message, tool_messages
assert tool_messages["get_tag_usage_data"] == tag_message, tool_messages
assert statuses == ("running", "complete") * len(_TOOLS), statuses

View file

@ -99,16 +99,9 @@ class _Activity(BaseModel):
results: tuple[_Day, ...]
class _UpstreamMessage(BaseModel):
model_config = ConfigDict(frozen=True)
role: str | None = None
class _UpstreamRequest(BaseModel):
model_config = ConfigDict(frozen=True)
stream: bool | None = None
tools: tuple[JsonValue, ...] | None = None
messages: tuple[_UpstreamMessage, ...] = ()
@dataclass(frozen=True, slots=True)
@ -171,17 +164,6 @@ def _stream_frames() -> tuple[bytes, ...]:
return (*(f"data: {json.dumps({**envelope, **frame})}\n\n".encode() for frame in frames), b"data: [DONE]\n\n")
def _usage_tool_call() -> Mapping[str, JsonValue]:
arguments: Final = json.dumps({"start_date": _day(-1), "end_date": _day(1)})
return {
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_usage", "type": "function", "function": {"name": "get_usage_data", "arguments": arguments}}
],
}
def _response_object() -> bytes:
return json.dumps(
{
@ -216,9 +198,6 @@ def _respond(request: Request) -> Reply:
body: Final = _UpstreamRequest.model_validate_json(request.body or b"{}")
if body.stream:
return Reply(content_type="text/event-stream", chunks=_stream_frames())
roles: Final = frozenset(message.role for message in body.messages)
if body.tools and "tool" not in roles:
return Reply(body=_completion(_usage_tool_call(), "tool_calls"))
return Reply(body=_completion({"role": "assistant", "content": REPLY_TEXT}, "stop"))
@ -459,12 +438,6 @@ def _restore(database_url: str, digest: str) -> None:
)
def _events(response: httpx.Response) -> tuple[Mapping[str, JsonValue], ...]:
return tuple(
json.loads(line.removeprefix("data: ")) for line in response.text.splitlines() if line.startswith("data: ")
)
@dataclass(frozen=True, slots=True)
class Tenant:
label: str
@ -714,39 +687,19 @@ def _rig(gateway: Gateway, directory: Path) -> Generator[tuple[Gateway, str]]:
@pytest.mark.timeout(360)
def test_usage_ai_chat_timeout_then_second_timeout_still_recovers_key_alias_after_database_frees(
def test_usage_page_timeout_then_second_timeout_still_recovers_key_alias_after_database_frees(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path) as (proxy, database_url):
spender: Final = _spender(proxy, database_url, "ai-chat")
spender: Final = _spender(proxy, database_url, "repeated-timeout")
with _pinned(proxy) as pinned:
with _locked_spend_logs(database_url):
with _recording(database_url) as during_chat:
chat: Final = pinned.request(
"POST",
"/usage/ai/chat",
body={
"messages": [{"role": "user", "content": "What did we spend?"}],
"model": "openai/gpt-4o-mini",
},
)
assert chat.status_code == 200, chat.text
assert chat.elapsed >= FAILED_LOOKUP_FLOOR, chat.elapsed
tool_call: Final = {
"type": "tool_call",
"tool_name": "get_usage_data",
"tool_label": "global usage data",
"arguments": {"start_date": _day(-1), "end_date": _day(1)},
}
assert _events(chat) == (
{"type": "status", "message": "Thinking..."},
{**tool_call, "status": "running"},
{**tool_call, "status": "complete"},
{"type": "status", "message": "Analyzing results..."},
{"type": "chunk", "content": REPLY_TEXT},
{"type": "done"},
), chat.text
assert len(_lookups(during_chat)) == 1
with _recording(database_url) as during_timeout:
timed_out: Final = _read(pinned, spender.digest)
assert timed_out.status_code == 200, timed_out.text
assert timed_out.elapsed >= FAILED_LOOKUP_FLOOR, timed_out.elapsed
assert _aliases(timed_out, spender.digest) == (None,), timed_out.text
assert len(_lookups(during_timeout)) == 1
cached_miss: Final = _read(pinned, spender.digest)
assert cached_miss.elapsed < FAILED_LOOKUP_FLOOR, cached_miss.elapsed
assert _aliases(cached_miss, spender.digest) == (None,), cached_miss.text
@ -1087,44 +1040,3 @@ def test_usage_page_rejects_bad_key_filters_and_unrelated_routes_ignore_a_locked
assert gateway_activity == 200, gateway_activity
chat: Final = proxy.chat(MODEL, text=f"locked {uuid.uuid4().hex}")
assert object_value(chat["usage"]) == dict(USAGE), chat
@pytest.mark.timeout(300)
def test_usage_ai_chat_survives_a_dropped_database_connection_during_alias_recovery(
gateway: Gateway, tmp_path: Path
) -> None:
with (
scratch_database() as database_url,
database_relay(database_url, b"AS " + LOOKUP_MARKER.encode()) as (relay, relayed_url),
wire_server(_respond) as wire,
_proxy(gateway, tmp_path, relayed_url, wire.url) as proxy,
):
spender: Final = _spender(proxy, database_url, "dropped-ai-chat")
with _pinned(proxy) as pinned:
relay.arm()
chat: Final = pinned.request(
"POST",
"/usage/ai/chat",
body={
"messages": [{"role": "user", "content": "What did we spend?"}],
"model": "openai/gpt-4o-mini",
},
)
assert chat.status_code == 200, chat.text
assert relay.tripped.is_set(), chat.text
tool_call: Final = {
"type": "tool_call",
"tool_name": "get_usage_data",
"tool_label": "global usage data",
"arguments": {"start_date": _day(-1), "end_date": _day(1)},
}
assert _events(chat) == (
{"type": "status", "message": "Thinking..."},
{**tool_call, "status": "running"},
{**tool_call, "status": "complete"},
{"type": "status", "message": "Analyzing results..."},
{"type": "chunk", "content": REPLY_TEXT},
{"type": "done"},
), chat.text
recovered: Final = _named_once_reconnected(pinned, spender)
assert _metadata(recovered, spender.digest) == _full_metadata(spender), recovered.text

View file

@ -6,7 +6,6 @@ from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime, timedelta
from hashlib import sha256
from pathlib import Path
from queue import SimpleQueue
from typing import Final
@ -31,8 +30,7 @@ from integration._support.daily_activity import (
user_row,
user_with_an_email,
)
from integration._support.database import read_rows, scratch_database
from integration._support.process import owned_proxy
from integration._support.database import read_rows
from integration._support.wire import Reply, Request, wire_server
from pydantic import JsonValue
@ -51,22 +49,6 @@ UNIFIED_ENDPOINTS: Final = ("/v1/chat/completions", "/v1/messages", "/v1/respons
REQUESTS_OF_A_BURST: Final = 21
READS_DURING_A_BURST: Final = 30
TOKEN_LIMIT_DISCOVERY: Final = ("GET", "/v1/models")
TOOL_CALL: Final = "call_integration_usage"
ANSWER: Final = "One request cost $0.25"
SUMMARY_OF_ONE_SEEDED_ROW: Final = "\n".join(
(
"Total Spend: $0.2500",
"Total Requests: 1",
"Successful: 1 | Failed: 0",
"Total Tokens: 15",
"",
"Top Models by Spend:",
" - gpt-4o-mini: $0.2500 (1 reqs, 15 tokens)",
"",
"Top Providers by Spend:",
" - openai: $0.2500 (1 reqs)",
)
)
def _chat_completion() -> dict[str, JsonValue]:
@ -105,55 +87,6 @@ def _provider(request: Request) -> Reply:
return Reply(body=json.dumps(body).encode())
def _usage_tool_call() -> dict[str, JsonValue]:
call: Final[dict[str, JsonValue]] = {
"id": TOOL_CALL,
"type": "function",
"function": {
"name": "get_usage_data",
"arguments": json.dumps({"start_date": DAY, "end_date": DAY}),
},
}
return {
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": None, "tool_calls": [call]},
"finish_reason": "tool_calls",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
def _streamed_chunk(delta: dict[str, JsonValue], finish_reason: str | None) -> bytes:
chunk: Final = {
"id": "chatcmpl-integration-usage",
"object": "chat.completion.chunk",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
return f"data: {json.dumps(chunk)}\n\n".encode()
def _usage_analyst(request: Request) -> Reply:
if json.loads(request.body).get("stream"):
return Reply(
chunks=(
_streamed_chunk({"role": "assistant", "content": ANSWER}, None),
_streamed_chunk({}, "stop"),
b"data: [DONE]\n\n",
),
content_type="text/event-stream",
)
return Reply(body=json.dumps(_usage_tool_call()).encode())
def _sent_for_callers(requests: Iterable[Request]) -> tuple[Request, ...]:
return tuple(request for request in requests if (request.method, request.target) != TOKEN_LIMIT_DISCOVERY)
@ -322,65 +255,6 @@ def test_cli_session_spend_is_reported_with_the_user_and_team_of_the_session(
)
@pytest.mark.timeout(300)
def test_usage_ai_chat_hands_the_model_the_usage_summary_without_any_key_owner(
gateway: Gateway, tmp_path: Path
) -> None:
question: Final = f"what did we spend {uuid.uuid4().hex}"
owner: Final = f"integration-{uuid.uuid4().hex}"
ownerless_key: Final = f"integration-ownerless-{uuid.uuid4().hex}"
with (
scratch_database() as scratch_url,
wire_server(_usage_analyst) as wire,
owned_proxy(
gateway,
tmp_path,
{
"DATABASE_URL": scratch_url,
"OPENAI_API_BASE": f"{wire.url}/v1",
"OPENAI_BASE_URL": f"{wire.url}/v1",
"OPENAI_API_KEY": "integration-provider-key",
},
remove_environment=("DATABASE_URL_READ_REPLICA",),
) as candidate,
):
candidate.post("/user/new", {"user_id": owner, "user_email": f"{owner}@example.com", "auto_create_key": False})
with daily_rows((user_row(owner, ownerless_key, DAY),), database_url=scratch_url):
answer: Final = candidate.request(
"POST",
"/usage/ai/chat",
{"messages": [{"role": "user", "content": question}], "model": "openai/gpt-4o-mini"},
)
assert answer.status_code == 200, answer.text
tool_call: Final = {
"type": "tool_call",
"tool_name": "get_usage_data",
"tool_label": "global usage data",
"arguments": {"start_date": DAY, "end_date": DAY},
}
events: Final = [
json.loads(line.removeprefix("data: ")) for line in answer.text.splitlines() if line.startswith("data: ")
]
assert events == [
{"type": "status", "message": "Thinking..."},
{**tool_call, "status": "running"},
{**tool_call, "status": "complete"},
{"type": "status", "message": "Analyzing results..."},
{"type": "chunk", "content": ANSWER},
{"type": "done"},
], answer.text
asked, analysed = wire.drain()
assert [asked.target, analysed.target] == ["/v1/chat/completions", "/v1/chat/completions"]
assert json.loads(asked.body)["messages"][-1] == {"role": "user", "content": question}
assert json.loads(analysed.body)["messages"][-1] == {
"role": "tool",
"tool_call_id": TOOL_CALL,
"content": SUMMARY_OF_ONE_SEEDED_ROW,
}
assert owner not in analysed.body.decode()
assert ownerless_key not in analysed.body.decode()
@pytest.mark.timeout(300)
def test_owner_is_reported_on_every_route_while_a_burst_of_requests_waits_on_the_provider(gateway: Gateway) -> None:
released: Final = threading.Event()

View file

@ -1,6 +1,9 @@
# tests/unit
In-process. No network or subprocess
In-process. No network or subprocess. `interactions/test_openapi_compliance.py` reads the spec snapshot beside it,
and only the `interactions-spec-canary` workflow passes `--interactions-spec-url` (registered in
`interactions/conftest.py`, so name that file or directory on the command line) with `ai.google.dev` allowed, to run
the same tests against Google's live spec
Unit tests must not depend on the current time or execution speed. Use fixed timestamps for fixtures
and controlled clocks for time-dependent behavior. Incidental clock reads inside production code are allowed

View file

@ -0,0 +1,9 @@
import pytest
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--interactions-spec-url",
default=None,
help="Run the Interactions OpenAPI compliance tests against this live spec instead of the vendored snapshot",
)

File diff suppressed because it is too large Load diff

View file

@ -1,41 +1,56 @@
"""
OpenAPI compliance tests for Google Interactions API.
Validates that our SDK requests/responses match the OpenAPI spec at:
https://ai.google.dev/static/api/interactions.openapi.json
Validates that our SDK requests/responses match the OpenAPI spec snapshot in
interactions.openapi.json, taken from https://ai.google.dev/static/api/interactions.openapi.json
(litellm/types/interactions/README.md says how to refresh it). The scheduled
interactions-spec-canary workflow runs the same tests against the live spec by passing
--interactions-spec-url, so a change Google makes shows up there, not in every PR.
Run with: pytest tests/unit/interactions/test_openapi_compliance.py -v
"""
import json
import os
import re
from typing import Any, Dict
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Dict, Final, get_args
from unittest.mock import MagicMock, patch
import httpx
import pytest
from openapi_core import OpenAPI
from pydantic import BaseModel, TypeAdapter
OPENAPI_SPEC_URL = "https://ai.google.dev/static/api/interactions.openapi.json"
OPENAPI_SPEC_SNAPSHOT: Final = Path(__file__).with_name("interactions.openapi.json")
SPEC_URL_OPTION: Final = TypeAdapter(str | None)
def _load_openapi_spec_dict() -> Dict[str, Any]:
"""
Load the OpenAPI spec JSON.
@dataclass(frozen=True, slots=True)
class LoadedSpec:
source: str
spec: Dict[str, Any]
In CI or offline environments, network access may not be available.
In that case, gracefully skip these tests instead of erroring.
"""
try:
response = httpx.get(OPENAPI_SPEC_URL, timeout=5.0)
response.raise_for_status()
return response.json()
except Exception as e: # pragma: no cover - defensive, env-dependent
pytest.skip(
f"Skipping Google Interactions OpenAPI compliance tests - "
f"unable to load spec from {OPENAPI_SPEC_URL}: {e}"
)
def _status_enum_of(model: type[BaseModel]) -> type[Enum]:
annotation: Final = model.model_fields["status"].annotation
candidates: Final = (annotation, *get_args(annotation))
return next(candidate for candidate in candidates if isinstance(candidate, type) and issubclass(candidate, Enum))
def _requested_spec_url(request: pytest.FixtureRequest) -> str | None:
return SPEC_URL_OPTION.validate_python(request.config.getoption("interactions_spec_url"))
def _load_openapi_spec(live_url: str | None) -> LoadedSpec:
if live_url is None:
return LoadedSpec(source=str(OPENAPI_SPEC_SNAPSHOT), spec=json.loads(OPENAPI_SPEC_SNAPSHOT.read_text()))
with httpx.Client(transport=httpx.HTTPTransport(retries=3), timeout=30.0) as client:
response: Final = client.get(live_url)
response.raise_for_status()
return LoadedSpec(source=str(response.url), spec=response.json())
def _model_create_request_schema(spec_dict: Dict[str, Any]) -> Dict[str, Any]:
@ -65,9 +80,13 @@ def _declared_type_value(variant_schema: Dict[str, Any]) -> Any:
@pytest.fixture(scope="module")
def spec_dict() -> Dict[str, Any]:
"""Load raw spec dict for manual validation."""
return _load_openapi_spec_dict()
def loaded_spec(request: pytest.FixtureRequest) -> LoadedSpec:
return _load_openapi_spec(_requested_spec_url(request))
@pytest.fixture(scope="module")
def spec_dict(loaded_spec: LoadedSpec) -> Dict[str, Any]:
return loaded_spec.spec
@pytest.fixture(scope="module")
@ -76,6 +95,18 @@ def openapi_spec(spec_dict: Dict[str, Any]) -> OpenAPI:
return OpenAPI.from_dict(spec_dict)
def test_spec_comes_from_the_requested_source(loaded_spec: LoadedSpec, request: pytest.FixtureRequest) -> None:
assert loaded_spec.source == (_requested_spec_url(request) or str(OPENAPI_SPEC_SNAPSHOT))
def test_interaction_status_export_carries_the_interaction_models_statuses() -> None:
from litellm.types.interactions import InteractionStatus, generated
exported: Final = frozenset(member.value for member in InteractionStatus)
modelled: Final = frozenset(member.value for member in _status_enum_of(generated.Interaction))
assert exported == modelled, sorted(exported ^ modelled)
class TestRequestCompliance:
"""Tests that our request bodies match the OpenAPI spec."""
@ -249,27 +280,24 @@ class TestResponseCompliance:
assert field in schema["properties"], f"Output field '{field}' not in spec"
print(f"✓ Output field '{field}' exists in spec")
def test_status_enum_values(self, spec_dict):
"""Verify status enum values match spec."""
# `status` is an output-only field; validate against the response schema.
schema = spec_dict["components"]["schemas"]["Interaction"]
status_prop = schema["properties"]["status"]
# Google Interactions API uses lowercase status values (updated Feb 2026).
# Keep this an exact match: this test intentionally breaks CI when
# Google changes the live spec — that breakage is how we get notified
# to review the change.
expected_statuses = [
"in_progress",
"requires_action",
"completed",
"failed",
"cancelled",
"incomplete",
"budget_exceeded",
"queued",
]
assert status_prop["enum"] == expected_statuses
print(f"✓ Status enum values: {expected_statuses}")
@pytest.mark.parametrize(
"schema_name",
[
"Interaction",
"InteractionStatusUpdate",
"CreateModelInteractionParams",
"CreateAgentInteractionParams",
"UrlContextResult",
],
)
def test_status_enum_values(self, spec_dict, schema_name):
from litellm.types.interactions import generated
schema = spec_dict["components"]["schemas"][schema_name]
spec_statuses: Final = frozenset(schema["properties"]["status"]["enum"])
parsed_statuses: Final = frozenset(member.value for member in _status_enum_of(getattr(generated, schema_name)))
assert spec_statuses
assert spec_statuses <= parsed_statuses, sorted(spec_statuses - parsed_statuses)
def test_usage_schema(self, spec_dict):
"""Verify Usage schema fields."""

View file

@ -160,6 +160,12 @@ def test_api_base_line_keeps_an_at_sign_in_the_path(api_base: str) -> None:
# Tuple format: (error_message, expected_result)
context_window_test_cases = [
# Positive cases (should return True)
(
'"{\n "error": {\n "message": "Your input exceeds the context window of this model. '
'Please adjust your input and try again.",\n "type": "invalid_request_error",\n'
' "param": "input",\n "code": "context_length_exceeded"\n }\n}"',
True,
),
(
"An error occurred: The input exceeds the model's maximum context limit of 8192 tokens.",
True,

View file

@ -2033,6 +2033,7 @@ async def test_anthropic_messages_with_extra_headers():
],
"model": "claude-haiku-4-5-20251001",
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 20},
}
@ -2096,6 +2097,7 @@ async def test_anthropic_messages_with_thinking():
],
"model": "claude-haiku-4-5-20251001",
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 20},
}
@ -2210,6 +2212,7 @@ async def test_anthropic_messages_bedrock_dynamic_region():
"content": [{"type": "text", "text": "This is a mock response"}],
"model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 20},
}

View file

@ -1,17 +1,23 @@
from collections.abc import Mapping
from datetime import datetime, timezone
from pathlib import Path
from typing import Final
from unittest.mock import patch
import httpx
import pytest
from pydantic import JsonValue
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.anthropic.pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.anthropic.wif import get_anthropic_wif_token
from litellm.llms.minimax.messages.transformation import MinimaxMessagesConfig
from litellm.llms.openai_like.messages.transformation import OpenAILikeAnthropicMessagesConfig
from litellm.llms.tencent.messages.transformation import TencentAnthropicMessagesConfig
from litellm.types.llms.anthropic import AnthropicFinishReason
from litellm.types.router import GenericLiteLLMParams
from tests.unit.llms.anthropic.test_anthropic_wif import (
ScriptedPoster,
@ -27,6 +33,52 @@ _WIF_PARAMS: Final[dict] = {
}
@pytest.mark.parametrize("config", (AnthropicMessagesConfig(), OpenAILikeAnthropicMessagesConfig()), ids=type)
@pytest.mark.parametrize(
("stop_reason", "stop_fields", "expected_stop_sequence"),
(
pytest.param("end_turn", {}, None, id="end_turn_missing"),
pytest.param("max_tokens", {}, None, id="max_tokens_missing"),
pytest.param("tool_use", {}, None, id="tool_use_missing"),
pytest.param("end_turn", {"stop_sequence": None}, None, id="explicit_null"),
pytest.param("stop_sequence", {"stop_sequence": ", 7"}, ", 7", id="matched_stop"),
),
)
def test_native_response_defaults_stop_sequence_without_changing_other_fields(
config: AnthropicMessagesConfig,
stop_reason: AnthropicFinishReason,
stop_fields: Mapping[str, str | None],
expected_stop_sequence: str | None,
) -> None:
upstream: Final[dict[str, JsonValue]] = {
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": "test-model",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": stop_reason,
"usage": {"input_tokens": 19, "output_tokens": 42},
**stop_fields,
}
logging_obj: Final = Logging(
model="test-model",
messages=[],
stream=False,
call_type="anthropic_messages",
start_time=datetime(2026, 1, 1, tzinfo=timezone.utc),
litellm_call_id="test-call",
function_id="test-function",
)
response: Final = config.transform_anthropic_messages_response(
model="test-model", raw_response=httpx.Response(200, json=upstream), logging_obj=logging_obj
)
assert response == {
**upstream,
"stop_sequence": expected_stop_sequence,
}
def test_workload_identity_allowed_for_anthropic() -> None:
assert AnthropicMessagesConfig()._allows_workload_identity is True

View file

@ -26,7 +26,9 @@ from litellm.llms.anthropic.pass_through.messages.streaming_iterator import (
is_provider_error_chunk,
parse_anthropic_error_event,
)
from litellm.llms.anthropic.pass_through.messages.transformation import AnthropicMessagesConfig
from litellm.llms.anthropic.pass_through.safeguards import SafeguardsEvaluator
from litellm.llms.openai_like.messages.transformation import OpenAILikeAnthropicMessagesConfig
from litellm.types.utils import Choices, Message, ModelResponse
@ -1733,6 +1735,49 @@ async def _drained(stream: AsyncIterator[bytes]) -> list[bytes]:
return [chunk async for chunk in stream]
@pytest.mark.asyncio
@pytest.mark.parametrize("config", (AnthropicMessagesConfig(), OpenAILikeAnthropicMessagesConfig()), ids=type)
@pytest.mark.parametrize("stop_sequence", (None, ", 7"), ids=("null", "matched_stop"))
async def test_native_stream_preserves_nullable_stop_fields_across_chunk_boundaries(
config: AnthropicMessagesConfig, stop_sequence: str | None
) -> None:
logging_obj: Final = _make_logging_obj("native_stream_stop_fields")
events: Final = (
{
"type": "message_start",
"message": {
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": logging_obj.model,
"content": [],
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 19, "output_tokens": 0},
},
},
{
"type": "message_delta",
"delta": {
"stop_reason": "end_turn" if stop_sequence is None else "stop_sequence",
"stop_sequence": stop_sequence,
},
"usage": {"output_tokens": 0},
},
{"type": "message_stop"},
)
upstream: Final = b"".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode() for event in events)
response: Final = httpx.Response(200, content=_chunked(upstream, 7))
stream: Final = config.get_async_streaming_response_iterator(
model=logging_obj.model,
httpx_response=response,
request_body={"model": logging_obj.model},
litellm_logging_obj=logging_obj,
)
assert b"".join(await _drained(stream)) == upstream
def _data_events(frames: Sequence[bytes]) -> list[Mapping[str, object]]:
return [
json.loads(line[len(b"data:") :]) for frame in frames for line in frame.splitlines() if line.startswith(b"data:")

View file

@ -126,6 +126,51 @@ def test_proxy_decisions_route_returns_answers_and_cost(
assert upstream.calls[0].request.headers["authorization"] == "Bearer test-key"
@pytest.mark.parametrize("endpoint", ("/v1/systemone", "/systemone"))
@pytest.mark.parametrize("question_count", (128, 129, 154))
def test_proxy_systemone_preserves_large_question_batches(
client: TestClient,
respx_mock: respx.MockRouter,
endpoint: str,
question_count: int,
) -> None:
questions: Final = {
f"q{index}": {"type": "noul", "instructions": f"Question {index}?"} for index in range(question_count)
}
answers: Final = {f"q{index}": {"type": "noul", "noul": index / question_count} for index in range(question_count)}
upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(
json={**_RESPONSE, "answers": answers}
)
response: Final = client.post(endpoint, json={**_REQUEST, "questions": questions})
assert response.status_code == 200, response.text
assert json.loads(upstream.calls[0].request.content) == {
**_REQUEST,
"model": "pplx-decider-v1-27b",
"questions": questions,
}
assert response.json()["answers"] == answers
@pytest.mark.parametrize("endpoint", ("/v1/systemone", "/systemone"))
@pytest.mark.parametrize(
"questions",
({}, {"": {"type": "noul", "instructions": "Is this a defect?"}}, {"is_defect": {"type": "noul"}}),
ids=("empty", "empty_name", "missing_instructions_or_criteria"),
)
def test_proxy_systemone_rejects_invalid_questions_before_http(
client: TestClient,
respx_mock: respx.MockRouter,
endpoint: str,
questions: Mapping[str, object],
) -> None:
response: Final = client.post(endpoint, json={**_REQUEST, "questions": questions})
assert response.status_code == 400, response.text
assert len(respx_mock.calls) == 0
def test_proxy_decisions_dispatches_typesafe_deployment(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,

View file

@ -1,657 +0,0 @@
"""
Tests for AI Usage Chat module.
"""
import json
from collections.abc import AsyncIterator
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat import (
TOOL_HANDLERS,
TOOLS_ADMIN,
TOOLS_BASE,
_build_system_prompt,
_summarise_entity_data,
_summarise_usage_data,
stream_usage_ai_chat,
)
SAMPLE_AGGREGATED_RESPONSE = {
"results": [
{
"date": "2025-01-15",
"metrics": {
"spend": 50.25,
"prompt_tokens": 20000,
"completion_tokens": 10000,
"total_tokens": 30000,
"api_requests": 500,
"successful_requests": 480,
"failed_requests": 20,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
},
"breakdown": {
"models": {
"gpt-4": {
"metrics": {
"spend": 40.0,
"api_requests": 300,
"total_tokens": 25000,
},
"metadata": {},
"api_key_breakdown": {},
},
},
"providers": {
"openai": {
"metrics": {"spend": 50.25, "api_requests": 500},
"metadata": {},
"api_key_breakdown": {},
},
},
"api_keys": {
"sk-test123": {
"metrics": {"spend": 50.25},
"metadata": {"key_alias": "Production Key"},
},
},
"model_groups": {},
"mcp_servers": {},
"entities": {},
},
},
],
"metadata": {
"total_spend": 50.25,
"total_api_requests": 500,
"total_successful_requests": 480,
"total_failed_requests": 20,
"total_tokens": 30000,
},
}
SAMPLE_TEAM_RESPONSE = {
"results": [
{
"date": "2025-01-15",
"metrics": {"spend": 100.0, "api_requests": 1000, "total_tokens": 50000},
"breakdown": {
"entities": {
"team-1": {
"metrics": {
"spend": 60.0,
"api_requests": 600,
"total_tokens": 30000,
},
"metadata": {"alias": "Engineering"},
"api_key_breakdown": {},
},
"team-2": {
"metrics": {
"spend": 40.0,
"api_requests": 400,
"total_tokens": 20000,
},
"metadata": {"alias": "Marketing"},
"api_key_breakdown": {},
},
},
"models": {},
"providers": {},
"api_keys": {},
"model_groups": {},
"mcp_servers": {},
},
},
],
"metadata": {"total_spend": 100.0, "total_api_requests": 1000},
}
class TestToolSchemas:
def test_admin_tools_include_all(self):
assert len(TOOLS_ADMIN) == 3
names = {t["function"]["name"] for t in TOOLS_ADMIN}
assert "get_usage_data" in names
assert "get_team_usage_data" in names
assert "get_tag_usage_data" in names
def test_base_tools_restricted_to_usage_only(self):
assert len(TOOLS_BASE) == 1
assert TOOLS_BASE[0]["function"]["name"] == "get_usage_data"
def test_admin_prompt_mentions_all_tools(self):
prompt = _build_system_prompt(is_admin=True)
assert "get_usage_data" in prompt
assert "get_team_usage_data" in prompt
assert "get_tag_usage_data" in prompt
def test_non_admin_prompt_only_mentions_usage_tool(self):
prompt = _build_system_prompt(is_admin=False)
assert "get_usage_data" in prompt
assert "get_team_usage_data" not in prompt
assert "get_tag_usage_data" not in prompt
def test_system_prompt_includes_todays_date(self):
from datetime import date
prompt = _build_system_prompt(is_admin=True)
assert date.today().isoformat() in prompt
class TestSummariseUsageData:
def test_summarise_includes_totals(self):
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
assert "$50.25" in summary
assert "500" in summary
def test_summarise_includes_models(self):
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
assert "gpt-4" in summary
def test_summarise_includes_providers(self):
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
assert "openai" in summary
def test_summarise_handles_empty_data(self):
empty = {"results": [], "metadata": {}}
summary = _summarise_usage_data(empty)
assert "no data" in summary.lower()
class TestSummariseEntityData:
def test_team_summary_includes_teams(self):
summary = _summarise_entity_data(SAMPLE_TEAM_RESPONSE, "Team")
assert "Engineering" in summary
assert "Marketing" in summary
assert "$60.0" in summary
assert "$40.0" in summary
def test_team_summary_empty(self):
empty = {"results": [], "metadata": {}}
summary = _summarise_entity_data(empty, "Team")
assert "No Team usage data" in summary
class TestStreamUsageAiChat:
@pytest.mark.asyncio
async def test_stream_emits_status_events(self):
mock_tool_call = MagicMock()
mock_tool_call.id = "call_123"
mock_tool_call.function.name = "get_usage_data"
mock_tool_call.function.arguments = json.dumps(
{
"start_date": "2025-01-01",
"end_date": "2025-01-31",
}
)
mock_first_response = MagicMock()
mock_first_response.choices = [MagicMock()]
mock_first_response.choices[0].message.tool_calls = [mock_tool_call]
mock_first_response.choices[0].message.model_dump.return_value = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "get_usage_data",
"arguments": '{"start_date":"2025-01-01","end_date":"2025-01-31"}',
},
}
],
}
async def mock_stream():
chunk = MagicMock()
chunk.choices = [MagicMock()]
chunk.choices[0].delta.content = "Total spend is $50.25"
yield chunk
with (
patch(
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm"
) as mock_litellm,
patch(
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat._fetch_usage_data",
new_callable=AsyncMock,
) as mock_fetch,
):
mock_litellm.acompletion = AsyncMock(
side_effect=[
mock_first_response,
mock_stream(),
]
)
mock_fetch.return_value = SAMPLE_AGGREGATED_RESPONSE
events = []
async for event in stream_usage_ai_chat(
messages=[{"role": "user", "content": "What is my total spend?"}],
model="gpt-4o-mini",
user_id="user-123",
is_admin=True,
):
events.append(json.loads(event.replace("data: ", "").strip()))
status_events = [e for e in events if e["type"] == "status"]
tool_call_events = [e for e in events if e["type"] == "tool_call"]
chunk_events = [e for e in events if e["type"] == "chunk"]
done_events = [e for e in events if e["type"] == "done"]
assert len(status_events) >= 1
assert "Thinking" in status_events[0]["message"]
assert len(tool_call_events) >= 1
assert tool_call_events[0]["tool_name"] == "get_usage_data"
assert tool_call_events[0]["status"] in ("running", "complete")
assert len(chunk_events) >= 1
assert len(done_events) == 1
@pytest.mark.asyncio
async def test_stream_handles_team_tool(self):
mock_tool_call = MagicMock()
mock_tool_call.id = "call_team"
mock_tool_call.function.name = "get_team_usage_data"
mock_tool_call.function.arguments = json.dumps(
{
"start_date": "2025-01-01",
"end_date": "2025-01-31",
}
)
mock_first_response = MagicMock()
mock_first_response.choices = [MagicMock()]
mock_first_response.choices[0].message.tool_calls = [mock_tool_call]
mock_first_response.choices[0].message.model_dump.return_value = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_team",
"type": "function",
"function": {
"name": "get_team_usage_data",
"arguments": '{"start_date":"2025-01-01","end_date":"2025-01-31"}',
},
}
],
}
async def mock_stream():
chunk = MagicMock()
chunk.choices = [MagicMock()]
chunk.choices[0].delta.content = "Engineering is the top team."
yield chunk
with (
patch(
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm"
) as mock_litellm,
patch(
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat._fetch_team_usage_data",
new_callable=AsyncMock,
) as mock_fetch,
):
mock_litellm.acompletion = AsyncMock(
side_effect=[
mock_first_response,
mock_stream(),
]
)
mock_fetch.return_value = SAMPLE_TEAM_RESPONSE
events = []
async for event in stream_usage_ai_chat(
messages=[{"role": "user", "content": "Which team spends the most?"}],
model="gpt-4o-mini",
is_admin=True,
):
events.append(json.loads(event.replace("data: ", "").strip()))
chunk_events = [e for e in events if e["type"] == "chunk"]
assert len(chunk_events) >= 1
assert "Engineering" in chunk_events[0]["content"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"reason",
[
"end_date must be on or after start_date",
"start_date and end_date must be valid YYYY-MM-DD dates",
],
ids=["reversed", "invalid-format"],
)
@pytest.mark.parametrize(
"tool_name", ["get_usage_data", "get_team_usage_data", "get_tag_usage_data"]
)
async def test_invalid_tool_dates_are_reported_to_the_model(
self, tool_name: str, reason: str
) -> None:
fetch: Final = AsyncMock(
side_effect=HTTPException(status_code=400, detail={"error": reason})
)
tool_message, statuses = await _run_one_tool(
tool_name, "2025-01-31", "2025-01-01", fetch
)
fetch.assert_awaited_once()
assert tool_message == f"Invalid date range: {reason}"
assert statuses == ["running", "complete"]
@pytest.mark.asyncio
async def test_tool_dates_the_query_layer_accepts_are_queried_as_sent(
self,
) -> None:
fetch: Final = AsyncMock(return_value=SAMPLE_AGGREGATED_RESPONSE)
tool_message, statuses = await _run_one_tool(
"get_usage_data", "2025/01/01", "2025-01-31T00:00:00", fetch
)
fetch.assert_awaited_once_with(
start_date="2025/01/01", end_date="2025-01-31T00:00:00"
)
assert tool_message.startswith("Total Spend: $")
assert statuses == ["running", "complete"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error",
[
HTTPException(status_code=500, detail={"error": "db down"}),
HTTPException(status_code=400, detail="page and page_size must be >= 1"),
],
ids=["server-error", "other-400"],
)
async def test_other_tool_errors_stay_a_generic_fetch_error(
self, error: HTTPException
) -> None:
tool_message, statuses = await _run_one_tool(
"get_team_usage_data",
"2025-01-01",
"2025-01-31",
AsyncMock(side_effect=error),
)
assert tool_message == "Error fetching team usage data. Please try again."
assert statuses == ["running", "error"]
@pytest.mark.asyncio
async def test_stream_handles_error(self):
with patch(
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm"
) as mock_litellm:
mock_litellm.acompletion = AsyncMock(side_effect=Exception("LLM error"))
events = []
async for event in stream_usage_ai_chat(
messages=[{"role": "user", "content": "test"}],
):
events.append(json.loads(event.replace("data: ", "").strip()))
error_events = [e for e in events if e["type"] == "error"]
assert len(error_events) == 1
assert "internal error" in error_events[0]["message"].lower()
@pytest.mark.asyncio
async def test_non_admin_enforces_user_id(self):
mock_tool_call = MagicMock()
mock_tool_call.id = "call_456"
mock_tool_call.function.name = "get_usage_data"
mock_tool_call.function.arguments = json.dumps(
{
"start_date": "2025-01-01",
"end_date": "2025-01-31",
"user_id": "other-user",
}
)
mock_first_response = MagicMock()
mock_first_response.choices = [MagicMock()]
mock_first_response.choices[0].message.tool_calls = [mock_tool_call]
mock_first_response.choices[0].message.model_dump.return_value = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_456",
"type": "function",
"function": {
"name": "get_usage_data",
"arguments": '{"start_date":"2025-01-01","end_date":"2025-01-31","user_id":"other-user"}',
},
}
],
}
async def mock_stream():
chunk = MagicMock()
chunk.choices = [MagicMock()]
chunk.choices[0].delta.content = "Data."
yield chunk
mock_fetch = AsyncMock(return_value=SAMPLE_AGGREGATED_RESPONSE)
with (
patch(
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm"
) as mock_litellm,
patch.dict(
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.TOOL_HANDLERS",
{
"get_usage_data": {
"fetch": mock_fetch,
"summarise": _summarise_usage_data,
"label": "global usage data",
}
},
),
):
mock_litellm.acompletion = AsyncMock(
side_effect=[
mock_first_response,
mock_stream(),
]
)
events = []
async for event in stream_usage_ai_chat(
messages=[{"role": "user", "content": "Show data"}],
model="gpt-4o-mini",
user_id="my-user-id",
is_admin=False,
):
events.append(event)
mock_fetch.assert_called_once_with(
start_date="2025-01-01",
end_date="2025-01-31",
user_id="my-user-id",
)
class TestUsageAiChatServiceAccountGuard:
"""
Security regression: a non-admin caller with user_id=None (service-account
key) must be rejected at the endpoint boundary, before any tool dispatch.
"""
@pytest.mark.asyncio
async def test_non_admin_with_user_id_none_is_rejected(self):
from fastapi import HTTPException
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.usage_endpoints.endpoints import (
ChatMessage,
UsageAIChatRequest,
usage_ai_chat,
)
service_account_key = UserAPIKeyAuth(
user_id=None,
user_role=LitellmUserRoles.INTERNAL_USER,
)
request = MagicMock()
body = UsageAIChatRequest(
messages=[ChatMessage(role="user", content="hi")],
model="gpt-4o-mini",
)
with pytest.raises(HTTPException) as exc_info:
await usage_ai_chat(
data=body,
request=request,
user_api_key_dict=service_account_key,
)
assert exc_info.value.status_code == 403
assert "Service-account keys" in str(exc_info.value.detail)
def test_resolve_fetch_kwargs_tripwire_fires_on_none_user_id(self):
"""
Defense-in-depth: if a future endpoint forgets the entry guard and
a non-admin caller with user_id=None reaches _resolve_fetch_kwargs,
the tripwire must fire rather than issuing an unscoped query.
"""
from litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat import (
_resolve_fetch_kwargs,
)
with pytest.raises(ValueError, match='Non-admin caller has user_id=None; refusing to issue an') as exc_info:
_resolve_fetch_kwargs(
fn_name="get_usage_data",
fn_args={"start_date": "2025-01-01", "end_date": "2025-01-31"},
user_id=None,
is_admin=False,
)
assert "Endpoint-level guard missing" in str(exc_info.value)
class TestUsageAiChatKeepalive:
async def _collect_endpoint_body(self, monkeypatch, interval, delay=0.3) -> tuple[list[bytes], dict]:
import asyncio
import litellm
from fastapi.responses import StreamingResponse
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.usage_endpoints.endpoints import (
ChatMessage,
UsageAIChatRequest,
usage_ai_chat,
)
monkeypatch.setattr(litellm, "sse_keepalive_ping_interval_seconds", interval)
async def slow_acompletion(**kwargs):
await asyncio.sleep(delay)
response = MagicMock()
response.choices = [MagicMock()]
response.choices[0].message.tool_calls = None
response.choices[0].message.content = "Total spend is $50.25"
return response
with patch( # test-quality-ok: the stream calls the module-level litellm.acompletion directly; no injection seam
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm.acompletion",
new=AsyncMock(side_effect=slow_acompletion),
):
response = await usage_ai_chat(
data=UsageAIChatRequest(messages=[ChatMessage(role="user", content="hi")], model="gpt-4o-mini"),
request=MagicMock(),
user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert isinstance(response, StreamingResponse)
chunks = [chunk if isinstance(chunk, bytes) else chunk.encode() async for chunk in response.body_iterator]
return chunks, dict(response.headers)
@pytest.mark.asyncio
async def test_endpoint_pings_while_the_planning_completion_is_still_running(self, monkeypatch):
chunks, headers = await self._collect_endpoint_body(monkeypatch, interval=0.05)
assert headers["content-type"].startswith("text/event-stream")
assert headers["cache-control"] == "no-cache"
assert headers["x-accel-buffering"] == "no"
assert chunks[0].startswith(b'data: {"type": "status"')
assert chunks[1] == b": ping\n\n"
assert chunks.count(b": ping\n\n") >= 3
assert b'"content": "Total spend is $50.25"' in b"".join(chunks)
assert chunks[-1] == b'data: {"type": "done"}\n\n'
@pytest.mark.asyncio
async def test_endpoint_stream_is_untouched_while_keepalives_are_unconfigured(self, monkeypatch):
chunks, _ = await self._collect_endpoint_body(monkeypatch, interval=None, delay=0.15)
assert b": ping\n\n" not in chunks
assert chunks[0].startswith(b'data: {"type": "status"')
assert chunks[-1] == b'data: {"type": "done"}\n\n'
async def _run_one_tool(
tool_name: str, start_date: str, end_date: str, fetch: AsyncMock
) -> tuple[str, list[object]]:
"""Runs one admin chat turn whose model calls `tool_name` with these dates; returns the
tool message the follow-up model call carried and the tool statuses streamed."""
arguments: Final = json.dumps({"start_date": start_date, "end_date": end_date})
mock_tool_call: Final = MagicMock()
mock_tool_call.id = "call_dates"
mock_tool_call.function.name = tool_name
mock_tool_call.function.arguments = arguments
mock_first_response: Final = MagicMock()
mock_first_response.choices = [MagicMock()]
mock_first_response.choices[0].message.tool_calls = [mock_tool_call]
mock_first_response.choices[0].message.model_dump.return_value = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_dates",
"type": "function",
"function": {"name": tool_name, "arguments": arguments},
}
],
}
async def mock_stream() -> AsyncIterator[MagicMock]:
chunk: Final = MagicMock()
chunk.choices = [MagicMock()]
chunk.choices[0].delta.content = "Done."
yield chunk
with (
patch(
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm"
) as mock_litellm,
patch.dict(TOOL_HANDLERS[tool_name], {"fetch": fetch}),
):
mock_litellm.acompletion = AsyncMock(
side_effect=[mock_first_response, mock_stream()]
)
events: Final[tuple[dict[str, object], ...]] = tuple(
[
json.loads(event.replace("data: ", "").strip())
async for event in stream_usage_ai_chat(
messages=[{"role": "user", "content": "How much did we spend?"}],
model="gpt-4o-mini",
is_admin=True,
)
]
)
final_messages: Final = mock_litellm.acompletion.call_args_list[1].kwargs[
"messages"
]
tool_messages: Final = [m for m in final_messages if m["role"] == "tool"]
assert len(tool_messages) == 1, tool_messages
assert tool_messages[0]["tool_call_id"] == "call_dates"
return str(tool_messages[0]["content"]), [
e["status"] for e in events if e["type"] == "tool_call"
]

View file

@ -716,6 +716,53 @@ def test_config_list_exposes_config_reload_interval(client, auth_as, mock_prisma
assert entry["field_default_value"] == 30
@pytest.mark.parametrize("enabled", (True, False))
def test_config_list_exposes_and_saves_wildcard_routes_toggle(
client: TestClient,
auth_as: Callable[..., contextlib.AbstractContextManager[object]],
mock_prisma: MagicMock,
monkeypatch: pytest.MonkeyPatch,
enabled: bool,
) -> None:
from pydantic import TypeAdapter
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import ConfigList, LitellmUserRoles
table: Final = _install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
_seed_settings_store(monkeypatch, {})
monkeypatch.setattr(ps, "general_settings", ps.proxy_config.settings)
entries: Final = TypeAdapter(list[ConfigList])
with auth_as(LitellmUserRoles.PROXY_ADMIN):
before: Final = client.get("/config/list", params={"config_type": "general_settings"})
assert before.status_code == 200, before.text
toggle: Final = next(
entry for entry in entries.validate_json(before.content) if entry.field_name == "return_wildcard_routes"
)
assert toggle.field_type == "Boolean"
assert toggle.field_value is None
assert toggle.editable is True
saved: Final = client.post(
"/config/field/update",
json={"field_name": "return_wildcard_routes", "field_value": enabled, "config_type": "general_settings"},
)
assert saved.status_code == 200, saved.text
persisted: Final = TypeAdapter(dict[str, bool]).validate_json(
table.upsert.call_args.kwargs["data"]["create"]["param_value"]
)
assert persisted == {"return_wildcard_routes": enabled}
after: Final = client.get("/config/list", params={"config_type": "general_settings"})
assert after.status_code == 200, after.text
updated: Final = next(
entry for entry in entries.validate_json(after.content) if entry.field_name == "return_wildcard_routes"
)
assert updated.field_value is enabled
def test_config_field_update_accepts_config_reload_interval(client, auth_as, mock_prisma, monkeypatch):
"""POST /config/field/update accepts proxy_config_reload_interval_seconds and persists
it to the DB general_settings row for all pods to pick up."""

View file

@ -5,19 +5,21 @@ import hashlib
import json
import re
import sqlite3
from collections.abc import Mapping
from datetime import timezone
from typing import Final
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock, patch
import prisma
import pytest
from fastapi import HTTPException
from fastapi import HTTPException, Request
from fastapi.testclient import TestClient
import litellm
import litellm.proxy.proxy_server as ps
from litellm.proxy.auth.authorization import OwnedRows
from litellm.proxy.auth.authorization_dependencies import get_log_team_lookup, load_permitted_log_team_ids
from litellm.proxy.auth.route_checks import RouteChecks
def _default_date_range():
@ -268,6 +270,8 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
from litellm.proxy._types import (
LiteLLM_TeamTable,
LiteLLM_UserTable,
LitellmUserRoles,
Member,
ProxyException,
@ -1894,6 +1898,91 @@ async def test_ui_view_spend_logs_user_filter_intersects_permitted_team_scope(cl
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.parametrize(
("team_role", "selected_user", "expected_ids"),
(
("admin", None, ("member-in-team", "second-member-in-team")),
("admin", "member", ("member-in-team",)),
("admin", "caller", ()),
("admin", "outsider", ()),
("user", None, None),
(None, "member", None),
),
)
def test_ui_client_usage_enforces_selected_team_and_member_scope(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
team_role: Literal["admin", "user"] | None,
selected_user: str | None,
expected_ids: tuple[str, ...] | None,
) -> None:
logs: Final = tuple(
{
"request_id": request_id,
"user": user_id,
"team_id": team_id,
"api_key": "shared-key",
"spend": 0.1,
"startTime": "2026-01-02T12:00:00+00:00",
"model": "test-model",
}
for request_id, user_id, team_id in (
("member-in-team", "member", "selected-team"),
("same-member-other-team", "member", "other-team"),
("second-member-in-team", "second-member", "selected-team"),
("caller-personal", "caller", None),
("outsider-request", "outsider", "other-team"),
)
)
team: Final = LiteLLM_TeamTable(
team_id="selected-team",
members_with_roles=([Member(user_id="caller", role=team_role)] if team_role else [])
+ [Member(user_id="member", role="user"), Member(user_id="second-member", role="user")],
team_member_permissions=[],
)
async def team_lookup(where: Mapping[str, object]) -> LiteLLM_TeamTable | None:
return team if where.get("team_id") == team.team_id else None
def filter_logs(where: Mapping[str, object]) -> tuple[dict[str, str | float | None], ...]:
return tuple(
row
for row in logs
if (where.get("team_id") is None or row["team_id"] == where["team_id"])
and (where.get("user") is None or row["user"] == where["user"])
)
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="caller")
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=LiteLLM_UserTable(user_id="caller", user_role=LitellmUserRoles.INTERNAL_USER.value),
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/spend/logs/ui",
request=Request({"type": "http", "method": "GET", "path": "/spend/logs/ui", "query_string": b""}),
valid_token=auth,
request_data={},
)
monkeypatch.setattr(ps, "prisma_client", make_ui_spend_logs_mock_prisma(logs, filter_logs, team_lookup))
monkeypatch.setitem(app.dependency_overrides, ps.user_api_key_auth, lambda: auth)
response: Final = client.get(
"/spend/logs/ui",
params={
"start_date": "2026-01-01 00:00:00",
"end_date": "2026-01-03 00:00:00",
"team_id": "selected-team",
"page_size": 1000,
"group_by_session": "false",
**({"user_id": selected_user} if selected_user else {}),
},
headers={"Authorization": "Bearer sk-test"},
)
if expected_ids is None:
assert response.status_code == 403
return
assert response.status_code == 200
assert tuple(row["request_id"] for row in response.json()["data"]) == expected_ids
assert response.json()["total"] == len(expected_ids)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_pagination(client, monkeypatch):
mock_spend_logs = [
@ -6524,7 +6613,6 @@ def test_ui_view_request_response_reads_from_cold_storage(client, monkeypatch):
from litellm.proxy._types import (
LiteLLM_OrganizationMembershipTable,
LiteLLM_UserTable,
LiteLLMRoutes,
hash_token,
)

View file

@ -93,6 +93,20 @@ def test_retired_roi_calculator_cannot_be_loaded_or_called(
assert response.status_code == 404, f"{method} {path}: {response.text}"
@pytest.mark.parametrize("disable_lazy_routes", ("false", "true"))
@pytest.mark.parametrize("path", ("/lazy/warm/usage_ai", "/usage/ai/chat"))
def test_retired_usage_chat_cannot_be_loaded_or_called(
monkeypatch: pytest.MonkeyPatch, disable_lazy_routes: str, path: str
) -> None:
monkeypatch.setenv(FLAG, disable_lazy_routes)
app: Final = FastAPI()
attach_lazy_features(app)
with TestClient(app) as client:
response: Final = client.post(path)
assert response.status_code == 404, f"{path}: {response.text}"
@pytest.mark.parametrize("value", ("1", "true", "TRUE", "yes", "on"))
def test_flag_registers_every_feature_at_startup(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
monkeypatch.setenv(FLAG, value)

View file

@ -3813,3 +3813,47 @@ def test_live_session_preserves_pretty_printed_start_frame(
assert captured.route_type == "_arealtime"
assert routed_data["model"] == "gpt-live-1"
assert routed_data["live_session_start"] == expected_start
@pytest.mark.parametrize("scope", [None, "expand"])
@pytest.mark.parametrize(
"general_settings, wildcard_query, wildcard_listed",
[
({}, None, False),
({"return_wildcard_routes": False}, None, False),
({"return_wildcard_routes": True}, None, True),
({"return_wildcard_routes": "true"}, None, False),
({"return_wildcard_routes": True}, False, False),
({"return_wildcard_routes": False}, True, True),
({}, True, True),
],
)
@pytest.mark.asyncio
async def test_model_list_return_wildcard_routes_defaults_to_general_setting(
monkeypatch: pytest.MonkeyPatch,
general_settings: dict[str, object],
wildcard_query: bool | None,
wildcard_listed: bool,
scope: str | None,
) -> None:
router: Final = litellm.Router(
model_list=[
{"model_name": "gpt-6.1-sol", "litellm_params": {"model": "openai/gpt-6.1-sol", "api_key": "sk-fake"}},
{"model_name": "openai/*", "litellm_params": {"model": "openai/*", "api_key": "sk-fake"}},
]
)
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", router)
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_model_list", router.model_list)
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", None)
monkeypatch.setattr(litellm.proxy.proxy_server, "user_model", None)
monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", general_settings)
response: Final = await litellm.proxy.proxy_server.model_list(
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", user_role=LitellmUserRoles.PROXY_ADMIN),
return_wildcard_routes=wildcard_query,
scope=scope,
)
listed: Final = [model["id"] for model in response["data"]]
assert "gpt-6.1-sol" in listed, listed
assert ("openai/*" in listed) is wildcard_listed, listed

View file

@ -12,10 +12,11 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.proxy_server import app
from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import (
CHANGELOG_FEED_URL,
CHANGELOG_URL,
LATEST_RELEASE_CACHE_KEY,
LATEST_RELEASE_CACHE_TTL_SECONDS,
LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS,
LATEST_RELEASE_URL,
RELEASE_BY_TAG_URL,
LatestReleaseInfo,
LatestReleaseUnavailable,
@ -49,37 +50,68 @@ SAMPLE_RELEASE: Final = {
"html_url": "https://github.com/BerriAI/litellm/releases/tag/v1.102.0",
"body": SAMPLE_BODY,
}
def _changelog_page(folder: str) -> str:
return f"{CHANGELOG_URL}{folder}/{folder.removeprefix('v').replace('.', '-')}"
def _feed(*folders: str) -> httpx.Response:
items: Final = "".join(
f"<item><title>{folder}</title><link>{_changelog_page(folder)}</link></item>" for folder in folders
)
return httpx.Response(200, content=f'<?xml version="1.0"?><rss><channel>{items}</channel></rss>'.encode())
EXPECTED_INFO: Final = {
"version": "1.102.0",
"new_features": 2,
"bug_fixes": 2,
"other_updates": 4,
"release_url": SAMPLE_RELEASE["html_url"],
"release_url": _changelog_page("v1.102.0"),
}
class _RecordingClient:
def __init__(self, outcomes: list[httpx.Response | Exception]) -> None:
self._outcomes = outcomes
self.calls: list[tuple[str, float | None]] = []
async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
self.calls.append((url, timeout))
outcome = self._outcomes[min(len(self.calls) - 1, len(self._outcomes) - 1)]
if isinstance(outcome, Exception):
raise outcome
return outcome
def _github_response(status: int = 200, payload: object = SAMPLE_RELEASE) -> httpx.Response:
return httpx.Response(status, content=json.dumps(payload).encode())
def _tag_url(tag: str) -> str:
return RELEASE_BY_TAG_URL.format(tag=tag)
class _ByUrl:
"""Answers each URL from a fixed map; unknown URLs are 404, like a tag with no release."""
def __init__(self, routes: dict[str, httpx.Response | Exception]) -> None:
self._routes = routes
self.calls: list[tuple[str, float | None]] = []
async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
self.calls.append((url, timeout))
outcome = self._routes.get(url, httpx.Response(404, content=b"{}"))
if isinstance(outcome, Exception):
raise outcome
return outcome
@property
def urls(self) -> list[str]:
return [url for url, _ in self.calls]
def _minor_release_routes(**overrides: httpx.Response | Exception) -> dict[str, httpx.Response | Exception]:
return {CHANGELOG_FEED_URL: _feed("v1.102.0"), _tag_url("v1.102.0"): _github_response(), **overrides}
def _minor_release(**overrides: httpx.Response | Exception) -> _ByUrl:
return _ByUrl(_minor_release_routes(**overrides))
def _fresh_cache() -> InMemoryCache:
return InMemoryCache(max_size_in_memory=1, default_ttl=LATEST_RELEASE_CACHE_TTL_SECONDS)
def _override_dependencies(client: _RecordingClient, cache: InMemoryCache, role: LitellmUserRoles) -> None:
def _override_dependencies(client: _ByUrl, cache: InMemoryCache, role: LitellmUserRoles) -> None:
async def auth() -> UserAPIKeyAuth:
return UserAPIKeyAuth(api_key="sk-test", user_id="test-user", user_role=role)
@ -127,35 +159,68 @@ class TestCountReleaseBullets:
class TestGetLatestReleaseInfo:
@pytest.mark.asyncio
async def test_fetches_and_parses_github_release(self):
client = _RecordingClient([_github_response()])
async def test_announces_the_changelog_release_and_links_its_changelog_page(self):
client = _minor_release()
result = await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock())
assert isinstance(result, LatestReleaseInfo)
assert result.model_dump() == EXPECTED_INFO
assert client.calls == [(LATEST_RELEASE_URL, 5)]
assert client.calls == [(CHANGELOG_FEED_URL, 5), (_tag_url("v1.102.0"), 5)]
@pytest.mark.asyncio
async def test_github_release_missing_from_the_changelog_is_not_announced(self):
automated: Final = _github_response(payload=_release("v1.103.0", "* feat(proxy): automated release\n"))
result = await _info(_minor_release(**{_tag_url("v1.103.0"): automated}))
assert isinstance(result, LatestReleaseInfo)
assert result.model_dump() == EXPECTED_INFO
@pytest.mark.asyncio
async def test_highest_stable_version_wins_regardless_of_feed_order(self):
client = _minor_release(**{CHANGELOG_FEED_URL: _feed("v1.101.6", "v1.103.0rc1", "v1.102.0", "v1.100.9")})
result = await _info(client)
assert isinstance(result, LatestReleaseInfo)
assert result.model_dump() == EXPECTED_INFO
@pytest.mark.asyncio
@pytest.mark.parametrize(
"feed",
[
httpx.Response(503, content=_feed("v1.102.0").content),
httpx.Response(200, content=b"<html>not a feed"),
_feed("v1.103.0rc1", "v1.103.0-dev.1"),
httpx.Response(
200,
content=f"<rss><channel><item><link>https://example.com/?{_changelog_page('v9.0.0')}</link>"
"</item><item><link>https://example.com/release_notes/v9.0.0/x</link></item></channel></rss>".encode(),
),
],
ids=["server_error", "not_xml", "only_prereleases", "foreign_links"],
)
async def test_bad_changelog_feeds_are_unavailable(self, feed: httpx.Response):
client = _minor_release(**{CHANGELOG_FEED_URL: feed})
result = await _info(client)
assert isinstance(result, LatestReleaseUnavailable)
assert client.urls == [CHANGELOG_FEED_URL]
@pytest.mark.asyncio
async def test_second_call_within_ttl_does_not_refetch(self):
client = _RecordingClient([_github_response()])
client = _minor_release()
cache = _fresh_cache()
fetch_lock = asyncio.Lock()
first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
second = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
assert first == second
assert len(client.calls) == 1
assert len(client.calls) == 2
@pytest.mark.asyncio
async def test_success_is_cached_for_the_full_ttl(self):
cache = _fresh_cache()
await get_latest_release_info(
client=_RecordingClient([_github_response()]), cache=cache, fetch_lock=asyncio.Lock()
)
await get_latest_release_info(client=_minor_release(), cache=cache, fetch_lock=asyncio.Lock())
remaining = await cache.async_get_ttl(LATEST_RELEASE_CACHE_KEY) - time.time()
assert LATEST_RELEASE_CACHE_TTL_SECONDS - 5 < remaining <= LATEST_RELEASE_CACHE_TTL_SECONDS
@pytest.mark.asyncio
async def test_failure_is_cached_briefly_so_github_is_not_hammered(self):
client = _RecordingClient([httpx.ConnectError("boom")])
client = _ByUrl({CHANGELOG_FEED_URL: httpx.ConnectError("boom")})
cache = _fresh_cache()
fetch_lock = asyncio.Lock()
first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
@ -172,30 +237,29 @@ class TestGetLatestReleaseInfo:
@pytest.mark.parametrize(
"response",
[
_github_response(status=404, payload={"message": "Not Found"}),
_github_response(status=403, payload={"message": "rate limited"}),
_github_response(status=500, payload={}),
_github_response(payload={"tag_name": "v1.0.0"}),
httpx.Response(200, content=b"not json"),
httpx.ConnectError("boom"),
],
ids=["rate_limited", "server_error", "missing_fields", "not_json"],
ids=["no_release_for_tag", "rate_limited", "server_error", "missing_fields", "not_json", "unreachable"],
)
async def test_bad_github_responses_are_unavailable(self, response: httpx.Response):
result = await get_latest_release_info(
client=_RecordingClient([response]), cache=_fresh_cache(), fetch_lock=asyncio.Lock()
)
async def test_bad_github_responses_are_unavailable(self, response: httpx.Response | Exception):
result = await _info(_minor_release(**{_tag_url("v1.102.0"): response}))
assert isinstance(result, LatestReleaseUnavailable)
@pytest.mark.asyncio
async def test_concurrent_misses_share_one_fetch(self):
event = asyncio.Event()
class _BlockingClient(_RecordingClient):
class _BlockingClient(_ByUrl):
async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
self.calls.append((url, timeout))
await event.wait()
return _github_response()
return await super().get(url, timeout=timeout)
client = _BlockingClient([])
client = _BlockingClient(_minor_release_routes())
cache = _fresh_cache()
fetch_lock = asyncio.Lock()
tasks = [
@ -208,19 +272,19 @@ class TestGetLatestReleaseInfo:
results = await asyncio.gather(*tasks)
expected: Final = LatestReleaseInfo.model_validate(EXPECTED_INFO)
assert results == [expected] * 5
assert len(client.calls) == 1
assert len(client.calls) == 2
@pytest.mark.asyncio
async def test_failure_under_lock_is_also_coalesced(self):
event = asyncio.Event()
class _FailingBlockingClient(_RecordingClient):
class _FailingBlockingClient(_ByUrl):
async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
self.calls.append((url, timeout))
await event.wait()
raise httpx.ConnectError("boom")
client = _FailingBlockingClient([])
client = _FailingBlockingClient({})
cache = _fresh_cache()
fetch_lock = asyncio.Lock()
tasks = [
@ -245,40 +309,23 @@ def _release(tag: str, body: str | None, *, prerelease: bool = False) -> dict[st
}
def _tag_url(tag: str) -> str:
return RELEASE_BY_TAG_URL.format(tag=tag)
class _GitHubByUrl:
"""Answers each GitHub URL from a fixed map; unknown URLs are 404, like a tag with no release."""
def __init__(self, routes: dict[str, httpx.Response | Exception]) -> None:
self._routes = routes
self.urls: list[str] = []
async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
self.urls.append(url)
outcome = self._routes.get(url, httpx.Response(404, content=b"{}"))
if isinstance(outcome, Exception):
raise outcome
return outcome
PATCH_RELEASE: Final = _release("v1.102.2", "* fix(proxy): second backport by @k in https://x/pull/10\n")
FIRST_PATCH: Final = _release("v1.102.1", "* fix(proxy): first backport by @k in https://x/pull/9\n")
def _minor_line(**overrides: httpx.Response | Exception) -> dict[str, httpx.Response | Exception]:
routes: dict[str, httpx.Response | Exception] = {
LATEST_RELEASE_URL: _github_response(payload=PATCH_RELEASE),
_tag_url("v1.102.0"): _github_response(payload=SAMPLE_RELEASE),
_tag_url("v1.102.1"): _github_response(payload=FIRST_PATCH),
}
routes.update({_tag_url(tag): outcome for tag, outcome in overrides.items()})
return routes
def _minor_line(**overrides: httpx.Response | Exception) -> _ByUrl:
return _ByUrl(
{
CHANGELOG_FEED_URL: _feed("v1.102.2", "v1.101.6"),
_tag_url("v1.102.2"): _github_response(payload=PATCH_RELEASE),
_tag_url("v1.102.0"): _github_response(payload=SAMPLE_RELEASE),
_tag_url("v1.102.1"): _github_response(payload=FIRST_PATCH),
**{_tag_url(tag): outcome for tag, outcome in overrides.items()},
}
)
async def _info(client: _GitHubByUrl) -> LatestReleaseInfo | LatestReleaseUnavailable:
async def _info(client: _ByUrl) -> LatestReleaseInfo | LatestReleaseUnavailable:
return await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock())
@ -289,7 +336,7 @@ class TestPatchReleaseAggregatesMinorLine:
@pytest.mark.asyncio
async def test_patch_release_counts_include_every_earlier_patch_on_its_line(self):
client = _GitHubByUrl(_minor_line())
client = _minor_line()
result = await _info(client)
assert isinstance(result, LatestReleaseInfo)
assert result.model_dump() == {
@ -297,23 +344,11 @@ class TestPatchReleaseAggregatesMinorLine:
"new_features": 2,
"bug_fixes": 4,
"other_updates": 4,
"release_url": PATCH_RELEASE["html_url"],
"release_url": _changelog_page("v1.102.2"),
}
assert sorted(client.urls) == sorted([LATEST_RELEASE_URL, _tag_url("v1.102.0"), _tag_url("v1.102.1")])
@pytest.mark.asyncio
async def test_minor_release_makes_a_single_request(self):
client = _RecordingClient([_github_response()])
await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock())
assert client.calls == [(LATEST_RELEASE_URL, 5)]
@pytest.mark.asyncio
async def test_non_semver_latest_tag_counts_only_its_own_notes(self):
client = _GitHubByUrl({LATEST_RELEASE_URL: _github_response(payload=_release("v1.102.1-hotfix", "* fix: x\n"))})
result = await _info(client)
assert isinstance(result, LatestReleaseInfo)
assert (result.new_features, result.bug_fixes, result.other_updates) == (0, 1, 0)
assert client.urls == [LATEST_RELEASE_URL]
assert sorted(client.urls) == sorted(
[CHANGELOG_FEED_URL, _tag_url("v1.102.2"), _tag_url("v1.102.0"), _tag_url("v1.102.1")]
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
@ -326,15 +361,13 @@ class TestPatchReleaseAggregatesMinorLine:
ids=["never_published", "prerelease", "null_body"],
)
async def test_missing_prerelease_or_empty_patch_adds_nothing(self, skipped: httpx.Response):
result = await _info(_GitHubByUrl(_minor_line(**{"v1.102.1": skipped})))
result = await _info(_minor_line(**{"v1.102.1": skipped}))
assert isinstance(result, LatestReleaseInfo)
assert (result.new_features, result.bug_fixes, result.other_updates) == (2, 3, 4)
@pytest.mark.asyncio
async def test_latest_release_with_null_body_still_counts_its_line(self):
routes = _minor_line()
routes[LATEST_RELEASE_URL] = _github_response(payload=_release("v1.102.2", None))
result = await _info(_GitHubByUrl(routes))
result = await _info(_minor_line(**{"v1.102.2": _github_response(payload=_release("v1.102.2", None))}))
assert isinstance(result, LatestReleaseInfo)
assert (result.new_features, result.bug_fixes, result.other_updates) == (2, 3, 4)
@ -349,31 +382,50 @@ class TestPatchReleaseAggregatesMinorLine:
ids=["unreachable", "rate_limited", "not_json"],
)
async def test_earlier_patch_failure_is_unavailable(self, outcome: httpx.Response | Exception):
result = await _info(_GitHubByUrl(_minor_line(**{"v1.102.0": outcome})))
result = await _info(_minor_line(**{"v1.102.0": outcome}))
assert isinstance(result, LatestReleaseUnavailable)
class TestLatestReleaseInfoEndpoint:
def test_returns_release_stats_for_authenticated_user(self, http_client):
_override_dependencies(_RecordingClient([_github_response()]), _fresh_cache(), LitellmUserRoles.INTERNAL_USER)
_override_dependencies(_minor_release(), _fresh_cache(), LitellmUserRoles.INTERNAL_USER)
response = http_client.get("/get/latest_release_info")
assert response.status_code == 200
assert response.json() == EXPECTED_INFO
def test_returns_null_when_github_is_unreachable(self, http_client):
def test_returns_null_when_the_changelog_is_unreachable(self, http_client):
_override_dependencies(
_RecordingClient([httpx.ConnectError("boom")]), _fresh_cache(), LitellmUserRoles.PROXY_ADMIN
_ByUrl({CHANGELOG_FEED_URL: httpx.ConnectError("boom")}), _fresh_cache(), LitellmUserRoles.PROXY_ADMIN
)
response = http_client.get("/get/latest_release_info")
assert response.status_code == 200
assert response.json() is None
@pytest.mark.parametrize(
("flag", "expected_body", "expected_urls"),
[
("true", None, []),
(" TRUE ", None, []),
("false", EXPECTED_INFO, [CHANGELOG_FEED_URL, _tag_url("v1.102.0")]),
],
)
def test_disable_flag_skips_every_outbound_request(
self, http_client, monkeypatch, flag, expected_body, expected_urls
):
monkeypatch.setenv("LITELLM_DISABLE_LATEST_RELEASE_CHECK", flag)
client = _minor_release()
_override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN)
response = http_client.get("/get/latest_release_info")
assert response.status_code == 200
assert response.json() == expected_body
assert client.urls == expected_urls
def test_repeated_requests_reuse_cache(self, http_client):
client = _RecordingClient([_github_response()])
client = _minor_release()
_override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN)
assert http_client.get("/get/latest_release_info").json() == EXPECTED_INFO
assert http_client.get("/get/latest_release_info").json() == EXPECTED_INFO
assert len(client.calls) == 1
assert len(client.calls) == 2
def test_rejects_unauthenticated_requests(self, monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", MASTER_KEY)

View file

@ -90,7 +90,6 @@ from litellm.utils import (
get_llm_provider,
get_optional_params_embeddings,
ProviderConfigManager,
TextCompletionStreamWrapper,
_check_provider_match,
get_potential_model_names,
_is_litellm_router_call,
@ -2528,41 +2527,6 @@ def test_priced_router_deployment_id_stays_out_of_wildcard_model_listing():
assert litellm.model_cost[deployment_id]["input_cost_per_token"] == 0.000001
def test_reasoning_content_preserved_in_text_completion_wrapper():
"""Ensure reasoning_content is copied from delta to text_choices."""
chunk = ModelResponseStream(
id="test-id",
created=1234567890,
model="test-model",
object="chat.completion.chunk",
choices=[
StreamingChoices(
finish_reason=None,
index=0,
delta=Delta(
content="Some answer text",
role="assistant",
reasoning_content="Here's my chain of thought...",
),
)
],
)
wrapper = TextCompletionStreamWrapper(
completion_stream=None, # Not used in convert_to_text_completion_object
model="test-model",
stream_options=None,
)
transformed = wrapper.convert_to_text_completion_object(chunk)
assert "choices" in transformed
assert len(transformed["choices"]) == 1
choice = transformed["choices"][0]
assert choice["text"] == "Some answer text"
assert choice["reasoning_content"] == "Here's my chain of thought..."
def test_anthropic_claude_4_invoke_chat_provider_config():
"""Test that the Anthropic Claude 4 Invoke chat provider config is correct."""
from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import (

View file

View file

@ -0,0 +1,144 @@
from collections.abc import AsyncIterator, Iterator
from typing import Final
import pytest
from litellm.exceptions import APIConnectionError
from litellm.text_completions.streaming_iterator import TextCompletionStreamWrapper
from litellm.types.utils import Choices, Delta, ModelResponseStream, StreamingChoices, Usage
def test_reasoning_content_preserved_in_text_completion_wrapper():
"""Ensure reasoning_content is copied from delta to text_choices."""
chunk = ModelResponseStream(
id="test-id",
created=1234567890,
model="test-model",
object="chat.completion.chunk",
choices=[
StreamingChoices(
finish_reason=None,
index=0,
delta=Delta(
content="Some answer text",
role="assistant",
reasoning_content="Here's my chain of thought...",
),
)
],
)
wrapper = TextCompletionStreamWrapper(
completion_stream=None, # Not used in convert_to_text_completion_object
model="test-model",
stream_options=None,
)
transformed = wrapper.convert_to_text_completion_object(chunk)
assert "choices" in transformed
assert len(transformed["choices"]) == 1
choice = transformed["choices"][0]
assert choice["text"] == "Some answer text"
assert choice["reasoning_content"] == "Here's my chain of thought..."
@pytest.mark.asyncio
@pytest.mark.parametrize("async_stream", (False, True))
@pytest.mark.parametrize("empty_choices", (False, True))
@pytest.mark.parametrize("stream_options", (None, {}, {"include_usage": False}, {"include_usage": True}))
@pytest.mark.parametrize("usage", (None, Usage(prompt_tokens=7, completion_tokens=2, total_tokens=9), Usage()))
async def test_text_completion_stream_usage_is_opt_in(
async_stream: bool, empty_choices: bool, stream_options: dict[str, bool] | None, usage: Usage | None
) -> None:
chunk: Final = ModelResponseStream(
id="test-id",
created=1234567890,
model="test-model",
choices=[] if empty_choices else [StreamingChoices(index=0, delta=Delta(content="Hello"))],
usage=usage,
)
async def chunks() -> AsyncIterator[ModelResponseStream]:
yield chunk
wrapper: Final = TextCompletionStreamWrapper(
completion_stream=chunks() if async_stream else iter((chunk,)),
model="test-model",
stream_options=stream_options,
)
responses: Final = [response async for response in wrapper] if async_stream else list(wrapper)
expected_usage: Final = (
{"usage": usage.model_dump(exclude_none=True)}
if stream_options and stream_options.get("include_usage") is True and usage is not None
else {}
)
assert [response.model_dump(exclude_none=True) for response in responses] == [
{
"id": "test-id",
"object": "text_completion",
"created": 1234567890,
"model": "test-model",
"choices": [] if empty_choices else [{"index": 0, "text": "Hello"}],
**expected_usage,
}
]
def test_text_completion_conversion_rejects_non_streaming_choices() -> None:
chunk: Final = Choices()
wrapper: Final = TextCompletionStreamWrapper(completion_stream=None, model="test-model")
with pytest.raises(Exception, match="Error occurred converting to text completion object") as error:
wrapper.convert_to_text_completion_object(chunk)
assert str(chunk) in str(error.value)
@pytest.mark.parametrize("chunk", (None, "None"))
def test_sync_text_completion_stream_rejects_invalid_chunks(chunk: str | None) -> None:
wrapper: Final = TextCompletionStreamWrapper(
completion_stream=iter((chunk,)), model="test-model", custom_llm_provider="openai"
)
with pytest.raises(APIConnectionError, match="OpenAIException") as error:
next(wrapper)
assert (error.value.model, error.value.llm_provider) == ("test-model", "openai")
@pytest.mark.asyncio
@pytest.mark.parametrize("chunk", (None, "None"))
async def test_async_text_completion_stream_rejects_invalid_chunks(chunk: str | None) -> None:
async def chunks() -> AsyncIterator[str | None]:
yield chunk
wrapper: Final = TextCompletionStreamWrapper(completion_stream=chunks(), model="test-model")
with pytest.raises(Exception, match=r"^$") as error:
await anext(wrapper)
assert not isinstance(error.value, StopAsyncIteration)
def test_sync_text_completion_stream_maps_upstream_failure_after_content() -> None:
chunk: Final = ModelResponseStream(
id="test-id",
created=1234567890,
model="test-model",
choices=[StreamingChoices(index=0, delta=Delta(content="Hello"))],
)
def chunks() -> Iterator[ModelResponseStream]:
yield chunk
raise RuntimeError("upstream disconnected")
wrapper: Final = TextCompletionStreamWrapper(
completion_stream=chunks(), model="test-model", custom_llm_provider="openai"
)
assert next(wrapper).choices[0].text == "Hello"
with pytest.raises(APIConnectionError, match="upstream disconnected") as error:
next(wrapper)
assert (error.value.model, error.value.llm_provider) == ("test-model", "openai")

View file

@ -1098,14 +1098,6 @@
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx": {
"no-nested-ternary": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/UsagePageView.tsx": {
"react-hooks/purity": {
"count": 1
@ -1767,16 +1759,16 @@
"count": 1
},
"max-params": {
"count": 15
"count": 14
},
"no-nested-ternary": {
"count": 5
},
"no-restricted-syntax": {
"count": 140
"count": 139
},
"prefer-const": {
"count": 29
"count": 28
}
},
"src/components/object_permissions_view.tsx": {
@ -1798,9 +1790,6 @@
},
"prefer-const": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/components/organization/organization_view.tsx": {

View file

@ -28,7 +28,7 @@
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.31.0",
"next": "16.3.8",
"next": "16.4.1",
"next-themes": "^0.4.6",
"nuqs": "^2.9.4",
"openai": "6.49.0",
@ -69,8 +69,8 @@
"@types/react-copy-to-clipboard": "5.0.7",
"@types/react-dom": "19.2.4",
"@types/react-syntax-highlighter": "15.5.13",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"@vitest/coverage-v8": "4.1.12",
"@vitest/ui": "4.1.12",
"eslint": "9.39.2",
"eslint-config-next": "16.3.3",
"eslint-config-prettier": "10.1.8",
@ -87,7 +87,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.60.1",
"vite": "7.3.7",
"vitest": "4.1.11"
"vitest": "4.1.12"
},
"engines": {
"node": ">=24.14.1",
@ -2153,9 +2153,9 @@
}
},
"node_modules/@next/env": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz",
"integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==",
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.1.tgz",
"integrity": "sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@ -2170,9 +2170,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz",
"integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==",
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.1.tgz",
"integrity": "sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg==",
"cpu": [
"arm64"
],
@ -2186,9 +2186,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz",
"integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==",
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.1.tgz",
"integrity": "sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ==",
"cpu": [
"x64"
],
@ -2202,9 +2202,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz",
"integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==",
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.1.tgz",
"integrity": "sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw==",
"cpu": [
"arm64"
],
@ -2221,9 +2221,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz",
"integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==",
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.1.tgz",
"integrity": "sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw==",
"cpu": [
"arm64"
],
@ -2240,9 +2240,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz",
"integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==",
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.1.tgz",
"integrity": "sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A==",
"cpu": [
"x64"
],
@ -2259,9 +2259,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz",
"integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==",
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.1.tgz",
"integrity": "sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw==",
"cpu": [
"x64"
],
@ -2278,9 +2278,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz",
"integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==",
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.1.tgz",
"integrity": "sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA==",
"cpu": [
"arm64"
],
@ -2294,9 +2294,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz",
"integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==",
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.1.tgz",
"integrity": "sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg==",
"cpu": [
"x64"
],
@ -4490,14 +4490,14 @@
]
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.11",
"@vitest/utils": "4.1.12",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@ -4511,8 +4511,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
"@vitest/browser": "4.1.12",
"vitest": "4.1.12"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@ -4521,16 +4521,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.12",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -4539,13 +4539,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.11",
"@vitest/spy": "4.1.12",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -4566,9 +4566,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4579,13 +4579,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.11",
"@vitest/utils": "4.1.12",
"pathe": "^2.0.3"
},
"funding": {
@ -4593,14 +4593,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/pretty-format": "4.1.12",
"@vitest/utils": "4.1.12",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -4609,9 +4609,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
"dev": true,
"license": "MIT",
"funding": {
@ -4619,13 +4619,13 @@
}
},
"node_modules/@vitest/ui": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.11.tgz",
"integrity": "sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.12.tgz",
"integrity": "sha512-eYUTHtsp52Fu6YGUw9T9173bba7YUr9j/Xj9CdhUaOb8AppLKcknHUaiZQeqJ8MiJ8C2VyKiZuUGSKdvPIKQKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.11",
"@vitest/utils": "4.1.12",
"fflate": "^0.8.2",
"flatted": "^3.4.2",
"pathe": "^2.0.3",
@ -4637,17 +4637,17 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.11"
"vitest": "4.1.12"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/pretty-format": "4.1.12",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -9710,12 +9710,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz",
"integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==",
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/next/-/next-16.4.1.tgz",
"integrity": "sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q==",
"license": "MIT",
"dependencies": {
"@next/env": "16.3.8",
"@next/env": "16.4.1",
"@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@ -9729,15 +9729,15 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.3.8",
"@next/swc-darwin-x64": "16.3.8",
"@next/swc-linux-arm64-gnu": "16.3.8",
"@next/swc-linux-arm64-musl": "16.3.8",
"@next/swc-linux-x64-gnu": "16.3.8",
"@next/swc-linux-x64-musl": "16.3.8",
"@next/swc-win32-arm64-msvc": "16.3.8",
"@next/swc-win32-x64-msvc": "16.3.8",
"sharp": "^0.35.4"
"@next/swc-darwin-arm64": "16.4.1",
"@next/swc-darwin-x64": "16.4.1",
"@next/swc-linux-arm64-gnu": "16.4.1",
"@next/swc-linux-arm64-musl": "16.4.1",
"@next/swc-linux-x64-gnu": "16.4.1",
"@next/swc-linux-x64-musl": "16.4.1",
"@next/swc-win32-arm64-msvc": "16.4.1",
"@next/swc-win32-x64-msvc": "16.4.1",
"sharp": "^0.35.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@ -12512,19 +12512,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/expect": "4.1.12",
"@vitest/mocker": "4.1.12",
"@vitest/pretty-format": "4.1.12",
"@vitest/runner": "4.1.12",
"@vitest/snapshot": "4.1.12",
"@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.12",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -12552,12 +12552,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"@vitest/browser-playwright": "4.1.12",
"@vitest/browser-preview": "4.1.12",
"@vitest/browser-webdriverio": "^4.1.11",
"@vitest/coverage-istanbul": "4.1.12",
"@vitest/coverage-v8": "4.1.12",
"@vitest/ui": "4.1.12",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

@ -45,7 +45,7 @@
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.31.0",
"next": "16.3.8",
"next": "16.4.1",
"next-themes": "^0.4.6",
"nuqs": "^2.9.4",
"openai": "6.49.0",
@ -86,8 +86,8 @@
"@types/react-copy-to-clipboard": "5.0.7",
"@types/react-dom": "19.2.4",
"@types/react-syntax-highlighter": "15.5.13",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"@vitest/coverage-v8": "4.1.12",
"@vitest/ui": "4.1.12",
"eslint": "9.39.2",
"eslint-config-next": "16.3.3",
"eslint-config-prettier": "10.1.8",
@ -104,7 +104,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.60.1",
"vite": "7.3.7",
"vitest": "4.1.11"
"vitest": "4.1.12"
},
"overrides": {
"prismjs": "1.30.0",
@ -117,7 +117,10 @@
"axios": "1.13.6",
"postcss": "8.5.23",
"esbuild": "0.28.1",
"sharp": "^0.35.5"
"sharp": "^0.35.5",
"@litellm/lens-ui": {
"next": "$next"
}
},
"engines": {
"node": ">=24.14.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

View file

@ -0,0 +1,27 @@
<!--
Source: https://github.com/lobehub/lobe-icons/blob/master/packages/static-svg/icons/claude-color.svg
Retrieved: 2026-10-22
MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

View file

@ -141,7 +141,7 @@ describe("HomePage", () => {
expect(screen.queryByRole("link", { name: /create new virtual key/i })).not.toBeInTheDocument();
});
it("offers LiteAdmin on Home inside the dashboard frame", async () => {
it("should offer Aria Agent on Home inside the dashboard frame", async () => {
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<LiteAdminFrame>
@ -149,7 +149,7 @@ describe("HomePage", () => {
</LiteAdminFrame>
</QueryClientProvider>,
);
const entry = screen.getByRole("region", { name: "Ask LiteAdmin" });
const entry = screen.getByRole("region", { name: "Ask Aria Agent" });
expect(await within(entry).findByPlaceholderText("What would you like to do?")).toBeInTheDocument();
expect(within(entry).getByRole("button", { name: "Send message" })).toBeDisabled();
expect(await screen.findByText("$501.74")).toBeInTheDocument();

View file

@ -0,0 +1,407 @@
import { useState, type ComponentProps } from "react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { uiSpendLogsCall, userListCall, dailyActivityExportCall, type UserInfo } from "@/components/networking";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import {
chooseSelectOption,
renderWithProviders,
screen,
testQueryClient,
waitFor,
within,
} from "../../../../../../tests/test-utils";
import ClientUsagePanel from "./ClientUsagePanel";
import { UsageClient, type AgentRow } from "./overview/agentCatalog";
vi.mock("@/components/networking", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/networking")>()),
uiSpendLogsCall: vi.fn(),
dailyActivityExportCall: vi.fn(),
userListCall: vi.fn(),
}));
const desktopTag = "User-Agent: claude-cli/2.1.263 (external, claude-desktop-3p)";
const agent: AgentRow = {
id: "claude-desktop",
label: "Claude Desktop",
description: "Desktop assistant",
kind: "agent",
logo: null,
spend: 12.5,
tokens: 9000,
requests: 3,
users: 2,
tags: [desktopTag],
};
const metrics: SpendMetrics = {
spend: 12.5,
total_tokens: 9000,
prompt_tokens: 8000,
completion_tokens: 1000,
cache_read_input_tokens: 5000,
cache_creation_input_tokens: 1000,
api_requests: 3,
successful_requests: 3,
failed_requests: 0,
};
const daily: DailyData[] = [
{
date: "2026-10-22",
metrics,
breakdown: {
models: {},
model_groups: {},
providers: {},
api_keys: {},
mcp_servers: {},
entities: { [desktopTag]: { metrics, metadata: {}, api_key_breakdown: {} } },
},
},
];
const defaultProps: ComponentProps<typeof ClientUsagePanel> = {
client: UsageClient.ClaudeDesktop,
userId: null,
canChooseUser: true,
onUserChange: vi.fn(),
onClose: vi.fn(),
accessToken: "test-token",
startTime: new Date("2026-10-22T00:00:00Z"),
endTime: new Date("2026-10-22T23:59:59.999Z"),
dateValue: { from: new Date("2026-10-22T00:00:00Z"), to: new Date("2026-10-22T23:59:59.999Z") },
presetShortLabel: null,
onDateChange: vi.fn(),
agent,
daily: [],
loading: false,
};
const personLog = {
request_id: "request-1",
user: "person-1",
startTime: "2026-10-22T12:00:00Z",
model: "test-model",
spend: 3,
total_tokens: 1000,
prompt_tokens: 800,
completion_tokens: 200,
status: "success",
request_tags: [desktopTag],
metadata: {},
};
const person: UserInfo = {
user_id: "person-1",
user_email: "person@example.com",
user_alias: null,
allowed_cache_controls: [],
key_count: 0,
model_max_budget: {},
model_spend: {},
models: [],
policies: [],
spend: 0,
teams: [],
};
describe("ClientUsagePanel", () => {
beforeEach(() => {
testQueryClient.clear();
vi.clearAllMocks();
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
callback({ didTimeout: false, timeRemaining: () => 50 });
return 1;
});
const userPage = {
users: [person],
total: 1,
page: 1,
page_size: 50,
total_pages: 1,
};
const requestPage = {
data: [personLog],
total: 1,
page: 1,
total_pages: 1,
total_is_capped: false,
};
const exportedRows = [
{
date: "2026-10-22",
entity_id: desktopTag,
model: "test-model",
spend: 12.5,
flat_cost: 0,
prompt_tokens: 8000,
completion_tokens: 1000,
api_requests: 3,
successful_requests: 3,
failed_requests: 0,
cache_read_input_tokens: 5000,
cache_creation_input_tokens: 1000,
},
];
vi.mocked(dailyActivityExportCall).mockResolvedValue(
Object.assign(new Blob([JSON.stringify(exportedRows)], { type: "application/json" }), {
text: async () => JSON.stringify(exportedRows),
}),
);
vi.mocked(userListCall).mockResolvedValue(userPage);
vi.mocked(uiSpendLogsCall).mockResolvedValue(requestPage);
});
afterEach(() => vi.unstubAllGlobals());
it("should immediately show the clicked all-users summary and logo without requiring a person", () => {
renderWithProviders(<ClientUsagePanel {...defaultProps} loading />);
const panel = screen.getByRole("dialog", { name: "Claude Desktop" });
expect(within(panel).getByRole("img", { name: "Claude Desktop logo" })).toBeInTheDocument();
expect(panel).toHaveTextContent("All users");
expect(panel).toHaveTextContent(/Spend\s*\$12\.50/);
expect(panel).toHaveTextContent(/Total tokens\s*9K/);
expect(panel).toHaveTextContent(/Requests\s*3/);
expect(screen.queryByRole("status", { name: "Loading client usage" })).not.toBeInTheDocument();
expect(screen.queryByText(/Select a user to/)).not.toBeInTheDocument();
expect(uiSpendLogsCall).not.toHaveBeenCalled();
});
it("should filter to a person inside the open panel and keep absent cache details unavailable", async () => {
const user = userEvent.setup();
const onUserChange = vi.fn();
const onClose = vi.fn();
function ControlledPanel() {
const [userId, setUserId] = useState<string | null>(null);
return (
<ClientUsagePanel
{...defaultProps}
canChooseUser
userId={userId}
onClose={onClose}
onUserChange={(id) => {
setUserId(id);
onUserChange(id);
}}
/>
);
}
renderWithProviders(<ControlledPanel />);
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by person" }), /person@example.com/);
expect(onUserChange).toHaveBeenCalledWith("person-1");
expect(onClose).not.toHaveBeenCalled();
await waitFor(() =>
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ user_id: "person-1" }),
}),
),
);
const panel = screen.getByRole("dialog", { name: "Claude Desktop" });
await waitFor(() => expect(panel).toHaveTextContent(/Spend\s*\$3\.00/));
expect(panel).toHaveTextContent("Selected user");
expect(panel).toHaveTextContent(/Cache reads\s*Unavailable/);
expect(panel).toHaveTextContent(/Cache writes\s*Unavailable/);
expect(panel).toHaveTextContent(/Uncached input\s*Unavailable/);
expect(panel).toHaveTextContent(/Output\s*200/);
await user.click(screen.getByRole("button", { name: "All users" }));
expect(onUserChange).toHaveBeenLastCalledWith(null);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Claude Desktop" })).toHaveTextContent(/Spend\s*\$12\.50/);
});
it.each(["Back to Usage", "Close"])("should return to the overview through %s", async (button) => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithProviders(<ClientUsagePanel {...defaultProps} onClose={onClose} />);
await user.click(screen.getByRole("button", { name: button }));
expect(onClose).toHaveBeenCalledOnce();
});
it("should preserve summary totals and mark partial model details unavailable", async () => {
const partialAgent = { ...agent, spend: 25, tokens: 18000, requests: 6 };
renderWithProviders(<ClientUsagePanel {...defaultProps} agent={partialAgent} daily={daily} />);
const panel = screen.getByRole("dialog", { name: "Claude Desktop" });
expect(panel).toHaveTextContent(/Spend\s*\$25\.00/);
expect(within(panel).getAllByText("Unavailable")).toHaveLength(4);
expect(await screen.findByText(/Model usage does not match these client totals yet/)).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Daily usage" })).not.toBeInTheDocument();
expect(screen.queryByText("5,000")).not.toBeInTheDocument();
});
it("should not expose a cached all-users summary without a permitted user scope", () => {
renderWithProviders(<ClientUsagePanel {...defaultProps} canChooseUser={false} userId={null} daily={daily} />);
expect(screen.getByRole("alert")).toHaveTextContent("Sign in to view your client usage");
expect(screen.queryByText("$12.50")).not.toBeInTheDocument();
expect(screen.queryByText("Total tokens")).not.toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: "Filter by person" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Daily usage" })).not.toBeInTheDocument();
expect(uiSpendLogsCall).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Try again" })).not.toBeInTheDocument();
});
it("should show a failed summary as an error and retry without closing or showing stale totals", async () => {
const user = userEvent.setup();
const onRetry = vi.fn();
const onClose = vi.fn();
renderWithProviders(<ClientUsagePanel {...defaultProps} summaryFailed onRetry={onRetry} onClose={onClose} />);
expect(screen.getByRole("alert")).toHaveTextContent("Could not load client totals");
expect(screen.queryByText("$12.50")).not.toBeInTheDocument();
expect(screen.queryByText("No Claude Desktop usage recorded in this date range.")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Try again" }));
expect(onRetry).toHaveBeenCalledOnce();
expect(onClose).not.toHaveBeenCalled();
});
it("should change the panel time range through the existing picker without closing", async () => {
const user = userEvent.setup();
const onDateChange = vi.fn();
const onClose = vi.fn();
renderWithProviders(<ClientUsagePanel {...defaultProps} onDateChange={onDateChange} onClose={onClose} />);
await user.click(screen.getByRole("button", { name: /22 Oct/ }));
await user.click(screen.getByRole("button", { name: /Last 7 days/ }));
await user.click(screen.getByRole("button", { name: "Apply" }));
expect(onDateChange).toHaveBeenCalledWith(
expect.objectContaining({ from: expect.any(Date), to: expect.any(Date) }),
"7d",
);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Claude Desktop" })).toBeInTheDocument();
});
it("should show the client model breakdown using the overview controls", async () => {
renderWithProviders(<ClientUsagePanel {...defaultProps} />);
expect(await screen.findByText("test-model")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Daily usage" })).toBeInTheDocument();
expect(screen.getByRole("radiogroup", { name: "Metric" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Tokens" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Weekly" })).toBeInTheDocument();
});
it("should show personal client usage without loading the global user list or all-user export", async () => {
renderWithProviders(<ClientUsagePanel {...defaultProps} userId="person-1" canChooseUser={false} />);
const panel = screen.getByRole("dialog", { name: "Claude Desktop" });
await waitFor(() => expect(panel).toHaveTextContent(/Spend\s*\$3\.00/));
expect(panel).toHaveTextContent("My usage");
expect(screen.queryByRole("combobox", { name: "Filter by person" })).not.toBeInTheDocument();
expect(userListCall).not.toHaveBeenCalled();
expect(dailyActivityExportCall).not.toHaveBeenCalled();
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({ params: expect.objectContaining({ user_id: "person-1" }) }),
);
expect(vi.mocked(uiSpendLogsCall).mock.lastCall?.[0].params).not.toHaveProperty("team_id");
});
it("should keep team and member filters intersected inside the open panel without fetching global users", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const onTeamChange = vi.fn();
const onUserChange = vi.fn();
const teamOptions = [{ value: "engineering", label: "Engineering" }];
const memberOptions = [
{ value: "person-1", label: "John" },
{ value: "person-2", label: "Maya" },
];
const johnLog = { ...personLog, team_id: "engineering" };
const mayaLog = { ...johnLog, request_id: "request-2", user: "person-2", spend: 7 };
vi.mocked(uiSpendLogsCall).mockImplementation(async (request) => {
const logs = [johnLog, mayaLog].filter((log) => !request.params?.user_id || log.user === request.params.user_id);
return { data: logs, total: logs.length, page: 1, total_pages: 1, total_is_capped: false };
});
function TeamPanel() {
const [teamId, setTeamId] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>("person-1");
const changeTeam = (id: string | null) => {
setTeamId(id);
setUserId(id ? null : "person-1");
onTeamChange(id);
};
return (
<ClientUsagePanel
{...defaultProps}
canChooseUser={false}
defaultScopeLabel="My usage"
teams={teamOptions}
members={teamId ? memberOptions : []}
teamId={teamId}
userId={userId}
onClose={onClose}
onTeamChange={changeTeam}
onUserChange={(id) => {
setUserId(id);
onUserChange(id);
}}
/>
);
}
renderWithProviders(<TeamPanel />);
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Usage scope" }), "Engineering");
expect(onTeamChange).toHaveBeenCalledWith("engineering");
const panel = screen.getByRole("dialog", { name: "Claude Desktop" });
await waitFor(() => expect(panel).toHaveTextContent(/Spend\s*\$10\.00/));
expect(panel).toHaveTextContent("Engineering · All members");
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
expect.objectContaining({ params: expect.objectContaining({ team_id: "engineering" }) }),
);
expect(vi.mocked(uiSpendLogsCall).mock.lastCall?.[0].params).not.toHaveProperty("user_id");
await user.click(screen.getByRole("combobox", { name: "Team member" }));
expect(await screen.findByRole("option", { name: "Maya" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "John" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Outside user" })).not.toBeInTheDocument();
await user.click(screen.getByRole("option", { name: "Maya" }));
expect(onUserChange).toHaveBeenCalledWith("person-2");
await waitFor(() => expect(panel).toHaveTextContent(/Spend\s*\$7\.00/));
expect(panel).toHaveTextContent("Engineering · Maya");
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
expect.objectContaining({ params: expect.objectContaining({ team_id: "engineering", user_id: "person-2" }) }),
);
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Team member" }), "All members");
expect(onUserChange).toHaveBeenLastCalledWith(null);
await waitFor(() => expect(panel).toHaveTextContent(/Spend\s*\$10\.00/));
expect(onClose).not.toHaveBeenCalled();
expect(userListCall).not.toHaveBeenCalled();
expect(dailyActivityExportCall).not.toHaveBeenCalled();
});
it.each([
[null, "Choose an administered team before viewing another member's usage"],
["engineering", "Choose a member of the selected team"],
])("should block requests and cached summaries for an invalid scope in team %s", (teamId, scopeError) => {
renderWithProviders(
<ClientUsagePanel
{...defaultProps}
canChooseUser={false}
teamId={teamId}
userId="outside-user"
scopeError={scopeError}
daily={daily}
/>,
);
expect(screen.getByRole("alert")).toHaveTextContent(scopeError);
expect(screen.queryByText("$12.50")).not.toBeInTheDocument();
expect(screen.queryByText("Total tokens")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Daily usage" })).not.toBeInTheDocument();
expect(uiSpendLogsCall).not.toHaveBeenCalled();
expect(dailyActivityExportCall).not.toHaveBeenCalled();
expect(userListCall).not.toHaveBeenCalled();
});
it("should recover a newer model export by refreshing the cached summary on retry", async () => {
const user = userEvent.setup();
function StaleSummaryPanel() {
const [summary, setSummary] = useState({ ...agent, spend: 12 });
return <ClientUsagePanel {...defaultProps} agent={summary} onRetry={() => setSummary(agent)} />;
}
renderWithProviders(<StaleSummaryPanel />);
expect(await screen.findByText(/Model usage does not match these client totals/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry model breakdown" }));
expect(await screen.findByText("test-model")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,362 @@
"use client";
import { useMemo } from "react";
import { ArrowLeft, Coins } from "lucide-react";
import { useTagSummary } from "@/app/(dashboard)/hooks/tags/useTagSummary";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import UserDropdown from "@/components/common_components/UserDropdown";
import { SearchSelect } from "@/components/shared/SearchSelect";
import type { ClientUsageScopeOption } from "../hooks/clientUsageScope";
import { stackedUsageColor } from "@/components/shared/charts";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { EMPTY_DAILY_ACTIVITY_METADATA, toDailyData } from "@/components/UsagePage/dailyActivityApi";
import type { DailyData } from "@/components/UsagePage/types";
import claudeLogo from "../../../../../../public/assets/logos/claude.svg";
import type { UsageClient } from "../hooks/clientUsageData";
import { useClientUsage } from "../hooks/useClientUsage";
import { topAgents, type AgentRow } from "./overview/agentCatalog";
import { useClientAgentModels } from "../hooks/useClientAgentModels";
import UsageBreakdownChart from "./overview/UsageBreakdownChart";
import { clientAgentDetail, type ClientAgentDetail } from "./overview/clientAgentDetail";
import { modelColorMap, formatCompact, formatUsd, overviewTotals } from "./overview/overviewData";
import { reconciledTokenBreakdown } from "./overview/tokenBreakdown";
import { Panel, Stat } from "./overview/Primitives";
import { AgentMark } from "./overview/TopAgents";
interface ClientUsagePanelProps {
client: UsageClient;
userId: string | null;
canChooseUser: boolean;
teamId?: string | null;
scopeError?: string | null;
teams?: readonly ClientUsageScopeOption[];
members?: readonly ClientUsageScopeOption[];
defaultScopeLabel?: string;
onTeamChange?: (teamId: string | null) => void;
onUserChange: (userId: string | null) => void;
onClose: () => void;
accessToken: string | null;
startTime: Date | null;
endTime: Date | null;
dateValue: DateRangePickerValue;
presetShortLabel: string | null;
onDateChange: (value: DateRangePickerValue, preset: string | null) => void;
colorSource?: readonly DailyData[];
agent: AgentRow | null;
daily: readonly DailyData[];
loading: boolean;
summaryFailed?: boolean;
onRetry?: () => void;
}
const dateLabel = (date: Date | null) => date?.toLocaleDateString("en-US", { month: "short", day: "numeric" }) ?? "—";
const countLabel = (value: number | null) => (value === null ? "Unavailable" : value.toLocaleString());
export default function ClientUsagePanel({
client,
userId,
canChooseUser,
teamId = null,
scopeError: accessError = null,
teams = [],
members = [],
defaultScopeLabel = "All users",
onTeamChange,
onUserChange,
onClose,
accessToken,
startTime,
endTime,
dateValue,
presetShortLabel,
onDateChange,
colorSource,
agent,
daily,
loading,
summaryFailed = false,
onRetry,
}: ClientUsagePanelProps) {
const label = client === "claude-desktop" ? "Claude Desktop" : "Claude Code";
const scopedRequest = Boolean(userId || teamId) && !accessError;
const personRequest = {
accessToken,
startTime,
endTime,
userId,
teamId,
client: scopedRequest ? client : null,
};
const personUsage = useClientUsage(personRequest);
const allUsersEnabled = canChooseUser && !scopedRequest && !accessError;
const teamLabel = teams.find((team) => team.value === teamId)?.label ?? teamId;
const personLabel = members.find((member) => member.value === userId)?.label ?? "Selected user";
const selectedScopeLabel = (() => {
if (teamId) return `${teamLabel} · ${userId ? personLabel : "All members"}`;
if (!canChooseUser) return "My usage";
return userId ? "Selected user" : "All users";
})();
const summaryQuery = useTagSummary(startTime, endTime, allUsersEnabled && agent === null);
const activeAgent = useMemo(
() => agent ?? topAgents(summaryQuery.data ?? []).find((row) => row.id === client) ?? null,
[summaryQuery.data, agent, client],
);
const modelRequest = { accessToken, startTime, endTime, agent: activeAgent, enabled: allUsersEnabled };
const agentModels = useClientAgentModels(modelRequest);
const agentDaily = agentModels.results.length > 0 ? agentModels.results : daily;
const agentDetail = useMemo(
() => (activeAgent ? clientAgentDetail(activeAgent, agentDaily) : null),
[activeAgent, agentDaily],
);
const colorByModel = useMemo(() => (colorSource ? modelColorMap(colorSource) : undefined), [colorSource]);
const personTotals = overviewTotals(personUsage.data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, null);
const pending =
!accessError && (scopedRequest ? personUsage.loading : (loading || summaryQuery.isLoading) && activeAgent === null);
const summaryError = summaryQuery.isError || summaryFailed ? "Could not load client totals" : null;
const scopeError =
accessError ?? (!canChooseUser && !scopedRequest ? "Sign in to view your client usage" : summaryError);
const error = accessError ?? (scopedRequest ? personUsage.error : scopeError);
const summary = scopedRequest ? personTotals : agentDetail?.summary;
const rawBreakdown = scopedRequest
? {
inputTokens: personTotals.inputTokens,
outputTokens: personTotals.outputTokens,
cacheReadTokens: personUsage.cacheAvailable ? personTotals.cacheReadTokens : null,
cacheWriteTokens: personUsage.cacheAvailable ? personTotals.cacheWriteTokens : null,
}
: agentDetail?.tokenBreakdown;
const breakdown = rawBreakdown && summary ? reconciledTokenBreakdown(summary.tokens, rawBreakdown) : undefined;
const modelResults = scopedRequest ? toDailyData(personUsage.data) : agentModels.results;
const chartLoading = scopedRequest ? personUsage.loading : agentModels.loading;
const chartError = scopedRequest ? personUsage.error : agentModels.error;
const canFilterPerson = canChooseUser && !teamId && !accessError;
const canRetry = !accessError && (scopedRequest || canChooseUser);
const ready = !pending && !error;
const retryModels = () => {
void summaryQuery.refetch();
onRetry?.();
agentModels.reload();
};
return (
<Sheet
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<SheetContent className="gap-0 data-[side=right]:w-[calc(100%-16px)] data-[side=right]:sm:max-w-[600px]">
<SheetHeader className="shrink-0 border-b px-6 pt-5 pb-4">
<Button variant="ghost" size="sm" className="mb-3 w-fit -ml-2 text-muted-foreground" onClick={onClose}>
<ArrowLeft className="size-4" /> Back to Usage
</Button>
<div className="flex items-center gap-3">
<AgentMark agent={{ id: client, label, logo: claudeLogo.src }} />
<div>
<SheetTitle className="text-sm font-medium">{label}</SheetTitle>
<SheetDescription className="mt-0.5 text-xs">
{dateLabel(startTime)} – {dateLabel(endTime)} · {selectedScopeLabel}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto bg-background px-4 py-4 sm:px-6">
<div className="border-b pb-3">
<AdvancedDatePicker
compact
value={dateValue}
presetShortLabel={presetShortLabel}
onValueChange={onDateChange}
/>
</div>
{teams.length > 0 && onTeamChange && (
<div className="space-y-2">
<label htmlFor="client-usage-team" className="text-xs font-medium text-muted-foreground">
Usage scope
</label>
<SearchSelect
id="client-usage-team"
aria-label="Usage scope"
value={teamId ? `team:${teamId}` : "default"}
options={[
{ value: "default", label: defaultScopeLabel },
...teams.map((team) => ({ ...team, value: `team:${team.value}` })),
]}
allowClear={false}
onValueChange={(value) => onTeamChange(value?.startsWith("team:") ? value.slice(5) : null)}
/>
</div>
)}
{teamId && (
<div className="space-y-2">
<label htmlFor="client-usage-member" className="text-xs font-medium text-muted-foreground">
Team member
</label>
<SearchSelect
id="client-usage-member"
aria-label="Team member"
value={userId ? `user:${userId}` : "all"}
options={[
{ value: "all", label: "All members" },
...members.map((member) => ({ ...member, value: `user:${member.value}` })),
]}
allowClear={false}
onValueChange={(value) => onUserChange(value?.startsWith("user:") ? value.slice(5) : null)}
/>
</div>
)}
{canFilterPerson && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label htmlFor="client-usage-user" className="text-xs font-medium text-muted-foreground">
Filter by person
</label>
{userId && (
<Button variant="ghost" size="sm" onClick={() => onUserChange(null)}>
All users
</Button>
)}
</div>
<UserDropdown id="client-usage-user" value={userId} onChange={onUserChange} />
</div>
)}
{pending && (
<div role="status" aria-label="Loading client usage">
<Skeleton className="h-24 w-full" />
</div>
)}
{error && (
<div role="alert" className="rounded-lg border p-4">
<p className="font-medium">Could not load client usage</p>
<p className="mt-1 text-muted-foreground">{error}</p>
{canRetry && (
<Button
className="mt-3"
variant="outline"
onClick={() => {
if (scopedRequest) {
personUsage.reload();
} else {
void summaryQuery.refetch();
onRetry?.();
}
}}
>
Try again
</Button>
)}
</div>
)}
{ready && !summary && (
<p className="rounded-lg border p-4 text-muted-foreground">No {label} usage recorded in this date range.</p>
)}
{ready && summary && (
<>
<div className="grid grid-cols-3 divide-x overflow-hidden rounded-xl border bg-card py-4">
<SummaryStat label="Spend" value={formatUsd(summary.spend)} />
<SummaryStat label="Total tokens" value={formatCompact(summary.tokens)} />
<SummaryStat label="Requests" value={summary.requests.toLocaleString()} />
</div>
{summary.requests === 0 ? (
<p className="text-sm text-muted-foreground">
No {label} requests in the selected range. Try another date range.
{(canChooseUser || teamId) && " You can also choose another person."}
</p>
) : (
<>
{!chartError && (
<UsageBreakdownChart
dimension="models"
results={modelResults}
loading={chartLoading}
compact
colorByModel={colorByModel}
/>
)}
{chartError && (
<div className="rounded-lg border p-4">
<p role="status" className="text-xs text-muted-foreground">
{chartError}
</p>
<Button variant="outline" size="sm" className="mt-3" onClick={retryModels}>
Retry model breakdown
</Button>
</div>
)}
{breakdown && <TokenExplanation total={summary.tokens} breakdown={breakdown} />}
</>
)}
<p className="border-t pt-3 text-xs leading-5 text-muted-foreground">
{scopedRequest
? "Based on retained request logs within the exact selected time range."
: "All-user totals use UTC daily aggregates for the selected dates. Filtering by person uses the exact selected times. Cache values are reported counts."}{" "}
Spend is the cost recorded by LiteLLM.
</p>
</>
)}
</div>
</SheetContent>
</Sheet>
);
}
function SummaryStat({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 px-4">
<Stat label={label} value={value} />
</div>
);
}
function TokenExplanation({ total, breakdown }: { total: number; breakdown: ClientAgentDetail["tokenBreakdown"] }) {
const { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens } = breakdown;
const freshInput =
inputTokens !== null && cacheReadTokens !== null && cacheWriteTokens !== null
? inputTokens - cacheReadTokens - cacheWriteTokens
: null;
const rows = [
{ label: "Cache reads", value: cacheReadTokens, color: stackedUsageColor(0) },
{ label: "Cache writes", value: cacheWriteTokens, color: stackedUsageColor(1) },
{ label: "Uncached input", value: freshInput, color: stackedUsageColor(2) },
{ label: "Output", value: outputTokens, color: stackedUsageColor(3) },
];
const complete = rows.every((row) => row.value !== null);
return (
<Panel icon={Coins} title="Token breakdown" subtitle={`${formatCompact(total)} total tokens`} bodyClassName="gap-3">
{complete && total > 0 && (
<div aria-hidden="true" className="flex h-2 overflow-hidden rounded-full bg-muted">
{rows.map((row) => (
<span
key={row.label}
style={{ backgroundColor: row.color, width: `${((row.value ?? 0) / total) * 100}%` }}
/>
))}
</div>
)}
{cacheReadTokens !== null && total > 0 && (
<p className="text-xs text-muted-foreground">
{((cacheReadTokens / total) * 100).toFixed(1)}% of these tokens are cache reads.
</p>
)}
<dl className="divide-y rounded-lg border px-3 text-xs">
{rows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-3 py-2.5">
<dt className="flex items-center gap-2 text-muted-foreground">
<span className="size-2 rounded-[2px]" style={{ backgroundColor: row.color }} />
{row.label}
</dt>
<dd className="font-medium tabular-nums">{countLabel(row.value)}</dd>
</div>
))}
</dl>
<p className="text-xs leading-5 text-muted-foreground">
Total tokens include input, cached input and output. Cached input is priced differently, so token count alone
does not explain spend. Compare the same person and dates when checking the count shown by a client.
</p>
</Panel>
);
}

View file

@ -0,0 +1,25 @@
import { SearchSelect } from "@/components/shared/SearchSelect";
import { parseUsageClient, UsageClient } from "./overview/agentCatalog";
const CLIENT_OPTIONS = [
{ value: UsageClient.ClaudeDesktop, label: "Claude Desktop" },
{ value: UsageClient.ClaudeCode, label: "Claude Code" },
];
export default function ClientUsageSelect({
value,
onChange,
}: {
value: UsageClient | null;
onChange: (client: UsageClient | null) => void;
}) {
return (
<SearchSelect
aria-label="View client usage"
placeholder="View client usage"
value={value}
options={CLIENT_OPTIONS}
onValueChange={(client) => onChange(parseUsageClient(client))}
/>
);
}

View file

@ -1,83 +0,0 @@
import { screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import UsageAIChatPanel from "./UsageAIChatPanel";
beforeAll(() => {
if (typeof window !== "undefined" && !window.ResizeObserver) {
window.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
} as any;
}
});
vi.mock("@/components/networking", () => ({
modelHubCall: vi.fn().mockResolvedValue({
data: [{ model_group: "gpt-4" }, { model_group: "claude-3-opus" }],
}),
usageAiChatStream: vi.fn(),
}));
const defaultProps = {
open: true,
onClose: vi.fn(),
accessToken: "test-token",
};
describe("UsageAIChatPanel", () => {
it("should render the panel when open", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
expect(screen.getByText("Ask AI")).toBeInTheDocument();
expect(screen.getByText("Ask about your spend, models, keys, and trends")).toBeInTheDocument();
});
it("should render model selector", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
// One library paints the prompt as its own text node and the other leaves it on the input's
// placeholder attribute, so either one means the user is being told what to pick.
const prompt = "Select a model (optional, defaults to gpt-4o-mini)";
expect(screen.queryAllByText(prompt).length + screen.queryAllByPlaceholderText(prompt).length).toBeGreaterThan(0);
});
it("should render empty state message when no conversation", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
expect(screen.getByText("Ask a question about your usage")).toBeInTheDocument();
});
it("should render the send button", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
expect(screen.getByText("Send")).toBeInTheDocument();
});
it("should render input placeholder", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
expect(screen.getByPlaceholderText("Ask about your usage...")).toBeInTheDocument();
});
it("should render clear chat button", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
expect(screen.getByText("Clear chat")).toBeInTheDocument();
});
it("should have the panel element even when closed (just off-screen)", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} open={false} />);
expect(screen.getByTestId("usage-ai-chat-panel")).toBeInTheDocument();
expect(screen.getByTestId("usage-ai-chat-panel")).toHaveClass("translate-x-full");
});
it("should not have translate-x-full class when open", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} open={true} />);
expect(screen.getByTestId("usage-ai-chat-panel")).not.toHaveClass("translate-x-full");
expect(screen.getByTestId("usage-ai-chat-panel")).toHaveClass("translate-x-0");
});
});

View file

@ -1,385 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { MessageSquare, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Textarea } from "@/components/ui/textarea";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { modelHubCall, usageAiChatStream, UsageAiToolCallEvent } from "@/components/networking";
interface ToolCallStep {
tool_name: string;
tool_label: string;
arguments: Record<string, string>;
status: "running" | "complete" | "error";
error?: string;
}
interface ChatMessage {
role: "user" | "assistant";
content: string;
toolCalls?: ToolCallStep[];
}
interface UsageAIChatPanelProps {
open: boolean;
onClose: () => void;
accessToken: string | null;
}
const TOOL_ICONS: Record<string, string> = {
get_usage_data: "📊",
get_team_usage_data: "👥",
get_tag_usage_data: "🏷️",
};
const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
const icon = TOOL_ICONS[step.tool_name] || "🔧";
const args = step.arguments;
const dateRange = args.start_date && args.end_date ? `${args.start_date} → ${args.end_date}` : "";
const filter = args.team_ids || args.tags || args.user_id || "";
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-muted border border-border text-xs">
<span className="shrink-0 mt-0.5">
{step.status === "running" ? (
<UiLoadingSpinner className="size-3.5" />
) : step.status === "error" ? (
<span className="text-destructive">✗</span>
) : (
<span className="text-success">✓</span>
)}
</span>
<div className="min-w-0">
<div className="font-medium text-foreground">
{icon} {step.tool_label}
</div>
{dateRange && <div className="text-muted-foreground mt-0.5">{dateRange}</div>}
{filter && <div className="text-muted-foreground mt-0.5">Filter: {filter}</div>}
{step.status === "error" && step.error && <div className="text-destructive mt-0.5">{step.error}</div>}
</div>
</div>
);
};
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => (
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
ul: ({ children }) => <ul className="list-disc pl-4 mb-2 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-4 mb-2 space-y-0.5">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
h1: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
h2: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
h3: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
code: ({ children, className }) => {
const isBlock = className?.includes("language-");
return isBlock ? (
<pre className="bg-muted rounded-sm p-2 my-1 overflow-x-auto text-xs">
<code>{children}</code>
</pre>
) : (
<code className="px-1 py-0.5 rounded-sm bg-muted text-xs font-mono">{children}</code>
);
},
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="text-xs border-collapse w-full">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-border px-2 py-1 bg-muted font-medium text-left">{children}</th>
),
td: ({ children }) => <td className="border border-border px-2 py-1">{children}</td>,
}}
>
{content}
</ReactMarkdown>
);
const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, accessToken }) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [activeToolCalls, setActiveToolCalls] = useState<ToolCallStep[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
if (open && availableModels.length === 0) {
loadModels();
}
}, [open]);
useEffect(() => {
if (typeof messagesEndRef.current?.scrollIntoView === "function") {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages, streamingContent, activeToolCalls, statusMessage]);
const loadModels = async () => {
if (!accessToken) return;
setIsLoadingModels(true);
try {
const fetchedModels = await modelHubCall(accessToken);
if (fetchedModels?.data?.length > 0) {
const models = fetchedModels.data.map((item: any) => item.model_group as string).sort();
setAvailableModels(models);
}
} catch (error) {
console.error("Failed to load models:", error);
} finally {
setIsLoadingModels(false);
}
};
const handleSend = async () => {
if (!accessToken || !inputText.trim() || isLoading) return;
const userMessage: ChatMessage = { role: "user", content: inputText.trim() };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInputText("");
setIsLoading(true);
setStreamingContent("");
setStatusMessage(null);
setActiveToolCalls([]);
const abortController = new AbortController();
abortControllerRef.current = abortController;
let accumulated = "";
const toolCalls: ToolCallStep[] = [];
try {
await usageAiChatStream(
accessToken,
updatedMessages.slice(-20).map((m) => ({ role: m.role, content: m.content })),
selectedModel || "",
(content: string) => {
setStatusMessage(null);
accumulated += content;
setStreamingContent(accumulated);
},
() => {
setStatusMessage(null);
setActiveToolCalls([]);
setMessages((prev) => [
...prev,
{ role: "assistant", content: accumulated, toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined },
]);
setStreamingContent("");
},
(errorMsg: string) => {
setStatusMessage(null);
setActiveToolCalls([]);
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${errorMsg}` }]);
setStreamingContent("");
},
(status: string) => {
setStatusMessage(status);
},
(event: UsageAiToolCallEvent) => {
const idx = toolCalls.findIndex((tc) => tc.tool_name === event.tool_name);
if (idx >= 0) {
toolCalls[idx] = { ...event };
} else {
toolCalls.push({ ...event });
}
setActiveToolCalls([...toolCalls]);
},
abortController.signal,
);
} catch (error: any) {
if (error?.name === "AbortError" || abortController.signal.aborted) {
return;
}
const errorMsg = error?.message || "Failed to get response. Please try again.";
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${errorMsg}` }]);
setStreamingContent("");
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClose = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
onClose();
};
const handleClear = () => {
setMessages([]);
setStreamingContent("");
setActiveToolCalls([]);
setStatusMessage(null);
};
return (
<div
data-testid="usage-ai-chat-panel"
className={`fixed top-0 right-0 h-full bg-card border-l border-border shadow-2xl z-overlay flex flex-col transition-transform duration-300 ease-in-out ${
open ? "translate-x-0" : "translate-x-full"
}`}
style={{ width: 420 }}
>
{/* Header */}
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-5 pt-4 pb-3">
<div className="min-w-0">
<h3 className="flex items-center gap-2 text-sm leading-5 font-medium text-foreground">
<Sparkles aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" strokeWidth={1.75} />
Ask AI
</h3>
<p className="mt-0.5 text-xs text-muted-foreground">Ask about your spend, models, keys, and trends</p>
</div>
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={handleClose}>
<X />
</Button>
</div>
{/* Model selector */}
<div className="px-5 py-3 border-b border-border shrink-0">
<Combobox
items={availableModels}
value={selectedModel ?? null}
onValueChange={(value: string | null) => setSelectedModel(value ?? undefined)}
>
<ComboboxInput
className="w-full"
placeholder="Select a model (optional, defaults to gpt-4o-mini)"
aria-label="Select a model (optional, defaults to gpt-4o-mini)"
aria-busy={isLoadingModels}
showClear={selectedModel !== undefined}
/>
<ComboboxContent>
<ComboboxEmpty>{isLoadingModels ? "Loading models…" : "No models found"}</ComboboxEmpty>
<ComboboxList>
{(model: string) => (
<ComboboxItem key={model} value={model}>
{model}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{/* Chat messages */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3 bg-muted/40">
{messages.length === 0 && !streamingContent && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquare aria-hidden="true" className="mb-2 size-6" strokeWidth={1.5} />
<p className="text-sm font-medium text-foreground">Ask a question about your usage</p>
<p className="text-xs mt-1">e.g. &quot;Which model costs me the most?&quot;</p>
</div>
)}
{messages.map((msg, idx) => (
<div key={idx}>
{msg.role === "user" ? (
<div className="flex justify-end">
<div className="max-w-[88%] rounded-xl px-3.5 py-2 text-sm leading-relaxed bg-info text-info-foreground">
{msg.content}
</div>
</div>
) : (
<div className="space-y-2">
{/* Tool calls for this message */}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="space-y-1.5">
{msg.toolCalls.map((tc, tcIdx) => (
<ToolCallDisplay key={tcIdx} step={tc} />
))}
</div>
)}
{/* Response */}
<div className="max-w-[95%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed bg-card border border-border text-foreground">
<MarkdownContent content={msg.content} />
</div>
</div>
)}
</div>
))}
{/* Active tool calls (in-progress) */}
{isLoading && activeToolCalls.length > 0 && (
<div className="space-y-1.5">
{activeToolCalls.map((tc, idx) => (
<ToolCallDisplay key={idx} step={tc} />
))}
</div>
)}
{/* Status / spinner */}
{isLoading && !streamingContent && (
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
<UiLoadingSpinner className="size-3.5" />
<span className="italic">{statusMessage || "Thinking..."}</span>
</div>
)}
{/* Streaming response */}
{streamingContent && (
<div className="max-w-[95%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed bg-card border border-border text-foreground">
<MarkdownContent content={streamingContent} />
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="px-5 py-3 border-t border-border bg-card shrink-0">
<div className="flex gap-2">
<Textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about your usage..."
rows={1}
className="flex-1 min-h-9 max-h-24"
disabled={isLoading}
/>
<Button onClick={handleSend} disabled={!inputText.trim() || isLoading}>
{isLoading && <UiLoadingSpinner className="size-4" />}
Send
</Button>
</div>
<div className="flex justify-between items-center mt-2">
<button
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={messages.length === 0}
>
Clear chat
</button>
<span className="text-xs text-muted-foreground">Enter to send</span>
</div>
</div>
</div>
);
};
export default UsageAIChatPanel;

View file

@ -5,10 +5,11 @@ import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import type { ComponentProps } from "react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { UrlUpdateEvent } from "nuqs/adapters/testing";
import { renderWithProviders } from "@/../tests/test-utils";
import { chooseSelectOption, renderWithProviders } from "@/../tests/test-utils";
import { toast } from "@/lib/toast";
import { processActivityData } from "@/components/activity_metrics";
import type { Organization } from "@/components/networking";
@ -17,8 +18,23 @@ import * as networking from "@/components/networking";
import { STACKED_USAGE_PALETTE } from "@/components/shared/charts";
import { OTHER_COLOR } from "./overview/overviewData";
import UsagePage from "./UsagePageView";
import type ClientUsagePanel from "./ClientUsagePanel";
const mockEntityUsageMounted = vi.hoisted(() => vi.fn());
const mockClientUsagePanel = vi.hoisted(() => vi.fn());
vi.mock("./ClientUsagePanel", () => ({
default: (props: ComponentProps<typeof ClientUsagePanel>) => {
mockClientUsagePanel(props);
return (
<section aria-label="Client usage panel">
<button onClick={() => props.onTeamChange?.("design")}>Select Design scope</button>
<button onClick={() => props.onUserChange("maya")}>Select Maya</button>
<button onClick={props.onClose}>Close client panel</button>
</section>
);
},
}));
// The Overview tab. The Key Activity tab stays mounted and carries its own spend-derived totals,
// so assertions about the overview's request tiles are scoped here rather than to the whole page.
@ -223,8 +239,9 @@ vi.mock("@/components/EntityUsageExport", () => ({
default: () => <div>Entity Usage Export Modal</div>,
}));
vi.mock("./UsageAIChatPanel", () => ({
default: () => <div data-testid="usage-ai-chat-panel">Usage AI Chat Panel</div>,
vi.mock("@/components/liteadmin/LiteAdmin", () => ({
AriaUsageTrigger: () => <button aria-label="Open Aria Agent">Aria</button>,
AriaUsageComposer: () => <section aria-label="Ask Aria Agent">Shared Aria composer</section>,
}));
vi.mock("@/app/(dashboard)/hooks/customers/useCustomers", () => ({
@ -499,11 +516,13 @@ describe("UsagePage", () => {
};
beforeEach(() => {
mockClientUsagePanel.mockClear();
vi.mocked(processActivityData).mockReset();
vi.mocked(processActivityData).mockImplementation((_data, key) => ({
[key]: modelActivity(`activity-source:${key}`),
}));
mockUseAuthorized.mockReturnValue({
...nonAdminSession,
isLoading: false,
isAuthorized: true,
token: "mock-token",
@ -861,7 +880,7 @@ describe("UsagePage", () => {
});
it("should withhold the tag list until it resolves so no empty state is shown while loading", async () => {
let resolveTagList: (tags: Record<string, unknown>) => void = () => {};
let resolveTagList: (tags: Awaited<ReturnType<typeof networking.tagListCall>>) => void = () => {};
mockTagListCall.mockReturnValue(
new Promise((resolve) => {
resolveTagList = resolve;
@ -900,7 +919,7 @@ describe("UsagePage", () => {
);
});
let resolveNewRange: (tags: Record<string, unknown>) => void = () => {};
let resolveNewRange: (tags: Awaited<ReturnType<typeof networking.tagListCall>>) => void = () => {};
mockTagListCall.mockReturnValue(
new Promise((resolve) => {
resolveNewRange = resolve;
@ -922,6 +941,7 @@ describe("UsagePage", () => {
it("should show tag usage selector option for internal users", async () => {
mockUseAuthorized.mockReturnValue({
...nonAdminSession,
isLoading: false,
isAuthorized: true,
token: "mock-token",
@ -971,6 +991,7 @@ describe("UsagePage", () => {
const mockUseIsOrgAdmin = vi.mocked(useIsOrgAdmin);
mockUseIsOrgAdmin.mockReturnValue(true);
mockUseAuthorized.mockReturnValue({
...nonAdminSession,
isLoading: false,
isAuthorized: true,
token: "mock-token",
@ -1244,6 +1265,7 @@ describe("UsagePage", () => {
describe("non-admin user behavior", () => {
it("should not render user selector for non-admin users", async () => {
mockUseAuthorized.mockReturnValue({
...nonAdminSession,
isLoading: false,
isAuthorized: true,
token: "mock-token",
@ -1269,6 +1291,7 @@ describe("UsagePage", () => {
it("should always pass own userId for non-admin users", async () => {
mockUseAuthorized.mockReturnValue({
...nonAdminSession,
isLoading: false,
isAuthorized: true,
token: "mock-token",
@ -1366,25 +1389,33 @@ describe("UsagePage", () => {
});
});
describe("Ask AI button", () => {
it("should render Ask AI button in global view", async () => {
describe("Aria Agent", () => {
it("should render the shared Aria prompt and entry point in global view", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Ask AI")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open Aria Agent" })).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Ask Aria Agent" })).toBeVisible();
expect(screen.queryByText("Ask AI")).not.toBeInTheDocument();
});
it("should render AI chat panel component", async () => {
it("should keep the shared Aria prompt available in entity usage views", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByTestId("usage-ai-chat-panel")).toBeInTheDocument();
act(() => {
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "team" } });
});
expect(await screen.findByText("Entity Usage")).toBeInTheDocument();
expect(screen.getAllByRole("region", { name: "Ask Aria Agent" })).toHaveLength(1);
expect(screen.getByRole("region", { name: "Ask Aria Agent" })).toBeVisible();
});
});
@ -1591,6 +1622,195 @@ describe("UsagePage", () => {
expect(screen.getByText("Endpoint Activity")).toBeInTheDocument();
});
});
describe("client scope wiring", () => {
const administeredTeams = [
{
...defaultProps.teams[0],
team_id: "engineering",
team_alias: "Engineering",
members_with_roles: [
{ user_id: "user-123", role: "admin" },
{ user_id: "alice", role: "user", user_email: "alice@example.com" },
],
},
{
...defaultProps.teams[0],
team_id: "design",
team_alias: "Design",
members_with_roles: [
{ user_id: "user-123", role: "admin" },
{ user_id: "maya", role: "user", user_email: "maya@example.com" },
],
},
{
...defaultProps.teams[0],
team_id: "outside-team",
team_alias: "Outside team",
members_with_roles: [
{ user_id: "user-123", role: "user" },
{ user_id: "outside-user", role: "admin" },
],
},
];
const renderClientAt = (searchParams: string, props: ComponentProps<typeof UsagePage> = defaultProps) => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
const view = renderWithProviders(<UsagePage {...props} />, {
searchParams,
onUrlUpdate,
resetUrlUpdateQueueOnMount: false,
});
return { ...view, onUrlUpdate };
};
const lastPanelProps = (): ComponentProps<typeof ClientUsagePanel> => mockClientUsagePanel.mock.lastCall?.[0];
it("should default a proxy administrator's client panel to all users", async () => {
renderClientAt("?client=claude-desktop");
expect(await screen.findByRole("region", { name: "Client usage panel" })).toBeInTheDocument();
expect(lastPanelProps()).toMatchObject({ userId: null, teamId: null, canChooseUser: true });
expect(lastPanelProps().scopeError).toBeNull();
});
it("should honor a proxy administrator's linked person", async () => {
renderClientAt("?client=claude-desktop&client_user=selected-person");
expect(await screen.findByRole("region", { name: "Client usage panel" })).toBeInTheDocument();
const expectedScope = {
userId: "selected-person",
teamId: null,
canChooseUser: true,
scopeError: null,
defaultScopeLabel: "All users",
};
expect(lastPanelProps()).toMatchObject(expectedScope);
});
it("should default an internal user to their own client usage without a global person selector", async () => {
mockUseAuthorized.mockReturnValue(nonAdminSession);
renderClientAt("?client=claude-desktop");
expect(await screen.findByRole("region", { name: "Client usage panel" })).toBeInTheDocument();
const expectedScope = {
userId: "user-123",
teamId: null,
canChooseUser: false,
scopeError: null,
teams: [],
defaultScopeLabel: "My usage",
};
expect(lastPanelProps()).toMatchObject(expectedScope);
});
it("should keep an administered-team member scoped and reset the person when the team changes", async () => {
mockUseAuthorized.mockReturnValue(nonAdminSession);
const user = userEvent.setup();
const props = { ...defaultProps, teams: administeredTeams };
const { onUrlUpdate } = renderClientAt("?client=claude-desktop&client_team=engineering&client_user=alice", props);
expect(await screen.findByRole("region", { name: "Client usage panel" })).toBeInTheDocument();
const expectedScope = {
userId: "alice",
teamId: "engineering",
canChooseUser: false,
scopeError: null,
};
expect(lastPanelProps()).toMatchObject(expectedScope);
expect(lastPanelProps().teams).toEqual([
{ value: "engineering", label: "Engineering" },
{ value: "design", label: "Design" },
]);
expect(lastPanelProps().members?.map((member) => member.value)).toEqual(["user-123", "alice"]);
await user.click(screen.getByRole("button", { name: "Select Design scope" }));
await waitFor(() => expect(lastPanelProps()).toMatchObject({ teamId: "design", userId: null, scopeError: null }));
expect(lastPanelProps().members?.map((member) => member.value)).toEqual(["user-123", "maya"]);
expect(new URLSearchParams(onUrlUpdate.mock.lastCall?.[0].queryString).get("client_team")).toBe("design");
await user.click(screen.getByRole("button", { name: "Select Maya" }));
await waitFor(() =>
expect(lastPanelProps()).toMatchObject({ teamId: "design", userId: "maya", scopeError: null }),
);
expect(screen.getByRole("region", { name: "Client usage panel" })).toBeInTheDocument();
});
it("should reject a member from another administered team in a saved link", async () => {
mockUseAuthorized.mockReturnValue(nonAdminSession);
const props = { ...defaultProps, teams: administeredTeams };
renderClientAt("?client=claude-desktop&client_team=engineering&client_user=maya", props);
expect(await screen.findByRole("region", { name: "Client usage panel" })).toBeInTheDocument();
const expectedScope = {
userId: "maya",
teamId: "engineering",
canChooseUser: false,
scopeError: "Choose a member of the selected team",
};
expect(lastPanelProps()).toMatchObject(expectedScope);
expect(mockClientUsagePanel.mock.calls.every(([props]) => Boolean(props.scopeError))).toBe(true);
});
it.each([
"?client=claude-desktop&client_user=outside-user",
"?client=claude-desktop&client_team=outside-team&client_user=outside-user",
])("should never broaden an ordinary user's saved client scope from %s", async (searchParams) => {
mockUseAuthorized.mockReturnValue(nonAdminSession);
const { onUrlUpdate } = renderClientAt(searchParams);
expect(await screen.findByRole("region", { name: "Client usage panel" })).toBeInTheDocument();
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
if (searchParams.includes("client_team")) {
await waitFor(() => expect(lastPanelProps().scopeError).toBe("Choose a team you administer"));
} else {
await waitFor(() => expect(lastPanelProps()).toMatchObject({ userId: "user-123", scopeError: null }));
}
expect(mockClientUsagePanel.mock.calls.length).toBeGreaterThan(0);
for (const [props] of mockClientUsagePanel.mock.calls) {
expect(props.canChooseUser).toBe(false);
const personalScope = props.userId === "user-123" && props.teamId === null;
expect(personalScope || Boolean(props.scopeError)).toBe(true);
}
expect(lastPanelProps().teams).toEqual([]);
});
it("should preserve an unauthorized main team selection when opening client usage instead of substituting an allowed team", async () => {
mockUseAuthorized.mockReturnValue(nonAdminSession);
const user = userEvent.setup();
const props = { ...defaultProps, teams: administeredTeams };
const { onUrlUpdate } = renderClientAt("?view=team&team=outside-team", props);
await chooseSelectOption(user, screen.getByRole("combobox", { name: "View client usage" }), "Claude Desktop");
expect(await screen.findByRole("region", { name: "Client usage panel" })).toBeInTheDocument();
expect(lastPanelProps()).toMatchObject({
teamId: "outside-team",
scopeError: "Choose a team you administer",
canChooseUser: false,
});
expect(new URLSearchParams(onUrlUpdate.mock.lastCall?.[0].queryString).get("client_team")).toBe("outside-team");
});
it("should keep an administrator's explicit My usage view personal despite client scope URL parameters", async () => {
renderClientAt("?view=my-usage&client=claude-desktop&client_team=outside-team&client_user=outside-user");
expect(await screen.findByRole("region", { name: "Client usage panel" })).toBeInTheDocument();
const expectedScope = {
userId: "user-123",
teamId: null,
canChooseUser: false,
teams: [],
scopeError: null,
};
expect(lastPanelProps()).toMatchObject(expectedScope);
});
it("should wait for team membership before mounting a linked client panel", async () => {
mockUseAuthorized.mockReturnValue(nonAdminSession);
const pendingProps = { ...defaultProps, teams: [], teamsLoading: true };
const { rerender } = renderClientAt(
"?client=claude-desktop&client_team=engineering&client_user=alice",
pendingProps,
);
expect(screen.queryByRole("region", { name: "Client usage panel" })).not.toBeInTheDocument();
expect(mockClientUsagePanel).not.toHaveBeenCalled();
rerender(<UsagePage {...defaultProps} teams={administeredTeams} teamsLoading={false} />);
expect(await screen.findByRole("region", { name: "Client usage panel" })).toBeInTheDocument();
expect(lastPanelProps()).toMatchObject({ userId: "alice", teamId: "engineering", scopeError: null });
});
});
describe("URL state", () => {
const renderAt = (searchParams: string) => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();

View file

@ -6,7 +6,7 @@
* Works at 1m+ spend logs, by querying an aggregate table instead.
*/
import { Download, Link2, Search, Sparkles, X } from "lucide-react";
import { Download, Link2, Search, X } from "lucide-react";
import { useQueryStates } from "nuqs";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -27,7 +27,7 @@ import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrgan
import { toast } from "@/lib/toast";
import { hasCapability } from "@/utils/capabilities";
import { copyToClipboard } from "@/utils/dataUtils";
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
import { hasProxyWideSpendView, internalUserRoles } from "@/utils/roles";
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
import UserDropdown from "@/components/common_components/UserDropdown";
@ -72,11 +72,15 @@ import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView";
import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations";
import UsageAIChatPanel from "./UsageAIChatPanel";
import { AriaUsageComposer, AriaUsageTrigger } from "@/components/liteadmin/LiteAdmin";
import { allowedUsageOptions, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
import UserRecordLink from "./UserRecordLink";
import {
CLIENT_USAGE_VIEWS,
cleanUsageUrl,
clientDateRangeFromParams,
clientDateRangePatch,
clientPanelPatch,
dateRangeFromParams,
dateRangePatch,
entitySelectionPatch,
@ -90,7 +94,11 @@ import {
} from "../usageUrlState";
import { overviewTotals, rollUpBreakdown } from "./overview/overviewData";
import TopAgents from "./overview/TopAgents";
import type { AgentRow } from "./overview/agentCatalog";
import { parseUsageClient, topAgents, type AgentRow } from "./overview/agentCatalog";
import ClientUsagePanel from "./ClientUsagePanel";
import { resolveClientUsageScope } from "../hooks/clientUsageScope";
import { useUsagePanelContext } from "../hooks/useUsagePanelContext";
import ClientUsageSelect from "./ClientUsageSelect";
import SpendByProvider from "./EntityUsage/SpendByProvider";
import { useTagSummary } from "@/app/(dashboard)/hooks/tags/useTagSummary";
import { Panel } from "./overview/Primitives";
@ -99,10 +107,11 @@ import UsageOverview from "./overview/UsageOverview";
interface UsagePageProps {
teams: Team[];
organizations: Organization[];
teamsLoading?: boolean;
}
const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoading = false }) => {
const { accessToken, userRole, userId: userID, premiumUser, isLoading: authorizationLoading } = useAuthorized();
const [gatewayActivityData, setGatewayActivityData] = useState<FetchedGatewayActivity | null>(null);
// Create initial dates outside of state to prevent recreation
@ -116,13 +125,23 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
[range, from, to, initialFromDate, initialToDate],
);
const { client_range, client_from, client_to } = urlParams;
const clientDateValue = useMemo<DateRangePickerValue>(
() => clientDateRangeFromParams({ client_range, client_from, client_to }) ?? dateValue,
[client_range, client_from, client_to, dateValue],
);
const clientStartTime = clientDateValue.from ?? null;
const clientEndTime = clientDateValue.to ?? null;
const clientInheritsRange =
clientStartTime?.getTime() === dateValue.from?.getTime() && clientEndTime?.getTime() === dateValue.to?.getTime();
const [fetchedTags, setFetchedTags] = useState<FetchedForRange<EntityList[]> | null>(null);
// No [] default: an unresolved query must stay undefined so the customer
// filter reads as loading rather than as a range with no customers.
const { data: customers } = useCustomers();
const { data: agentsResponse } = useAgents();
const { data: currentUser } = useCurrentUser();
const isAdmin = all_admin_roles.includes(userRole || "");
const isAdmin = hasProxyWideSpendView(userRole);
const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || "");
const isOrgAdmin = useIsOrgAdmin();
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
@ -135,26 +154,51 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const [modelQuery, setModelQuery] = useState("");
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
const selectedUsageView = usageViewFromParams(urlParams);
const selectedTab = usageTabFromParams(urlParams, isAdmin);
const usageView = allowedViews.includes(selectedUsageView) ? selectedUsageView : DEFAULT_USAGE_VIEW;
const selectedClient = CLIENT_USAGE_VIEWS.includes(usageView) ? urlParams.client : null;
const { pageRef, remember: rememberUsageContext } = useUsagePanelContext(selectedClient !== null);
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
// For non-admins or "my-usage" view, always pass their own user_id
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : urlParams.user;
const clientScopeInput = {
userId: userID,
userRole,
teams,
requestedTeamId: urlParams.client_team,
requestedUserId: urlParams.client_user ?? (urlParams.client_team ? null : effectiveUserId),
ownUsage: usageView === "my-usage",
};
const clientScope = resolveClientUsageScope(clientScopeInput);
const clientTeamId = usageView === "my-usage" ? null : urlParams.client_team || null;
const clientUserId = clientScope.scope?.userId ?? (clientTeamId ? urlParams.client_user || null : null);
const canViewTeamClients = clientScope.administeredTeams.length > 0;
const openClientPanel = (client: typeof selectedClient) => {
rememberUsageContext();
const initialTeam = usageView === "team" ? urlParams.team[0] ?? clientScope.administeredTeams[0]?.value : null;
void setUrlParams(
{ ...clientPanelPatch(client), client_team: client ? initialTeam ?? null : null },
{ scroll: false },
);
};
const { isLoading: organizationsLoading } = useOrganizations();
const accessSettled = userRole !== null && !organizationsLoading;
const authorizationSettled = !authorizationLoading && userRole !== null;
const accessSettled = authorizationSettled && !organizationsLoading && !teamsLoading;
useEffect(() => {
if (!accessSettled) return;
const cleanup = cleanUsageUrl(urlParams, { allowedViews, isAdmin });
const cleanup = cleanUsageUrl(urlParams, { allowedViews, isAdmin, canViewTeamClients });
if (!cleanup) return;
if (cleanup.deniedAccess)
toast.warning("You don't have access to that usage view", { id: "usage-url-access-denied" });
void setUrlParams(cleanup.patch);
}, [accessSettled, urlParams, allowedViews, isAdmin, setUrlParams]);
}, [accessSettled, urlParams, allowedViews, isAdmin, canViewTeamClients, setUrlParams]);
const entitySelection = (entityType: EntityType) => ({
selectedEntities: selectedEntitiesFromParams(urlParams, entityType),
@ -229,24 +273,33 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
// Tag data is deployment-wide with no per-user dimension, so Top agents only appears where the
// rest of the page is deployment-wide too: an admin's global view with no user selected.
const showTopAgents = isAdmin && usageView === "global" && effectiveUserId === null;
const globalClientScope = clientUserId === null && clientTeamId === null && !clientScope.error;
const globalClientSelected = isAdmin && selectedClient !== null && globalClientScope;
const needsAgentSummary = showTopAgents || globalClientSelected;
const tagDailyRequest = useMemo<DailyActivityRequest | null>(
() => (accessToken && startTime && endTime ? { accessToken, startTime, endTime, entityIds: null } : null),
[accessToken, startTime, endTime],
);
const { data: tagDailyRaw, loading: tagDailyLoading } = useAggregatedDailyActivity({
fetch: () => ENTITY_API.tag.aggregated(tagDailyRequest as DailyActivityRequest),
enabled: tagDailyRequest !== null && showTopAgents,
deps: [accessToken, startTime, endTime, showTopAgents],
enabled: tagDailyRequest !== null && needsAgentSummary,
deps: [accessToken, startTime, endTime, needsAgentSummary],
});
const tagDaily = useMemo(() => toDailyData(tagDailyRaw), [tagDailyRaw]);
const openAgentActivity = useCallback(
(agent: AgentRow) => {
const client = parseUsageClient(agent.id);
if (client) {
rememberUsageContext();
void setUrlParams(clientPanelPatch(client), { scroll: false });
return;
}
void setUrlParams({
...usageViewPatch("user-agent-activity"),
...entitySelectionPatch("tag", agent.tags),
});
},
[setUrlParams],
[setUrlParams, rememberUsageContext],
);
// Gateway request counts (SGR). Admin-only: the source table is
@ -270,7 +323,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
});
}, [isAdmin, gatewayRequest, currentGatewayRangeKey]);
const gatewayActivity = selectGatewayActivity(isAdmin, gatewayActivityData, currentGatewayRangeKey);
const gatewayActivity = selectGatewayActivity(
isAdmin && effectiveUserId === null,
gatewayActivityData,
currentGatewayRangeKey,
);
const [requestErrorData, setRequestErrorData] = useState<FetchedForRange<RequestErrorActivity | null> | null>(null);
const requestErrorFetchIdRef = useRef(0);
@ -323,7 +380,17 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
() => rollUpBreakdown(userSpendData.results, "providers").map(({ key, ...row }) => ({ provider: key, ...row })),
[userSpendData.results],
);
const { data: tagSummary, isLoading: tagSummaryLoading } = useTagSummary(startTime, endTime, showTopAgents);
const {
data: tagSummary,
isLoading: tagSummaryLoading,
isError: tagSummaryFailed,
refetch: reloadTagSummary,
} = useTagSummary(startTime, endTime, needsAgentSummary);
const clientAgent = useMemo(
() => topAgents(tagSummary ?? []).find((agent) => agent.id === selectedClient) ?? null,
[tagSummary, selectedClient],
);
// Calculate top API keys from the breakdown data
const topKeys = useMemo<TopKeyItem[]>(
@ -390,7 +457,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
);
return (
<div className="relative w-full px-4 pt-3 pb-8 sm:px-6">
<div ref={pageRef} className="relative w-full px-4 pt-3 pb-8 sm:px-6">
<header className="mb-3 flex w-full flex-wrap items-center justify-between gap-3 border-b pb-3">
<UsageViewSelect
value={usageView}
@ -408,6 +475,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
{urlParams.user && <UserRecordLink userId={urlParams.user} />}
</div>
)}
{CLIENT_USAGE_VIEWS.includes(usageView) && (
<div className="w-44">
<ClientUsageSelect value={selectedClient} onChange={openClientPanel} />
</div>
)}
<div className="flex items-center gap-2">
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} presetShortLabel={urlParams.range} />
<Button variant="outline" onClick={() => void copyToClipboard(window.location.href, "Link copied")}>
@ -417,6 +489,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</div>
</div>
</header>
<AriaUsageComposer />
{aggregatedFailed && (
<ErrorState title="Failed to load spend data" message={aggregatedError} onRetry={reloadAggregated} />
)}
@ -457,10 +530,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
)}
</TabsList>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsAiChatOpen(true)}>
<Sparkles />
Ask AI
</Button>
<AriaUsageTrigger />
<Button variant="outline" size="sm" onClick={() => setIsGlobalExportModalOpen(true)}>
<Download />
Export Data
@ -728,6 +798,34 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</div>
</div>
{selectedClient && accessSettled && (
<ClientUsagePanel
client={selectedClient}
userId={clientUserId}
canChooseUser={clientScope.canChooseGlobalUser && clientTeamId === null}
teamId={clientTeamId}
scopeError={clientScope.error}
teams={usageView === "my-usage" ? [] : clientScope.administeredTeams}
members={clientScope.members}
defaultScopeLabel={isAdmin ? "All users" : "My usage"}
onTeamChange={(teamId) => void setUrlParams({ client_team: teamId, client_user: "" }, { scroll: false })}
onUserChange={(userId) => void setUrlParams({ client_user: userId ?? "" }, { scroll: false })}
onClose={() => void setUrlParams(clientPanelPatch(null), { scroll: false })}
accessToken={accessToken}
startTime={clientStartTime}
endTime={clientEndTime}
dateValue={clientDateValue}
presetShortLabel={client_range ?? (clientInheritsRange ? urlParams.range : null)}
onDateChange={(value, preset) => void setUrlParams(clientDateRangePatch(value, preset), { scroll: false })}
agent={clientInheritsRange ? clientAgent : null}
daily={clientInheritsRange ? tagDaily : []}
colorSource={userSpendData.results}
loading={!accessSettled || tagSummaryLoading || tagDailyLoading}
summaryFailed={clientInheritsRange && tagSummaryFailed}
onRetry={() => void reloadTagSummary()}
/>
)}
{/* CloudZero Export Modal */}
<CloudZeroExportModal
isOpen={isCloudZeroModalOpen}
@ -749,9 +847,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
selectedFilters={[]}
customTitle="Export Usage Data"
/>
{/* AI Chat Panel */}
<UsageAIChatPanel open={isAiChatOpen} onClose={() => setIsAiChatOpen(false)} accessToken={accessToken} />
</div>
);
};

View file

@ -6,6 +6,7 @@ import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import type { DailyData } from "@/components/UsagePage/types";
import { cn } from "@/lib/cva.config";
import {
colorForModel,
formatMetricValue,
leaderboard,
OTHER_COLOR,
@ -36,8 +37,16 @@ export interface BreakdownState {
dimension: BreakdownDimension;
}
export const useBreakdown = (results: readonly DailyData[], { metric, dimension }: BreakdownState, top = 6) => {
const series = useMemo(() => seriesBy(results, dimension, metric, top), [results, dimension, metric, top]);
export const useBreakdown = (
results: readonly DailyData[],
{ metric, dimension }: BreakdownState,
top = 6,
colorByKey?: ReadonlyMap<string, string>,
) => {
const series = useMemo(
() => seriesBy(results, dimension, metric, { top, colorByKey }),
[results, dimension, metric, top, colorByKey],
);
const ranking = useMemo(() => leaderboard(results, dimension, metric), [results, dimension, metric]);
return { series, ranking };
};
@ -114,6 +123,7 @@ export function Leaderboard({
limit = 10,
columns = 1,
dense = false,
colorByKey,
}: {
ranking: readonly LeaderRow[];
series: Series;
@ -122,8 +132,10 @@ export function Leaderboard({
limit?: number;
columns?: 1 | 2;
dense?: boolean;
colorByKey?: ReadonlyMap<string, string>;
}) {
const colorOf = (name: string) => {
if (colorByKey) return colorForModel(name, colorByKey);
const index = series.labels.indexOf(name);
return index === -1 ? OTHER_COLOR : series.colors[index];
};

View file

@ -0,0 +1,57 @@
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import TopAgents from "./TopAgents";
import type { TagSummaryRow } from "./agentCatalog";
const desktopTag = "User-Agent: claude-cli/2.1.263 (external, claude-desktop-3p)";
const row = (tag: string, tokens: number): TagSummaryRow => ({
tag,
total_spend: tokens / 100,
total_tokens: tokens,
total_requests: 1,
successful_requests: 1,
failed_requests: 0,
unique_users: 1,
});
const rows = [row(desktopTag, 400), row("User-Agent: claude-code/2.1.263", 200)];
describe("TopAgents row navigation", () => {
it("should render recognizable client logos and open usage when the row totals are clicked", async () => {
const user = userEvent.setup();
const onOpenAgent = vi.fn();
render(<TopAgents rows={rows} daily={[]} loading={false} totalTokens={600} onOpenAgent={onOpenAgent} />);
expect(screen.getByRole("heading", { name: "Top agents" })).toBeInTheDocument();
const desktop = screen.getByRole("button", { name: "View Claude Desktop usage" });
expect(within(desktop).getByRole("img", { name: "Claude Desktop logo" })).toHaveAttribute(
"src",
expect.any(String),
);
expect(screen.getByRole("img", { name: "Claude Code logo" })).toBeInTheDocument();
await user.click(within(desktop).getByText("400 tokens"));
expect(onOpenAgent).toHaveBeenCalledOnce();
expect(onOpenAgent).toHaveBeenCalledWith(expect.objectContaining({ id: "claude-desktop", tags: [desktopTag] }));
});
it("should open a focused agent row with the keyboard", async () => {
const user = userEvent.setup();
const onOpenAgent = vi.fn();
render(<TopAgents rows={rows} daily={[]} loading={false} totalTokens={600} onOpenAgent={onOpenAgent} />);
act(() => screen.getByRole("button", { name: "View Claude Desktop usage" }).focus());
await user.keyboard("{Enter}");
expect(onOpenAgent).toHaveBeenCalledWith(expect.objectContaining({ id: "claude-desktop" }));
});
it("should leave an unfilterable residual descriptive instead of making it an inaccurate drill", () => {
const rollup = { ...row("User-Agent: claude-cli", 1000), total_requests: 2 };
render(<TopAgents rows={[rollup, rows[0]]} daily={[]} loading={false} totalTokens={1000} onOpenAgent={vi.fn()} />);
expect(screen.getByText("Claude (unspecified)")).toBeInTheDocument();
expect(screen.getByText("Client not identified")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "View Claude (unspecified) usage" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "View Claude Desktop usage" })).toBeInTheDocument();
});
});

View file

@ -1,7 +1,7 @@
"use client";
import React, { useMemo, useState } from "react";
import { ArrowRight, Bot } from "lucide-react";
import { ArrowRight, Bot, Monitor, Terminal } from "lucide-react";
import { StackedUsageChart } from "@/components/shared/charts";
import { Logo } from "@/components/molecules/logo/Logo";
import type { DailyData } from "@/components/UsagePage/types";
@ -34,33 +34,13 @@ const MONOGRAMS: Readonly<Record<string, { text: string; className: string }>> =
node: { text: "JS", className: "bg-[#3c873a] text-white" },
curl: { text: "cu", className: "bg-[#073551] text-white" },
};
const CLIENT_SURFACES: Readonly<Partial<Record<string, typeof Monitor>>> = {
"claude-desktop": Monitor,
"claude-code": Terminal,
};
/** Claude Code's mark: a white spark on Claude terracotta. Drawn here since no Claude asset ships in the repo. */
function ClaudeSpark() {
const rays = Array.from({ length: 12 }, (_, i) => i * 30);
return (
<span className="flex size-full items-center justify-center bg-[#d97757]">
<svg viewBox="0 0 24 24" className="size-5" aria-hidden="true">
{rays.map((angle) => (
<rect
key={angle}
x="11.1"
y="3"
width="1.8"
height="9"
rx="0.9"
fill="white"
transform={`rotate(${angle} 12 12)`}
/>
))}
</svg>
</span>
);
}
function AgentGlyph({ agent }: { agent: AgentRow }) {
if (agent.id === "claude-code") return <ClaudeSpark />;
if (agent.logo) return <Logo src={agent.logo} label={agent.label} className="size-5" />;
function AgentGlyph({ agent }: { agent: Pick<AgentRow, "id" | "label" | "logo"> }) {
if (agent.logo) return <Logo src={agent.logo} label={agent.label} className="size-6 object-contain" />;
const monogram = MONOGRAMS[agent.id];
if (monogram) {
return (
@ -77,10 +57,16 @@ function AgentGlyph({ agent }: { agent: AgentRow }) {
return <span className="text-sm font-semibold uppercase text-muted-foreground">{agent.label.charAt(0)}</span>;
}
function AgentMark({ agent }: { agent: AgentRow }) {
export function AgentMark({ agent }: { agent: Pick<AgentRow, "id" | "label" | "logo"> }) {
const Surface = CLIENT_SURFACES[agent.id];
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-background">
<span className="relative inline-flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-background">
<AgentGlyph agent={agent} />
{Surface && (
<span className="absolute top-0 right-0 rounded-bl border-b border-l bg-background p-0.5 text-foreground">
<Surface aria-hidden="true" className="size-2.5" />
</span>
)}
</span>
);
}
@ -98,8 +84,9 @@ function AgentItem({
color: string | undefined;
onOpen?: (agent: AgentRow) => void;
}) {
return (
<li className="group grid grid-cols-[1.5rem_auto_minmax(0,1fr)_auto] items-center gap-3 py-2.5">
const openRow = onOpen && agent.tags.length > 0 ? () => onOpen(agent) : undefined;
const content = (
<>
<span className="text-sm tabular-nums text-muted-foreground">{rank}.</span>
<span className="relative">
<AgentMark agent={agent} />
@ -113,15 +100,11 @@ function AgentItem({
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">{agent.label}</span>
{onOpen ? (
<button
type="button"
onClick={() => onOpen(agent)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{openRow ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
View activity
<ArrowRight aria-hidden="true" className="size-3 transition-transform group-hover:translate-x-0.5" />
</button>
</span>
) : (
<span className="block truncate text-xs text-muted-foreground">{agent.description}</span>
)}
@ -132,6 +115,27 @@ function AgentItem({
{formatCompactUsd(agent.spend)} · {share.toFixed(1)}%
</span>
</span>
</>
);
const rowClassName =
"group grid w-full grid-cols-[1.5rem_auto_minmax(0,1fr)_auto] items-center gap-3 py-2.5 text-left";
return (
<li>
{openRow ? (
<button
type="button"
aria-label={`View ${agent.label} usage`}
onClick={openRow}
className={cn(
rowClassName,
"cursor-pointer rounded-md transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary",
)}
>
{content}
</button>
) : (
<div className={rowClassName}>{content}</div>
)}
</li>
);
}

View file

@ -0,0 +1,80 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import UsageBreakdownChart from "./UsageBreakdownChart";
const metrics: SpendMetrics = {
spend: 9,
total_tokens: 800,
prompt_tokens: 600,
completion_tokens: 200,
api_requests: 3,
successful_requests: 3,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
const results: DailyData[] = [
{
date: "2026-10-01",
metrics,
breakdown: {
model_groups: { "claude-sonnet-4": { metrics, metadata: {}, api_key_breakdown: {} } },
models: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: {},
},
},
];
describe("UsageBreakdownChart", () => {
it("should render the current Usage chart controls and model leaderboard", () => {
render(<UsageBreakdownChart results={results} loading={false} />);
expect(screen.getByRole("heading", { name: "Daily usage" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Top models" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Spend" })).toBeChecked();
expect(screen.getByRole("radio", { name: "Daily" })).toBeChecked();
expect(screen.getByRole("radio", { name: "Linear" })).toBeChecked();
expect(screen.getByText("claude-sonnet-4")).toBeInTheDocument();
expect(within(screen.getByRole("listitem")).getByText("$9.00")).toBeInTheDocument();
});
it("should keep metric, time bucket and scale controls working in the compact panel", async () => {
const user = userEvent.setup();
const deploymentResults = results.map((day) => ({
...day,
breakdown: { ...day.breakdown, model_groups: {}, models: day.breakdown.model_groups },
}));
render(<UsageBreakdownChart results={deploymentResults} loading={false} compact dimension="models" />);
expect(screen.getByText("claude-sonnet-4")).toBeInTheDocument();
await user.click(screen.getByRole("radio", { name: "Tokens" }));
expect(screen.getByText("Daily tokens by model (top 8, rest grouped as Other)")).toBeInTheDocument();
expect(within(screen.getByRole("listitem")).getByText("800")).toBeInTheDocument();
expect(screen.queryByText("$9.00")).not.toBeInTheDocument();
await user.click(screen.getByRole("radio", { name: "Weekly" }));
expect(screen.getByRole("heading", { name: "Weekly usage" })).toBeInTheDocument();
expect(screen.getByText("Weekly tokens by model (top 8, rest grouped as Other)")).toBeInTheDocument();
await user.click(screen.getByRole("radio", { name: "Log" }));
expect(screen.getByRole("radio", { name: "Log" })).toBeChecked();
await user.click(screen.getByRole("radio", { name: "Requests" }));
expect(screen.getByText("Weekly requests by model (top 8, rest grouped as Other)")).toBeInTheDocument();
expect(within(screen.getByRole("listitem")).getByText("3")).toBeInTheDocument();
expect(screen.queryByText("800")).not.toBeInTheDocument();
});
it("should show loading rather than an empty leaderboard until the data has settled", () => {
render(<UsageBreakdownChart results={[]} loading compact />);
expect(screen.getByRole("status", { name: "Loading top models" })).toBeInTheDocument();
expect(screen.queryByText("No usage in this range")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,106 @@
"use client";
import { useMemo, useState } from "react";
import type { DailyData } from "@/components/UsagePage/types";
import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/charts";
import { cn } from "@/lib/cva.config";
import { BreakdownControls, Leaderboard, useBreakdown, type BreakdownState } from "./BreakdownChart";
import {
bucketSeries,
bucketTotals,
formatMetricValue,
labelForDate,
modelColorMap,
type BreakdownDimension,
type Granularity,
type UsageMetric,
} from "./overviewData";
import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented } from "./Primitives";
const METRIC_NOUN: Record<UsageMetric, string> = { spend: "spend", tokens: "tokens", requests: "requests" };
const GRANULARITY_OPTIONS = [
{ value: "day", label: "Daily" },
{ value: "week", label: "Weekly" },
] as const satisfies readonly { value: Granularity; label: string }[];
const SCALE_OPTIONS = [
{ value: "linear", label: "Linear" },
{ value: "log", label: "Log" },
] as const satisfies readonly { value: StackedUsageScale; label: string }[];
interface UsageBreakdownChartProps {
results: readonly DailyData[];
loading: boolean;
compact?: boolean;
colorByModel?: ReadonlyMap<string, string>;
dimension?: BreakdownDimension;
}
export default function UsageBreakdownChart({
results,
loading,
compact = false,
colorByModel,
dimension = "model_groups",
}: UsageBreakdownChartProps) {
const [metric, setMetric] = useState<UsageMetric>("spend");
const state: BreakdownState = { metric, dimension };
const [granularity, setGranularity] = useState<Granularity>("day");
const [scale, setScale] = useState<StackedUsageScale>("linear");
const modelColors = useMemo(() => colorByModel ?? modelColorMap(results), [colorByModel, results]);
const { series: dailySeries, ranking } = useBreakdown(results, state, 8, modelColors);
const series = useMemo(() => bucketSeries(dailySeries, granularity), [dailySeries, granularity]);
const totalsByBucket = useMemo(() => bucketTotals(series), [series]);
const format = (value: number) => formatMetricValue(value, state.metric);
return (
<Panel
title={`${granularity === "day" ? "Daily" : "Weekly"} usage`}
subtitle={`${granularity === "day" ? "Daily" : "Weekly"} ${METRIC_NOUN[state.metric]} by model (top 8, rest grouped as Other)`}
action={
<>
<BreakdownControls state={state} onChange={(next) => setMetric(next.metric)} showDimension={false} />
<Segmented label="Bucket size" value={granularity} options={GRANULARITY_OPTIONS} onChange={setGranularity} />
<Segmented label="Scale" value={scale} options={SCALE_OPTIONS} onChange={setScale} />
</>
}
bodyClassName="px-0 pt-4 pb-0"
>
<div className="px-2 pb-2">
{loading ? (
<ChartSkeleton className={cn("mx-3 w-auto", compact ? "h-56" : "h-[380px]")} />
) : (
<StackedUsageChart
data={series.data}
series={series.keys}
labels={series.labels}
colors={series.colors}
xKey="date"
xLabel={(date) => labelForDate(series, date)}
scale={scale}
format={format}
totalFor={(date) => totalsByBucket.get(date)}
className={compact ? "h-56" : undefined}
/>
)}
</div>
<div className={cn("border-t pt-3 pb-2", PANEL_INSET_X)}>
<h4 className="text-sm leading-5 font-medium text-foreground">Top models</h4>
{loading ? (
<div role="status" aria-label="Loading top models">
<ChartSkeleton className="my-2 h-20" />
</div>
) : (
<Leaderboard
ranking={ranking}
series={dailySeries}
metric={state.metric}
dimension={state.dimension}
colorByKey={modelColors}
columns={compact ? 1 : 2}
limit={10}
/>
)}
</div>
</Panel>
);
}

View file

@ -6,23 +6,13 @@
* matching colors.
*/
import { type ReactNode, useMemo, useState } from "react";
import { type ReactNode } from "react";
import type { DailyData } from "@/components/UsagePage/types";
import { KeyRound, Users } from "lucide-react";
import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/charts";
import {
bucketSeries,
bucketTotals,
labelForDate,
formatMetricValue,
type Granularity,
type OverviewTotals,
type UsageMetric,
} from "./overviewData";
import { BreakdownControls, Leaderboard, useBreakdown, type BreakdownState } from "./BreakdownChart";
import { cn } from "@/lib/cva.config";
import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented } from "./Primitives";
import type { OverviewTotals } from "./overviewData";
import { Panel } from "./Primitives";
import UsageStatStrip from "./UsageStatStrip";
import UsageBreakdownChart from "./UsageBreakdownChart";
interface UsageOverviewProps {
results: readonly DailyData[];
@ -37,16 +27,6 @@ interface UsageOverviewProps {
providerBreakdown: ReactNode;
}
const METRIC_NOUN: Record<UsageMetric, string> = { spend: "spend", tokens: "tokens", requests: "requests" };
const GRANULARITY_OPTIONS = [
{ value: "day", label: "Daily" },
{ value: "week", label: "Weekly" },
] as const satisfies readonly { value: Granularity; label: string }[];
const SCALE_OPTIONS = [
{ value: "linear", label: "Linear" },
{ value: "log", label: "Log" },
] as const satisfies readonly { value: StackedUsageScale; label: string }[];
export default function UsageOverview({
results,
totals,
@ -59,14 +39,6 @@ export default function UsageOverview({
providerBreakdown,
budget,
}: UsageOverviewProps) {
const [state, setState] = useState<BreakdownState>({ metric: "spend", dimension: "model_groups" });
const [granularity, setGranularity] = useState<Granularity>("day");
const [scale, setScale] = useState<StackedUsageScale>("linear");
const { series: dailySeries, ranking } = useBreakdown(results, state, 8);
const series = useMemo(() => bucketSeries(dailySeries, granularity), [dailySeries, granularity]);
const totalsByBucket = useMemo(() => bucketTotals(series), [series]);
const format = (value: number) => formatMetricValue(value, state.metric);
return (
<div className="grid gap-3">
<UsageStatStrip
@ -77,52 +49,7 @@ export default function UsageOverview({
budget={budget}
/>
<Panel
title={`${granularity === "day" ? "Daily" : "Weekly"} usage`}
subtitle={`${granularity === "day" ? "Daily" : "Weekly"} ${METRIC_NOUN[state.metric]} by model (top 8, rest grouped as Other)`}
action={
<>
<BreakdownControls state={state} onChange={setState} showDimension={false} />
<Segmented
label="Bucket size"
value={granularity}
options={GRANULARITY_OPTIONS}
onChange={setGranularity}
/>
<Segmented label="Scale" value={scale} options={SCALE_OPTIONS} onChange={setScale} />
</>
}
bodyClassName="px-0 pt-4 pb-0"
>
<div className="px-2 pb-2">
{loading ? (
<ChartSkeleton className="mx-3 h-[380px] w-auto" />
) : (
<StackedUsageChart
data={series.data}
series={series.keys}
labels={series.labels}
colors={series.colors}
xKey="date"
xLabel={(date) => labelForDate(series, date)}
scale={scale}
format={format}
totalFor={(date) => totalsByBucket.get(date)}
/>
)}
</div>
<div className={cn("border-t pt-3 pb-2", PANEL_INSET_X)}>
<h4 className="text-sm leading-5 font-medium text-foreground">Top models</h4>
<Leaderboard
ranking={ranking}
series={dailySeries}
metric={state.metric}
dimension={state.dimension}
columns={2}
limit={10}
/>
</div>
</Panel>
<UsageBreakdownChart results={results} loading={loading} />
{topAgents}
{providerBreakdown}

View file

@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { OverviewTotals } from "./overviewData";
import UsageStatStrip from "./UsageStatStrip";
const totals: OverviewTotals = {
spend: 42.5,
requests: 10,
successful: 8,
failed: 2,
tokens: 9123456,
inputTokens: 8123456,
outputTokens: 1000000,
cacheReadTokens: 5123456,
cacheWriteTokens: 2000000,
avgCostPerRequest: 4.25,
avgLatencyMs: 250,
successRate: 80,
};
const defaultProps = {
results: [],
totals,
loading: false,
requestCountsPending: false,
budget: null,
};
describe("UsageStatStrip token breakdown", () => {
it("should render the existing totals and open the full token counts", async () => {
const user = userEvent.setup();
render(<UsageStatStrip {...defaultProps} />);
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(screen.getByText("ok")).toBeInTheDocument();
expect(screen.getByText("failed")).toBeInTheDocument();
expect(screen.getByText("80.0% success rate")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View token breakdown" }));
const breakdown = await screen.findByRole("dialog", { name: "What counts toward total tokens?" });
expect(breakdown).toHaveTextContent(/Input tokens\s*8,123,456/);
expect(breakdown).toHaveTextContent(/Cache reads\s*5,123,456/);
expect(breakdown).toHaveTextContent(/Cache writes\s*2,000,000/);
expect(breakdown).toHaveTextContent(/Output tokens\s*1,000,000/);
expect(breakdown).toHaveTextContent(/Total tokens\s*9,123,456/);
expect(breakdown).toHaveTextContent("Input includes cache reads and writes.");
expect(breakdown).toHaveTextContent("Compare the same person, client and time range");
});
it("should hide the token breakdown and request counts while usage is loading", () => {
render(<UsageStatStrip {...defaultProps} loading requestCountsPending />);
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "View token breakdown" })).not.toBeInTheDocument();
expect(screen.queryByText("ok")).not.toBeInTheDocument();
expect(screen.queryByText("failed")).not.toBeInTheDocument();
});
});

View file

@ -3,6 +3,7 @@
import React, { useMemo } from "react";
import type { DailyData } from "@/components/UsagePage/types";
import { cn } from "@/lib/cva.config";
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { dailyTotals, formatCompact, formatLatency, formatUsd, type OverviewTotals } from "./overviewData";
import { PANEL_INSET_X, Sparkline, Stat } from "./Primitives";
@ -59,6 +60,46 @@ export default function UsageStatStrip({
pending={loading}
hint={loading ? undefined : `${formatCompact(totals.cacheReadTokens)} cache read`}
/>
{!loading && (
<Popover>
<PopoverTrigger className="mt-1 text-xs font-medium text-primary underline-offset-4 hover:underline">
View token breakdown
</PopoverTrigger>
<PopoverContent side="right" align="start" className="w-80 gap-3">
<PopoverTitle>What counts toward total tokens?</PopoverTitle>
<dl className="space-y-2 text-sm tabular-nums">
<div className="flex justify-between gap-4">
<dt>Input tokens</dt>
<dd>{totals.inputTokens.toLocaleString()}</dd>
</div>
<div className="flex justify-between gap-4 pl-3 text-xs text-muted-foreground">
<dt>Cache reads</dt>
<dd>{totals.cacheReadTokens.toLocaleString()}</dd>
</div>
<div className="flex justify-between gap-4 pl-3 text-xs text-muted-foreground">
<dt>Cache writes</dt>
<dd>{totals.cacheWriteTokens.toLocaleString()}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Output tokens</dt>
<dd>{totals.outputTokens.toLocaleString()}</dd>
</div>
<div className="flex justify-between gap-4 border-t pt-2 font-medium">
<dt>Total tokens</dt>
<dd>{totals.tokens.toLocaleString()}</dd>
</div>
</dl>
<p className="text-xs leading-5 text-muted-foreground">
Input includes cache reads and writes. Cached input has separate pricing, so total tokens alone cannot
explain the cost.
</p>
<p className="text-xs leading-5 text-muted-foreground">
A client may show a different subset of tokens. Compare the same person, client and time range before
comparing counts.
</p>
</PopoverContent>
</Popover>
)}
</StatCell>
<StatCell>
<div className="grid gap-3">

View file

@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { agentDailyData, isUserAgentTag, topAgents, userAgentProduct, type TagSummaryRow } from "./agentCatalog";
import {
agentDailyData,
isUserAgentTag,
parseUsageClient,
topAgents,
usageClientFromUserAgent,
userAgentProduct,
UsageClient,
type TagSummaryRow,
} from "./agentCatalog";
import type { DailyData } from "@/components/UsagePage/types";
const metric = (spend: number, tokens: number) => ({
@ -33,6 +42,26 @@ const row = (
unique_users: users,
});
describe("client identification", () => {
it("should accept only supported client IDs at a selection boundary", () => {
expect(parseUsageClient("claude-desktop")).toBe(UsageClient.ClaudeDesktop);
expect(parseUsageClient("claude-code")).toBe(UsageClient.ClaudeCode);
expect(parseUsageClient("claude-cli")).toBeNull();
expect(parseUsageClient(null)).toBeNull();
});
it.each<[string, UsageClient | null]>([
[" User Agent: claude-desktop/1.0 ", UsageClient.ClaudeDesktop],
["claude-cli/1.0 (external, cli)", UsageClient.ClaudeCode],
["claude-cli/1.0 (external, cli, claude-desktop-3p)", UsageClient.ClaudeDesktop],
["python-httpx/1.0 (claude-desktop-3p)", null],
["claude-cli/1.0 (integration=claude-desktop-3p)", null],
["claude-cli", null],
])("should classify the full user agent %s without matching incidental markers", (agent, expected) => {
expect(usageClientFromUserAgent(agent)).toBe(expected);
});
});
describe("userAgentProduct", () => {
it("drops the prefix, version and platform detail", () => {
expect(userAgentProduct("User-Agent: claude-cli/2.1.263 (external, cli)")).toBe("claude-cli");
@ -79,13 +108,14 @@ describe("topAgents", () => {
expect(agents[0].logo).toBeTruthy();
});
it("maps claude-cli to Claude Code and ranks by tokens", () => {
it("should keep a bare claude-cli rollup unspecified and rank by tokens", () => {
const agents = topAgents([
row("User-Agent: python", 600, 300),
row("User-Agent: claude-cli", 370, 343),
row("User-Agent: Cursor/1.0", 1, 7),
]);
expect(agents.map((agent) => agent.label)).toEqual(["Claude Code", "Python", "Cursor"]);
expect(agents.map((agent) => agent.label)).toEqual(["Claude (unspecified)", "Python", "Cursor"]);
expect(agents[0]).toMatchObject({ description: "Client not identified", tags: ["User-Agent: claude-cli"] });
expect(agents[0].kind).toBe("agent");
expect(agents[1].kind).toBe("sdk");
});
@ -138,7 +168,10 @@ describe("topAgents", () => {
row("User-Agent: claude-cli/2.1.263 (external, cli)", 10, 100),
row("User-Agent: claude-code", 2, 20),
]);
expect([...agent.tags].sort()).toEqual(["User-Agent: claude-cli", "User-Agent: claude-code"]);
expect([...agent.tags].sort()).toEqual([
"User-Agent: claude-cli/2.1.263 (external, cli)",
"User-Agent: claude-code",
]);
expect(agent).toMatchObject({ spend: 12, tokens: 120 });
});
@ -152,6 +185,100 @@ describe("topAgents", () => {
"User-Agent: opencode/1.18.30 ai-sdk",
]);
});
it("should split Desktop and Code from full user agents without counting their bare rollup again", () => {
const desktop = row("User-Agent: claude-cli/2.1.263 (external, claude-desktop-3p)", 4, 400, { requests: 2 });
const code = row("User-Agent: claude-cli/2.1.264 (external, cli)", 6, 600, { requests: 3 });
const agents = topAgents([row("User-Agent: claude-cli", 10, 1000, { requests: 5 }), desktop, code]);
const expectedDesktop = {
label: "Claude Desktop",
spend: 4,
tokens: 400,
requests: 2,
tags: [desktop.tag],
};
const expectedCode = {
label: "Claude Code",
spend: 6,
tokens: 600,
requests: 3,
tags: [code.tag],
};
expect(agents).toHaveLength(2);
expect(agents.find((agent) => agent.id === "claude-desktop")).toMatchObject(expectedDesktop);
expect(agents.find((agent) => agent.id === "claude-code")).toMatchObject(expectedCode);
});
it("should identify Desktop before cli markers and keep unmarked versions unspecified without a rollup", () => {
const desktopTag = "User-Agent: CLAUDE-CLI/2.1.263 (external, cli, claude-desktop-3p)";
const ambiguousTag = "User-Agent: claude-cli/2.1.264";
const agents = topAgents([row(desktopTag, 4, 400), row(ambiguousTag, 6, 600)]);
expect(agents).toHaveLength(2);
expect(agents.find((agent) => agent.id === "claude-desktop")).toMatchObject({
spend: 4,
tokens: 400,
tags: [desktopTag],
});
expect(agents.find((agent) => agent.id === "claude-unspecified")).toMatchObject({
spend: 6,
tokens: 600,
tags: [ambiguousTag],
});
});
it("should not treat an unknown cli-like marker as proof of Claude Code", () => {
const tag = "User-Agent: claude-cli/2.1.263 (external, custom-cli)";
const [agent] = topAgents([row(tag, 4, 400)]);
expect(agent).toMatchObject({ id: "claude-unspecified", tags: [tag] });
});
it("should retain missing version usage as unspecified without offering an inaccurate bare-tag filter", () => {
const agents = topAgents([
row("User-Agent: claude-cli", 10, 1000, { requests: 5, users: 4 }),
row("User-Agent: claude-cli/2.1.263 (external, claude-desktop-3p)", 4, 400, { requests: 2, users: 2 }),
row("User-Agent: claude-cli/2.1.264", 2, 200, { requests: 1, users: 1 }),
]);
const expectedUnspecified = {
spend: 6,
tokens: 600,
requests: 3,
users: 1,
tags: [],
};
expect(agents).toHaveLength(2);
expect(agents.find((agent) => agent.id === "claude-desktop")).toMatchObject({ spend: 4, tokens: 400, requests: 2 });
expect(agents.find((agent) => agent.id === "claude-unspecified")).toMatchObject(expectedUnspecified);
});
it.each([
{ spend: 11, tokens: 1000, requests: 5 },
{ spend: 10, tokens: 1001, requests: 5 },
{ spend: 10, tokens: 1000, requests: 6 },
])("should keep the rollup unspecified when version totals exceed it (%j)", ({ spend, tokens, requests }) => {
const rollup = row("User-Agent: claude-cli", 10, 1000, { requests: 5 });
const agents = topAgents([
rollup,
row("User-Agent: claude-cli/2.1.263 (external, claude-desktop-3p)", spend, tokens, { requests }),
]);
const expectedRollup = {
id: "claude-unspecified",
spend: 10,
tokens: 1000,
requests: 5,
tags: [rollup.tag],
};
expect(agents).toHaveLength(1);
expect(agents[0]).toMatchObject(expectedRollup);
});
it("should avoid a spurious unspecified client from floating-point cost sums", () => {
const agents = topAgents([
row("User-Agent: claude-cli", 0.3, 300, { requests: 2 }),
row("User-Agent: claude-cli/2.1.263 (external, claude-desktop-3p)", 0.1, 100),
row("User-Agent: claude-cli/2.1.264 (external, cli)", 0.2, 200),
]);
expect(agents.map((agent) => agent.id).sort()).toEqual(["claude-code", "claude-desktop"]);
});
});
describe("agentDailyData", () => {
@ -180,4 +307,35 @@ describe("agentDailyData", () => {
expect(result.breakdown.models["Claude Code"].metrics).toMatchObject({ spend: 10, total_tokens: 100 });
expect(result.breakdown.models["Codex"].metrics).toMatchObject({ spend: 4, total_tokens: 40 });
});
it("should preserve Desktop attribution and unspecified residual in the daily chart", () => {
const day = {
date: "2026-10-01",
metrics: metric(10, 1000).metrics,
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: {
"User-Agent: claude-cli": { metrics: { ...metric(10, 1000).metrics, api_requests: 2 } },
"User-Agent: claude-cli/2.1.263 (external, claude-desktop-3p)": metric(4, 400),
},
},
} as unknown as DailyData;
const [result] = agentDailyData([day]);
expect(result.metrics).toMatchObject({ spend: 10, total_tokens: 1000, api_requests: 2 });
expect(Object.keys(result.breakdown.models).sort()).toEqual(["Claude (unspecified)", "Claude Desktop"]);
expect(result.breakdown.models["Claude Desktop"].metrics).toMatchObject({
spend: 4,
total_tokens: 400,
api_requests: 1,
});
expect(result.breakdown.models["Claude (unspecified)"].metrics).toMatchObject({
spend: 6,
total_tokens: 600,
api_requests: 1,
});
});
});

View file

@ -1,4 +1,5 @@
import anthropicLogo from "../../../../../../../public/assets/logos/anthropic.svg";
import claudeLogo from "../../../../../../../public/assets/logos/claude.svg";
import cursorLogo from "../../../../../../../public/assets/logos/cursor.svg";
import copilotLogo from "../../../../../../../public/assets/logos/github_copilot.svg";
import githubLogo from "../../../../../../../public/assets/logos/github.svg";
@ -18,6 +19,17 @@ export type TagSummaryRow = components["schemas"]["TagSummaryMetrics"];
export type AgentKind = "agent" | "sdk";
export enum UsageClient {
ClaudeDesktop = "claude-desktop",
ClaudeCode = "claude-code",
}
export const parseUsageClient = (value: string | null): UsageClient | null => {
if (value === UsageClient.ClaudeDesktop) return UsageClient.ClaudeDesktop;
if (value === UsageClient.ClaudeCode) return UsageClient.ClaudeCode;
return null;
};
export interface AgentRow {
id: string;
label: string;
@ -45,17 +57,32 @@ interface AgentFamily {
/**
* Most specific first. Coding agents beat the SDK they are built on, so `codex_sdk_ts` lands on
* Codex and only a bare `OpenAI/...` client falls through to the OpenAI SDK. Codex wears OpenAI's
* mark as in the Lens trace view; Claude Code gets its own spark tile rather than Anthropic's wordmark.
* mark as in the Lens trace view; Claude clients use the bundled Claude brand mark.
*/
const FAMILIES: readonly AgentFamily[] = [
{
id: "claude-code",
id: UsageClient.ClaudeDesktop,
label: "Claude Desktop",
description: "Anthropic's desktop assistant",
kind: "agent",
logo: claudeLogo.src,
match: /^claude[-_ ]desktop\b|^claude-cli\/[^\s]+\s+\((?:[^)]*,\s*)?claude-desktop-3p\s*[,)]/i,
},
{
id: UsageClient.ClaudeCode,
label: "Claude Code",
description: "Anthropic's coding agent",
kind: "agent",
// No Claude asset ships in the repo, so TopAgents draws the spark tile itself.
logo: null,
match: /^(claude-cli|claude-code|claude_code|claude-user)\b/i,
logo: claudeLogo.src,
match: /^(claude-code|claude_code|claude-user)\b|^claude-cli\/[^\s]+\s+\((?:[^)]*,\s*)?cli\s*[,)]/i,
},
{
id: "claude-unspecified",
label: "Claude (unspecified)",
description: "Client not identified",
kind: "agent",
logo: claudeLogo.src,
match: /^claude-cli\b/i,
},
{
id: "codex",
@ -210,6 +237,9 @@ export const userAgentProduct = (tag: string): string => {
const familyFor = (product: string): AgentFamily | null =>
FAMILIES.find((family) => family.match.test(product)) ?? null;
export const usageClientFromUserAgent = (userAgent: string): UsageClient | null =>
parseUsageClient(familyFor(userAgent.trim().replace(USER_AGENT_PREFIX, ""))?.id ?? null);
const isBareTag = (row: TagSummaryRow, product: string) =>
row.tag.trim().replace(USER_AGENT_PREFIX, "").trim() === product;
@ -246,9 +276,44 @@ const mergeAgents = (a: AgentRow, b: AgentRow): AgentRow => ({
tokens: a.tokens + b.tokens,
requests: a.requests + b.requests,
users: Math.max(a.users, b.users),
tags: [...a.tags, ...b.tags],
tags: a.tags.length && b.tags.length ? [...a.tags, ...b.tags] : [],
});
const agentsForProduct = (product: string, rows: readonly TagSummaryRow[]): AgentRow[] => {
if (product.toLowerCase() !== "claude-cli") return [agentFor(product, contributingRows(product, rows))];
const bare = rows.find((row) => isBareTag(row, product));
const versions = rows.filter((row) => !isBareTag(row, product));
const versionAgents = versions.map((row) => agentFor(row.tag.trim().replace(USER_AGENT_PREFIX, ""), [row]));
if (!bare) return versionAgents;
const rollup = agentFor(product, [bare]);
if (!versions.length) return [rollup];
const emptyTotals = { spend: 0, tokens: 0, requests: 0, users: 0 };
const totals = versionAgents.reduce(
(sum, agent) => ({
spend: sum.spend + agent.spend,
tokens: sum.tokens + agent.tokens,
requests: sum.requests + agent.requests,
users: sum.users + agent.users,
}),
emptyTotals,
);
if (totals.spend > rollup.spend + 1e-9 || totals.tokens > rollup.tokens || totals.requests > rollup.requests) {
return [rollup];
}
const residual = {
...rollup,
spend: Math.max(0, rollup.spend - totals.spend),
tokens: rollup.tokens - totals.tokens,
requests: rollup.requests - totals.requests,
users: Math.max(0, rollup.users - totals.users),
// A bare tag also selects the identified clients, so it cannot filter the residual alone.
tags: [],
};
return residual.spend > 1e-9 || residual.tokens > 0 || residual.requests > 0
? [...versionAgents, residual]
: versionAgents;
};
/**
* One row per agent from `/tag/summary`. Products are keyed case-sensitively: prod reports `python`
* and `Python` as distinct products, and folding them let one rollup overwrite the other.
@ -256,13 +321,10 @@ const mergeAgents = (a: AgentRow, b: AgentRow): AgentRow => ({
export const topAgents = (rows: readonly TagSummaryRow[]): AgentRow[] => {
const userAgentRows = rows.filter((row) => isUserAgentTag(row.tag) && userAgentProduct(row.tag));
const products = [...new Set(userAgentRows.map((row) => userAgentProduct(row.tag)))];
const perProduct = products.map((product) =>
agentFor(
const perProduct = products.flatMap((product) =>
agentsForProduct(
product,
contributingRows(
product,
userAgentRows.filter((row) => userAgentProduct(row.tag) === product),
),
userAgentRows.filter((row) => userAgentProduct(row.tag) === product),
),
);
const byAgent = perProduct.reduce<ReadonlyMap<string, AgentRow>>((acc, agent) => {

View file

@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import type { AgentRow } from "./agentCatalog";
import { clientAgentDetail } from "./clientAgentDetail";
const desktopTag = "User-Agent: claude-cli/2.1.1 (external, claude-desktop-3p)";
const codeTag = "User-Agent: claude-cli/2.1.1 (external, cli)";
const agent: AgentRow = {
id: "claude-desktop",
label: "Claude Desktop",
description: "Desktop assistant",
kind: "agent",
logo: null,
spend: 3,
tokens: 300,
requests: 3,
users: 2,
tags: [desktopTag],
};
const oneRequestAgent = { ...agent, spend: 1, tokens: 100, requests: 1 };
const unknownTokenBreakdown = { inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null };
const metrics = (overrides: Partial<SpendMetrics> = {}): SpendMetrics => ({
spend: 1,
prompt_tokens: 80,
completion_tokens: 20,
total_tokens: 100,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 30,
cache_creation_input_tokens: 10,
...overrides,
});
const day = (date: string, entities: Record<string, SpendMetrics>): DailyData => ({
date,
metrics: metrics({ spend: 999, total_tokens: 999_999, api_requests: 999 }),
breakdown: {
models: { unrelated: { metrics: metrics({ spend: 999 }), metadata: {}, api_key_breakdown: {} } },
model_groups: {},
providers: {},
api_keys: {},
mcp_servers: {},
entities: Object.fromEntries(
Object.entries(entities).map(([tag, value]) => [tag, { metrics: value, metadata: {}, api_key_breakdown: {} }]),
),
},
});
describe("clientAgentDetail", () => {
it("should preserve the clicked summary before daily data arrives", () => {
const expected = {
summary: { spend: 3, tokens: 300, requests: 3 },
dailyMatchesSummary: false,
tokenBreakdown: unknownTokenBreakdown,
};
expect(clientAgentDetail(agent, [])).toEqual(expected);
});
it("should reconcile exact contributing tags once without global traffic", () => {
const secondTag = "User-Agent: claude-desktop/2.0";
const result = clientAgentDetail({ ...agent, tags: [desktopTag, secondTag, desktopTag] }, [
day("2026-10-22", { [desktopTag]: metrics(), [secondTag]: metrics(), [codeTag]: metrics({ spend: 50 }) }),
day("2026-10-20", { [desktopTag]: metrics(), "User-Agent: claude-cli": metrics({ spend: 500 }) }),
day("2026-10-21", { [codeTag]: metrics() }),
]);
expect(result.dailyMatchesSummary).toBe(true);
const expectedTokens = {
inputTokens: 240,
outputTokens: 60,
cacheReadTokens: 90,
cacheWriteTokens: 30,
};
expect(result.tokenBreakdown).toEqual(expectedTokens);
});
it("should keep missing cache counts unknown without hiding recorded token counts", () => {
const result = clientAgentDetail(oneRequestAgent, [
day("2026-10-22", { [desktopTag]: metrics({ cache_read_input_tokens: undefined }) }),
]);
const expectedTokens = {
inputTokens: 80,
outputTokens: 20,
cacheReadTokens: null,
cacheWriteTokens: null,
};
expect(result.tokenBreakdown).toEqual(expectedTokens);
});
it("should distinguish recorded zero caching from missing counts", () => {
const result = clientAgentDetail(oneRequestAgent, [
day("2026-10-22", {
[desktopTag]: metrics({ cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }),
}),
]);
expect(result.tokenBreakdown.cacheReadTokens).toBe(0);
expect(result.tokenBreakdown.cacheWriteTokens).toBe(0);
});
it("should preserve summary totals but withhold token breakdown when daily snapshots disagree", () => {
const result = clientAgentDetail(agent, [day("2026-10-22", { [desktopTag]: metrics() })]);
expect(result.summary).toEqual({ spend: 3, tokens: 300, requests: 3 });
expect(result.dailyMatchesSummary).toBe(false);
expect(result.tokenBreakdown).toEqual(unknownTokenBreakdown);
});
it("should not turn unrelated tags or ambiguous residuals into zero client activity", () => {
const unrelated = clientAgentDetail(agent, [day("2026-10-22", { [codeTag]: metrics() })]);
const residual = clientAgentDetail({ ...agent, tags: [] }, [day("2026-10-22", { [desktopTag]: metrics() })]);
expect(unrelated.tokenBreakdown).toEqual(unknownTokenBreakdown);
expect(residual.tokenBreakdown).toEqual(unknownTokenBreakdown);
expect(residual.summary.spend).toBe(agent.spend);
});
it("should allow floating-point rounding without dropping known breakdowns", () => {
const decimalAgent = { ...agent, spend: 0.3, tokens: 200, requests: 2 };
const result = clientAgentDetail(decimalAgent, [
day("2026-10-21", { [desktopTag]: metrics({ spend: 0.1 }) }),
day("2026-10-22", { [desktopTag]: metrics({ spend: 0.2 }) }),
]);
expect(result.dailyMatchesSummary).toBe(true);
expect(result.tokenBreakdown.inputTokens).toBe(160);
});
it.each([
{ prompt_tokens: 100 },
{ cache_read_input_tokens: 80, cache_creation_input_tokens: 10 },
{ cache_read_input_tokens: Number.NaN },
{ cache_read_input_tokens: Number.POSITIVE_INFINITY },
{ cache_read_input_tokens: -1 },
])("should suppress cache counts that do not reconcile (%o)", (invalidCounts) => {
const result = clientAgentDetail(oneRequestAgent, [day("2026-10-22", { [desktopTag]: metrics(invalidCounts) })]);
expect(result.tokenBreakdown.cacheReadTokens).toBeNull();
expect(result.tokenBreakdown.cacheWriteTokens).toBeNull();
});
it("should withhold non-finite daily counts while preserving the known summary", () => {
const result = clientAgentDetail(agent, [
day("2026-10-22", { [desktopTag]: metrics({ total_tokens: Number.NaN }) }),
]);
expect(result.dailyMatchesSummary).toBe(false);
expect(result.summary.tokens).toBe(300);
expect(result.tokenBreakdown.inputTokens).toBeNull();
});
});

View file

@ -0,0 +1,46 @@
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import type { AgentRow } from "./agentCatalog";
import { reconciledTokenBreakdown, type TokenBreakdown } from "./tokenBreakdown";
export interface ClientAgentDetail {
summary: Pick<AgentRow, "spend" | "tokens" | "requests">;
dailyMatchesSummary: boolean;
tokenBreakdown: TokenBreakdown;
}
const knownTotal = (metrics: readonly SpendMetrics[], key: keyof SpendMetrics): number | null => {
const values = metrics.map((entry) => entry[key]);
return values.length > 0 && values.every((value) => typeof value === "number" && Number.isFinite(value))
? values.reduce<number>((sum, value) => sum + (value ?? 0), 0)
: null;
};
export const clientAgentDetail = (agent: AgentRow, days: readonly DailyData[]): ClientAgentDetail => {
const tags = [...new Set(agent.tags)];
const metrics = days.flatMap((day) =>
tags.flatMap((tag) => {
const entry = day.breakdown.entities[tag];
return entry ? [entry.metrics] : [];
}),
);
const spend = knownTotal(metrics, "spend");
const spendMatches = spend !== null && Math.abs(spend - agent.spend) <= Math.max(1e-8, Math.abs(agent.spend) * 1e-9);
const countsMatch =
knownTotal(metrics, "total_tokens") === agent.tokens && knownTotal(metrics, "api_requests") === agent.requests;
const dailyMatchesSummary = spendMatches && countsMatch;
const tokenTotal = (key: keyof SpendMetrics) => {
const total = dailyMatchesSummary ? knownTotal(metrics, key) : null;
return total !== null && total >= 0 ? total : null;
};
const inputTokens = tokenTotal("prompt_tokens");
const outputTokens = tokenTotal("completion_tokens");
const cacheReadTokens = tokenTotal("cache_read_input_tokens");
const cacheWriteTokens = tokenTotal("cache_creation_input_tokens");
const tokenBreakdown = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
return {
summary: { spend: agent.spend, tokens: agent.tokens, requests: agent.requests },
dailyMatchesSummary,
tokenBreakdown: reconciledTokenBreakdown(agent.tokens, tokenBreakdown),
};
};

View file

@ -1,6 +1,15 @@
import { describe, expect, it } from "vitest";
import type { DailyData } from "@/components/UsagePage/types";
import { bucketSeries, bucketTotals, labelForDate, rollUpBreakdown, seriesBy } from "./overviewData";
import {
bucketSeries,
bucketTotals,
colorForModel,
labelForDate,
modelColorMap,
OTHER_COLOR,
rollUpBreakdown,
seriesBy,
} from "./overviewData";
const metrics = (spend: number) => ({
spend,
@ -89,3 +98,86 @@ describe("rollUpBreakdown", () => {
expect(JSON.stringify(days)).toBe(before);
});
});
describe("model colors", () => {
it("should include deployment IDs without replacing the main chart's group colors or guessing aliases", () => {
const result = day("2026-10-01", 12, { "claude-sonnet": 10, "gpt-5": 2 });
const original = modelColorMap([result]);
result.breakdown.models = {
"claude-sonnet": result.breakdown.model_groups["claude-sonnet"],
"provider-deployment-123": result.breakdown.model_groups["gpt-5"],
};
const colors = modelColorMap([result]);
const deployments = seriesBy([result], "models", "spend", { top: 8, colorByKey: colors });
expect(colors.get("claude-sonnet")).toBe(original.get("claude-sonnet"));
expect(colors.get("gpt-5")).toBe(original.get("gpt-5"));
expect(colors.size).toBe(3);
expect(colors.get("provider-deployment-123")).not.toBe(colors.get("gpt-5"));
expect(deployments.colors).toEqual([colors.get("claude-sonnet"), colors.get("provider-deployment-123")]);
});
it("should preserve the overview's existing spend palette and reuse it when a client ranks models differently", () => {
const overview = [day("2026-10-01", 15, { "claude-sonnet": 10, "gpt-5": 4, "gemini-pro": 1 })];
const client = [day("2026-10-01", 5, { "gpt-5": 4, "claude-sonnet": 1 })];
const colors = modelColorMap(overview);
const originalSeries = seriesBy(overview, "model_groups", "spend");
const clientSeries = seriesBy(client, "model_groups", "spend", { top: 8, colorByKey: colors });
expect(originalSeries.colors).toEqual(originalSeries.labels.map((model) => colors.get(model)));
expect(clientSeries.labels).toEqual(["gpt-5", "claude-sonnet"]);
expect(clientSeries.colors).toEqual([colors.get("gpt-5"), colors.get("claude-sonnet")]);
expect(clientSeries.colors[0]).not.toBe(originalSeries.colors[0]);
expect(bucketTotals(clientSeries).get("2026-10-01")).toBe(5);
});
it("should retain model colors when token ranking and weekly grouping change the chart", () => {
const result = day("2026-10-01", 12, { "claude-sonnet": 10, "gpt-5": 2 });
result.breakdown.model_groups["claude-sonnet"].metrics.total_tokens = 20;
result.breakdown.model_groups["gpt-5"].metrics.total_tokens = 100;
const colors = modelColorMap([result]);
const tokens = seriesBy([result], "model_groups", "tokens", { top: 8, colorByKey: colors });
const weekly = bucketSeries(tokens, "week");
expect(tokens.labels).toEqual(["gpt-5", "claude-sonnet"]);
expect(tokens.colors).toEqual([colors.get("gpt-5"), colors.get("claude-sonnet")]);
expect(weekly.colors).toEqual(tokens.colors);
expect(bucketTotals(weekly).get("2026-10-01")).toBe(120);
});
it("should give a client-only model a deterministic color when it changes rank", () => {
const colors = modelColorMap([day("2026-10-01", 10, { "claude-sonnet": 10 })]);
const first = seriesBy([day("2026-10-01", 10, { "claude-sonnet": 8, "new-model": 2 })], "model_groups", "spend", {
top: 8,
colorByKey: colors,
});
const second = seriesBy([day("2026-10-01", 10, { "new-model": 8, "claude-sonnet": 2 })], "model_groups", "spend", {
top: 8,
colorByKey: colors,
});
expect(first.colors[1]).toBe(second.colors[0]);
expect(first.colors[1]).toBe(colorForModel("new-model", colors));
expect(colors.has("new-model")).toBe(false);
});
it("should keep a model named Other distinct from the grey remainder and retain colors outside the top eight", () => {
const models = { Other: 10, a: 9, b: 8, c: 7, d: 6, e: 5, f: 4, g: 3, h: 2 };
const results = [day("2026-10-01", 55, models)];
const colors = modelColorMap(results);
const series = seriesBy(results, "model_groups", "spend", { top: 8, colorByKey: colors });
const client = seriesBy([day("2026-10-01", 2, { h: 2 })], "model_groups", "spend", {
top: 8,
colorByKey: colors,
});
expect(series.labels[0]).toBe("Other");
expect(series.colors[0]).toBe(colors.get("Other"));
expect(series.colors[0]).not.toBe(OTHER_COLOR);
expect(series.labels.at(-1)).toBe("Other");
expect(series.colors.at(-1)).toBe(OTHER_COLOR);
expect(series.labels).not.toContain("h");
expect(colorForModel("h", colors)).toBe(client.colors[0]);
expect(bucketTotals(series).get("2026-10-01")).toBe(55);
});
});

View file

@ -40,6 +40,20 @@ export const rollUpBreakdown = (results: readonly DailyData[], dimension: Breakd
return [...totals.values()].sort((a, b) => b.spend - a.spend);
};
export const modelColorMap = (results: readonly DailyData[]): ReadonlyMap<string, string> => {
const groups = rollUpBreakdown(results, "model_groups");
const models = rollUpBreakdown(results, "models");
const names = [...new Set([...groups, ...models].map((row) => row.key))];
return new Map(names.map((name, index) => [name, stackedUsageColor(index)]));
};
export const colorForModel = (model: string, colorByModel: ReadonlyMap<string, string>): string => {
const assignedColor = colorByModel.get(model);
if (assignedColor) return assignedColor;
const hash = Array.from(model).reduce((value, character) => (value * 31 + character.charCodeAt(0)) >>> 0, 0);
return stackedUsageColor(hash);
};
export const OTHER_COLOR = "#94a3b8";
const OTHER_SERIES = "Other";
@ -93,8 +107,9 @@ export const seriesBy = (
results: readonly DailyData[],
dimension: BreakdownDimension,
metric: UsageMetric,
top = 6,
options: number | { top?: number; colorByKey?: ReadonlyMap<string, string> } = 6,
): Series => {
const { top = 6, colorByKey } = typeof options === "number" ? { top: options } : options;
const leaders = rollUpBreakdown(results, dimension)
.filter((row) => rankValue(row, metric) > 0)
.sort((a, b) => rankValue(b, metric) - rankValue(a, metric))
@ -118,9 +133,10 @@ export const seriesBy = (
label: formatDayLabel(day.date),
...Object.fromEntries((hasOther ? [...values, other] : values).map((value, index) => [keys[index], value])),
}));
const colors = labels.map((label, index) =>
hasOther && index === labels.length - 1 ? OTHER_COLOR : stackedUsageColor(index),
);
const colors = labels.map((label, index) => {
if (hasOther && index === labels.length - 1) return OTHER_COLOR;
return colorByKey ? colorForModel(label, colorByKey) : stackedUsageColor(index);
});
return { data, keys, labels, colors };
};

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { reconciledTokenBreakdown, type TokenBreakdown } from "./tokenBreakdown";
const recorded: TokenBreakdown = {
inputTokens: 80,
outputTokens: 20,
cacheReadTokens: 30,
cacheWriteTokens: 10,
};
const unavailableCache = { ...recorded, cacheReadTokens: null, cacheWriteTokens: null };
describe("reconciledTokenBreakdown", () => {
it("should preserve recorded counts when input, output and cache reconcile", () => {
expect(reconciledTokenBreakdown(100, recorded)).toEqual(recorded);
});
it("should preserve known input and output but suppress cache when totals disagree", () => {
expect(reconciledTokenBreakdown(150, recorded)).toEqual(unavailableCache);
});
it("should suppress overlapping cache counts that exceed input tokens", () => {
const overlapping = { ...recorded, cacheReadTokens: 75 };
expect(reconciledTokenBreakdown(100, overlapping)).toEqual(unavailableCache);
});
it("should keep recorded zero counts when the total is also zero", () => {
const zero = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
expect(reconciledTokenBreakdown(0, zero)).toEqual(zero);
});
it("should not fill missing cache counts with zero", () => {
const missingCache = { ...recorded, cacheReadTokens: null };
expect(reconciledTokenBreakdown(100, missingCache)).toEqual(unavailableCache);
});
it.each([null, Number.NaN, Number.POSITIVE_INFINITY, -1])(
"should preserve known output but suppress cache when input is invalid (%s)",
(inputTokens) => {
const invalidInput = { ...recorded, inputTokens };
const expected = { ...unavailableCache, inputTokens: null };
expect(reconciledTokenBreakdown(100, invalidInput)).toEqual(expected);
},
);
it.each([null, Number.NaN, Number.POSITIVE_INFINITY, -1])(
"should preserve known input but suppress cache when output is invalid (%s)",
(outputTokens) => {
const invalidOutput = { ...recorded, outputTokens };
const expected = { ...unavailableCache, outputTokens: null };
expect(reconciledTokenBreakdown(100, invalidOutput)).toEqual(expected);
},
);
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1])(
"should suppress invalid cache counts (%s)",
(cacheWriteTokens) => {
const invalidCache = { ...recorded, cacheWriteTokens };
expect(reconciledTokenBreakdown(100, invalidCache)).toEqual(unavailableCache);
},
);
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1])(
"should suppress cache when the total is invalid (%s)",
(total) => {
expect(reconciledTokenBreakdown(total, recorded)).toEqual(unavailableCache);
},
);
});

View file

@ -0,0 +1,27 @@
export interface TokenBreakdown {
inputTokens: number | null;
outputTokens: number | null;
cacheReadTokens: number | null;
cacheWriteTokens: number | null;
}
const knownCount = (value: number | null): number | null =>
value !== null && Number.isFinite(value) && value >= 0 ? value : null;
export const reconciledTokenBreakdown = (total: number, breakdown: TokenBreakdown): TokenBreakdown => {
const inputTokens = knownCount(breakdown.inputTokens);
const outputTokens = knownCount(breakdown.outputTokens);
const cacheReadTokens = knownCount(breakdown.cacheReadTokens);
const cacheWriteTokens = knownCount(breakdown.cacheWriteTokens);
const inputReconciles =
inputTokens !== null && outputTokens !== null && inputTokens + outputTokens === knownCount(total);
const cacheCountsKnown = cacheReadTokens !== null && cacheWriteTokens !== null;
const cacheReconciles = inputReconciles && cacheCountsKnown && cacheReadTokens + cacheWriteTokens <= inputTokens;
return {
inputTokens,
outputTokens,
cacheReadTokens: cacheReconciles ? cacheReadTokens : null,
cacheWriteTokens: cacheReconciles ? cacheWriteTokens : null,
};
};

View file

@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import type { AgentRow } from "../components/overview/agentCatalog";
import { clientAgentDetail } from "../components/overview/clientAgentDetail";
import { clientAgentModelsFromExport } from "./clientAgentModelsData";
const desktop = "User-Agent: claude-cli/2.1.1 (external, claude-desktop-3p)";
const code = "User-Agent: claude-cli/2.1.1 (external, cli)";
const record = {
date: "2026-10-22",
entity_id: desktop,
model: "model-a",
spend: 1,
flat_cost: 0,
prompt_tokens: 80,
completion_tokens: 20,
api_requests: 2,
successful_requests: 2,
failed_requests: 0,
cache_read_input_tokens: 30,
cache_creation_input_tokens: 10,
};
const agent: AgentRow = {
id: "claude-desktop",
label: "Claude Desktop",
description: "Desktop client",
kind: "agent",
logo: null,
spend: 3,
tokens: 300,
requests: 6,
users: 1,
tags: [desktop, desktop],
};
const records = [
{ ...record, model: "model-b", spend: 2, prompt_tokens: 160, completion_tokens: 40, api_requests: 4 },
record,
{ ...record, entity_id: code, spend: 400, model: "code-only" },
{ ...record, entity_id: "User-Agent: claude-cli", spend: 500, model: "bare-rollup" },
];
describe("clientAgentModelsFromExport", () => {
it("should return exact Desktop models without Code or bare-tag totals", () => {
const result = clientAgentModelsFromExport(records, agent);
expect(result.error).toBeNull();
expect(result.results).toHaveLength(1);
expect(Object.keys(result.results[0].breakdown.models)).toEqual(["model-a", "model-b"]);
expect(result.results[0].metrics).toMatchObject({ spend: 3, total_tokens: 300, api_requests: 6 });
expect(result.results[0].breakdown.models["model-b"].metrics.spend).toBe(2);
expect(Object.keys(result.results[0].breakdown.entities)).toEqual([desktop]);
expect(clientAgentDetail(agent, result.results).dailyMatchesSummary).toBe(true);
});
it("should preserve recorded input and cache counts for the panel explanation", () => {
const { results } = clientAgentModelsFromExport(records, agent);
const expected = { inputTokens: 240, outputTokens: 60, cacheReadTokens: 60, cacheWriteTokens: 20 };
expect(clientAgentDetail(agent, results).tokenBreakdown).toEqual(expected);
});
it("should sum the same model across canonical tags and sort days", () => {
const secondTag = "User-Agent: claude-desktop/3.0";
const multiTagAgent = { ...agent, tags: [desktop, secondTag] };
const rows = [record, { ...record, entity_id: secondTag }, { ...record, date: "2026-10-21" }];
const { results } = clientAgentModelsFromExport(rows, multiTagAgent);
expect(results.map((day) => day.date)).toEqual(["2026-10-21", "2026-10-22"]);
expect(results[1].breakdown.models["model-a"].metrics.total_tokens).toBe(200);
expect(results[1].metrics.spend).toBe(2);
});
it("should label missing model names without losing their recorded usage", () => {
const oneRow = { ...record, model: null };
const singleAgent = { ...agent, spend: 1, tokens: 100, requests: 2 };
const result = clientAgentModelsFromExport([oneRow], singleAgent);
expect(result.error).toBeNull();
expect(result.results[0].breakdown.models["Unknown model"].metrics.spend).toBe(1);
});
it.each(["cache_read_input_tokens", "prompt_tokens", "api_requests"])(
"should reject missing %s without inventing zero telemetry",
(key) => {
const incomplete = { ...record, [key]: undefined };
const result = clientAgentModelsFromExport([incomplete], agent);
expect(result.error).toContain("incomplete");
expect(result.results).toEqual([]);
},
);
it("should reject duplicate tag, model and date rows rather than double-count them", () => {
const result = clientAgentModelsFromExport([...records, record], agent);
expect(result.error).toContain("duplicate");
expect(result.results).toEqual([]);
});
it.each([{ spend: 4 }, { tokens: 400 }, { requests: 8 }])(
"should withhold models when the client summary changed (%o)",
(changed) => {
const result = clientAgentModelsFromExport(records, { ...agent, ...changed });
expect(result.error).toContain("does not match");
expect(result.results).toEqual([]);
},
);
it("should never fall back to unrelated models when no exact tags match", () => {
const otherAgent = { ...agent, tags: ["User-Agent: absent"] };
const result = clientAgentModelsFromExport(records, otherAgent);
expect(result.error).not.toBeNull();
expect(result.results).toEqual([]);
});
});

View file

@ -0,0 +1,90 @@
import { z } from "zod";
import type { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
import type { AgentRow } from "../components/overview/agentCatalog";
import { clientAgentDetail } from "../components/overview/clientAgentDetail";
const count = z.number().finite().nonnegative();
const exportRowFields = {
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
entity_id: z.string(),
model: z.string().nullable(),
spend: z.number().finite(),
flat_cost: z.number().finite(),
prompt_tokens: count,
completion_tokens: count,
api_requests: count,
successful_requests: count,
failed_requests: count,
cache_read_input_tokens: count,
cache_creation_input_tokens: count,
};
const exportRow = z.object(exportRowFields);
type ExportRow = z.infer<typeof exportRow>;
type MetricKey = Exclude<keyof ExportRow, "date" | "entity_id" | "model">;
export interface ClientAgentModelsResult {
results: DailyData[];
error: string | null;
}
const grouped = (rows: readonly ExportRow[], key: (row: ExportRow) => string) => {
const sorted = [...rows].sort((a, b) => key(a).localeCompare(key(b)));
const starts = sorted.flatMap((row, index) => (index === 0 || key(row) !== key(sorted[index - 1]) ? [index] : []));
return starts.map((start, index) => [key(sorted[start]), sorted.slice(start, starts[index + 1])] as const);
};
const sum = (rows: readonly ExportRow[], key: MetricKey) => rows.reduce((total, row) => total + row[key], 0);
const metricsFor = (rows: readonly ExportRow[]): SpendMetrics => ({
spend: sum(rows, "spend"),
flat_cost: sum(rows, "flat_cost"),
prompt_tokens: sum(rows, "prompt_tokens"),
completion_tokens: sum(rows, "completion_tokens"),
total_tokens: sum(rows, "prompt_tokens") + sum(rows, "completion_tokens"),
api_requests: sum(rows, "api_requests"),
successful_requests: sum(rows, "successful_requests"),
failed_requests: sum(rows, "failed_requests"),
cache_read_input_tokens: sum(rows, "cache_read_input_tokens"),
cache_creation_input_tokens: sum(rows, "cache_creation_input_tokens"),
});
const breakdownFor = (
rows: readonly ExportRow[],
key: (row: ExportRow) => string,
): Record<string, MetricWithMetadata> =>
Object.fromEntries(
grouped(rows, key).map(([name, members]) => [
name,
{ metrics: metricsFor(members), metadata: {}, api_key_breakdown: {} },
]),
);
export const clientAgentModelsFromExport = (payload: unknown, agent: AgentRow): ClientAgentModelsResult => {
const parsed = z.array(exportRow).safeParse(payload);
if (!parsed.success) return { results: [], error: "The proxy returned incomplete model usage data" };
const tags = new Set(agent.tags);
const rows = parsed.data.filter((row) => tags.has(row.entity_id));
const identities = rows.map((row) => JSON.stringify([row.date, row.entity_id, row.model]));
if (new Set(identities).size !== identities.length) {
return { results: [], error: "The proxy returned duplicate model usage data. Try again." };
}
const results = grouped(rows, (row) => row.date).map(([date, members]) => ({
date,
metrics: metricsFor(members),
breakdown: {
models: breakdownFor(members, (row) => row.model || "Unknown model"),
model_groups: {},
entities: breakdownFor(members, (row) => row.entity_id),
providers: {},
api_keys: {},
mcp_servers: {},
},
}));
if (!clientAgentDetail(agent, results).dailyMatchesSummary) {
return {
results: [],
error: "Model usage does not match these client totals yet. Refresh or choose a shorter range.",
};
}
return { results, error: null };
};

View file

@ -0,0 +1,283 @@
import { describe, expect, it } from "vitest";
import {
clientUsageFromLogs,
identifyUsageClient,
INCOMPLETE_CLIENT_USAGE,
UsageClient,
type ClientUsageScope,
} from "./clientUsageData";
const desktopAgent = "claude-cli/2.1.299 (external, claude-desktop-3p, agent-sdk/0.3.299)";
const log = (overrides: Record<string, unknown> = {}) => ({
request_id: "request-1",
user: "user-1",
startTime: "2026-10-22T10:00:00Z",
model: "provider-model",
model_group: "deployment-alias",
custom_llm_provider: "anthropic",
spend: 0.013,
total_tokens: 1200,
prompt_tokens: 1000,
completion_tokens: 200,
request_duration_ms: 250,
status: "success",
request_tags: ["User-Agent: claude-cli", `User-Agent: ${desktopAgent}`],
metadata: {
user_agent: desktopAgent,
additional_usage_values: { cache_read_input_tokens: 500, cache_creation_input_tokens: 100 },
},
...overrides,
});
const page = (rows: ReturnType<typeof log>[], overrides: Record<string, unknown> = {}) => ({
data: rows,
total: rows.length,
page: 1,
total_pages: 1,
total_is_capped: false,
...overrides,
});
const scope: ClientUsageScope = {
userId: "user-1",
client: UsageClient.ClaudeDesktop,
startTime: new Date("2026-10-21T00:00:00Z"),
endTime: new Date("2026-10-23T00:00:00Z"),
};
describe("client usage from request logs", () => {
it.each([
["claude-desktop/1.0", "claude-desktop"],
["Claude_Desktop/1.0", "claude-desktop"],
["Claude Desktop/1.0", "claude-desktop"],
["claude-code/1.0", "claude-code"],
["claude_code/1.0", "claude-code"],
["claude-user/1.0", "claude-code"],
["claude-cli/1.0 (external, cli)", "claude-code"],
["claude-cli/1.0 (external, claude-desktop-3p, cli)", "claude-desktop"],
["claude-cli", null],
["claude-cli/1.0 (external, agent-sdk/1.0)", null],
])("should classify %s as %s consistently with the agent catalog", (agent, expected) => {
expect(identifyUsageClient(agent, [])).toBe(expected);
});
it("should preserve recorded totals, local days, model groups, providers and measured latency", () => {
const result = clientUsageFromLogs(page([log(), log({ request_id: "request-2", status: "failure" })]), scope);
expect(result.error).toBeNull();
const expectedTotals = {
total_spend: 0.026,
total_tokens: 2400,
total_prompt_tokens: 2000,
total_completion_tokens: 400,
total_api_requests: 2,
total_successful_requests: 1,
total_failed_requests: 1,
total_cache_read_input_tokens: 1000,
total_cache_creation_input_tokens: 200,
total_response_time_ms: 250,
total_timed_requests: 1,
};
expect(result.data.metadata).toMatchObject(expectedTotals);
expect(result.cacheAvailable).toBe(true);
const day = result.data.results?.[0];
expect(day?.breakdown?.models?.["provider-model"].metrics?.api_requests).toBe(2);
expect(day?.breakdown?.model_groups?.["deployment-alias"].metrics?.spend).toBe(0.026);
expect(day?.breakdown?.providers?.anthropic.metrics?.total_tokens).toBe(2400);
expect(day?.breakdown?.api_keys).toEqual({});
const timestamp = new Date("2026-10-22T10:00:00Z");
const localDay = `${timestamp.getFullYear()}-${String(timestamp.getMonth() + 1).padStart(2, "0")}-${String(timestamp.getDate()).padStart(2, "0")}`;
expect(day?.date).toBe(localDay);
});
it("should deduplicate request ids without double counting bare and full user-agent tags", () => {
const result = clientUsageFromLogs(page([log(), log({ spend: 0.02 })]), scope);
expect(result.data.metadata?.total_api_requests).toBe(1);
expect(result.data.metadata?.total_spend).toBe(0.02);
expect(result.data.metadata?.total_tokens).toBe(1200);
});
it("should distinguish Desktop from explicit Code clients and exclude ambiguous CLI records", () => {
const rows = [
log(),
log({ request_id: "code", request_tags: [], metadata: { user_agent: "claude-cli/2.1.299 (external, cli)" } }),
log({ request_id: "unknown", request_tags: ["User-Agent: claude-cli"], metadata: null }),
];
const desktop = clientUsageFromLogs(page(rows), scope);
const code = clientUsageFromLogs(page(rows), { ...scope, client: UsageClient.ClaudeCode });
expect(desktop.data.metadata?.total_api_requests).toBe(1);
expect(code.data.metadata?.total_api_requests).toBe(1);
expect(identifyUsageClient("claude-code/3.0", [])).toBe("claude-code");
expect(identifyUsageClient("claude-cli/3.0 (external, agent-sdk/0.3)", [])).toBeNull();
expect(identifyUsageClient(null, JSON.stringify([`User-Agent: ${desktopAgent}`]))).toBe("claude-desktop");
expect(identifyUsageClient(null, { "User-Agent": desktopAgent })).toBe("claude-desktop");
expect(identifyUsageClient(null, [desktopAgent])).toBeNull();
});
it.each([{ total: 1001, total_pages: 2 }, { total: 10000, total_is_capped: true }, { total_pages: 2 }])(
"should refuse totals for a partial page %j",
(overrides) => {
const result = clientUsageFromLogs(page([log()], overrides), scope);
expect(result.error).toBe(INCOMPLETE_CLIENT_USAGE);
expect(result.data.results).toEqual([]);
expect(result.data.metadata?.total_spend).toBe(0);
},
);
it("should refuse malformed or unexpectedly truncated data instead of displaying inaccurate totals", () => {
expect(clientUsageFromLogs(page([log()], { total: 2 }), scope).error).toBeTruthy();
expect(clientUsageFromLogs(page([log({ spend: "0.013" })]), scope).error).toBeTruthy();
expect(clientUsageFromLogs(page([log({ startTime: "invalid" })]), scope).error).toBeTruthy();
});
it("should reject rows attributed to another request user regardless of key metadata", () => {
const result = clientUsageFromLogs(page([log({ user: "someone-else" })]), scope);
expect(result.error).toBe("The proxy returned usage outside the selected user");
expect(result.data.results).toEqual([]);
});
it("should keep unknown cache separate from recorded zero values", () => {
const unknown = clientUsageFromLogs(page([log({ status: null, metadata: { user_agent: desktopAgent } })]), scope);
expect(unknown.cacheAvailable).toBe(false);
expect(unknown.data.metadata?.total_tokens).toBe(1200);
expect(unknown.data.metadata?.total_spend).toBe(0.013);
const zero = clientUsageFromLogs(
page([
log({
metadata: {
user_agent: desktopAgent,
additional_usage_values: { cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
},
}),
]),
scope,
);
expect(zero.cacheAvailable).toBe(true);
expect(zero.data.metadata?.total_cache_read_input_tokens).toBe(0);
});
it("should use recorded nested cache counts and metadata status when direct fields are absent", () => {
const result = clientUsageFromLogs(
page([
log({
status: null,
metadata: {
user_agent: desktopAgent,
status: "failure",
usage_object: { prompt_tokens_details: { cached_tokens: 500, cache_creation_tokens: 100 } },
},
}),
]),
scope,
);
expect(result.cacheAvailable).toBe(true);
expect(result.data.metadata?.total_cache_read_input_tokens).toBe(500);
expect(result.data.metadata?.total_failed_requests).toBe(1);
expect(result.data.metadata?.total_timed_requests).toBe(0);
});
it("should enforce exact timestamp bounds after the API query rounds to seconds", () => {
const rows = [
log({ request_id: "before", startTime: "2026-10-22T10:00:00.100Z" }),
log({ request_id: "inside", startTime: "2026-10-22T10:00:00.500" }),
log({ request_id: "after", startTime: "2026-10-22T10:00:01Z" }),
];
const result = clientUsageFromLogs(page(rows), {
...scope,
startTime: new Date("2026-10-22T10:00:00.400Z"),
endTime: new Date("2026-10-22T10:00:00.800Z"),
});
expect(result.data.metadata?.total_api_requests).toBe(1);
});
it.each([0, 100])("should retain legacy recorded cache_write_tokens of %s", (cacheWrite) => {
const result = clientUsageFromLogs(
page([
log({
metadata: {
user_agent: desktopAgent,
additional_usage_values: { prompt_tokens_details: { cached_tokens: 0, cache_write_tokens: cacheWrite } },
},
}),
]),
scope,
);
expect(result.cacheAvailable).toBe(true);
expect(result.data.metadata?.total_cache_creation_input_tokens).toBe(cacheWrite);
});
it("should allow a complete empty page or exactly 1,000 records", () => {
const empty = clientUsageFromLogs(page([]), scope);
expect(empty.error).toBeNull();
expect(empty.data.results).toEqual([]);
const full = clientUsageFromLogs(
page(Array.from({ length: 1000 }, (_, index) => log({ request_id: `${index}` }))),
scope,
);
expect(full.error).toBeNull();
expect(full.data.metadata?.total_api_requests).toBe(1000);
});
it("should aggregate all selected-team requests including unassigned team keys", () => {
const teamScope = { ...scope, userId: null, teamId: "engineering" };
const rows = [log({ team_id: "engineering" }), log({ request_id: "team-key", user: null, team_id: "engineering" })];
const result = clientUsageFromLogs(page(rows), teamScope);
expect(result.error).toBeNull();
expect(result.data.metadata?.total_api_requests).toBe(2);
expect(result.data.metadata?.total_spend).toBe(0.026);
});
it.each([null, undefined, "different-team"])("should reject team results with mismatched team %s", (teamId) => {
const result = clientUsageFromLogs(page([log({ team_id: teamId })]), { ...scope, teamId: "engineering" });
expect(result.error).toBe("The proxy returned usage outside the selected team");
expect(result.data.results).toEqual([]);
});
it("should require both selected member and selected team to match", () => {
const memberScope = { ...scope, teamId: "engineering" };
const ownOtherTeam = clientUsageFromLogs(page([log({ team_id: "design" })]), memberScope);
const otherTeamMember = clientUsageFromLogs(page([log({ team_id: "engineering", user: "user-2" })]), memberScope);
const member = clientUsageFromLogs(page([log({ team_id: "engineering" })]), memberScope);
expect(ownOtherTeam.error).toContain("outside the selected team");
expect(otherTeamMember.error).toContain("outside the selected user");
expect(member.error).toBeNull();
expect(member.data.metadata?.total_api_requests).toBe(1);
});
it("should refuse unscoped request-log aggregation", () => {
expect(clientUsageFromLogs(page([log()]), { ...scope, userId: null }).error).toContain("Select a user or team");
});
it("should exclude unrelated clients containing a Desktop marker from Desktop totals", () => {
const rows = [
log(),
log({
request_id: "unrelated-client",
request_tags: ["User-Agent: python-httpx/1.0 (claude-desktop-3p)"],
metadata: { user_agent: "python-httpx/1.0 (claude-desktop-3p)" },
}),
log({
request_id: "ambiguous-cli",
request_tags: ["User-Agent: claude-cli/1.0 (integration=claude-desktop-3p)"],
metadata: null,
}),
];
const result = clientUsageFromLogs(page(rows), scope);
expect(result.error).toBeNull();
expect(result.data.metadata?.total_api_requests).toBe(1);
expect(result.data.metadata?.total_spend).toBe(0.013);
});
it.each([
{ requestTags: [" User Agent: claude-desktop/1.0 "] },
{ requestTags: { " User Agent ": "claude-desktop/1.0" } },
{ requestTags: { " User-Agent: claude-desktop/1.0 ": true } },
])("should recognize the catalog's normalized tag formats %j", ({ requestTags }) => {
const result = clientUsageFromLogs(page([log({ request_tags: requestTags, metadata: null })]), scope);
expect(result.data.metadata?.total_api_requests).toBe(1);
});
it("should retain recorded zero and positive cache counts from the Responses API usage shape", () => {
const responseUsage = { input_tokens_details: { cached_tokens: 0, cache_write_tokens: 100 } };
const result = clientUsageFromLogs(page([log({ metadata: { usage_object: responseUsage } })]), scope);
expect(result.cacheAvailable).toBe(true);
expect(result.data.metadata?.total_cache_read_input_tokens).toBe(0);
expect(result.data.metadata?.total_cache_creation_input_tokens).toBe(100);
});
});

View file

@ -0,0 +1,234 @@
import { z } from "zod";
import {
EMPTY_DAILY_ACTIVITY_METADATA,
EMPTY_DAILY_ACTIVITY_RESPONSE,
type DailyActivityAggregatedResponse,
} from "@/components/UsagePage/dailyActivityApi";
import type { components } from "@/lib/http/schema";
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
import { isUserAgentTag, usageClientFromUserAgent, UsageClient } from "../components/overview/agentCatalog";
type DailyData = components["schemas"]["DailySpendData"];
type MetricWithMetadata = components["schemas"]["MetricWithMetadata"];
type SpendMetrics = components["schemas"]["SpendMetrics"];
export { UsageClient } from "../components/overview/agentCatalog";
export const CLIENT_REQUEST_LIMIT = 1000;
export const INCOMPLETE_CLIENT_USAGE =
"Narrow the date range to fewer than 1,000 requests to see complete client usage";
const count = z.number().int().nonnegative();
const cacheDetailsSchema = z.object({
cached_tokens: count.nullish(),
cache_creation_tokens: count.nullish(),
cache_write_tokens: count.nullish(),
});
const usageFields = {
cache_read_input_tokens: count.nullish(),
cache_creation_input_tokens: count.nullish(),
prompt_tokens_details: cacheDetailsSchema.nullish(),
input_tokens_details: cacheDetailsSchema.nullish(),
};
const usageSchema = z.object(usageFields);
const metadataFields = {
user_agent: z.string().nullish(),
status: z.string().nullish(),
additional_usage_values: usageSchema.nullish(),
usage_object: usageSchema.nullish(),
};
const logFields = {
request_id: z.string().min(1),
user: z.string().nullish(),
team_id: z.string().nullish(),
startTime: z
.string()
.transform((value) => {
const withZone = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`;
return new Date(withZone);
})
.refine((value) => Number.isFinite(value.getTime())),
model: z.string().nullish(),
model_group: z.string().nullish(),
custom_llm_provider: z.string().nullish(),
spend: z.number().finite(),
total_tokens: count,
prompt_tokens: count,
completion_tokens: count,
request_duration_ms: z.number().nonnegative().nullish(),
status: z.string().nullish(),
request_tags: z.unknown(),
metadata: z.object(metadataFields).nullish(),
};
const logSchema = z.object(logFields);
const pageFields = {
data: z.array(logSchema),
total: count,
page: z.literal(1),
total_pages: count,
total_is_capped: z.boolean(),
};
const pageSchema = z.object(pageFields);
type ClientLog = z.infer<typeof logSchema>;
export interface ClientUsageScope {
userId: string | null;
teamId?: string | null;
client: UsageClient;
startTime: Date;
endTime: Date;
}
export interface ClientUsageResult {
data: DailyActivityAggregatedResponse;
error: string | null;
cacheAvailable: boolean;
}
export const emptyClientUsage = (error: string | null = null): ClientUsageResult => ({
data: EMPTY_DAILY_ACTIVITY_RESPONSE,
error,
cacheAvailable: false,
});
function userAgentTags(tags: unknown): string[] {
if (Array.isArray(tags)) return tags.filter((tag): tag is string => typeof tag === "string");
if (typeof tags === "string") {
try {
return userAgentTags(JSON.parse(tags));
} catch {
return [tags];
}
}
if (tags !== null && typeof tags === "object") {
return Object.entries(tags).flatMap(([key, value]) => {
if (isUserAgentTag(key)) return [key];
return isUserAgentTag(`${key.trim()}:`) && typeof value === "string" ? [`User-Agent: ${value}`] : [];
});
}
return [];
}
export function identifyUsageClient(metadataAgent: string | null | undefined, tags: unknown): UsageClient | null {
const candidates = [...(metadataAgent ? [metadataAgent] : []), ...userAgentTags(tags).filter(isUserAgentTag)].map(
usageClientFromUserAgent,
);
if (candidates.includes(UsageClient.ClaudeDesktop)) return UsageClient.ClaudeDesktop;
if (candidates.includes(UsageClient.ClaudeCode)) return UsageClient.ClaudeCode;
return null;
}
const statusOf = (row: ClientLog) => row.status ?? row.metadata?.status;
const cacheTokens = (row: ClientLog) => {
const additional = extractPromptCacheTokens(row.metadata?.additional_usage_values, true);
const usage = extractPromptCacheTokens(row.metadata?.usage_object, true);
return {
cacheReadTokens: additional.cacheReadTokens ?? usage.cacheReadTokens,
cacheCreationTokens: additional.cacheCreationTokens ?? usage.cacheCreationTokens,
};
};
const sum = (rows: readonly ClientLog[], value: (row: ClientLog) => number) =>
rows.reduce((total, row) => total + value(row), 0);
const metricsFor = (rows: readonly ClientLog[]): SpendMetrics => ({
flat_cost: 0,
ptu_hours: 0,
compression_saved_tokens: 0,
compression_savings_spend: 0,
prompt_caching_savings_spend: 0,
gateway_injected_caching_savings_spend: 0,
autorouter_savings_spend: 0,
spend: sum(rows, (row) => row.spend),
prompt_tokens: sum(rows, (row) => row.prompt_tokens),
completion_tokens: sum(rows, (row) => row.completion_tokens),
total_tokens: sum(rows, (row) => row.total_tokens),
api_requests: rows.length,
successful_requests: rows.filter((row) => statusOf(row) === "success").length,
failed_requests: rows.filter((row) => statusOf(row) === "failure").length,
cache_read_input_tokens: sum(rows, (row) => cacheTokens(row).cacheReadTokens ?? 0),
cache_creation_input_tokens: sum(rows, (row) => cacheTokens(row).cacheCreationTokens ?? 0),
total_response_time_ms: sum(rows, (row) => (statusOf(row) === "success" ? row.request_duration_ms ?? 0 : 0)),
timed_requests: rows.filter((row) => statusOf(row) === "success" && row.request_duration_ms != null).length,
});
const breakdownFor = (
rows: readonly ClientLog[],
dimension: (row: ClientLog) => string,
): Record<string, MetricWithMetadata> =>
Object.fromEntries(
[...new Set(rows.map(dimension))].map((name) => [
name,
{
metrics: metricsFor(rows.filter((row) => dimension(row) === name)),
metadata: {},
api_key_breakdown: {},
},
]),
);
const localDay = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const modelName = (row: ClientLog) => row.model || "Unknown model";
const dailyData = (rows: readonly ClientLog[]): DailyData[] =>
[...new Set(rows.map((row) => localDay(row.startTime)))].sort().map((date) => {
const day = rows.filter((row) => localDay(row.startTime) === date);
return {
date,
metrics: metricsFor(day),
breakdown: {
models: breakdownFor(day, modelName),
model_groups: breakdownFor(day, (row) => row.model_group || modelName(row)),
providers: breakdownFor(day, (row) => row.custom_llm_provider || "Unknown provider"),
api_keys: {},
mcp_servers: {},
entities: {},
endpoints: {},
},
};
});
export function clientUsageFromLogs(response: unknown, scope: ClientUsageScope): ClientUsageResult {
if (!scope.userId && !scope.teamId) return emptyClientUsage("Select a user or team to view client usage");
const parsed = pageSchema.safeParse(response);
if (!parsed.success) return emptyClientUsage("The proxy returned incomplete client usage data");
const page = parsed.data;
if (page.total_is_capped || page.total_pages > 1 || page.total > CLIENT_REQUEST_LIMIT) {
return emptyClientUsage(INCOMPLETE_CLIENT_USAGE);
}
if (page.data.length !== page.total) return emptyClientUsage("The proxy returned incomplete client usage data");
if (scope.userId && page.data.some((row) => row.user !== scope.userId)) {
return emptyClientUsage("The proxy returned usage outside the selected user");
}
if (scope.teamId && page.data.some((row) => row.team_id !== scope.teamId)) {
return emptyClientUsage("The proxy returned usage outside the selected team");
}
const uniqueRows = [...new Map(page.data.map((row) => [row.request_id, row])).values()];
const rows = uniqueRows.filter(
(row) =>
row.startTime >= scope.startTime &&
row.startTime <= scope.endTime &&
identifyUsageClient(row.metadata?.user_agent, row.request_tags) === scope.client,
);
const metrics = metricsFor(rows);
return {
data: {
results: dailyData(rows),
metadata: {
...EMPTY_DAILY_ACTIVITY_METADATA,
total_spend: metrics.spend,
total_tokens: metrics.total_tokens,
total_prompt_tokens: metrics.prompt_tokens,
total_completion_tokens: metrics.completion_tokens,
total_api_requests: metrics.api_requests,
total_successful_requests: metrics.successful_requests,
total_failed_requests: metrics.failed_requests,
total_cache_read_input_tokens: metrics.cache_read_input_tokens,
total_cache_creation_input_tokens: metrics.cache_creation_input_tokens,
total_response_time_ms: metrics.total_response_time_ms,
total_timed_requests: metrics.timed_requests,
},
},
error: null,
cacheAvailable: rows.every((row) => {
const cache = cacheTokens(row);
return cache.cacheReadTokens !== undefined && cache.cacheCreationTokens !== undefined;
}),
};
}

View file

@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import { resolveClientUsageScope, type ClientUsageScopeInput, type ClientUsageTeam } from "./clientUsageScope";
const teams: readonly ClientUsageTeam[] = [
{
team_id: "engineering",
team_alias: "Engineering",
members_with_roles: [
{ user_id: "john", role: "admin", user_alias: "John", user_email: "john@example.com" },
{ user_id: "alice", role: "user", user_email: "alice@example.com" },
{ user_id: "alice", role: "user", user_email: "duplicate@example.com" },
{ user_id: "id-only", role: "user" },
{ user_id: null, role: "user", user_email: "invited@example.com" },
],
},
{
team_id: "design",
team_alias: "Design",
members_with_roles: [
{ user_id: "john", role: "admin" },
{ user_id: "bob", role: "user", user_alias: "Bob" },
],
},
{
team_id: "external",
members_with_roles: [
{ user_id: "john", role: "user" },
{ user_id: "outside-admin", role: "admin" },
],
},
];
const defaults: ClientUsageScopeInput = {
userId: "john",
userRole: "Internal User",
teams,
requestedTeamId: null,
requestedUserId: null,
};
const scopeFor = (overrides: Partial<ClientUsageScopeInput> = {}) =>
resolveClientUsageScope({ ...defaults, ...overrides });
describe("resolveClientUsageScope", () => {
it("should default a signed-in user to personal usage", () => {
const result = scopeFor({ userId: "alice" });
expect(result.scope).toEqual({ userId: "alice", teamId: null });
expect(result.canChooseGlobalUser).toBe(false);
expect(result.administeredTeams).toEqual([]);
expect(result.members).toEqual([]);
expect(result.error).toBeNull();
});
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])(
"should permit proxy-wide and selected-user read scopes for %s",
(userRole) => {
const allUsers = scopeFor({ userRole });
const selectedUser = scopeFor({ userRole, requestedUserId: "any-proxy-user" });
expect(allUsers.scope).toEqual({ userId: null, teamId: null });
expect(allUsers.canChooseGlobalUser).toBe(true);
expect(allUsers.administeredTeams.map((team) => team.value)).toEqual(["engineering", "design", "external"]);
expect(selectedUser.scope).toEqual({ userId: "any-proxy-user", teamId: null });
},
);
it.each(["Org Admin", "org_admin", "Internal User", "internal_user", "team_admin"])(
"should not grant proxy-wide reads from the %s role name",
(userRole) => {
const result = scopeFor({ userRole, teams: [], requestedUserId: "outside-user" });
expect(result.canChooseGlobalUser).toBe(false);
expect(result.scope).toBeNull();
expect(result.error).toBe("Choose an administered team before viewing another member's usage");
},
);
it("should offer only administered teams and keep a team administrator in personal scope until a team is selected", () => {
const result = scopeFor();
expect(result.scope).toEqual({ userId: "john", teamId: null });
expect(result.administeredTeams).toEqual([
{ value: "engineering", label: "Engineering" },
{ value: "design", label: "Design" },
]);
expect(result.canChooseGlobalUser).toBe(false);
});
it("should expose only identified members of the selected administered team with their actual labels", () => {
const result = scopeFor({ requestedTeamId: "engineering" });
expect(result.scope).toEqual({ teamId: "engineering", userId: null });
expect(result.members).toEqual([
{ value: "john", label: "John (john@example.com)" },
{ value: "alice", label: "alice@example.com" },
{ value: "id-only", label: "id-only" },
]);
expect(result.error).toBeNull();
});
it("should keep the team filter when selecting a team member or the administrator's own team usage", () => {
const alice = scopeFor({ requestedTeamId: "engineering", requestedUserId: "alice" });
const john = scopeFor({ requestedTeamId: "engineering", requestedUserId: "john" });
expect(alice.scope).toEqual({ teamId: "engineering", userId: "alice" });
expect(john.scope).toEqual({ teamId: "engineering", userId: "john" });
});
it("should reject a member from another administered team instead of querying that person's global usage", () => {
const result = scopeFor({ requestedTeamId: "engineering", requestedUserId: "bob" });
expect(result.scope).toBeNull();
expect(result.error).toBe("Choose a member of the selected team");
expect(result.members.map((member) => member.value)).not.toContain("bob");
});
it.each(["external", "missing-team"])("should reject the unauthorized team %s", (requestedTeamId) => {
const result = scopeFor({ requestedTeamId });
expect(result.scope).toBeNull();
expect(result.members).toEqual([]);
expect(result.error).toBe("Choose a team you administer");
});
it("should reject a formerly valid selection as soon as team membership is removed", () => {
const result = scopeFor({ teams: [], requestedTeamId: "engineering", requestedUserId: "alice" });
expect(result.scope).toBeNull();
expect(result.administeredTeams).toEqual([]);
expect(result.members).toEqual([]);
});
it("should not use a missing team list to authorize a saved team or member selection", () => {
const result = scopeFor({ teams: undefined, requestedTeamId: "engineering", requestedUserId: "alice" });
expect(result.scope).toBeNull();
expect(result.error).toBe("Choose a team you administer");
});
it("should restrict proxy-admin member options to the selected team too", () => {
const selected = { userRole: "Admin", requestedTeamId: "external" };
const allowed = scopeFor({ ...selected, requestedUserId: "outside-admin" });
const denied = scopeFor({ ...selected, requestedUserId: "alice" });
expect(allowed.scope).toEqual({ teamId: "external", userId: "outside-admin" });
expect(allowed.canChooseGlobalUser).toBe(false);
expect(denied.scope).toBeNull();
});
it("should force personal usage for every role when ownUsage is set", () => {
const requested = { ownUsage: true, requestedTeamId: "missing-team", requestedUserId: "outside-user" };
for (const userRole of ["Admin", "Internal User"]) {
const result = scopeFor({ ...requested, userRole });
expect(result.scope).toEqual({ userId: "john", teamId: null });
expect(result.members).toEqual([]);
expect(result.canChooseGlobalUser).toBe(false);
expect(result.error).toBeNull();
}
});
it("should fail closed without a signed-in identity even if a proxy role or null-ID membership is present", () => {
const result = scopeFor({ userId: null, userRole: "Admin", requestedTeamId: "engineering" });
expect(result.scope).toBeNull();
expect(result.administeredTeams).toEqual([]);
expect(result.members).toEqual([]);
expect(result.canChooseGlobalUser).toBe(false);
expect(result.error).toBe("Sign in to view client usage");
});
it("should not mutate the supplied team membership data", () => {
const before = JSON.stringify(teams);
scopeFor({ requestedTeamId: "engineering", requestedUserId: "alice" });
expect(JSON.stringify(teams)).toBe(before);
});
});

View file

@ -0,0 +1,98 @@
import type { Member } from "@/components/networking";
import { hasProxyWideSpendView, isUserTeamAdminForSingleTeam } from "@/utils/roles";
export interface ClientUsageTeam {
readonly team_id: string;
readonly team_alias?: string | null;
readonly members_with_roles?: readonly Member[] | null;
}
export interface ClientUsageScopeOption {
value: string;
label: string;
}
export interface ClientUsageScopeInput {
userId: string | null;
userRole: string | null;
teams: readonly ClientUsageTeam[] | null | undefined;
requestedTeamId: string | null;
requestedUserId: string | null;
ownUsage?: boolean;
}
export interface ClientUsageScope {
userId: string | null;
teamId: string | null;
}
export interface ResolvedClientUsageScope {
scope: ClientUsageScope | null;
administeredTeams: readonly ClientUsageScopeOption[];
members: readonly ClientUsageScopeOption[];
canChooseGlobalUser: boolean;
error: string | null;
}
const memberLabel = (member: Member): string => {
const name = member.user_alias?.trim();
const email = member.user_email?.trim();
if (name && email && name !== email) return `${name} (${email})`;
if (name) return name;
return email || member.user_id || "";
};
const memberOptions = (team: ClientUsageTeam): ClientUsageScopeOption[] => {
const members = team.members_with_roles ?? [];
const identified = members.filter((member): member is Member & { user_id: string } => Boolean(member.user_id));
const unique = identified.filter(
(member, index) => identified.findIndex((row) => row.user_id === member.user_id) === index,
);
return unique.map((member) => ({ value: member.user_id, label: memberLabel(member) }));
};
export const resolveClientUsageScope = ({
userId,
userRole,
teams,
requestedTeamId,
requestedUserId,
ownUsage = false,
}: ClientUsageScopeInput): ResolvedClientUsageScope => {
const proxyAdmin = Boolean(userId) && hasProxyWideSpendView(userRole);
const availableTeams = userId
? (teams ?? []).filter(
(team) => proxyAdmin || isUserTeamAdminForSingleTeam(team.members_with_roles ?? null, userId),
)
: [];
const administeredTeams = availableTeams.map((team) => ({
value: team.team_id,
label: team.team_alias || team.team_id,
}));
const base: ResolvedClientUsageScope = {
scope: null,
administeredTeams,
members: [],
canChooseGlobalUser: proxyAdmin && !ownUsage && !requestedTeamId,
error: null,
};
if (!userId) return { ...base, error: "Sign in to view client usage" };
if (ownUsage) return { ...base, scope: { userId, teamId: null } };
if (requestedTeamId) {
const selectedTeam = availableTeams.find((team) => team.team_id === requestedTeamId);
if (!selectedTeam) return { ...base, error: "Choose a team you administer" };
const members = memberOptions(selectedTeam);
const memberId = requestedUserId || null;
if (memberId && !members.some((member) => member.value === memberId)) {
return { ...base, members, error: "Choose a member of the selected team" };
}
return { ...base, members, scope: { teamId: selectedTeam.team_id, userId: memberId } };
}
if (proxyAdmin) return { ...base, scope: { teamId: null, userId: requestedUserId || null } };
if (requestedUserId && requestedUserId !== userId) {
return { ...base, error: "Choose an administered team before viewing another member's usage" };
}
return { ...base, scope: { teamId: null, userId } };
};

View file

@ -0,0 +1,168 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { dailyActivityExportCall } from "@/components/networking";
import type { AgentRow } from "../components/overview/agentCatalog";
import { MAX_CLIENT_MODEL_EXPORT_BYTES, useClientAgentModels } from "./useClientAgentModels";
vi.mock("@/components/networking", () => ({ dailyActivityExportCall: vi.fn() }));
const tag = "User-Agent: claude-cli/2.1.1 (external, claude-desktop-3p)";
const agent: AgentRow = {
id: "claude-desktop",
label: "Claude Desktop",
description: "Desktop client",
kind: "agent",
logo: null,
spend: 1,
tokens: 100,
requests: 2,
users: 1,
tags: [tag],
};
const records = [
{
date: "2026-10-22",
entity_id: tag,
model: "model-a",
spend: 1,
flat_cost: 0,
prompt_tokens: 80,
completion_tokens: 20,
api_requests: 2,
successful_requests: 2,
failed_requests: 0,
cache_read_input_tokens: 30,
cache_creation_input_tokens: 10,
},
];
const options = {
accessToken: "test-token",
agent,
enabled: true,
startTime: new Date("2026-10-22T07:00:00Z"),
endTime: new Date("2026-10-23T06:59:59.999Z"),
};
const blob = (text: string) => Object.assign(new Blob([text]), { text: async () => text });
const jsonBlob = (value: unknown) => blob(JSON.stringify(value));
describe("useClientAgentModels", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should request the complete authenticated model export for exact local filtering", async () => {
vi.mocked(dailyActivityExportCall).mockResolvedValue(jsonBlob(records));
const { result } = renderHook(() => useClientAgentModels(options));
await waitFor(() => expect(result.current.loading).toBe(false));
const expectedRequest = {
accessToken: options.accessToken,
startTime: options.startTime,
endTime: options.endTime,
entityIds: null,
};
expect(dailyActivityExportCall).toHaveBeenCalledExactlyOnceWith(
"tag",
expectedRequest,
"daily_with_models",
"json",
);
expect(result.current.error).toBeNull();
expect(result.current.results[0].metrics.spend).toBe(1);
});
it("should require the caller's enabled gate and authentication", () => {
const { result, rerender } = renderHook(
({ enabled, accessToken }) => useClientAgentModels({ ...options, enabled, accessToken }),
{ initialProps: { enabled: false, accessToken: "test-token" as string | null } },
);
expect(result.current.results).toEqual([]);
rerender({ enabled: true, accessToken: null });
expect(result.current.loading).toBe(false);
expect(dailyActivityExportCall).not.toHaveBeenCalled();
});
it("should reject an oversized export without parsing or displaying partial totals", async () => {
const oversized = new Blob([new Uint8Array(MAX_CLIENT_MODEL_EXPORT_BYTES + 1)]);
vi.mocked(dailyActivityExportCall).mockResolvedValue(oversized);
const { result } = renderHook(() => useClientAgentModels(options));
await waitFor(() => expect(result.current.error).toContain("shorter date range"));
expect(result.current.results).toEqual([]);
});
it("should reject incomplete JSON rather than consume part of an export", async () => {
vi.mocked(dailyActivityExportCall).mockResolvedValue(blob('[{"date":"2026-10-22"'));
const { result } = renderHook(() => useClientAgentModels(options));
await waitFor(() => expect(result.current.error).toBe("Could not load complete model usage. Try again."));
expect(result.current.results).toEqual([]);
});
it("should clear loaded results synchronously when the range changes", async () => {
vi.mocked(dailyActivityExportCall)
.mockResolvedValueOnce(jsonBlob(records))
.mockReturnValueOnce(new Promise(() => {}));
const { result, rerender } = renderHook(({ startTime }) => useClientAgentModels({ ...options, startTime }), {
initialProps: { startTime: options.startTime },
});
await waitFor(() => expect(result.current.results).toHaveLength(1));
rerender({ startTime: new Date("2026-10-21T07:00:00Z") });
expect(result.current.results).toEqual([]);
expect(result.current.loading).toBe(true);
});
it("should ignore stale responses after the canonical tags change", async () => {
let resolveFirst: ((value: Blob) => void) | undefined;
const first = new Promise<Blob>((resolve) => {
resolveFirst = resolve;
});
const changedAgent = { ...agent, tags: ["User-Agent: claude-desktop/3.0"] };
const changedRows = records.map((row) => ({ ...row, entity_id: changedAgent.tags[0] }));
vi.mocked(dailyActivityExportCall).mockReturnValueOnce(first).mockResolvedValueOnce(jsonBlob(changedRows));
const { result, rerender } = renderHook(({ agent }) => useClientAgentModels({ ...options, agent }), {
initialProps: { agent },
});
rerender({ agent: changedAgent });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => resolveFirst?.(jsonBlob(records)));
expect(Object.keys(result.current.results[0].breakdown.entities)).toEqual(changedAgent.tags);
});
it("should clear loaded data immediately when the all-user panel is disabled", async () => {
vi.mocked(dailyActivityExportCall).mockResolvedValue(jsonBlob(records));
const { result, rerender } = renderHook(({ enabled }) => useClientAgentModels({ ...options, enabled }), {
initialProps: { enabled: true },
});
await waitFor(() => expect(result.current.results).toHaveLength(1));
rerender({ enabled: false });
expect(result.current.results).toEqual([]);
expect(result.current.error).toBeNull();
expect(result.current.loading).toBe(false);
});
it("should retry a failed export with unchanged scope and clear its error immediately", async () => {
vi.mocked(dailyActivityExportCall)
.mockRejectedValueOnce(new Error("Export unavailable"))
.mockResolvedValueOnce(jsonBlob(records));
const { result } = renderHook(() => useClientAgentModels(options));
await waitFor(() => expect(result.current.error).toBe("Export unavailable"));
act(() => result.current.reload());
expect(result.current.error).toBeNull();
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.results).toHaveLength(1));
expect(dailyActivityExportCall).toHaveBeenCalledTimes(2);
expect(vi.mocked(dailyActivityExportCall).mock.calls[1]).toEqual(vi.mocked(dailyActivityExportCall).mock.calls[0]);
});
it("should hide the previous model totals until a reload completes", async () => {
vi.mocked(dailyActivityExportCall)
.mockResolvedValueOnce(jsonBlob(records))
.mockReturnValueOnce(new Promise(() => {}));
const { result } = renderHook(() => useClientAgentModels(options));
await waitFor(() => expect(result.current.results).toHaveLength(1));
act(() => result.current.reload());
expect(result.current.results).toEqual([]);
expect(result.current.loading).toBe(true);
});
});

View file

@ -0,0 +1,67 @@
import { useCallback, useEffect, useState } from "react";
import { dailyActivityExportCall } from "@/components/networking";
import type { DailyActivityRequest } from "@/components/UsagePage/dailyActivityApi";
import type { AgentRow } from "../components/overview/agentCatalog";
import { clientAgentModelsFromExport, type ClientAgentModelsResult } from "./clientAgentModelsData";
interface ClientAgentModelsOptions {
accessToken: string | null;
startTime: Date | null;
endTime: Date | null;
agent: AgentRow | null;
enabled: boolean;
}
export const MAX_CLIENT_MODEL_EXPORT_BYTES = 20 * 1024 * 1024;
const loadModels = async (request: DailyActivityRequest, agent: AgentRow): Promise<ClientAgentModelsResult> => {
try {
const blob = await dailyActivityExportCall("tag", request, "daily_with_models", "json");
if (blob.size > MAX_CLIENT_MODEL_EXPORT_BYTES) {
return { results: [], error: "Choose a shorter date range to load the model breakdown" };
}
const payload: unknown = JSON.parse(await blob.text());
return clientAgentModelsFromExport(payload, agent);
} catch (error: unknown) {
if (error instanceof SyntaxError) {
return { results: [], error: "Could not load complete model usage. Try again." };
}
return { results: [], error: error instanceof Error ? error.message : "Could not load the model breakdown" };
}
};
const agentKey = (agent: AgentRow | null) =>
agent ? [agent.id, agent.tags, agent.spend, agent.tokens, agent.requests] : null;
const timestamp = (date: Date | null) => date?.getTime() ?? Number.NaN;
export function useClientAgentModels({ accessToken, startTime, endTime, agent, enabled }: ClientAgentModelsOptions) {
const start = timestamp(startTime);
const end = timestamp(endTime);
const validRange = Number.isFinite(start) && Number.isFinite(end) && start <= end;
const active = enabled && Boolean(accessToken && agent && agent.tags.length > 0);
const [attempt, setAttempt] = useState(0);
const reload = useCallback(() => setAttempt((current) => current + 1), []);
const key = JSON.stringify([attempt, accessToken, start, end, agentKey(agent)]);
const [settled, setSettled] = useState<{ key: string; result: ClientAgentModelsResult } | null>(null);
useEffect(() => {
if (!active || !validRange) return;
if (!accessToken || !agent) return;
let cancelled = false;
const request = { accessToken, startTime: new Date(start), endTime: new Date(end), entityIds: null };
loadModels(request, agent).then((result) => {
if (!cancelled) setSettled({ key, result });
});
return () => {
cancelled = true;
};
}, [active, validRange, accessToken, start, end, agent, key]);
const current = active && validRange && settled?.key === key ? settled.result : null;
return {
results: current?.results ?? [],
loading: active && validRange && current === null,
error: active && !validRange ? "Select a valid date range" : current?.error ?? null,
reload,
};
}

View file

@ -0,0 +1,169 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { uiSpendLogsCall } from "@/components/networking";
import { UsageClient } from "./clientUsageData";
import { useClientUsage } from "./useClientUsage";
vi.mock("@/components/networking", () => ({ uiSpendLogsCall: vi.fn() }));
const options = {
accessToken: "test-token",
userId: "user-1",
client: UsageClient.ClaudeDesktop,
startTime: new Date("2026-10-22T07:00:00.500Z"),
endTime: new Date("2026-10-23T06:59:59.999Z"),
};
const emptyPage = { data: [], total: 0, page: 1, total_pages: 0, total_is_capped: false };
describe("useClientUsage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should send a bounded authenticated user query with UTC bounds covering the selected interval", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyPage);
const { result } = renderHook(() => useClientUsage(options));
await waitFor(() => expect(result.current.loading).toBe(false));
const expectedRequest = {
accessToken: "test-token",
start_date: "2026-10-22 07:00:00",
end_date: "2026-10-23 07:00:00",
page: 1,
page_size: 1000,
params: { user_id: "user-1", sort_by: "startTime", sort_order: "desc", group_by_session: false },
};
expect(uiSpendLogsCall).toHaveBeenCalledExactlyOnceWith(expectedRequest);
expect(result.current.error).toBeNull();
});
it("should require a selected user and avoid querying the all-clients view", () => {
const { result, rerender } = renderHook(
({ userId, client }: { userId: string | null; client: UsageClient | null }) =>
useClientUsage({ ...options, userId, client }),
{ initialProps: { userId: null, client: UsageClient.ClaudeDesktop } },
);
expect(result.current.error).toBe("Select a user or team to view client usage");
expect(result.current.loading).toBe(false);
rerender({ userId: "user-1", client: null });
expect(result.current.error).toBeNull();
expect(uiSpendLogsCall).not.toHaveBeenCalled();
});
it("should expose incomplete results as an error without loading more pages", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue({ ...emptyPage, total: 1001, total_pages: 2 });
const { result } = renderHook(() => useClientUsage(options));
await waitFor(() => expect(result.current.error).toContain("Narrow the date range"));
expect(result.current.data.results).toEqual([]);
expect(uiSpendLogsCall).toHaveBeenCalledTimes(1);
});
it("should expose network failures", async () => {
vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("No permission to view these logs"));
const { result } = renderHook(() => useClientUsage(options));
await waitFor(() => expect(result.current.error).toBe("No permission to view these logs"));
expect(result.current.loading).toBe(false);
});
it("should ignore a stale response after the user changes", async () => {
let resolveFirst: ((value: unknown) => void) | undefined;
const first = new Promise<unknown>((resolve) => {
resolveFirst = resolve;
});
vi.mocked(uiSpendLogsCall).mockReturnValueOnce(first).mockResolvedValueOnce(emptyPage);
const { result, rerender } = renderHook(({ userId }) => useClientUsage({ ...options, userId }), {
initialProps: { userId: "user-1" },
});
rerender({ userId: "user-2" });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
resolveFirst?.({ ...emptyPage, total: 1001, total_pages: 2 });
});
expect(result.current.error).toBeNull();
expect(result.current.data.metadata?.total_spend).toBe(0);
});
it("should clear loaded data immediately when client filtering is disabled", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue({ ...emptyPage, total: 1001, total_pages: 2 });
const { result, rerender } = renderHook(
({ client }: { client: UsageClient | null }) => useClientUsage({ ...options, client }),
{ initialProps: { client: UsageClient.ClaudeDesktop } },
);
await waitFor(() => expect(result.current.error).toContain("Narrow the date range"));
rerender({ client: null });
expect(result.current.error).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.data.results).toEqual([]);
});
it("should retry a failed request with the same authenticated user and dates", async () => {
vi.mocked(uiSpendLogsCall).mockRejectedValueOnce(new Error("Logs unavailable")).mockResolvedValueOnce(emptyPage);
const { result } = renderHook(() => useClientUsage(options));
await waitFor(() => expect(result.current.error).toBe("Logs unavailable"));
act(() => result.current.reload());
expect(result.current.error).toBeNull();
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(uiSpendLogsCall).toHaveBeenCalledTimes(2);
expect(vi.mocked(uiSpendLogsCall).mock.calls[1]).toEqual(vi.mocked(uiSpendLogsCall).mock.calls[0]);
});
it("should hide previous person totals until a reload completes", async () => {
const row = {
request_id: "request-1",
user: options.userId,
startTime: "2026-10-22T12:00:00Z",
spend: 3,
total_tokens: 100,
prompt_tokens: 80,
completion_tokens: 20,
request_tags: [],
metadata: { user_agent: "claude-desktop/1.0" },
};
const page = { ...emptyPage, data: [row], total: 1, total_pages: 1 };
vi.mocked(uiSpendLogsCall)
.mockResolvedValueOnce(page)
.mockReturnValueOnce(new Promise(() => {}));
const { result } = renderHook(() => useClientUsage(options));
await waitFor(() => expect(result.current.data.metadata?.total_spend).toBe(3));
act(() => result.current.reload());
expect(result.current.data.results).toEqual([]);
expect(result.current.loading).toBe(true);
});
it("should query team totals and narrow to a member with both filters", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyPage);
const initialProps: { userId: string | null } = { userId: null };
const { result, rerender } = renderHook(
({ userId }: { userId: string | null }) => useClientUsage({ ...options, userId, teamId: "engineering" }),
{ initialProps },
);
await waitFor(() => expect(result.current.loading).toBe(false));
const teamParams = {
team_id: "engineering",
sort_by: "startTime",
sort_order: "desc",
group_by_session: false,
};
expect(vi.mocked(uiSpendLogsCall).mock.calls[0][0].params).toEqual(teamParams);
rerender({ userId: "user-1" });
await waitFor(() => expect(result.current.loading).toBe(false));
const memberParams = { ...teamParams, user_id: "user-1" };
expect(vi.mocked(uiSpendLogsCall).mock.calls[1][0].params).toEqual(memberParams);
});
it("should ignore an old response when switching teams", async () => {
let resolveFirst: ((response: unknown) => void) | undefined;
const first = new Promise<unknown>((resolve) => {
resolveFirst = resolve;
});
vi.mocked(uiSpendLogsCall).mockReturnValueOnce(first).mockResolvedValueOnce(emptyPage);
const { result, rerender } = renderHook(({ teamId }) => useClientUsage({ ...options, teamId, userId: null }), {
initialProps: { teamId: "engineering" },
});
rerender({ teamId: "design" });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => resolveFirst?.({ ...emptyPage, total: 1001, total_pages: 2 }));
expect(result.current.error).toBeNull();
expect(result.current.data.metadata?.total_spend).toBe(0);
});
});

View file

@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from "react";
import { uiSpendLogsCall } from "@/components/networking";
import {
CLIENT_REQUEST_LIMIT,
clientUsageFromLogs,
emptyClientUsage,
type ClientUsageResult,
type UsageClient,
} from "./clientUsageData";
interface ClientUsageOptions {
accessToken: string | null;
startTime: Date | null;
endTime: Date | null;
userId: string | null;
teamId?: string | null;
client: UsageClient | null;
}
const utcSecond = (time: number) => new Date(time).toISOString().slice(0, 19).replace("T", " ");
export function useClientUsage({ accessToken, startTime, endTime, userId, teamId = null, client }: ClientUsageOptions) {
const start = startTime?.getTime() ?? Number.NaN;
const end = endTime?.getTime() ?? Number.NaN;
const scopeError = (() => {
if (client === null) return null;
if (!userId && !teamId) return "Select a user or team to view client usage";
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) return "Select a valid date range";
if (!accessToken) return "Sign in to view client usage";
return null;
})();
const enabled = client !== null && scopeError === null;
const [attempt, setAttempt] = useState(0);
const reload = useCallback(() => setAttempt((current) => current + 1), []);
const key = JSON.stringify([attempt, accessToken, start, end, userId, teamId, client]);
const [settled, setSettled] = useState<{ key: string; result: ClientUsageResult } | null>(null);
useEffect(() => {
if (!enabled) return;
if (!accessToken || !client) return;
if (!userId && !teamId) return;
let cancelled = false;
const scope = { userId, teamId, client, startTime: new Date(start), endTime: new Date(end) };
const request = {
accessToken,
start_date: utcSecond(Math.floor(start / 1000) * 1000),
end_date: utcSecond(Math.ceil(end / 1000) * 1000),
page: 1,
page_size: CLIENT_REQUEST_LIMIT,
params: {
...(userId ? { user_id: userId } : {}),
...(teamId ? { team_id: teamId } : {}),
sort_by: "startTime",
sort_order: "desc" as const,
group_by_session: false,
},
};
uiSpendLogsCall(request)
.then((response: unknown) => {
if (!cancelled) setSettled({ key, result: clientUsageFromLogs(response, scope) });
})
.catch((error: unknown) => {
if (!cancelled) {
setSettled({
key,
result: emptyClientUsage(error instanceof Error ? error.message : "Could not load client usage"),
});
}
});
return () => {
cancelled = true;
};
}, [enabled, accessToken, start, end, userId, teamId, client, key]);
const current = enabled && settled?.key === key ? settled.result : emptyClientUsage(scopeError);
return { ...current, loading: enabled && settled?.key !== key, reload };
}

View file

@ -0,0 +1,85 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useUsagePanelContext } from "./useUsagePanelContext";
const dashboards: HTMLDivElement[] = [];
function createDashboard() {
const viewport = document.createElement("div");
const scroller = document.createElement("div");
const page = document.createElement("div");
const trigger = document.createElement("button");
const panelControl = document.createElement("button");
trigger.textContent = "View Claude Desktop usage";
panelControl.textContent = "Back to Usage";
page.append(trigger);
scroller.append(page);
viewport.append(scroller, panelControl);
document.body.append(viewport);
dashboards.push(viewport);
return { viewport, scroller, page, trigger, panelControl };
}
afterEach(() => {
dashboards.splice(0).forEach((dashboard) => dashboard.remove());
});
describe("useUsagePanelContext", () => {
it("should preserve nested scroll positions while open and return focus to the trigger on close", async () => {
const { viewport, scroller, page, trigger, panelControl } = createDashboard();
const { result, rerender } = renderHook(({ open }) => useUsagePanelContext(open), {
initialProps: { open: false },
});
act(() => {
result.current.pageRef.current = page;
viewport.scrollTop = 160;
scroller.scrollTop = 840;
scroller.scrollLeft = 45;
trigger.focus();
result.current.remember();
panelControl.focus();
viewport.scrollTop = 0;
scroller.scrollTop = 0;
scroller.scrollLeft = 0;
});
rerender({ open: true });
await waitFor(() => expect(scroller.scrollTop).toBe(840));
expect(viewport.scrollTop).toBe(160);
expect(scroller.scrollLeft).toBe(45);
expect(panelControl).toHaveFocus();
scroller.scrollTop = 12;
viewport.scrollTop = 0;
rerender({ open: false });
await waitFor(() => expect(trigger).toHaveFocus());
expect(viewport.scrollTop).toBe(160);
expect(scroller.scrollTop).toBe(840);
expect(scroller.scrollLeft).toBe(45);
});
it("should restore the new position when a user reopens the panel after scrolling", async () => {
const { scroller, page, trigger, panelControl } = createDashboard();
const { result, rerender } = renderHook(({ open }) => useUsagePanelContext(open), {
initialProps: { open: false },
});
act(() => {
result.current.pageRef.current = page;
});
for (const position of [250, 930]) {
act(() => {
scroller.scrollTop = position;
trigger.focus();
result.current.remember();
panelControl.focus();
scroller.scrollTop = 0;
});
rerender({ open: true });
await waitFor(() => expect(scroller.scrollTop).toBe(position));
rerender({ open: false });
await waitFor(() => expect(trigger).toHaveFocus());
expect(scroller.scrollTop).toBe(position);
}
});
});

View file

@ -0,0 +1,37 @@
import { useCallback, useLayoutEffect, useRef } from "react";
/** Keep the dashboard's nested scroll container in place across dialog focus changes. */
export function useUsagePanelContext(open: boolean) {
const pageRef = useRef<HTMLDivElement>(null);
const snapshot = useRef<{
scroll: { element: HTMLElement; top: number; left: number }[];
focus: HTMLElement | null;
} | null>(null);
const remember = useCallback(() => {
const scroll: { element: HTMLElement; top: number; left: number }[] = [];
for (let element = pageRef.current?.parentElement; element; element = element.parentElement) {
scroll.push({ element, top: element.scrollTop, left: element.scrollLeft });
}
const focus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
snapshot.current = { scroll, focus };
}, []);
useLayoutEffect(() => {
const saved = snapshot.current;
if (!saved) return;
const restore = () => {
for (const { element, top, left } of saved.scroll) {
element.scrollTop = top;
element.scrollLeft = left;
}
};
restore();
const frame = requestAnimationFrame(() => {
if (!open) saved.focus?.focus({ preventScroll: true });
restore();
if (!open) snapshot.current = null;
});
return () => cancelAnimationFrame(frame);
}, [open]);
return { pageRef, remember };
}

View file

@ -2,10 +2,14 @@ import moment from "moment";
import { createLoader, createSerializer } from "nuqs";
import { describe, expect, it } from "vitest";
import { UsageClient } from "./components/overview/agentCatalog";
import type { UsageOption } from "./components/UsageViewSelect/UsageViewSelect";
import { USAGE_OPTIONS } from "./components/UsageViewSelect/UsageViewSelect";
import {
cleanUsageUrl,
clientDateRangeFromParams,
clientDateRangePatch,
clientPanelPatch,
dateRangeFromParams,
dateRangePatch,
entitySelectionPatch,
@ -265,3 +269,105 @@ describe("cross-page links", () => {
expect(params.user).toBe("alice+ops@example.com");
});
});
describe("client usage filters", () => {
it("should preserve a recognized client and person in a share link", () => {
const params = load("?user=u1&client=claude-desktop&range=7d");
expect(params.client).toBe("claude-desktop");
expect(clean("?user=u1&client=claude-desktop&range=7d")).toBeNull();
expect(load(serialize(params)).user).toBe("u1");
});
it("should reject an unknown client", () => {
expect(load("?client=made-up").client).toBeNull();
});
it("should clear the client when changing to another usage view", () => {
expect(load(applyPatch("?client=claude-desktop&user=u1", usageViewPatch("team"))).client).toBeNull();
expect(clean("?view=organization&client=claude-desktop")).toEqual({
query: "?view=organization",
deniedAccess: false,
});
expect(clean("?view=team&client=claude-desktop")).toBeNull();
});
it("should preserve the underlying tab and person when a client panel opens", () => {
const query = applyPatch("?user=u1&tab=keys&range=7d", { client: UsageClient.ClaudeDesktop, client_user: "u2" });
expect(usageTabFromParams(load(query))).toBe("keys");
expect(clean(query)).toBeNull();
expect(load(query).user).toBe("u1");
expect(load(query).client_user).toBe("u2");
expect(applyPatch(query, { client: null, client_user: null })).toBe("?user=u1&tab=keys&range=7d");
});
it("should remove stray panel person filters", () => {
expect(clean("?client_user=u2&range=7d")).toEqual({ query: "?range=7d", deniedAccess: false });
expect(clean("?client=claude-desktop&client_user=u2", internalUserAccess)).toEqual({
query: "?client=claude-desktop",
deniedAccess: false,
});
});
});
describe("client panel date range", () => {
it("should change the panel dates without changing overview filters or dates", () => {
const query = applyPatch("?user=u1&tab=models&range=30d&client=claude-desktop", clientDateRangePatch({}, "7d"));
const params = load(query);
expect(params.range).toBe("30d");
expect(params.user).toBe("u1");
expect(params.tab).toBe("models");
expect(params.client_range).toBe("7d");
expect(clientDateRangeFromParams(params)).toBeDefined();
expect(clean(query)).toBeNull();
});
it("should round-trip custom panel dates independently", () => {
const value = { from: new Date(2026, 9, 20), to: new Date(2026, 9, 22) };
const query = applyPatch("?client=claude-desktop&client_range=7d&range=30d", clientDateRangePatch(value, null));
const params = load(query);
expect(params.client_range).toBeNull();
expect(params.client_from).toBe("2026-10-20");
expect(params.client_to).toBe("2026-10-22");
expect(moment(clientDateRangeFromParams(params)?.from).format("YYYY-MM-DD")).toBe("2026-10-20");
expect(params.range).toBe("30d");
});
it("should clear only panel state on close or when opening a different client", () => {
const query = "?client=claude-desktop&client_user=u2&client_range=7d&range=30d&user=u1&tab=keys";
expect(applyPatch(query, clientPanelPatch(null))).toBe("?range=30d&user=u1&tab=keys");
const next = load(applyPatch(query, clientPanelPatch(UsageClient.ClaudeCode)));
expect(next.client).toBe("claude-code");
expect(next.client_range).toBeNull();
expect(next.range).toBe("30d");
});
it("should remove invalid or orphaned panel dates without clearing overview dates", () => {
expect(clean("?range=7d&client_range=30d")).toEqual({ query: "?range=7d", deniedAccess: false });
expect(clean("?range=7d&client=claude-desktop&client_from=bad&client_to=2026-10-22")).toEqual({
query: "?range=7d&client=claude-desktop",
deniedAccess: false,
});
});
});
describe("client team scope", () => {
it("should preserve an administered-team member filter and independent dates", () => {
const query =
"?view=team&team=engineering&client=claude-desktop&client_team=engineering&client_user=member&client_range=30d";
const access = { allowedViews: USAGE_OPTIONS, isAdmin: false, canViewTeamClients: true };
expect(cleanUsageUrl(load(query), access)).toBeNull();
});
it("should discard an unscoped person filter for an internal user", () => {
const cleanup = cleanUsageUrl(load("?client=claude-desktop&client_user=other"), internalUserAccess);
expect(cleanup?.patch).toMatchObject({ client_user: null });
});
it("should clear panel team and member without changing the overview's selected teams", () => {
const query = applyPatch(
"?view=team&team=engineering&client=claude-desktop&client_team=design&client_user=member",
clientPanelPatch(null),
);
expect(query).toBe("?view=team&team=engineering");
});
});

View file

@ -1,11 +1,12 @@
import moment from "moment";
import { parseAsNativeArrayOf, parseAsString, type inferParserType } from "nuqs";
import { parseAsNativeArrayOf, parseAsString, parseAsStringEnum, type inferParserType } from "nuqs";
import type { EntityType } from "@/components/EntityUsageExport/types";
import { relativeTimeOptions } from "@/components/shared/advanced_date_picker";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { uiHref } from "@/utils/uiHref";
import { USAGE_OPTIONS, type UsageOption } from "./components/UsageViewSelect/UsageViewSelect";
import { UsageClient } from "./components/overview/agentCatalog";
export const DEFAULT_USAGE_VIEW: UsageOption = "global";
export const USAGE_TABS = ["cost", "models", "keys", "users", "mcp", "endpoints", "errors"] as const;
@ -21,6 +22,12 @@ export const usageUrlParsers = {
from: parseAsString,
to: parseAsString,
user: parseAsString,
client_user: parseAsString,
client_team: parseAsString,
client_range: parseAsString,
client_from: parseAsString,
client_to: parseAsString,
client: parseAsStringEnum<UsageClient>(Object.values(UsageClient)),
team: entityIds,
org: entityIds,
customer: entityIds,
@ -54,12 +61,20 @@ const VIEW_ENTITY_KEYS: Readonly<Record<UsageOption, readonly EntityKey[]>> = {
"user-agent-activity": ["tag"],
};
export const CLIENT_USAGE_VIEWS: readonly UsageOption[] = ["global", "my-usage", "team"];
const TABBED_VIEWS: readonly UsageOption[] = ["global", "my-usage"];
const RESET_TO_DEFAULT_VIEW: UsageUrlPatch = {
view: null,
tab: null,
user: null,
client: null,
client_user: null,
client_team: null,
client_range: null,
client_from: null,
client_to: null,
team: null,
org: null,
customer: null,
@ -77,8 +92,9 @@ const hasEntityValue = (params: UsageUrlParams, key: EntityKey): boolean =>
export const usageViewFromParams = (params: UsageUrlParams): UsageOption =>
isUsageOption(params.view) ? params.view : DEFAULT_USAGE_VIEW;
export const usageTabFromParams = (params: UsageUrlParams, isAdmin = true): UsageTab =>
isUsageTab(params.tab) && (params.tab !== "errors" || isAdmin) ? params.tab : DEFAULT_USAGE_TAB;
export const usageTabFromParams = (params: UsageUrlParams, isAdmin = true): UsageTab => {
return isUsageTab(params.tab) && (params.tab !== "errors" || isAdmin) ? params.tab : DEFAULT_USAGE_TAB;
};
export const usageViewPatch = (view: UsageOption): UsageUrlPatch => ({
...RESET_TO_DEFAULT_VIEW,
@ -128,6 +144,35 @@ export const dateRangePatch = (value: DateRangePickerValue, presetShortLabel: st
};
};
type ClientDateParams = Pick<UsageUrlParams, "client_range" | "client_from" | "client_to">;
const clientDateParams = (params: ClientDateParams): DateParams => ({
range: params.client_range,
from: params.client_from,
to: params.client_to,
});
export const clientDateRangeFromParams = (params: ClientDateParams): DateRangePickerValue | undefined =>
dateRangeFromParams(clientDateParams(params));
const asClientDatePatch = (patch: UsageUrlPatch): UsageUrlPatch => ({
...("range" in patch ? { client_range: patch.range } : {}),
...("from" in patch ? { client_from: patch.from } : {}),
...("to" in patch ? { client_to: patch.to } : {}),
});
export const clientDateRangePatch = (value: DateRangePickerValue, presetShortLabel: string | null): UsageUrlPatch =>
asClientDatePatch(dateRangePatch(value, presetShortLabel));
export const clientPanelPatch = (client: UsageUrlParams["client"]): UsageUrlPatch => ({
client,
client_user: null,
client_team: null,
client_range: null,
client_from: null,
client_to: null,
});
const invalidDatePatch = (params: DateParams): UsageUrlPatch => {
const hasCustomRange = params.from !== null || params.to !== null;
if (params.range !== null) {
@ -144,6 +189,7 @@ const invalidDatePatch = (params: DateParams): UsageUrlPatch => {
export interface UsageUrlAccess {
readonly allowedViews: readonly UsageOption[];
readonly isAdmin: boolean;
readonly canViewTeamClients?: boolean;
}
export interface UsageUrlCleanup {
@ -166,9 +212,25 @@ export const cleanUsageUrl = (params: UsageUrlParams, access: UsageUrlAccess): U
const invalidTab =
params.tab !== null &&
(!TABBED_VIEWS.includes(view) || !isUsageTab(params.tab) || (!access.isAdmin && params.tab === "errors"));
const panelOpen = CLIENT_USAGE_VIEWS.includes(view) && params.client !== null;
const canFilterClientUser = access.isAdmin || Boolean(access.canViewTeamClients && params.client_team);
const panelDates = clientDateParams(params);
const panelDateCleanup = panelOpen
? invalidDatePatch(panelDates)
: Object.fromEntries(
Object.entries(panelDates)
.filter(([, value]) => value !== null)
.map(([key]) => [key, null]),
);
const patch: UsageUrlPatch = {
...asClientDatePatch(panelDateCleanup),
...(params.view !== null && !isUsageOption(params.view) ? { view: null } : {}),
...(invalidTab ? { tab: null } : {}),
...(!CLIENT_USAGE_VIEWS.includes(view) && params.client !== null ? { client: null } : {}),
...((!panelOpen || !canFilterClientUser) && params.client_user !== null ? { client_user: null } : {}),
...((!CLIENT_USAGE_VIEWS.includes(view) || params.client === null) && params.client_team !== null
? { client_team: null }
: {}),
...Object.fromEntries(strayEntityKeys.map((key) => [key, null])),
...invalidDatePatch(params),
};

View file

@ -7,7 +7,7 @@ import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrgan
export default function UsagePage() {
useAuthorized();
const { data: teams } = useTeams();
const { data: teams, isLoading: teamsLoading } = useTeams();
const { data: organizations } = useOrganizations();
return <NewUsagePage teams={teams ?? []} organizations={organizations ?? []} />;
return <NewUsagePage teams={teams ?? []} organizations={organizations ?? []} teamsLoading={teamsLoading} />;
}

View file

@ -270,15 +270,16 @@ describe("CreateVectorStore", () => {
});
it("should display S3 Vectors provider-specific fields when selected", async () => {
const user = userEvent.setup();
render(<CreateVectorStore accessToken="test-token" />);
// Find and click the provider dropdown
const providerSelect = screen.getByRole("combobox");
await userEvent.click(providerSelect);
await user.click(providerSelect);
// Wait for dropdown options to appear
await userEvent.click(await screen.findByText("AWS S3 Vectors"));
await user.click(await screen.findByRole("option", { name: /AWS S3 Vectors/ }));
// Check if S3-specific fields are displayed
await waitFor(() => {
@ -289,6 +290,7 @@ describe("CreateVectorStore", () => {
});
it("should validate S3 Vectors required fields before submission", async () => {
const user = userEvent.setup();
render(<CreateVectorStore accessToken="test-token" />);
// Upload a file first
@ -306,11 +308,11 @@ describe("CreateVectorStore", () => {
});
// Select S3 Vectors provider
const providerSelect = screen.getByRole("combobox");
const providerSelect = screen.getByRole("combobox", { name: /Provider/ });
await userEvent.click(providerSelect);
await user.click(providerSelect);
await userEvent.click(await screen.findByText("AWS S3 Vectors"));
await user.click(await screen.findByRole("option", { name: /AWS S3 Vectors/ }));
// Try to create without filling required fields
const createButton = screen.getByRole("button", { name: /Create Vector Store/i });
@ -319,7 +321,7 @@ describe("CreateVectorStore", () => {
fireEvent.click(createButton);
});
// Should show validation warning (mocked message.warning would be called)
// The actual validation happens in the component
expect(toast.warning).toHaveBeenCalledWith("Please provide Vector Bucket Name");
expect(networking.ragIngestCall).not.toHaveBeenCalled();
});
});

View file

@ -30,7 +30,7 @@ vi.mock("@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButto
}));
vi.mock("@/components/Navbar/NotificationsBell/NotificationsBell", () => ({ NotificationsBell: () => null }));
vi.mock("@/components/Navbar/WorkerDropdown/WorkerDropdown", () => ({ default: () => null }));
vi.mock("@/components/liteadmin/LiteAdmin", () => ({ default: () => <button>LiteAdmin</button> }));
vi.mock("@/components/liteadmin/LiteAdmin", () => ({ default: () => <button>Aria Agent</button> }));
const renderDashboardHeader = () =>
render(
@ -107,10 +107,10 @@ describe("DashboardHeader breadcrumb", () => {
expect(separators[0].className).toContain("data-vertical:self-center");
});
it("places LiteAdmin in the header tools ahead of Docs", () => {
it("should place Aria Agent in the header tools ahead of Docs", () => {
renderDashboardHeader();
const liteAdmin = within(screen.getByRole("banner")).getByRole("button", { name: "LiteAdmin" });
const liteAdmin = within(screen.getByRole("banner")).getByRole("button", { name: "Aria Agent" });
expect(liteAdmin.compareDocumentPosition(screen.getByRole("link", { name: "Docs" }))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
@ -122,7 +122,7 @@ describe("DashboardHeader breadcrumb", () => {
const tools = await screen.findByRole("dialog", { name: "Gateway tools" });
expect(within(tools).getByRole("button", { name: "AI Gateway" })).toBeInTheDocument();
expect(within(tools).getByRole("link", { name: "Docs" })).toBeInTheDocument();
expect(within(tools).getByRole("button", { name: "LiteAdmin" })).toBeInTheDocument();
expect(within(tools).getByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
});
it("closes mobile tools when switching to desktop and keeps them closed when returning", async () => {

View file

@ -20,7 +20,7 @@ const RELEASE: LatestReleaseInfo = {
new_features: 12,
bug_fixes: 30,
other_updates: 8,
release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0",
release_url: "https://docs.litellm.ai/release_notes/v1.103.0/v1-103-0",
};
const bell = (unread: number) =>

View file

@ -2,6 +2,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin";
import { ARIA_AGENT_LABEL } from "@/components/liteadmin/aria-brand";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import {
emitLocalStorageChange,
@ -206,12 +207,12 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
</div>
{premiumUser === true && canUseLiteAdmin && (
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide LiteAdmin</span>
<span className="text-muted-foreground">Hide {ARIA_AGENT_LABEL}</span>
<Switch
size="sm"
checked={disableLiteAdmin}
onCheckedChange={setDisableLiteAdmin}
aria-label="Toggle hide LiteAdmin"
aria-label={`Toggle hide ${ARIA_AGENT_LABEL}`}
/>
</div>
)}

Some files were not shown because too many files have changed in this diff Show more