mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
Merge remote-tracking branch 'upstream/main' into fix-44211-deepseek-tool-images
This commit is contained in:
commit
d620e29f54
125 changed files with 16693 additions and 3539 deletions
91
.github/workflows/interactions-spec-canary.yml
vendored
Normal file
91
.github/workflows/interactions-spec-canary.yml
vendored
Normal 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.`,
|
||||
});
|
||||
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ RUN apk add --no-cache \
|
|||
python-3.13-dev \
|
||||
rust \
|
||||
openssl \
|
||||
libssl3 \
|
||||
openssl-dev \
|
||||
nodejs \
|
||||
npm \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ RUN apk add --no-cache \
|
|||
python-3.13 \
|
||||
python-3.13-dev \
|
||||
openssl \
|
||||
libssl3 \
|
||||
openssl-dev \
|
||||
nodejs \
|
||||
npm \
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ RUN for i in 1 2 3; do \
|
|||
coreutils \
|
||||
curl \
|
||||
openssl \
|
||||
libssl3 \
|
||||
libsndfile \
|
||||
nodejs \
|
||||
npm && break || sleep 5; \
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
BIN
litellm/proxy/_experimental/out/assets/aria-agent.png
Normal file
BIN
litellm/proxy/_experimental/out/assets/aria-agent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 685 KiB |
27
litellm/proxy/_experimental/out/assets/logos/claude.svg
Normal file
27
litellm/proxy/_experimental/out/assets/logos/claude.svg
Normal 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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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.",
|
||||
}
|
||||
)
|
||||
|
|
@ -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"},
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
0
litellm/text_completions/__init__.py
Normal file
0
litellm/text_completions/__init__.py
Normal file
88
litellm/text_completions/streaming_iterator.py
Normal file
88
litellm/text_completions/streaming_iterator.py
Normal 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
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
100
litellm/utils.py
100
litellm/utils.py
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
9
tests/unit/interactions/conftest.py
Normal file
9
tests/unit/interactions/conftest.py
Normal 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",
|
||||
)
|
||||
10024
tests/unit/interactions/interactions.openapi.json
Normal file
10024
tests/unit/interactions/interactions.openapi.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
0
tests/unit/text_completions/__init__.py
Normal file
0
tests/unit/text_completions/__init__.py
Normal file
144
tests/unit/text_completions/test_streaming_iterator.py
Normal file
144
tests/unit/text_completions/test_streaming_iterator.py
Normal 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")
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
198
ui/litellm-dashboard/package-lock.json
generated
198
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
ui/litellm-dashboard/public/assets/aria-agent.png
Normal file
BIN
ui/litellm-dashboard/public/assets/aria-agent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 685 KiB |
27
ui/litellm-dashboard/public/assets/logos/claude.svg
Normal file
27
ui/litellm-dashboard/public/assets/logos/claude.svg
Normal 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>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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. "Which model costs me the most?"</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;
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } };
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue