From a31c5230de303d342f62e0e2ecd62af83e5fcd4e Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:42:05 +0000 Subject: [PATCH 01/50] fix(proxy): forward dispatched_call_type through _failure_usage_to_lift (#47815) --- litellm/proxy/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 52b3866d04d..28be11509ae 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -1294,6 +1294,7 @@ def _failure_usage_to_lift( provider_billed: bool = False, deployment_id: str | None = None, base_model: str | None = None, + dispatched_call_type: str | None = None, ) -> tuple[object, object] | None: current_attempt: Final = _current_failure_usage_to_lift( model_call_details, @@ -1302,6 +1303,7 @@ def _failure_usage_to_lift( provider_billed, deployment_id, base_model, + dispatched_call_type=dispatched_call_type, ) failed_attempts: Final = _FAILED_ATTEMPTS_USAGE.validate_python(model_call_details.get("failed_attempts_usage", ())) if not failed_attempts: From 9d8fe1ddb51c0cb4c3190aa51274fbdde98eb05f Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:58:31 +0000 Subject: [PATCH 02/50] refactor(types): replace Any with proven types in 50 files (#47818) * refactor(types): replace Any with proven types in 100 files Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(types): keep untyped parsing where strict validation changed accepted shapes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(types): keep the OpenAI-compatible response parse untyped Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(types): keep only annotation changes, drop new runtime validation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/deepeval/types.py | 4 ++-- .../streaming_iterator.py | 6 +++--- litellm/llms/base_llm/ocr/transformation.py | 6 +++--- litellm/llms/base_llm/sandbox/transformation.py | 6 +++--- .../llms/github_copilot/embedding/transformation.py | 4 +++- litellm/llms/ollama/completion/handler.py | 4 ++-- litellm/models/managed_files.py | 8 ++++---- litellm/proxy/a2a/endpoints.py | 6 +++--- litellm/proxy/auth/auth_exception_handler.py | 2 +- litellm/proxy/management_endpoints/types.py | 4 ++-- litellm/proxy/spend_tracking/vantage_endpoints.py | 4 +++- litellm/rust_bridge/detectors.py | 2 +- litellm/types/caching.py | 4 ++-- litellm/types/completion.py | 2 +- litellm/types/extraction.py | 3 ++- litellm/types/google_genai/main.py | 2 +- litellm/types/images/main.py | 4 ++-- litellm/types/integrations/argilla.py | 4 ++-- litellm/types/integrations/cloudzero.py | 7 ++----- .../types/integrations/compression_interception.py | 4 ++-- litellm/types/integrations/gcs_bucket.py | 4 ++-- litellm/types/integrations/langsmith.py | 8 ++++---- litellm/types/integrations/posthog.py | 4 ++-- .../llms/anthropic_messages/anthropic_response.py | 4 ++-- litellm/types/llms/base.py | 4 ++-- litellm/types/llms/databricks.py | 6 +++--- .../management_endpoints/cache_settings_endpoints.py | 6 +++--- .../router_settings_endpoints.py | 6 +++--- litellm/types/mcp.py | 10 +++++----- litellm/types/mcp_server/mcp_server_manager.py | 4 ++-- litellm/types/prompts/init_prompts.py | 6 +++--- litellm/types/proxy/callback_logs_endpoints.py | 4 ++-- litellm/types/proxy/cloudzero_endpoints.py | 2 +- .../guardrail_hooks/azure/azure_prompt_shield.py | 4 +--- .../guardrail_hooks/azure/azure_text_moderation.py | 4 ++-- .../proxy/guardrails/guardrail_hooks/compresr.py | 4 ++-- .../guardrail_hooks/litellm_content_filter.py | 6 +++--- .../proxy/guardrails/guardrail_hooks/presidio.py | 8 ++++---- .../management_endpoints/common_daily_activity.py | 5 ++--- .../proxy/management_endpoints/config_overrides.py | 6 ++---- .../management_endpoints/internal_user_endpoints.py | 4 ++-- .../model_management_endpoints.py | 3 +-- .../proxy/management_endpoints/team_endpoints.py | 8 ++++---- litellm/types/proxy/prompt_endpoints.py | 4 +--- .../types/proxy/public_endpoints/public_endpoints.py | 4 ++-- litellm/types/proxy/vantage_endpoints.py | 6 +++--- litellm/types/rag.py | 6 +++--- litellm/types/responses/main.py | 4 ++-- litellm/types/vector_store_files.py | 12 +++++++----- litellm/types/vector_stores.py | 2 +- 50 files changed, 120 insertions(+), 124 deletions(-) diff --git a/litellm/integrations/deepeval/types.py b/litellm/integrations/deepeval/types.py index eb4bb1f9b2a..a547863e26a 100644 --- a/litellm/integrations/deepeval/types.py +++ b/litellm/integrations/deepeval/types.py @@ -1,6 +1,6 @@ # Duplicate -> https://github.com/confident-ai/deepeval/blob/main/deepeval/tracing/api.py from enum import Enum -from typing import Any, ClassVar, Literal +from typing import ClassVar, Literal from pydantic import ConfigDict, Field @@ -55,7 +55,7 @@ class TraceApi(LiteLLMBaseModel): tool_spans: list[BaseApiSpan] = Field(alias="toolSpans") start_time: str = Field(alias="startTime") end_time: str = Field(alias="endTime") - metadata: dict[str, Any] | None = Field(None) + metadata: dict[str, object] | None = Field(None) tags: list[str] | None = Field(None) environment: str | None = Field(None) diff --git a/litellm/interactions/litellm_responses_transformation/streaming_iterator.py b/litellm/interactions/litellm_responses_transformation/streaming_iterator.py index dab12d447df..46e633d2b96 100644 --- a/litellm/interactions/litellm_responses_transformation/streaming_iterator.py +++ b/litellm/interactions/litellm_responses_transformation/streaming_iterator.py @@ -3,8 +3,8 @@ Streaming iterator for transforming Responses API stream to Interactions API str """ from collections import deque -from collections.abc import AsyncIterator, Iterator -from typing import Any, Final, cast +from collections.abc import AsyncIterator, Iterator, Mapping +from typing import Final, cast from litellm.responses.streaming_iterator import ( BaseResponsesAPIStreamingIterator, @@ -44,7 +44,7 @@ class LiteLLMResponsesInteractionsStreamingIterator: request_input: InteractionInput | None, optional_params: InteractionsAPIOptionalRequestParams, custom_llm_provider: str | None = None, - litellm_metadata: dict[str, Any] | None = None, + litellm_metadata: Mapping[str, object] | None = None, ): self.model = model self.responses_stream_iterator = litellm_custom_stream_wrapper diff --git a/litellm/llms/base_llm/ocr/transformation.py b/litellm/llms/base_llm/ocr/transformation.py index 97d402e7a1b..171b193f60c 100644 --- a/litellm/llms/base_llm/ocr/transformation.py +++ b/litellm/llms/base_llm/ocr/transformation.py @@ -4,7 +4,7 @@ Base OCR types shared by the Rust OCR route and Python consumers. import builtins from collections.abc import Mapping -from typing import Any, Final, Literal +from typing import Final, Literal from pydantic import PrivateAttr @@ -45,7 +45,7 @@ class OCRPageImage(LiteLLMPydanticObjectBase): """Image extracted from OCR page.""" image_base64: str | None = None - bbox: dict[str, Any] | None = None + bbox: dict[str, builtins.object] | None = None model_config = {"extra": "allow"} @@ -80,7 +80,7 @@ class OCRResponse(LiteLLMPydanticObjectBase): pages: list[OCRPage] model: str - document_annotation: Any | None = None + document_annotation: builtins.object | None = None usage_info: OCRUsageInfo | None = None content: str | None = None tables: list[dict[str, builtins.object]] | None = None diff --git a/litellm/llms/base_llm/sandbox/transformation.py b/litellm/llms/base_llm/sandbox/transformation.py index 877edc15ec7..fe0e3abb87e 100644 --- a/litellm/llms/base_llm/sandbox/transformation.py +++ b/litellm/llms/base_llm/sandbox/transformation.py @@ -7,7 +7,7 @@ run code -> delete container; `code_interpreter_tool` combines all three. """ import builtins -from typing import Any, Final +from typing import Final import httpx from pydantic import Field, PrivateAttr @@ -42,8 +42,8 @@ class CodeExecutionResult(LiteLLMPydanticObjectBase): stdout: str = "" stderr: str = "" - results: list[dict[str, Any]] = Field(default_factory=list) - error: dict[str, Any] | None = None + results: list[dict[str, object]] = Field(default_factory=list) + error: dict[str, object] | None = None execution_count: int | None = None object: str = "code_execution" diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index f16564ba505..4f308b01159 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -202,7 +202,9 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): optional_params[param] = value return optional_params - def get_error_class(self, error_message: str, status_code: int, headers: Any) -> BaseLLMException: + def get_error_class( + self, error_message: str, status_code: int, headers: dict[str, str] | httpx.Headers + ) -> BaseLLMException: from litellm.llms.openai.openai import OpenAIConfig return OpenAIConfig().get_error_class(error_message=error_message, status_code=status_code, headers=headers) diff --git a/litellm/llms/ollama/completion/handler.py b/litellm/llms/ollama/completion/handler.py index d4110e55bf0..0c13b1cd1d3 100644 --- a/litellm/llms/ollama/completion/handler.py +++ b/litellm/llms/ollama/completion/handler.py @@ -87,7 +87,7 @@ async def ollama_aembeddings( model: str, prompts: list[str], model_response: EmbeddingResponse, - optional_params: dict, + optional_params: Mapping[str, object], logging_obj: object, encoding: TokenEncoder | None, ): @@ -110,7 +110,7 @@ def ollama_embeddings( api_base: str, model: str, prompts: list[str], - optional_params: dict, + optional_params: Mapping[str, object], model_response: EmbeddingResponse, logging_obj: object, encoding: TokenEncoder | None = None, diff --git a/litellm/models/managed_files.py b/litellm/models/managed_files.py index 435db632026..b2bfb572b7d 100644 --- a/litellm/models/managed_files.py +++ b/litellm/models/managed_files.py @@ -6,7 +6,7 @@ Canonical definitions for the ``litellm_managed*`` tables. Re-exported from """ from datetime import datetime -from typing import Any, Literal +from typing import Literal from litellm.types.llms.base import LiteLLMPydanticObjectBase from litellm.types.llms.openai import OpenAIFileObject, ResponsesAPIResponse @@ -39,7 +39,7 @@ class LiteLLM_ManagedVectorStoreTable(LiteLLMPydanticObjectBase): """Table for managing vector stores with target_model_names support.""" unified_resource_id: str - resource_object: Any | None = None + resource_object: object | None = None model_mappings: dict[str, str] flat_model_resource_ids: list[str] created_by: str | None = None @@ -54,11 +54,11 @@ class LiteLLM_ManagedVectorStoresTable(LiteLLMPydanticObjectBase): custom_llm_provider: str vector_store_name: str | None = None vector_store_description: str | None = None - vector_store_metadata: dict[str, Any] | None = None + vector_store_metadata: dict[str, object] | None = None created_at: datetime | None = None updated_at: datetime | None = None litellm_credential_name: str | None = None - litellm_params: dict[str, Any] | None = None + litellm_params: dict[str, object] | None = None team_id: str | None = None user_id: str | None = None is_config: bool = False diff --git a/litellm/proxy/a2a/endpoints.py b/litellm/proxy/a2a/endpoints.py index 56b512df934..730d40b46d0 100644 --- a/litellm/proxy/a2a/endpoints.py +++ b/litellm/proxy/a2a/endpoints.py @@ -9,7 +9,7 @@ admin pick which ones to expose through the proxy. The actual merge into a LiteLLM-fronted card happens when the agent is saved via ``POST /v1/agents``. """ -from typing import Any, Final +from typing import Final from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse @@ -50,7 +50,7 @@ class DiscoverAgentRequest(LiteLLMBaseModel): "query parameter." ), ) - params: dict[str, Any] | None = Field( + params: dict[str, object] | None = Field( default=None, description=( "Mode-specific parameters. ``langgraph_platform`` requires " @@ -61,7 +61,7 @@ class DiscoverAgentRequest(LiteLLMBaseModel): class DiscoverAgentResponse(LiteLLMBaseModel): url: str - agent_card: dict[str, Any] + agent_card: dict[str, object] @router.post( diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index ae473a62271..f6637a1fdf3 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -41,7 +41,7 @@ DB_UNAVAILABLE_FALLBACK_USER_ID: Final = "__db_unavailable_fallback__" if TYPE_CHECKING: from opentelemetry.trace import Span as _Span - Span = _Span | Any + Span = _Span else: Span = Any diff --git a/litellm/proxy/management_endpoints/types.py b/litellm/proxy/management_endpoints/types.py index 4414eed97b2..221a6221fab 100644 --- a/litellm/proxy/management_endpoints/types.py +++ b/litellm/proxy/management_endpoints/types.py @@ -5,7 +5,7 @@ Might include fastapi/proxy requirements.txt related imports """ from collections.abc import Iterable, Sequence -from typing import Any, Final, cast +from typing import Final, cast from fastapi_sso.sso.base import OpenID @@ -94,4 +94,4 @@ def get_litellm_user_role(role_str: object) -> LitellmUserRoles | None: class CustomOpenID(OpenID): team_ids: list[str] user_role: LitellmUserRoles | None = None - extra_fields: dict[str, Any] | None = None + extra_fields: dict[str, object] | None = None diff --git a/litellm/proxy/spend_tracking/vantage_endpoints.py b/litellm/proxy/spend_tracking/vantage_endpoints.py index d3858f95243..6938588444c 100644 --- a/litellm/proxy/spend_tracking/vantage_endpoints.py +++ b/litellm/proxy/spend_tracking/vantage_endpoints.py @@ -122,7 +122,9 @@ async def _get_vantage_settings(): encrypted_api_key: Final = settings.get("api_key") if encrypted_api_key: - decrypted_api_key = decrypt_value_helper(encrypted_api_key, key="vantage_api_key", exception_type="error") + decrypted_api_key: Final = decrypt_value_helper( + encrypted_api_key, key="vantage_api_key", exception_type="error" + ) if decrypted_api_key is None: raise HTTPException( status_code=500, diff --git a/litellm/rust_bridge/detectors.py b/litellm/rust_bridge/detectors.py index 0ad1b787e38..0af3a2e6fa6 100644 --- a/litellm/rust_bridge/detectors.py +++ b/litellm/rust_bridge/detectors.py @@ -128,7 +128,7 @@ def _custom_loggers(callbacks: Iterable[object]) -> tuple[CustomLogger, ...]: def overridden(method: str, callbacks: Iterable[object]) -> bool: - base: Final = getattr(CustomLogger, method) + base: Final[object] = getattr(CustomLogger, method) return any(getattr(type(callback), method, base) is not base for callback in _custom_loggers(callbacks)) diff --git a/litellm/types/caching.py b/litellm/types/caching.py index b9bcbbb93dd..ced2065a674 100644 --- a/litellm/types/caching.py +++ b/litellm/types/caching.py @@ -75,7 +75,7 @@ class RedisPipelineSetOperation(TypedDict): """ key: str - value: Any + value: object ttl: int | None @@ -133,7 +133,7 @@ class HealthCheckCacheParams(LiteLLMBaseModel): host: str | None = None port: str | int | None = None - redis_kwargs: dict[str, Any] | None = None + redis_kwargs: dict[str, object] | None = None namespace: str | None = None redis_version: str | int | float | None = None diff --git a/litellm/types/completion.py b/litellm/types/completion.py index a6693030e89..0f4bdbd75ce 100644 --- a/litellm/types/completion.py +++ b/litellm/types/completion.py @@ -246,7 +246,7 @@ _CompletionDispatchContext = CompletionDispatchContext _CompletionDispatchResult = Union[ - Coroutine[Any, Any, Union["ModelResponse", "CustomStreamWrapper"]], + Coroutine[object, object, Union["ModelResponse", "CustomStreamWrapper"]], "ModelResponse", "CustomStreamWrapper", ] diff --git a/litellm/types/extraction.py b/litellm/types/extraction.py index 4311e549b9a..562bc41f9de 100644 --- a/litellm/types/extraction.py +++ b/litellm/types/extraction.py @@ -13,6 +13,7 @@ from pydantic import ( model_validator, with_config, ) +from pydantic.fields import FieldInfo from typing_extensions import ReadOnly, Required, Self, TypedDict from litellm.types.llms.base import LiteLLMPydanticObjectBase @@ -30,7 +31,7 @@ def _is_none(value: object) -> bool: return value is None -_OMITTED_WHEN_NONE: Final = Field(exclude_if=_is_none) +_OMITTED_WHEN_NONE: Final = FieldInfo.from_field(exclude_if=_is_none) OptionalJson: TypeAlias = Annotated[JsonValue, _OMITTED_WHEN_NONE] OptionalFloat: TypeAlias = Annotated[float | None, _OMITTED_WHEN_NONE] OptionalInt: TypeAlias = Annotated[int | None, _OMITTED_WHEN_NONE] diff --git a/litellm/types/google_genai/main.py b/litellm/types/google_genai/main.py index 0241c9fa809..dcde0a031e3 100644 --- a/litellm/types/google_genai/main.py +++ b/litellm/types/google_genai/main.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: ToolConfigDict = _genai_types.ToolConfigDict class GenerateContentRequestDict(GenerateContentRequestParametersDict): - generationConfig: Any | None + generationConfig: object | None tools: ToolConfigDict | None class GenerateContentResponse(GoogleGenAIGenerateContentResponse, BaseLiteLLMOpenAIResponseObject): diff --git a/litellm/types/images/main.py b/litellm/types/images/main.py index 5d80135a8a1..963c980a4b8 100644 --- a/litellm/types/images/main.py +++ b/litellm/types/images/main.py @@ -1,4 +1,4 @@ -from typing import Any, Literal +from typing import Literal from typing_extensions import TypedDict @@ -20,7 +20,7 @@ class ImageEditOptionalRequestParams(TypedDict, total=False): response_format: Literal["url", "b64_json"] | None size: str | None user: str | None - imageConfig: dict[str, Any] | None + imageConfig: dict[str, object] | None class ImageEditRequestParams(ImageEditOptionalRequestParams, total=False): diff --git a/litellm/types/integrations/argilla.py b/litellm/types/integrations/argilla.py index 2c98486c7a7..754916c2f3a 100644 --- a/litellm/types/integrations/argilla.py +++ b/litellm/types/integrations/argilla.py @@ -1,10 +1,10 @@ -from typing import Any, Final +from typing import Final from typing_extensions import TypedDict class ArgillaItem(TypedDict): - fields: dict[str, Any] + fields: dict[str, object] class ArgillaPayload(TypedDict): diff --git a/litellm/types/integrations/cloudzero.py b/litellm/types/integrations/cloudzero.py index 988d501600f..dcc5d03ccca 100644 --- a/litellm/types/integrations/cloudzero.py +++ b/litellm/types/integrations/cloudzero.py @@ -1,7 +1,4 @@ -from typing import Any - - -class CBFRecord(dict[str, Any]): +class CBFRecord(dict[str, object]): """CloudZero Billing Format (CBF) record structure. This class represents a CBF record that is created from LiteLLM usage data @@ -31,4 +28,4 @@ class CBFRecord(dict[str, Any]): # Type alias for better readability in function signatures -CBFRecordDict = dict[str, Any] +CBFRecordDict = dict[str, object] diff --git a/litellm/types/integrations/compression_interception.py b/litellm/types/integrations/compression_interception.py index a1e66e5057e..e9c40fbf838 100644 --- a/litellm/types/integrations/compression_interception.py +++ b/litellm/types/integrations/compression_interception.py @@ -2,7 +2,7 @@ Type definitions for Compression Interception integration. """ -from typing import Any, Literal, TypedDict +from typing import Literal, TypedDict class CompressionInterceptionConfig(TypedDict, total=False): @@ -24,7 +24,7 @@ class CompressionInterceptionConfig(TypedDict, total=False): compression_trigger: int compression_target: int | None embedding_model: str | None - embedding_model_params: dict[str, Any] | None + embedding_model_params: dict[str, object] | None class CompressionSavingsMetadata(TypedDict): diff --git a/litellm/types/integrations/gcs_bucket.py b/litellm/types/integrations/gcs_bucket.py index 3840d7681a5..79563a0a6e4 100644 --- a/litellm/types/integrations/gcs_bucket.py +++ b/litellm/types/integrations/gcs_bucket.py @@ -31,5 +31,5 @@ class GCSLogQueueItem(TypedDict): """ payload: StandardLoggingPayload - kwargs: dict[str, Any] - response_obj: Any | None + kwargs: dict[str, object] + response_obj: object | None diff --git a/litellm/types/integrations/langsmith.py b/litellm/types/integrations/langsmith.py index 594190ddb5c..f053a5864c1 100644 --- a/litellm/types/integrations/langsmith.py +++ b/litellm/types/integrations/langsmith.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from datetime import datetime -from typing import Any, NamedTuple +from typing import NamedTuple from typing_extensions import TypedDict @@ -9,7 +9,7 @@ from litellm.types.llms.base import LiteLLMBaseModel class LangsmithInputs(LiteLLMBaseModel): model: str | None = None - messages: list[Any] | None = None + messages: list[object] | None = None stream: bool | None = None call_type: str | None = None litellm_call_id: str | None = None @@ -17,9 +17,9 @@ class LangsmithInputs(LiteLLMBaseModel): temperature: float | None = None max_tokens: int | None = None custom_llm_provider: str | None = None - input: list[Any] | None = None + input: list[object] | None = None log_event_type: str | None = None - original_response: Any | None = None + original_response: object | None = None response_cost: float | None = None # LiteLLM Virtual Key specific fields diff --git a/litellm/types/integrations/posthog.py b/litellm/types/integrations/posthog.py index ac04d08107d..d9739df7df7 100644 --- a/litellm/types/integrations/posthog.py +++ b/litellm/types/integrations/posthog.py @@ -1,4 +1,4 @@ -from typing import Any, Final, TypedDict +from typing import Final, TypedDict POSTHOG_MAX_BATCH_SIZE: Final = 100 @@ -7,7 +7,7 @@ class PostHogEventPayload(TypedDict): """PostHog event payload structure""" event: str # "$ai_generation" or "$ai_embedding" - properties: dict[str, Any] + properties: dict[str, object] distinct_id: str diff --git a/litellm/types/llms/anthropic_messages/anthropic_response.py b/litellm/types/llms/anthropic_messages/anthropic_response.py index 03956ae7a54..9ad13243451 100644 --- a/litellm/types/llms/anthropic_messages/anthropic_response.py +++ b/litellm/types/llms/anthropic_messages/anthropic_response.py @@ -1,5 +1,5 @@ from collections.abc import Sequence -from typing import Any, Literal, TypeAlias +from typing import Literal, TypeAlias from typing_extensions import NotRequired, ReadOnly, TypedDict @@ -20,7 +20,7 @@ class AnthropicResponseTextBlock(TypedDict, total=False): Anthropic Response Text Block: https://docs.anthropic.com/en/api/messages """ - citations: list[dict[str, Any]] | None + citations: list[dict[str, object]] | None text: str type: Literal["text"] diff --git a/litellm/types/llms/base.py b/litellm/types/llms/base.py index 47fec914f9c..5a2da071cec 100644 --- a/litellm/types/llms/base.py +++ b/litellm/types/llms/base.py @@ -1,4 +1,4 @@ -from typing import Any, Final +from typing import Final from openai._models import BaseModel as OpenAIObject from pydantic import ConfigDict @@ -45,7 +45,7 @@ class BaseLiteLLMOpenAIResponseObject(LiteLLMBaseModel): class HiddenParams(OpenAIObject): - original_response: str | Any | None = None + original_response: str | object | None = None model_id: str | None = None # used in Router for individual deployments api_base: str | None = None # returns api base used for making completion call _response_ms: float | None = None diff --git a/litellm/types/llms/databricks.py b/litellm/types/llms/databricks.py index c7023e35777..bc229421d91 100644 --- a/litellm/types/llms/databricks.py +++ b/litellm/types/llms/databricks.py @@ -1,4 +1,4 @@ -from typing import Any, Literal +from typing import Literal from pydantic import BaseModel from typing_extensions import ( @@ -23,7 +23,7 @@ class GenericStreamingChunk(TypedDict, total=False): class DatabricksTextContent(TypedDict, total=False): type: Literal["text"] text: Required[str] - citations: list[dict[str, Any]] | None + citations: list[dict[str, object]] | None class DatabricksReasoningSummary(TypedDict): @@ -35,7 +35,7 @@ class DatabricksReasoningSummary(TypedDict): class DatabricksReasoningContent(TypedDict, total=False): type: Literal["reasoning"] summary: Required[list[DatabricksReasoningSummary]] - citations: list[dict[str, Any]] | None + citations: list[dict[str, object]] | None AllDatabricksContentListValues = DatabricksTextContent | DatabricksReasoningContent diff --git a/litellm/types/management_endpoints/cache_settings_endpoints.py b/litellm/types/management_endpoints/cache_settings_endpoints.py index f6394f52c99..3584f8ce1da 100644 --- a/litellm/types/management_endpoints/cache_settings_endpoints.py +++ b/litellm/types/management_endpoints/cache_settings_endpoints.py @@ -2,7 +2,7 @@ Types and field definitions for cache settings management endpoints """ -from typing import Any, Final +from typing import Final from litellm.types.llms.base import LiteLLMBaseModel @@ -10,9 +10,9 @@ from litellm.types.llms.base import LiteLLMBaseModel class CacheSettingsField(LiteLLMBaseModel): field_name: str field_type: str - field_value: Any + field_value: object field_description: str - field_default: Any = None + field_default: object = None options: list[str] | None = None # For fields with predefined options/enum values ui_field_name: str # User-friendly display name link: str | None = None # Documentation link for the field diff --git a/litellm/types/management_endpoints/router_settings_endpoints.py b/litellm/types/management_endpoints/router_settings_endpoints.py index 9c8fe446d72..64068f30a16 100644 --- a/litellm/types/management_endpoints/router_settings_endpoints.py +++ b/litellm/types/management_endpoints/router_settings_endpoints.py @@ -2,7 +2,7 @@ Types and field definitions for router settings management endpoints """ -from typing import Any, Final, Literal +from typing import Final, Literal from pydantic import Field, field_validator @@ -72,9 +72,9 @@ class FallbackDeleteResponse(LiteLLMBaseModel): class RouterSettingsField(LiteLLMBaseModel): field_name: str field_type: str - field_value: Any + field_value: object field_description: str - field_default: Any = None + field_default: object = None options: list[str] | None = None # For fields with predefined options/enum values ui_field_name: str # User-friendly display name link: str | None = None # Documentation link for the field diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index f0be66f055d..3fa9adef913 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -113,7 +113,7 @@ class MCPPublicServer(LiteLLMBaseModel): transport: MCPTransportType spec_path: str | None = None auth_type: MCPAuthType | None = None - mcp_info: dict[str, Any] | None = None + mcp_info: dict[str, object] | None = None class MCPAllowedClient(LiteLLMBaseModel): @@ -448,11 +448,11 @@ class MCPPreCallRequestObject(LiteLLMBaseModel): """ tool_name: str - arguments: dict[str, Any] + arguments: dict[str, object] server_name: str | None = None tool_description: str | None = None tool_input_schema: Mapping[str, object] | None = None - user_api_key_auth: dict[str, Any] | None = None + user_api_key_auth: dict[str, object] | None = None hidden_params: HiddenParams = HiddenParams() @@ -462,7 +462,7 @@ class MCPPreCallResponseObject(LiteLLMBaseModel): """ should_proceed: bool = True - modified_arguments: dict[str, Any] | None = None + modified_arguments: dict[str, object] | None = None error_message: str | None = None hidden_params: HiddenParams = HiddenParams() @@ -473,7 +473,7 @@ class MCPDuringCallRequestObject(LiteLLMBaseModel): """ tool_name: str - arguments: dict[str, Any] + arguments: dict[str, object] server_name: str | None = None tool_description: str | None = None tool_input_schema: Mapping[str, object] | None = None diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 219f23200d2..d29e4db55a4 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -117,7 +117,7 @@ class MCPServer(LiteLLMBaseModel): # Admin-configured env vars. Each entry is {name, value, scope, description}. # scope=="global" values are interpolated into static_headers using ${NAME}. # scope=="user" values must be supplied per-user. - env_vars: list[dict[str, Any]] | None = None + env_vars: list[dict[str, object]] | None = None # OAuth-specific fields authorization_server_metadata: Mapping[str, object] | None = Field(default=None, exclude=True) client_id: str | None = None @@ -240,7 +240,7 @@ class MCPServer(LiteLLMBaseModel): # token_validation: key-value pairs that must match fields in the OAuth token # response (supports dot-notation for nested fields, e.g. "team.enterprise_id"). # Tokens that fail validation are rejected before storage. - token_validation: dict[str, Any] | None = None + token_validation: dict[str, object] | None = None oauth_identity_binding: MCPOAuthIdentityBinding | None = None # Optional TTL override (seconds) for the Redis per-user token cache, capped # at the token's expires_in minus the expiry buffer so a cached entry never diff --git a/litellm/types/prompts/init_prompts.py b/litellm/types/prompts/init_prompts.py index fc4c36e760a..c1a3ab56311 100644 --- a/litellm/types/prompts/init_prompts.py +++ b/litellm/types/prompts/init_prompts.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Any, Literal +from typing import Literal from pydantic import ConfigDict @@ -31,7 +31,7 @@ class PromptLiteLLMParams(LiteLLMBaseModel): api_base: str | None = None api_key: str | None = None - provider_specific_query_params: dict[str, Any] | None = None + provider_specific_query_params: dict[str, object] | None = None ignore_prompt_manager_model: bool | None = False ignore_prompt_manager_optional_params: bool | None = False @@ -66,7 +66,7 @@ class PromptSpec(LiteLLMBaseModel): class PromptTemplateBase(LiteLLMBaseModel): litellm_prompt_id: str content: str - metadata: dict[str, Any] | None = None + metadata: dict[str, object] | None = None class PromptInfoResponse(LiteLLMBaseModel): diff --git a/litellm/types/proxy/callback_logs_endpoints.py b/litellm/types/proxy/callback_logs_endpoints.py index b2519d97122..0291cc902bc 100644 --- a/litellm/types/proxy/callback_logs_endpoints.py +++ b/litellm/types/proxy/callback_logs_endpoints.py @@ -5,7 +5,7 @@ External producers (e.g. the litellm-rust gateway) POST finished logging payloads here; the proxy replays them through the standard callback fan-out. """ -from typing import Any, Literal +from typing import Literal from pydantic import Field @@ -17,7 +17,7 @@ class CallbackLogRecord(LiteLLMBaseModel): """A single finished logging event to replay through the callbacks.""" status: Literal["success", "failure"] - standard_logging_payload: dict[str, Any] + standard_logging_payload: dict[str, object] error: str | None = None diff --git a/litellm/types/proxy/cloudzero_endpoints.py b/litellm/types/proxy/cloudzero_endpoints.py index ece294674b9..f87537494bd 100644 --- a/litellm/types/proxy/cloudzero_endpoints.py +++ b/litellm/types/proxy/cloudzero_endpoints.py @@ -46,7 +46,7 @@ class CloudZeroExportResponse(LiteLLMBaseModel): dry_run_data: dict[str, Any] | None = Field( None, description="Dry run data including usage data and CBF transformed data" ) - summary: dict[str, Any] | None = Field(None, description="Summary statistics for dry run") + summary: dict[str, object] | None = Field(None, description="Summary statistics for dry run") class CloudZeroSettingsView(LiteLLMBaseModel): diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/azure/azure_prompt_shield.py b/litellm/types/proxy/guardrails/guardrail_hooks/azure/azure_prompt_shield.py index 60846b2a1bd..4f4bebf9704 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/azure/azure_prompt_shield.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/azure/azure_prompt_shield.py @@ -1,5 +1,3 @@ -from typing import Any - from pydantic import Field from typing_extensions import TypedDict @@ -23,7 +21,7 @@ class AzurePromptShieldGuardrailResponse(TypedDict): """Configuration parameters for the Azure Prompt Shield guardrail""" userPromptAnalysis: UserPromptAnalysis - documentsAnalysis: list[dict[str, Any]] + documentsAnalysis: list[dict[str, object]] class AzurePromptShieldGuardrailConfigModel( diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/azure/azure_text_moderation.py b/litellm/types/proxy/guardrails/guardrail_hooks/azure/azure_text_moderation.py index 90662e84ed5..6ee5741baf5 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/azure/azure_text_moderation.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/azure/azure_text_moderation.py @@ -1,4 +1,4 @@ -from typing import Any, Final, Literal +from typing import Final, Literal from pydantic import Field from typing_extensions import Required, TypedDict @@ -37,7 +37,7 @@ class AzureTextModerationGuardrailResponseCategoriesAnalysis(TypedDict): class AzureTextModerationGuardrailResponse(TypedDict): """Response from the Azure Text Moderation guardrail""" - blocklistsMatch: list[dict[str, Any]] + blocklistsMatch: list[dict[str, object]] categoriesAnalysis: list[AzureTextModerationGuardrailResponseCategoriesAnalysis] diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/compresr.py b/litellm/types/proxy/guardrails/guardrail_hooks/compresr.py index 6598f1de62b..c0f5ea5346e 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/compresr.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/compresr.py @@ -1,4 +1,4 @@ -from typing import Any, Literal +from typing import Literal from pydantic import Field @@ -93,7 +93,7 @@ class CompresrGuardrailOptionalParams(LiteLLMBaseModel): "Unset lets the server default apply (~10.0)." ), ) - compression_params: dict[str, Any] | None = Field( + compression_params: dict[str, object] | None = Field( default=None, description=( "Passthrough of extra parameters forwarded verbatim in the Compresr " diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py b/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py index 78800a5e9a2..d49a3afae04 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, Literal, TypedDict +from typing import Literal, TypedDict from pydantic import Field @@ -77,7 +77,7 @@ class CompetitorIntentDetection(TypedDict): action_hint: str entities: dict[str, list[str]] signals: list[str] - evidence: list[dict[str, Any]] + evidence: list[dict[str, object]] ContentFilterDetection = PatternDetection | BlockedWordDetection | CategoryKeywordDetection | CompetitorIntentDetection @@ -157,7 +157,7 @@ class LitellmContentFilterGuardrailConfigModel(GuardrailConfigModel): ) # Competitor intent blocker (generic; industry presets add domain_words, etc.) - competitor_intent_config: dict[str, Any] | None = Field( + competitor_intent_config: dict[str, object] | None = Field( default=None, description="Optional config for intent-based competitor comparison detection. " "Keys: brand_self (list), competitors (list), competitor_aliases (dict), " diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/types/proxy/guardrails/guardrail_hooks/presidio.py index 9d1e375af72..6fe324ce6b0 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/presidio.py @@ -1,6 +1,6 @@ -from typing import Any +from collections.abc import Mapping -from typing_extensions import TypedDict +from typing_extensions import ReadOnly, TypedDict from litellm.types.guardrails import PiiEntityType @@ -17,5 +17,5 @@ class PresidioAnalyzeResponseItem(TypedDict, total=False): start: int | None end: int | None score: float | None - analysis_explanation: dict[str, Any] | None - recognition_metadata: dict[str, Any] | None + analysis_explanation: ReadOnly[Mapping[str, object] | None] + recognition_metadata: ReadOnly[Mapping[str, object] | None] diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 66702abfb34..213c911cfdc 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -1,7 +1,6 @@ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from datetime import date from enum import Enum -from typing import Any from pydantic import ConfigDict, Field from typing_extensions import TypedDict @@ -61,7 +60,7 @@ class KeyMetricWithMetadata(MetricBase): class MetricWithMetadata(MetricBase): - metadata: dict[str, Any] = Field(default_factory=dict) + metadata: Mapping[str, object] = Field(default_factory=dict) # API key breakdown for this metric (e.g., which API keys are using this MCP server) api_key_breakdown: dict[str, KeyMetricWithMetadata] = Field(default_factory=dict) # api_key -> {metrics, metadata} diff --git a/litellm/types/proxy/management_endpoints/config_overrides.py b/litellm/types/proxy/management_endpoints/config_overrides.py index c82e616cf9a..b807b5537d5 100644 --- a/litellm/types/proxy/management_endpoints/config_overrides.py +++ b/litellm/types/proxy/management_endpoints/config_overrides.py @@ -1,5 +1,3 @@ -from typing import Any - from pydantic import Field from litellm.types.llms.base import LiteLLMBaseModel @@ -103,5 +101,5 @@ class ConfigOverrideSettingsResponse(LiteLLMBaseModel): """Response model for config override settings GET endpoints.""" config_type: str = Field(description="The type of config override") - values: dict[str, Any] = Field(description="Current configuration values (sensitive fields decrypted)") - field_schema: dict[str, Any] = Field(description="Schema information for UI rendering") + values: dict[str, object] = Field(description="Current configuration values (sensitive fields decrypted)") + field_schema: dict[str, object] = Field(description="Schema information for UI rendering") diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index febd4485d10..fc0b73f77b6 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,5 +1,5 @@ from collections.abc import Mapping, Sequence -from typing import Any, Final, Literal +from typing import Final, Literal from pydantic import ConfigDict, Field, field_validator from typing_extensions import ReadOnly, TypedDict @@ -80,7 +80,7 @@ class UserUpdateResult(LiteLLMBaseModel): user_email: str | None = None success: bool error: str | None = None - updated_user: dict[str, Any] | None = None + updated_user: dict[str, object] | None = None class BulkUpdateUserResponse(LiteLLMBaseModel): diff --git a/litellm/types/proxy/management_endpoints/model_management_endpoints.py b/litellm/types/proxy/management_endpoints/model_management_endpoints.py index 6a4160c3c50..c012f872280 100644 --- a/litellm/types/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/model_management_endpoints.py @@ -1,6 +1,5 @@ from collections.abc import Mapping from datetime import datetime -from typing import Any from pydantic import ConfigDict, Field @@ -22,7 +21,7 @@ class UpdateUsefulLinksRequest(LiteLLMBaseModel): # Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]]) # New format: { "displayName": { "url": "...", "index": 0 } } # Old format: { "displayName": "url" } (for backward compatibility) - useful_links: dict[str, str | dict[str, Any]] + useful_links: dict[str, str | dict[str, object]] class AutoRouterClassifierDefaultPromptResponse(LiteLLMBaseModel): diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 743b2d7c963..51c48e6ebf9 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import Any, Final, Literal +from typing import Final, Literal from pydantic import ConfigDict, Field, field_validator, model_validator @@ -121,8 +121,8 @@ class TeamMemberAddResult(LiteLLMBaseModel): user_email: str | None = None success: bool error: str | None = None - updated_user: dict[str, Any] | None = None - updated_team_membership: dict[str, Any] | None = None + updated_user: dict[str, object] | None = None + updated_team_membership: dict[str, object] | None = None class BulkTeamMemberAddResponse(LiteLLMBaseModel): @@ -133,7 +133,7 @@ class BulkTeamMemberAddResponse(LiteLLMBaseModel): total_requested: int successful_additions: int failed_additions: int - updated_team: dict[str, Any] | None = None + updated_team: dict[str, object] | None = None class TeamMemberRef(MemberDeleteRequest): diff --git a/litellm/types/proxy/prompt_endpoints.py b/litellm/types/proxy/prompt_endpoints.py index 930713663a1..cded9f5fb87 100644 --- a/litellm/types/proxy/prompt_endpoints.py +++ b/litellm/types/proxy/prompt_endpoints.py @@ -1,9 +1,7 @@ -from typing import Any - from litellm.types.llms.base import LiteLLMBaseModel class TestPromptRequest(LiteLLMBaseModel): dotprompt_content: str - prompt_variables: dict[str, Any] | None = None + prompt_variables: dict[str, object] | None = None conversation_history: list[dict[str, str]] | None = None diff --git a/litellm/types/proxy/public_endpoints/public_endpoints.py b/litellm/types/proxy/public_endpoints/public_endpoints.py index c487ce8b6c5..0e8a2bb3401 100644 --- a/litellm/types/proxy/public_endpoints/public_endpoints.py +++ b/litellm/types/proxy/public_endpoints/public_endpoints.py @@ -1,5 +1,5 @@ from collections.abc import Mapping, Sequence -from typing import Any, Literal +from typing import Literal from pydantic import ConfigDict @@ -13,7 +13,7 @@ class PublicModelHubInfo(LiteLLMBaseModel): # Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]]) # New format: { "displayName": { "url": "...", "index": 0 } } # Old format: { "displayName": "url" } (for backward compatibility) - useful_links: dict[str, str | dict[str, Any]] | None + useful_links: dict[str, str | dict[str, object]] | None class ProviderCredentialField(LiteLLMBaseModel): diff --git a/litellm/types/proxy/vantage_endpoints.py b/litellm/types/proxy/vantage_endpoints.py index 1833d8b8386..ccf050a8921 100644 --- a/litellm/types/proxy/vantage_endpoints.py +++ b/litellm/types/proxy/vantage_endpoints.py @@ -2,8 +2,8 @@ Vantage endpoint types for LiteLLM Proxy """ +from collections.abc import Mapping from datetime import datetime -from typing import Any from pydantic import Field, field_validator @@ -57,10 +57,10 @@ class VantageExportResponse(LiteLLMBaseModel): message: str status: str - dry_run_data: dict[str, Any] | None = Field( + dry_run_data: Mapping[str, object] | None = Field( None, description="Dry run data including usage data and FOCUS transformed data" ) - summary: dict[str, Any] | None = Field(None, description="Summary statistics for dry run") + summary: Mapping[str, object] | None = Field(None, description="Summary statistics for dry run") class VantageSettingsView(LiteLLMBaseModel): diff --git a/litellm/types/rag.py b/litellm/types/rag.py index 2f912d21587..02c06f242d1 100644 --- a/litellm/types/rag.py +++ b/litellm/types/rag.py @@ -3,7 +3,7 @@ Type definitions for RAG (Retrieval Augmented Generation) Ingest API. """ from collections.abc import Mapping -from typing import Any, Literal +from typing import Literal from pydantic import ConfigDict from typing_extensions import ReadOnly, TypedDict @@ -232,7 +232,7 @@ class RAGIngestRequest(LiteLLMBaseModel): file_url: str | None = None # URL to fetch file from file_id: str | None = None # Existing file ID - ingest_options: dict[str, Any] # RAGIngestOptions as dict for flexibility + ingest_options: dict[str, object] # RAGIngestOptions as dict for flexibility model_config = ConfigDict(extra="allow") # Allow additional fields @@ -260,7 +260,7 @@ class RAGQueryRequest(LiteLLMBaseModel): """Request body for RAG query API.""" model: str - messages: list[Any] + messages: list[object] retrieval_config: RAGRetrievalConfig rerank: RAGRerankConfig | None = None stream: bool | None = False diff --git a/litellm/types/responses/main.py b/litellm/types/responses/main.py index 5588527c141..72dc63a581f 100644 --- a/litellm/types/responses/main.py +++ b/litellm/types/responses/main.py @@ -5,7 +5,7 @@ from typing import Final, Literal, Optional, Union from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall from openai.types.responses.response_function_web_search import ActionSearchSource, ResponseFunctionWebSearch from pydantic import ConfigDict, PrivateAttr -from typing_extensions import Any, TypedDict +from typing_extensions import TypedDict from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject @@ -100,7 +100,7 @@ class OutputCodeInterpreterCall(BaseLiteLLMOpenAIResponseObject): def build_code_interpreter_log_outputs( - content: Any, + content: object, ) -> list[OutputCodeInterpreterCallLog] | None: """Convert Anthropic bash_code_execution stdout/stderr to log outputs. diff --git a/litellm/types/vector_store_files.py b/litellm/types/vector_store_files.py index 6b4953965d5..3623746216a 100644 --- a/litellm/types/vector_store_files.py +++ b/litellm/types/vector_store_files.py @@ -1,7 +1,9 @@ +import builtins +from collections.abc import Mapping from enum import Enum -from typing import Any, Literal +from typing import Literal -from typing_extensions import TypedDict +from typing_extensions import ReadOnly, TypedDict class VectorStoreFileStatus(str, Enum): @@ -33,7 +35,7 @@ class VectorStoreFileObject(TypedDict, total=False): usage_bytes: int | None vector_store_id: str status: VectorStoreFileStatus - last_error: dict[str, Any] | None + last_error: ReadOnly[Mapping[str, builtins.object] | None] chunking_strategy: VectorStoreFileChunkingStrategy | None attributes: dict[str, str | int | float | bool] | None @@ -83,5 +85,5 @@ class VectorStoreFileContentResponse(TypedDict, total=False): class VectorStoreFileAuthCredentials(TypedDict, total=False): - headers: dict[str, Any] - query_params: dict[str, Any] + headers: ReadOnly[Mapping[str, object]] + query_params: ReadOnly[Mapping[str, object]] diff --git a/litellm/types/vector_stores.py b/litellm/types/vector_stores.py index 5e7d517ece2..2259339e479 100644 --- a/litellm/types/vector_stores.py +++ b/litellm/types/vector_stores.py @@ -31,7 +31,7 @@ class LiteLLM_ManagedVectorStore(TypedDict, total=False): vector_store_name: str | None vector_store_description: str | None - vector_store_metadata: dict[str, Any] | str | None + vector_store_metadata: dict[str, object] | str | None created_at: datetime | None updated_at: datetime | None From 3f93350411ddb50b1beb413665df4d0eb5a065de Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 21 Oct 2026 06:37:24 -0700 Subject: [PATCH 03/50] docs: rename security.md to SECURITY.md and point contact to security@berri.ai (#47581) Co-authored-by: krrish-berri-2 <270687000+krrish-berri-2@users.noreply.github.com> --- security.md => SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename security.md => SECURITY.md (98%) diff --git a/security.md b/SECURITY.md similarity index 98% rename from security.md rename to SECURITY.md index c73379a9c01..d3603448d46 100644 --- a/security.md +++ b/SECURITY.md @@ -79,7 +79,7 @@ To qualify for a bounty, reports must include clear reproduction steps, a reprod - Audit Logs with retention policy - Control Allowed IP Addresses that can access your Cloud LiteLLM Instance -For security inquiries, please contact us at support@berri.ai +For security inquiries, please contact us at security@berri.ai #### Supported data regions for LiteLLM Cloud From b6f91a221b6b9df6f8a89ec6f8264ac87d63ff5d Mon Sep 17 00:00:00 2001 From: yujonglee Date: Wed, 21 Oct 2026 07:03:18 -0700 Subject: [PATCH 04/50] refactor(inference): rename RouteError to Error (#47771) Every other crate names its error Error. The format crates already re-exported it under that name, so only the inference crate and a few importers change --- .../crates/gateway-inference/src/caching.rs | 8 +++--- .../crates/gateway-inference/src/error.rs | 26 +++++++++-------- litellm-rust/crates/inference-chat/src/lib.rs | 2 +- .../crates/inference-chat/tests/caching.rs | 10 +++---- .../crates/inference-messages/src/lib.rs | 2 +- .../inference-messages/tests/caching.rs | 10 +++---- litellm-rust/crates/inference-ocr/AGENTS.md | 2 +- .../inference-ocr/src/provider_config.rs | 2 +- .../crates/inference-responses/src/lib.rs | 2 +- .../inference-responses/tests/caching.rs | 10 +++---- .../crates/inference-transcription/src/lib.rs | 2 +- litellm-rust/crates/inference/AGENTS.md | 6 ++-- litellm-rust/crates/inference/src/caching.rs | 26 ++++++++--------- litellm-rust/crates/inference/src/context.rs | 10 +++---- litellm-rust/crates/inference/src/error.rs | 24 ++++++++-------- litellm-rust/crates/inference/src/lib.rs | 2 +- litellm-rust/crates/inference/src/provider.rs | 2 +- .../crates/inference/tests/caching.rs | 24 ++++++++-------- .../crates/python-bridge/src/errors.rs | 28 +++++++++---------- .../python-bridge/src/routes/inference.rs | 10 +++---- 20 files changed, 104 insertions(+), 104 deletions(-) diff --git a/litellm-rust/crates/gateway-inference/src/caching.rs b/litellm-rust/crates/gateway-inference/src/caching.rs index 1abea61b164..426b8b91182 100644 --- a/litellm-rust/crates/gateway-inference/src/caching.rs +++ b/litellm-rust/crates/gateway-inference/src/caching.rs @@ -72,26 +72,26 @@ fn duration(seconds: f64) -> Result { #[derive(Clone, Default)] pub(crate) struct CacheHeaders(std::sync::Arc>); -impl litellm_host::interceptors::Interceptors for CacheHeaders { +impl litellm_host::interceptors::Interceptors for CacheHeaders { async fn before_provider_request( &self, wire: litellm_host::interceptors::WireRequest, _: litellm_host::interceptors::RequestContext, - ) -> Result { + ) -> Result { Ok(wire) } async fn after_provider_response( &self, _: litellm_host::interceptors::RawResponse, - ) -> Result<(), litellm_inference::RouteError> { + ) -> Result<(), litellm_inference::Error> { Ok(()) } async fn result_ready( &self, facts: litellm_host::interceptors::ExecutionFacts, - ) -> Result<(), litellm_inference::RouteError> { + ) -> Result<(), litellm_inference::Error> { if let litellm_host::interceptors::ResultSource::Cache { key } = facts.source { let _ = self.0.set(key); } diff --git a/litellm-rust/crates/gateway-inference/src/error.rs b/litellm-rust/crates/gateway-inference/src/error.rs index 39656b933eb..ec82a6ede12 100644 --- a/litellm-rust/crates/gateway-inference/src/error.rs +++ b/litellm-rust/crates/gateway-inference/src/error.rs @@ -4,7 +4,7 @@ use axum::{ response::{IntoResponse, Response}, }; use litellm_http::transport::Error as TransportError; -use litellm_inference::RouteError; +use litellm_inference::Error as InferenceError; use litellm_llms::base_llm::ocr::error::Error as OcrError; use serde_json::{Map, Value, json}; @@ -19,7 +19,7 @@ pub enum Error { )] UnknownModel(String), #[error(transparent)] - Route(#[from] RouteError), + Route(#[from] InferenceError), #[error(transparent)] Ocr(#[from] OcrError), #[error("{0} is not implemented by the Rust gateway")] @@ -36,8 +36,8 @@ impl IntoResponse for Error { } } -impl From> for Error { - fn from(error: litellm_host_http::Error) -> Self { +impl From> for Error { + fn from(error: litellm_host_http::Error) -> Self { match error { litellm_host_http::Error::Call(error) => Self::Route(error), litellm_host_http::Error::Protocol => Self::Internal(error.to_string()), @@ -50,7 +50,7 @@ impl Error { match self { Self::Auth(error) => error.status(), Self::Unsupported(_) - | Self::Route(RouteError::Unsupported(_)) + | Self::Route(InferenceError::Unsupported(_)) | Self::Ocr(OcrError::Unsupported(_)) => StatusCode::NOT_IMPLEMENTED, Self::BodyTooLarge => StatusCode::PAYLOAD_TOO_LARGE, Self::Ocr( @@ -64,10 +64,10 @@ impl Error { .and_then(|status| StatusCode::from_u16(status).ok()) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), Self::InvalidBody(_) | Self::UnknownModel(_) => StatusCode::BAD_REQUEST, - Self::Route(RouteError::Transport(TransportError::Http { status, .. })) => { + Self::Route(InferenceError::Transport(TransportError::Http { status, .. })) => { StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY) } - Self::Route(RouteError::Auth(litellm_auth::Error::MissingApiKey { .. })) => { + Self::Route(InferenceError::Auth(litellm_auth::Error::MissingApiKey { .. })) => { StatusCode::UNAUTHORIZED } Self::Route(error) if error.is_request() => StatusCode::BAD_REQUEST, @@ -97,7 +97,9 @@ impl Error { /// body already in that shape passes through, any other has its message extracted. pub fn body(&self, request_id: Option<&str>) -> Value { let raw = match self { - Self::Route(RouteError::Transport(TransportError::Http { body, .. })) => body.clone(), + Self::Route(InferenceError::Transport(TransportError::Http { body, .. })) => { + body.clone() + } other => other.to_string(), }; let parsed = serde_json::from_str::(&raw).ok(); @@ -175,7 +177,7 @@ mod tests { use super::*; fn upstream(status: u16, body: &str) -> Error { - Error::Route(RouteError::Transport(TransportError::Http { + Error::Route(InferenceError::Transport(TransportError::Http { status, body: body.into(), })) @@ -225,16 +227,16 @@ mod tests { #[rstest] #[case::upstream_status(upstream(429, ""), StatusCode::TOO_MANY_REQUESTS)] - #[case::rejected_request(Error::Route(RouteError::InvalidRequest("top_k".into())), StatusCode::BAD_REQUEST)] + #[case::rejected_request(Error::Route(InferenceError::InvalidRequest("top_k".into())), StatusCode::BAD_REQUEST)] #[case::missing_key( - Error::Route(RouteError::Auth(litellm_auth::Error::MissingApiKey { + Error::Route(InferenceError::Auth(litellm_auth::Error::MissingApiKey { provider: "Anthropic", environment_variable: "ANTHROPIC_API_KEY", })), StatusCode::UNAUTHORIZED, )] #[case::lost_connection( - Error::Route(RouteError::Transport(TransportError::Network("reset".into()))), + Error::Route(InferenceError::Transport(TransportError::Network("reset".into()))), StatusCode::INTERNAL_SERVER_ERROR, )] fn status_follows_who_is_at_fault(#[case] error: Error, #[case] status: StatusCode) { diff --git a/litellm-rust/crates/inference-chat/src/lib.rs b/litellm-rust/crates/inference-chat/src/lib.rs index f6b391e0ba1..3be83840901 100644 --- a/litellm-rust/crates/inference-chat/src/lib.rs +++ b/litellm-rust/crates/inference-chat/src/lib.rs @@ -1,7 +1,7 @@ use litellm_host::observation::ObservationSender; pub mod route; pub mod types; -pub use litellm_inference::RouteError as Error; +pub use litellm_inference::Error; mod common_utils; pub mod constants; pub(crate) mod handler; diff --git a/litellm-rust/crates/inference-chat/tests/caching.rs b/litellm-rust/crates/inference-chat/tests/caching.rs index 3fc432f8b46..70da8bc3255 100644 --- a/litellm-rust/crates/inference-chat/tests/caching.rs +++ b/litellm-rust/crates/inference-chat/tests/caching.rs @@ -21,7 +21,7 @@ use litellm_host::{ lifecycle::{CallEvent, ExecutionEvent}, observation::observation_channel, }; -use litellm_inference::RouteError; +use litellm_inference::Error; use rstest::{fixture, rstest}; use serde_json::{Value, json}; @@ -166,12 +166,12 @@ struct ChangingHooks { facts: std::sync::Mutex>, } -impl Interceptors for ChangingHooks { +impl Interceptors for ChangingHooks { async fn before_provider_request( &self, mut wire: WireRequest, _: RequestContext, - ) -> Result { + ) -> Result { let call = self.calls.fetch_add(1, Ordering::SeqCst); if self.rewrite { wire.body["temperature"] = json!(if call < 2 { 0.1 } else { 0.8 }); @@ -179,11 +179,11 @@ impl Interceptors for ChangingHooks { Ok(wire) } - async fn after_provider_response(&self, _: RawResponse) -> Result<(), RouteError> { + async fn after_provider_response(&self, _: RawResponse) -> Result<(), Error> { Ok(()) } - async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), RouteError> { + async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), Error> { self.facts.lock().unwrap().push(facts); Ok(()) } diff --git a/litellm-rust/crates/inference-messages/src/lib.rs b/litellm-rust/crates/inference-messages/src/lib.rs index f91d70445f3..bfefb9c63b0 100644 --- a/litellm-rust/crates/inference-messages/src/lib.rs +++ b/litellm-rust/crates/inference-messages/src/lib.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use crate::common_utils::messages_provider; -pub use litellm_inference::RouteError as Error; +pub use litellm_inference::Error; pub use types::{ MessagesCall, MessagesCallResponse, MessagesSettings, MessagesShaping, litellm_params, messages_body, diff --git a/litellm-rust/crates/inference-messages/tests/caching.rs b/litellm-rust/crates/inference-messages/tests/caching.rs index 32c962091ad..480b3c4f3b7 100644 --- a/litellm-rust/crates/inference-messages/tests/caching.rs +++ b/litellm-rust/crates/inference-messages/tests/caching.rs @@ -17,7 +17,7 @@ use litellm_host::interceptors::{ ResultSource, WireRequest, }; use litellm_inference::{ - RouteError, + Error, caching::{CacheRequest, execute_unary}, }; use rstest::{fixture, rstest}; @@ -133,12 +133,12 @@ struct ChangingHooks { facts: std::sync::Mutex>, } -impl Interceptors for ChangingHooks { +impl Interceptors for ChangingHooks { async fn before_provider_request( &self, mut wire: WireRequest, _: RequestContext, - ) -> Result { + ) -> Result { let call = self.calls.fetch_add(1, Ordering::SeqCst); if self.rewrite { wire.body["temperature"] = json!(if call < 2 { 0.1 } else { 0.8 }); @@ -146,11 +146,11 @@ impl Interceptors for ChangingHooks { Ok(wire) } - async fn after_provider_response(&self, _: RawResponse) -> Result<(), RouteError> { + async fn after_provider_response(&self, _: RawResponse) -> Result<(), Error> { Ok(()) } - async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), RouteError> { + async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), Error> { self.facts.lock().unwrap().push(facts); Ok(()) } diff --git a/litellm-rust/crates/inference-ocr/AGENTS.md b/litellm-rust/crates/inference-ocr/AGENTS.md index d74b7e7af06..e08f561e75d 100644 --- a/litellm-rust/crates/inference-ocr/AGENTS.md +++ b/litellm-rust/crates/inference-ocr/AGENTS.md @@ -3,4 +3,4 @@ - The route returns its completed response directly - Route closures supply OCR-specific host capabilities, such as the caller's Azure AD token provider (`src/route.rs`) - Provider code reaches the caller's hooks mid-call only through `litellm_llms::base_llm::ocr::handler::CallHooks`, which this crate implements over its host until it folds into `litellm_host::interceptors::Interceptors` -- OCR failures use `base_llm/ocr/error.rs`, the recorded exception to `litellm_inference::RouteError` until OCR folds into `litellm_llms::Error` +- OCR failures use `base_llm/ocr/error.rs`, the recorded exception to `litellm_inference::Error` until OCR folds into `litellm_llms::Error` diff --git a/litellm-rust/crates/inference-ocr/src/provider_config.rs b/litellm-rust/crates/inference-ocr/src/provider_config.rs index 2f0346f6e47..09041e9b72b 100644 --- a/litellm-rust/crates/inference-ocr/src/provider_config.rs +++ b/litellm-rust/crates/inference-ocr/src/provider_config.rs @@ -249,7 +249,7 @@ pub(crate) fn resolve_provider_config<'a>( provider: LlmProviders::Mistral, } } - Err(litellm_inference::RouteError::InvalidProvider(provider)) => { + Err(litellm_inference::Error::InvalidProvider(provider)) => { return Err(Error::InvalidProvider(provider)); } Err(error) => return Err(Error::InvalidProvider(error.to_string())), diff --git a/litellm-rust/crates/inference-responses/src/lib.rs b/litellm-rust/crates/inference-responses/src/lib.rs index 6a9d5372311..b6e151ecec3 100644 --- a/litellm-rust/crates/inference-responses/src/lib.rs +++ b/litellm-rust/crates/inference-responses/src/lib.rs @@ -1,5 +1,5 @@ use litellm_host::observation::ObservationSender; -pub use litellm_inference::RouteError as Error; +pub use litellm_inference::Error; pub mod ids; pub mod route; diff --git a/litellm-rust/crates/inference-responses/tests/caching.rs b/litellm-rust/crates/inference-responses/tests/caching.rs index f45cefdc13b..462461695c3 100644 --- a/litellm-rust/crates/inference-responses/tests/caching.rs +++ b/litellm-rust/crates/inference-responses/tests/caching.rs @@ -18,7 +18,7 @@ use litellm_host::interceptors::{ WireRequest, }; use litellm_inference::{ - RouteError, + Error, caching::{CacheRequest, execute_unary}, }; use rstest::{fixture, rstest}; @@ -124,12 +124,12 @@ struct ChangingHooks { facts: std::sync::Mutex>, } -impl Interceptors for ChangingHooks { +impl Interceptors for ChangingHooks { async fn before_provider_request( &self, mut wire: WireRequest, _: RequestContext, - ) -> Result { + ) -> Result { let call = self.calls.fetch_add(1, Ordering::SeqCst); if self.rewrite { wire.body["temperature"] = json!(if call < 2 { 0.1 } else { 0.8 }); @@ -137,11 +137,11 @@ impl Interceptors for ChangingHooks { Ok(wire) } - async fn after_provider_response(&self, _: RawResponse) -> Result<(), RouteError> { + async fn after_provider_response(&self, _: RawResponse) -> Result<(), Error> { Ok(()) } - async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), RouteError> { + async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), Error> { self.facts.lock().unwrap().push(facts); Ok(()) } diff --git a/litellm-rust/crates/inference-transcription/src/lib.rs b/litellm-rust/crates/inference-transcription/src/lib.rs index 47e7bc70b31..f11e3abcd54 100644 --- a/litellm-rust/crates/inference-transcription/src/lib.rs +++ b/litellm-rust/crates/inference-transcription/src/lib.rs @@ -1,5 +1,5 @@ pub mod types; -pub use litellm_inference::RouteError as Error; +pub use litellm_inference::Error; mod constants; mod handler; diff --git a/litellm-rust/crates/inference/AGENTS.md b/litellm-rust/crates/inference/AGENTS.md index 2c4bd00108b..7f2bd0387be 100644 --- a/litellm-rust/crates/inference/AGENTS.md +++ b/litellm-rust/crates/inference/AGENTS.md @@ -2,7 +2,7 @@ - `gateway-inference` (serving) -> `inference-` (one API's call) -> `inference` (shared base) - This crate is the shared base every format crate builds on - - `RouteError` (`src/error.rs`), `CallOptions` (`src/lib.rs`), `CallContext` (`src/context.rs`) + - `Error` (`src/error.rs`), `CallOptions` (`src/lib.rs`), `CallContext` (`src/context.rs`) - diagnostic spans (`src/diagnostic.rs`), outbound send and signing (`src/outbound.rs`), provider resolution (`src/provider.rs`), `CoreResources` (`src/resources.rs`) - response caching (`src/caching.rs`): `Cachable`, `StreamCachable`, `CacheRequest`, `CallCache`, `execute_unary`, `execute_streaming`, stream capture - Shared test helpers belong in litellm-inference-testing, used only as a dev dependency @@ -49,14 +49,14 @@ - `litellm-inference-` mirrors the route packages (`litellm/ocr/`, `litellm/messages/`, ...) - Provider code never imports from `inference` or `inference-*` - Handlers belong in `inference-*` or `llms`, never in a host crate -- Import every item from its canonical path. Never re-export another crate's items or give an item a second public path. The only allowed re-exports are a private submodule surfacing its item at its module root (`mod error; pub use error::Error;`) and each format crate's `pub use litellm_inference::RouteError as Error;`, which keeps the pre-split `::Error` name +- Import every item from its canonical path. Never re-export another crate's items or give an item a second public path. The only allowed re-exports are a private submodule surfacing its item at its module root (`mod error; pub use error::Error;`) and each format crate's `pub use litellm_inference::Error;`, which keeps the pre-split `::Error` name ## Error placement - The workspace `Error definitions` rules shape each crate's error. This section decides which crate and module a failure belongs to - A failure is declared once, by the lowest crate that raises it - every crate above nests it unchanged (`#[error(transparent)] Auth(#[from] litellm_auth::Error)`) or maps it once at its boundary, as `src/error.rs` does for `litellm_llms::Error` - - `RouteError` collects route failures for every format crate and never re-declares a variant a lower crate raises + - `Error` collects route failures for every format crate and never re-declares a variant a lower crate raises - Scope follows the concept, not the first caller - an error type under `litellm-llms`'s `/` is private to that provider: no other provider and nothing in `base_llm` may import it - a failure two providers or two routes can hit (wire framing, stream event decoding, a malformed provider response) belongs to the crate that owns the concept: `litellm-framer` for framing, `litellm_llms::Error` for the transformation layer diff --git a/litellm-rust/crates/inference/src/caching.rs b/litellm-rust/crates/inference/src/caching.rs index 6dfda20ead4..09d03c06215 100644 --- a/litellm-rust/crates/inference/src/caching.rs +++ b/litellm-rust/crates/inference/src/caching.rs @@ -24,9 +24,9 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; use tokio_util::codec::Decoder; -use crate::RouteError; +use crate::Error; -pub trait Cachable: Protocol { +pub trait Cachable: Protocol { const SURFACE: &'static str; fn reusable(_response: &Self::Response) -> bool { @@ -145,15 +145,15 @@ pub async fn execute_unary( request: CacheRequest, cache: Option>, options: Option, - interceptors: &impl Interceptors, + interceptors: &impl Interceptors, observers: Option<&ObservationSender>, provider: F, -) -> Result +) -> Result where P: Cachable, P::Response: Serialize + DeserializeOwned, F: FnOnce() -> Fut, - Fut: Future>, + Fut: Future>, { let identity = request.identity.clone(); crate::diagnostic::provider(&identity.model, &identity.provider); @@ -191,15 +191,15 @@ pub async fn execute_streaming( request: CacheRequest, cache: Option>, options: Option, - interceptors: &impl Interceptors, + interceptors: &impl Interceptors, observers: Option<&ObservationSender>, provider: F, -) -> Result, RouteError> +) -> Result, Error> where P: StreamCachable, P::Response: Serialize + DeserializeOwned, F: FnOnce() -> Fut, - Fut: Future, RouteError>>, + Fut: Future, Error>>, { let identity = request.identity.clone(); crate::diagnostic::provider(&identity.model, &identity.provider); @@ -299,9 +299,9 @@ impl CallCache

{ } fn capture_stream( - chunks: futures_util::stream::BoxStream<'static, Result>, + chunks: futures_util::stream::BoxStream<'static, Result>, session: CacheSession, -) -> futures_util::stream::BoxStream<'static, Result> { +) -> futures_util::stream::BoxStream<'static, Result> { stream::try_unfold( (chunks, Some(Vec::::new()), session), |(mut chunks, captured, session)| async move { @@ -330,7 +330,7 @@ fn capture_stream( { session.store(entry).await; } - Ok::<_, RouteError>(None) + Ok::<_, Error>(None) } } }, @@ -369,9 +369,9 @@ fn successful_stream(text: &str, terminal: &str) -> bool { async fn publish( facts: ExecutionFacts, - interceptors: &impl Interceptors, + interceptors: &impl Interceptors, observers: Option<&ObservationSender>, -) -> Result<(), RouteError> { +) -> Result<(), Error> { if let Some(observers) = observers { observers.emit(CallEvent::Execution(ExecutionEvent::ResultReady { facts: facts.clone(), diff --git a/litellm-rust/crates/inference/src/context.rs b/litellm-rust/crates/inference/src/context.rs index 6fcbb3051b4..655b768dde1 100644 --- a/litellm-rust/crates/inference/src/context.rs +++ b/litellm-rust/crates/inference/src/context.rs @@ -5,7 +5,7 @@ use litellm_host::{ observation::ObservationSender, }; -use crate::{CallOptions, RouteError}; +use crate::{CallOptions, Error}; pub struct CallContext<'a, I> { pub interceptors: &'a I, @@ -13,7 +13,7 @@ pub struct CallContext<'a, I> { pub cache: CachePolicy, } -impl<'a, I: Interceptors> CallContext<'a, I> { +impl<'a, I: Interceptors> CallContext<'a, I> { pub fn new(interceptors: &'a I, options: CallOptions) -> Self { Self { interceptors, @@ -22,7 +22,7 @@ impl<'a, I: Interceptors> CallContext<'a, I> { } } - pub async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), RouteError> { + pub async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), Error> { if let Some(observers) = &self.observers { observers.emit(CallEvent::Execution(ExecutionEvent::ResultReady { facts: facts.clone(), @@ -31,7 +31,7 @@ impl<'a, I: Interceptors> CallContext<'a, I> { self.interceptors.result_ready(facts).await } - pub async fn response_received(&self, body: &str) -> Result<(), RouteError> { + pub async fn response_received(&self, body: &str) -> Result<(), Error> { let raw = RawResponse { body: body.to_owned(), }; @@ -43,6 +43,6 @@ impl<'a, I: Interceptors> CallContext<'a, I> { self.interceptors .after_provider_response(raw) .await - .map_err(RouteError::post_call) + .map_err(Error::post_call) } } diff --git a/litellm-rust/crates/inference/src/error.rs b/litellm-rust/crates/inference/src/error.rs index e5f02754f3d..895c8c55e01 100644 --- a/litellm-rust/crates/inference/src/error.rs +++ b/litellm-rust/crates/inference/src/error.rs @@ -11,7 +11,7 @@ use litellm_http::transport::Error as TransportError; use litellm_llms::Error as LlmError; #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] -pub enum RouteError { +pub enum Error { #[error("expected {expected}, got {actual}")] InvalidType { expected: &'static str, @@ -38,10 +38,10 @@ pub enum RouteError { #[error(transparent)] Secret(#[from] SecretError), #[error("post-call hook failed: {0}")] - PostCallHook(#[source] Arc), + PostCallHook(#[source] Arc), } -impl From for RouteError { +impl From for Error { fn from(fault: litellm_host::machine::MachineFault) -> Self { use litellm_host::machine::MachineFault; Self::InvalidRequest(match fault { @@ -51,7 +51,7 @@ impl From for RouteError { } } -impl RouteError { +impl Error { pub fn post_call(error: Self) -> Self { Self::PostCallHook(Arc::new(error)) } @@ -76,7 +76,7 @@ impl RouteError { } } -impl From for RouteError { +impl From for Error { fn from(error: LlmError) -> Self { match error { LlmError::InvalidType { expected, actual } => Self::InvalidType { expected, actual }, @@ -99,7 +99,7 @@ impl SecretError { } } -impl From for RouteError { +impl From for Error { fn from(error: litellm_secrets::Error) -> Self { Self::Secret(SecretError(Arc::new(error))) } @@ -115,22 +115,22 @@ impl Eq for SecretError {} #[cfg(test)] mod tests { - use super::RouteError; + use super::Error; use litellm_llms::{Error as LlmError, ErrorDetail}; use rstest::rstest; #[test] fn a_missing_api_key_is_the_environment_not_the_request() { assert!( - !RouteError::Auth(litellm_auth::Error::MissingApiKey { + !Error::Auth(litellm_auth::Error::MissingApiKey { provider: "Anthropic", environment_variable: "ANTHROPIC_API_KEY", }) .is_request() ); - assert!(RouteError::Auth(litellm_auth::Error::InvalidHeader).is_request()); - assert!(RouteError::InvalidRequest("top_k".into()).is_request()); - assert!(!RouteError::InvalidResponse("bad json".into()).is_request()); + assert!(Error::Auth(litellm_auth::Error::InvalidHeader).is_request()); + assert!(Error::InvalidRequest("top_k".into()).is_request()); + assert!(!Error::InvalidResponse("bad json".into()).is_request()); } #[rstest] #[case::request(true)] @@ -139,7 +139,7 @@ mod tests { let source = serde_json::from_str::("{").unwrap_err(); let source_message = source.to_string(); let detail = ErrorDetail::invalid("test payload", source); - let error = RouteError::from(if request { + let error = Error::from(if request { LlmError::InvalidRequest(detail) } else { LlmError::InvalidResponse(detail) diff --git a/litellm-rust/crates/inference/src/lib.rs b/litellm-rust/crates/inference/src/lib.rs index a168802e7ec..80771e206ca 100644 --- a/litellm-rust/crates/inference/src/lib.rs +++ b/litellm-rust/crates/inference/src/lib.rs @@ -7,7 +7,7 @@ pub mod outbound; pub mod provider; pub mod resources; -pub use error::RouteError; +pub use error::Error; #[derive(Clone, Default)] pub struct CallOptions { diff --git a/litellm-rust/crates/inference/src/provider.rs b/litellm-rust/crates/inference/src/provider.rs index 7e1269db1cf..305aca85f97 100644 --- a/litellm-rust/crates/inference/src/provider.rs +++ b/litellm-rust/crates/inference/src/provider.rs @@ -2,7 +2,7 @@ use litellm_core_utils::get_llm_provider_logic::{ CustomLlmProvider, LlmProviders, get_custom_llm_provider, }; -use crate::error::RouteError as Error; +use crate::error::Error; #[derive(Debug)] pub struct ResolvedProvider<'a> { diff --git a/litellm-rust/crates/inference/tests/caching.rs b/litellm-rust/crates/inference/tests/caching.rs index 1bd53695c30..a0a6a757565 100644 --- a/litellm-rust/crates/inference/tests/caching.rs +++ b/litellm-rust/crates/inference/tests/caching.rs @@ -25,7 +25,7 @@ use litellm_host::{ protocol::Protocol, }; use litellm_inference::{ - RouteError, + Error, caching::{Cachable, CacheRequest, StreamCachable, execute_streaming, execute_unary}, }; use rstest::{fixture, rstest}; @@ -36,7 +36,7 @@ struct TestRoute; impl Protocol for TestRoute { type Request = Value; type Response = Value; - type Error = RouteError; + type Error = Error; type HostCall = Infallible; type Chunk = Bytes; type StreamHead = (); @@ -204,7 +204,7 @@ async fn streamed( .chunks(3) .map(|bytes| Ok(Bytes::copy_from_slice(bytes))) .collect::>(); - let ending = fail.then_some(Err(RouteError::Unsupported("test transport failure"))); + let ending = fail.then_some(Err(Error::Unsupported("test transport failure"))); Ok(CallOutput::Stream { head: (), chunks: stream::iter(chunks.into_iter().chain(ending)).boxed(), @@ -215,7 +215,7 @@ async fn streamed( .unwrap() } -async fn consume(output: OutputOf) -> Result, RouteError> { +async fn consume(output: OutputOf) -> Result, Error> { let CallOutput::Stream { chunks, .. } = output else { panic!("expected a stream"); }; @@ -315,7 +315,7 @@ async fn a_provider_failure_never_populates_the_cache(cache: Arc for CacheHitAccounting { - async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), RouteError> { +impl Interceptors for CacheHitAccounting { + async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), Error> { self.calls.fetch_add(1, Ordering::SeqCst); let ResultSource::Cache { key } = facts.source else { panic!("expected cache source") @@ -482,7 +482,7 @@ impl Interceptors for CacheHitAccounting { ); self.key.set(key).unwrap(); if self.reject { - return Err(RouteError::Unsupported("cache accounting rejected")); + return Err(Error::Unsupported("cache accounting rejected")); } Ok(()) } @@ -491,11 +491,11 @@ impl Interceptors for CacheHitAccounting { &self, wire: WireRequest, _: RequestContext, - ) -> Result { + ) -> Result { Ok(wire) } - async fn after_provider_response(&self, _: RawResponse) -> Result<(), RouteError> { + async fn after_provider_response(&self, _: RawResponse) -> Result<(), Error> { Ok(()) } } @@ -571,7 +571,7 @@ async fn cache_hits_notify_accounting_once_and_propagate_its_failure( if reject { assert!(matches!( result, - Err(RouteError::Unsupported("cache accounting rejected")) + Err(Error::Unsupported("cache accounting rejected")) )); } else { assert_eq!(result.unwrap(), expected); @@ -590,7 +590,7 @@ async fn cache_hits_notify_accounting_once_and_propagate_its_failure( impl Protocol for UnaryTestRoute { type Request = Value; type Response = Value; - type Error = RouteError; + type Error = Error; type HostCall = Infallible; type Chunk = Infallible; type StreamHead = Infallible; diff --git a/litellm-rust/crates/python-bridge/src/errors.rs b/litellm-rust/crates/python-bridge/src/errors.rs index dc63f0d6353..4dab0297e73 100644 --- a/litellm-rust/crates/python-bridge/src/errors.rs +++ b/litellm-rust/crates/python-bridge/src/errors.rs @@ -1,5 +1,5 @@ use litellm_http::transport::Error as TransportError; -use litellm_inference::RouteError; +use litellm_inference::Error; use pyo3::{ exceptions::{PyRuntimeError, PyValueError}, prelude::*, @@ -19,9 +19,9 @@ pyo3::create_exception!( "The provider call was already issued and failed. Args are (status, message); status is 0 when there was no HTTP response." ); -pub(crate) fn route_error_to_pyerr(error: RouteError) -> PyErr { +pub(crate) fn route_error_to_pyerr(error: Error) -> PyErr { match error { - RouteError::Transport(TransportError::Http { status, body }) => { + Error::Transport(TransportError::Http { status, body }) => { RustUpstreamError::new_err((status, body)) } other => by_fault(other.is_request(), other.to_string()), @@ -42,13 +42,13 @@ mod tests { use super::*; #[rstest::rstest] - #[case::unsupported(RouteError::Unsupported("test capability"), true)] - #[case::invalid_provider(RouteError::InvalidProvider("unknown".into()), true)] - #[case::invalid_request(RouteError::InvalidRequest("empty messages".into()), true)] + #[case::unsupported(Error::Unsupported("test capability"), true)] + #[case::invalid_provider(Error::InvalidProvider("unknown".into()), true)] + #[case::invalid_request(Error::InvalidRequest("empty messages".into()), true)] #[case::connection(TransportError::Connect("unreachable".into()).into(), false)] #[case::network(TransportError::Network("timed out".into()).into(), false)] - #[case::invalid_response(RouteError::InvalidResponse("missing usage".into()), false)] - fn route_failures_are_terminal(#[case] error: RouteError, #[case] is_request: bool) { + #[case::invalid_response(Error::InvalidResponse("missing usage".into()), false)] + fn route_failures_are_terminal(#[case] error: Error, #[case] is_request: bool) { Python::initialize(); Python::attach(|py| { let failure = route_error_to_pyerr(error); @@ -86,14 +86,12 @@ mod tests { fn missing_api_key_stays_a_runtime_error_while_other_auth_failures_are_value_errors() { Python::initialize(); Python::attach(|py| { - let missing = - route_error_to_pyerr(RouteError::Auth(litellm_auth::Error::MissingApiKey { - provider: "Anthropic", - environment_variable: "ANTHROPIC_API_KEY", - })); + let missing = route_error_to_pyerr(Error::Auth(litellm_auth::Error::MissingApiKey { + provider: "Anthropic", + environment_variable: "ANTHROPIC_API_KEY", + })); assert!(missing.is_instance_of::(py)); - let invalid = - route_error_to_pyerr(RouteError::Auth(litellm_auth::Error::InvalidHeader)); + let invalid = route_error_to_pyerr(Error::Auth(litellm_auth::Error::InvalidHeader)); assert!(invalid.is_instance_of::(py)); }); } diff --git a/litellm-rust/crates/python-bridge/src/routes/inference.rs b/litellm-rust/crates/python-bridge/src/routes/inference.rs index 3b590a522fe..c9eb60e47ed 100644 --- a/litellm-rust/crates/python-bridge/src/routes/inference.rs +++ b/litellm-rust/crates/python-bridge/src/routes/inference.rs @@ -7,7 +7,7 @@ use litellm_core_utils::get_llm_provider_logic::get_custom_llm_provider; use litellm_host::{call::HostedMachine, protocol::Protocol}; use litellm_host_python::{PythonBinding, PythonHostCalls, from_py}; use litellm_http::transport::Error as TransportError; -use litellm_inference::RouteError; +use litellm_inference::Error; use litellm_secrets::source::SecretSource; use pyo3::{ exceptions::PyValueError, @@ -119,14 +119,14 @@ impl InferenceHost { public_response(py, self.module, response) } - pub fn error(&self, py: Python<'_>, error: RouteError) -> PyResult { - if let RouteError::Secret(source) = &error + pub fn error(&self, py: Python<'_>, error: Error) -> PyResult { + if let Error::Secret(source) = &error && let Some(original) = crate::secrets::python_error(py, source.source_error()) { return Ok(original); } let native = match error { - RouteError::Transport(TransportError::Http { status, body }) => { + Error::Transport(TransportError::Http { status, body }) => { let error = RustUpstreamError::new_err((status, body)); error .value(py) @@ -144,7 +144,7 @@ impl InferenceHost { } pub(super) trait InferenceRoute: Sized + 'static { - type Protocol: Protocol; + type Protocol: Protocol; const OPERATION: LoggingOperation; const SYNC_CALL_TYPE: &'static str; const ASYNC_CALL_TYPE: &'static str; From f2118fc7670ba3fa736d9b8f4deef9077a45a720 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 21 Oct 2026 19:09:52 -0700 Subject: [PATCH 05/50] fix(security): stop trusting the compromised eMudhra root CA (#47670) Update certifi, Rust trust roots, and the Docker CA bundle to remove emSign ECC Root CA - G3 Require the fixed certifi release in litellm-core and retain libssl3 in Docker runtimes for Prisma compatibility Co-authored-by: shin-berri --- Dockerfile | 6 +++--- backend/Dockerfile | 6 +++--- docker/Dockerfile.database | 6 +++--- docker/Dockerfile.non_root | 6 +++--- gateway/Dockerfile | 6 +++--- litellm-rust/Cargo.lock | 8 ++++---- litellm-rust/Cargo.toml | 2 +- migrations/Dockerfile | 6 +++--- packaging/litellm-core/pyproject.toml | 1 + pyproject.toml | 3 +++ tests/code_coverage_tests/liccheck.ini | 1 + uv.lock | 11 ++++++++--- 12 files changed, 36 insertions(+), 26 deletions(-) diff --git a/Dockerfile b/Dockerfile index 890c90fe449..306c455c9de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ # syntax=docker/dockerfile:1.7 # Base image for building -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 # Runtime image -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a # Pinned by digest like the other base images; bump explicitly on Node upgrades. ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 @@ -141,7 +141,7 @@ USER root RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories # node (without npm) is required by the prisma CLI at runtime -RUN apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile libevent +RUN apk add --no-cache bash openssl libssl3 tzdata nodejs python-3.13 libsndfile libevent COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer WORKDIR /app diff --git a/backend/Dockerfile b/backend/Dockerfile index 84c90e16f67..772f3d5b351 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a FROM $UV_IMAGE AS uvbin @@ -109,7 +109,7 @@ ENV LITELLM_RELEASE_TAG=${LITELLM_RELEASE_TAG} USER root RUN for i in 1 2 3; do \ - apk add --no-cache bash openssl tzdata python-3.13 libsndfile libatomic && break; \ + apk add --no-cache bash openssl libssl3 tzdata python-3.13 libsndfile libatomic && break; \ [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ sleep 5; \ done diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index 56210458532..f1fa64be630 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -1,10 +1,10 @@ # syntax=docker/dockerfile:1.7 # Base image for building -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 # Runtime image -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a # Pinned by digest like the other base images; bump explicitly on Node upgrades. ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 @@ -122,7 +122,7 @@ ENV LITELLM_RELEASE_TAG=${LITELLM_RELEASE_TAG} USER root # node (without npm) is required by the prisma CLI at runtime -RUN apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile libevent +RUN apk add --no-cache bash openssl libssl3 tzdata nodejs python-3.13 libsndfile libevent COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer WORKDIR /app diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 086c38e0d60..69da9fe149b 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1.7 # Base images -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a # Pinned by digest like the other base images; bump explicitly on Node upgrades. ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 @@ -134,7 +134,7 @@ RUN for i in 1 2 3; do \ apk upgrade --no-cache && break || sleep 5; \ done && \ for i in 1 2 3; do \ - apk add --no-cache python-3.13 bash openssl tzdata libsndfile nodejs libevent && break || sleep 5; \ + apk add --no-cache python-3.13 bash openssl libssl3 tzdata libsndfile nodejs libevent && break || sleep 5; \ done COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 051b1c30c8f..6af25df0c5d 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -1,5 +1,5 @@ -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a # Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x) ARG PGBOUNCER_VERSION=1.26.0 @@ -125,7 +125,7 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime USER root RUN for i in 1 2 3; do \ - apk add --no-cache bash openssl tzdata python-3.13 libsndfile libatomic libevent && break; \ + apk add --no-cache bash openssl libssl3 tzdata python-3.13 libsndfile libatomic libevent && break; \ [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ sleep 5; \ done diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 98f36607fff..c868d3e4b8a 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -8202,18 +8202,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index f04eb115071..9e5e0a96b73 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -124,7 +124,7 @@ moka = { version = "0.12.16", features = ["future"] } strum = { version = "0.28.0", features = ["derive"] } url = "2.5.8" percent-encoding = "2.3" -webpki-roots = "1" +webpki-roots = "1.0.10" time = { version = "0.3.53", features = ["parsing"] } criterion = "0.8.2" fancy-regex = "0.19.2" diff --git a/migrations/Dockerfile b/migrations/Dockerfile index b39a9bfa9bf..55f100d9e02 100644 --- a/migrations/Dockerfile +++ b/migrations/Dockerfile @@ -1,5 +1,5 @@ -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:81211e15a86ea1d5357f284482af45730c51de45eaf10ad9ce205d6bfc2df323 ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a FROM $UV_IMAGE AS uvbin @@ -118,7 +118,7 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime USER root RUN for i in 1 2 3; do \ - apk add --no-cache bash openssl tzdata python-3.13 nodejs libsndfile libatomic && break; \ + apk add --no-cache bash openssl libssl3 tzdata python-3.13 nodejs libsndfile libatomic && break; \ [ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \ sleep 5; \ done diff --git a/packaging/litellm-core/pyproject.toml b/packaging/litellm-core/pyproject.toml index 2add6868bd9..890fa0fd773 100644 --- a/packaging/litellm-core/pyproject.toml +++ b/packaging/litellm-core/pyproject.toml @@ -30,6 +30,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", + "certifi>=2026.10.19", "typing-extensions>=4.13.0,<5.0", ] diff --git a/pyproject.toml b/pyproject.toml index 9076760153a..3aefd911937 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "pydantic-settings>=2.14.1,<3.0", "jsonschema>=4.0.0,<5.0", "boto3>=1.43.1,<2.0", + "certifi>=2026.10.19", "typing-extensions>=4.13.0,<5.0", ] @@ -369,6 +370,8 @@ override-dependencies = [ 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" } [tool.uv.sources] litellm-proxy-extras = { workspace = true } diff --git a/tests/code_coverage_tests/liccheck.ini b/tests/code_coverage_tests/liccheck.ini index e533907659b..cd951c6cd28 100644 --- a/tests/code_coverage_tests/liccheck.ini +++ b/tests/code_coverage_tests/liccheck.ini @@ -176,6 +176,7 @@ langgraph: >=1.0.10 # MIT License langgraph-prebuilt: >=1.0.8 # MIT License - https://github.com/langchain-ai/langgraph/blob/main/LICENSE hypothesis: >=6.165.10 # MPL 2.0 license pytest-rerunfailures: >=15.1 # MPL 2.0 license +certifi: >=2026.10.19 # MPL 2.0 license - https://github.com/certifi/python-certifi/blob/master/LICENSE pytest-recording: >=0.13.4 # MIT license expression: >=5.6.0 # MIT License - https://github.com/cognitedata/Expression/blob/main/LICENSE typing-extensions: >=4.13.0 # PSF-2.0 license - https://github.com/python/typing_extensions/blob/main/LICENSE diff --git a/uv.lock b/uv.lock index 03403828225..8d2a38c1806 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,9 @@ resolution-markers = [ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P3D" +[options.exclude-newer-package] +certifi = "2026-10-20T03:00:00Z" + [manifest] members = [ "litellm", @@ -998,11 +1001,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.10.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, ] [[package]] @@ -4611,6 +4614,7 @@ dependencies = [ { name = "anyio" }, { name = "async-timeout", marker = "python_full_version < '3.11'" }, { name = "boto3" }, + { name = "certifi" }, { name = "click" }, { name = "fastuuid" }, { name = "filelock" }, @@ -4881,6 +4885,7 @@ requires-dist = [ { name = "backports-zstd", marker = "python_full_version < '3.14' and extra == 'proxy'", specifier = ">=1.0.0,<2.0" }, { name = "boto3", specifier = ">=1.43.1,<2.0" }, { name = "boto3", marker = "extra == 'proxy'", specifier = ">=1.43.1,<2.0" }, + { name = "certifi", specifier = ">=2026.10.19" }, { name = "click", specifier = ">=8.0.0,<9.0" }, { name = "cryptography", marker = "extra == 'proxy'", specifier = ">=49.0.0,<51.0" }, { name = "ddtrace", marker = "extra == 'proxy-runtime'", specifier = ">=4.8.2,<5.0" }, From e41d3751692f9564676c279afa098cba34c534d0 Mon Sep 17 00:00:00 2001 From: Ishaan Date: Fri, 23 Oct 2026 10:04:53 -0700 Subject: [PATCH 06/50] feat(ui): inspect client usage for users and team members (#47974) * feat(ui): inspect client usage for users and team members * refactor(ui): share client usage classification and cache parsing * fix(ui): refresh client totals on retry and satisfy lint budgets * fix(deps): align patched telemetry dependency pins * test(otel): support current log records and worker names * test(interactions): validate advertised status parsing * fix(ui): refresh patched framework and test lockfiles * fix(ui): pin patched Next and Vitest versions * test(ui): wait for accessible S3 provider options * fix(deps): lock patched Python security dependencies * fix(vscode): lock patched Vitest dependencies * fix(vscode): require patched Vitest version * test(ui): settle router availability before untouched saves --------- Co-authored-by: moe-berri --- .../_experimental/out/assets/logos/claude.svg | 27 ++ .../interactions/test_openapi_compliance.py | 32 +- .../test_spend_management_endpoints.py | 94 +++- ui/litellm-dashboard/eslint-suppressions.json | 5 +- ui/litellm-dashboard/package-lock.json | 198 ++++----- ui/litellm-dashboard/package.json | 13 +- .../public/assets/logos/claude.svg | 27 ++ .../ClientUsagePanel.integration.test.tsx | 407 ++++++++++++++++++ .../components/ClientUsagePanel.tsx | 362 ++++++++++++++++ .../components/ClientUsageSelect.tsx | 25 ++ .../components/UsagePageView.test.tsx | 217 +++++++++- .../_components/components/UsagePageView.tsx | 129 +++++- .../components/overview/BreakdownChart.tsx | 16 +- .../overview/TopAgents.integration.test.tsx | 57 +++ .../components/overview/TopAgents.tsx | 80 ++-- .../UsageBreakdownChart.integration.test.tsx | 80 ++++ .../overview/UsageBreakdownChart.tsx | 106 +++++ .../components/overview/UsageOverview.tsx | 83 +--- .../UsageStatStrip.integration.test.tsx | 60 +++ .../components/overview/UsageStatStrip.tsx | 41 ++ .../components/overview/agentCatalog.test.ts | 166 ++++++- .../components/overview/agentCatalog.ts | 86 +++- .../overview/clientAgentDetail.test.ts | 154 +++++++ .../components/overview/clientAgentDetail.ts | 46 ++ .../components/overview/overviewData.test.ts | 94 +++- .../components/overview/overviewData.ts | 24 +- .../overview/tokenBreakdown.test.ts | 68 +++ .../components/overview/tokenBreakdown.ts | 27 ++ .../hooks/clientAgentModelsData.test.ts | 111 +++++ .../hooks/clientAgentModelsData.ts | 90 ++++ .../_components/hooks/clientUsageData.test.ts | 283 ++++++++++++ .../_components/hooks/clientUsageData.ts | 234 ++++++++++ .../hooks/clientUsageScope.test.ts | 165 +++++++ .../_components/hooks/clientUsageScope.ts | 98 +++++ .../hooks/useClientAgentModels.test.ts | 168 ++++++++ .../_components/hooks/useClientAgentModels.ts | 67 +++ .../_components/hooks/useClientUsage.test.ts | 169 ++++++++ .../usage/_components/hooks/useClientUsage.ts | 77 ++++ .../hooks/useUsagePanelContext.test.ts | 85 ++++ .../_components/hooks/useUsagePanelContext.ts | 37 ++ .../usage/_components/usageUrlState.test.ts | 106 +++++ .../usage/_components/usageUrlState.ts | 68 ++- .../src/app/(dashboard)/usage/page.tsx | 4 +- .../_components/CreateVectorStore.test.tsx | 16 +- ...dit_auto_router_modal.integration.test.tsx | 15 +- .../shared/advanced_date_picker.test.tsx | 46 +- .../shared/advanced_date_picker.tsx | 86 ++-- .../src/utils/promptCacheUsage.test.ts | 20 + .../src/utils/promptCacheUsage.ts | 16 +- ui/litellm-dashboard/src/utils/roles.ts | 5 +- vscode-extension/package-lock.json | 90 ++-- vscode-extension/package.json | 2 +- 52 files changed, 4398 insertions(+), 384 deletions(-) create mode 100644 litellm/proxy/_experimental/out/assets/logos/claude.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/claude.svg create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsagePanel.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsagePanel.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsageSelect.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageBreakdownChart.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageBreakdownChart.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/clientAgentDetail.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/clientAgentDetail.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/tokenBreakdown.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/tokenBreakdown.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientAgentModelsData.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientAgentModelsData.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageScope.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageScope.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientAgentModels.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientAgentModels.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientUsage.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientUsage.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useUsagePanelContext.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useUsagePanelContext.ts diff --git a/litellm/proxy/_experimental/out/assets/logos/claude.svg b/litellm/proxy/_experimental/out/assets/logos/claude.svg new file mode 100644 index 00000000000..ede30652b25 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/claude.svg @@ -0,0 +1,27 @@ + +Claude diff --git a/tests/unit/interactions/test_openapi_compliance.py b/tests/unit/interactions/test_openapi_compliance.py index 247d02298aa..46a79d545e2 100644 --- a/tests/unit/interactions/test_openapi_compliance.py +++ b/tests/unit/interactions/test_openapi_compliance.py @@ -10,7 +10,7 @@ Run with: pytest tests/unit/interactions/test_openapi_compliance.py -v import json import os import re -from typing import Any, Dict +from typing import Any, Dict, Final from unittest.mock import MagicMock, patch import httpx @@ -250,26 +250,16 @@ class TestResponseCompliance: 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}") + from pydantic import TypeAdapter + + from litellm.types.interactions import Interaction, InteractionStatus + + schema: Final = spec_dict["components"]["schemas"]["Interaction"] + statuses: Final = TypeAdapter(tuple[str, ...]).validate_python(schema["properties"]["status"]["enum"]) + assert statuses + for status in statuses: + assert Interaction.model_validate({"id": "interaction-status", "status": status}).status.value == status + assert InteractionStatus(status).value == status def test_usage_schema(self, spec_dict): """Verify Usage schema fields.""" diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py index 23bbe521670..8d53817f51e 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -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, ) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 4802aa057c6..1451256631f 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1776,7 +1776,7 @@ "count": 140 }, "prefer-const": { - "count": 29 + "count": 28 } }, "src/components/object_permissions_view.tsx": { @@ -1798,9 +1798,6 @@ }, "prefer-const": { "count": 2 - }, - "react-hooks/set-state-in-effect": { - "count": 4 } }, "src/components/organization/organization_view.tsx": { diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 944455721cd..add749c88cb 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -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" diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index f2a1a5e4e7f..8160836515f 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -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", diff --git a/ui/litellm-dashboard/public/assets/logos/claude.svg b/ui/litellm-dashboard/public/assets/logos/claude.svg new file mode 100644 index 00000000000..ede30652b25 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/claude.svg @@ -0,0 +1,27 @@ + +Claude diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsagePanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsagePanel.integration.test.tsx new file mode 100644 index 00000000000..0fff919de4d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsagePanel.integration.test.tsx @@ -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()), + 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 = { + 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(); + + 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(null); + return ( + { + setUserId(id); + onUserChange(id); + }} + /> + ); + } + renderWithProviders(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + 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(); + 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(); + + 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(null); + const [userId, setUserId] = useState("person-1"); + const changeTeam = (id: string | null) => { + setTeamId(id); + setUserId(id ? null : "person-1"); + onTeamChange(id); + }; + return ( + { + setUserId(id); + onUserChange(id); + }} + /> + ); + } + renderWithProviders(); + + 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( + , + ); + + 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 setSummary(agent)} />; + } + renderWithProviders(); + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsagePanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsagePanel.tsx new file mode 100644 index 00000000000..3ca8f2e038d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsagePanel.tsx @@ -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 ( + { + if (!open) onClose(); + }} + > + + + +

+ +
+ {label} + + {dateLabel(startTime)} – {dateLabel(endTime)} · {selectedScopeLabel} + +
+
+ +
+
+ +
+ {teams.length > 0 && onTeamChange && ( +
+ + ({ ...team, value: `team:${team.value}` })), + ]} + allowClear={false} + onValueChange={(value) => onTeamChange(value?.startsWith("team:") ? value.slice(5) : null)} + /> +
+ )} + {teamId && ( +
+ + ({ ...member, value: `user:${member.value}` })), + ]} + allowClear={false} + onValueChange={(value) => onUserChange(value?.startsWith("user:") ? value.slice(5) : null)} + /> +
+ )} + {canFilterPerson && ( +
+
+ + {userId && ( + + )} +
+ +
+ )} + {pending && ( +
+ +
+ )} + {error && ( +
+

Could not load client usage

+

{error}

+ {canRetry && ( + + )} +
+ )} + {ready && !summary && ( +

No {label} usage recorded in this date range.

+ )} + {ready && summary && ( + <> +
+ + + +
+ {summary.requests === 0 ? ( +

+ No {label} requests in the selected range. Try another date range. + {(canChooseUser || teamId) && " You can also choose another person."} +

+ ) : ( + <> + {!chartError && ( + + )} + {chartError && ( +
+

+ {chartError} +

+ +
+ )} + {breakdown && } + + )} +

+ {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. +

+ + )} +
+ + + ); +} + +function SummaryStat({ label, value }: { label: string; value: string }) { + return ( +
+ +
+ ); +} + +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 ( + + {complete && total > 0 && ( + + )} + {cacheReadTokens !== null && total > 0 && ( +

+ {((cacheReadTokens / total) * 100).toFixed(1)}% of these tokens are cache reads. +

+ )} +
+ {rows.map((row) => ( +
+
+ + {row.label} +
+
{countLabel(row.value)}
+
+ ))} +
+

+ 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. +

+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsageSelect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsageSelect.tsx new file mode 100644 index 00000000000..d0b0c7750d6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ClientUsageSelect.tsx @@ -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 ( + onChange(parseUsageClient(client))} + /> + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index b94e2b11b0a..6528445d0d9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -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) => { + mockClientUsagePanel(props); + return ( +
+ + + +
+ ); + }, +})); // 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. @@ -499,11 +515,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 +879,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) => void = () => {}; + let resolveTagList: (tags: Awaited>) => void = () => {}; mockTagListCall.mockReturnValue( new Promise((resolve) => { resolveTagList = resolve; @@ -900,7 +918,7 @@ describe("UsagePage", () => { ); }); - let resolveNewRange: (tags: Record) => void = () => {}; + let resolveNewRange: (tags: Awaited>) => void = () => {}; mockTagListCall.mockReturnValue( new Promise((resolve) => { resolveNewRange = resolve; @@ -922,6 +940,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 +990,7 @@ describe("UsagePage", () => { const mockUseIsOrgAdmin = vi.mocked(useIsOrgAdmin); mockUseIsOrgAdmin.mockReturnValue(true); mockUseAuthorized.mockReturnValue({ + ...nonAdminSession, isLoading: false, isAuthorized: true, token: "mock-token", @@ -1244,6 +1264,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 +1290,7 @@ describe("UsagePage", () => { it("should always pass own userId for non-admin users", async () => { mockUseAuthorized.mockReturnValue({ + ...nonAdminSession, isLoading: false, isAuthorized: true, token: "mock-token", @@ -1591,6 +1613,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 = defaultProps) => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + const view = renderWithProviders(, { + searchParams, + onUrlUpdate, + resetUrlUpdateQueueOnMount: false, + }); + return { ...view, onUrlUpdate }; + }; + const lastPanelProps = (): ComponentProps => 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(); + 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>(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index feb1adb0f1d..eabf3596415 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -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"; @@ -76,7 +76,11 @@ import UsageAIChatPanel from "./UsageAIChatPanel"; 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 = ({ teams, organizations }) => { - const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); +const UsagePage: React.FC = ({ teams, organizations, teamsLoading = false }) => { + const { accessToken, userRole, userId: userID, premiumUser, isLoading: authorizationLoading } = useAuthorized(); const [gatewayActivityData, setGatewayActivityData] = useState(null); // Create initial dates outside of state to prevent recreation @@ -116,13 +125,23 @@ const UsagePage: React.FC = ({ teams, organizations }) => { [range, from, to, initialFromDate, initialToDate], ); + const { client_range, client_from, client_to } = urlParams; + const clientDateValue = useMemo( + () => 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 | 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"); @@ -140,21 +159,47 @@ const UsagePage: React.FC = ({ teams, organizations }) => { 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(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 +274,33 @@ const UsagePage: React.FC = ({ 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( () => (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 +324,11 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }); }, [isAdmin, gatewayRequest, currentGatewayRangeKey]); - const gatewayActivity = selectGatewayActivity(isAdmin, gatewayActivityData, currentGatewayRangeKey); + const gatewayActivity = selectGatewayActivity( + isAdmin && effectiveUserId === null, + gatewayActivityData, + currentGatewayRangeKey, + ); const [requestErrorData, setRequestErrorData] = useState | null>(null); const requestErrorFetchIdRef = useRef(0); @@ -323,7 +381,17 @@ const UsagePage: React.FC = ({ 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( @@ -390,7 +458,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { ); return ( -
+
= ({ teams, organizations }) => { {urlParams.user && }
)} + {CLIENT_USAGE_VIEWS.includes(usageView) && ( +
+ +
+ )}
+ {selectedClient && accessSettled && ( + 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 */} { - 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, +) => { + 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; }) { const colorOf = (name: string) => { + if (colorByKey) return colorForModel(name, colorByKey); const index = series.labels.indexOf(name); return index === -1 ? OTHER_COLOR : series.colors[index]; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.integration.test.tsx new file mode 100644 index 00000000000..811202c4209 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.integration.test.tsx @@ -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(); + + 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(); + 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(); + + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.tsx index be47b96f3f3..2e3575639e3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.tsx @@ -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> = node: { text: "JS", className: "bg-[#3c873a] text-white" }, curl: { text: "cu", className: "bg-[#073551] text-white" }, }; +const CLIENT_SURFACES: Readonly>> = { + "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 ( - - - - ); -} - -function AgentGlyph({ agent }: { agent: AgentRow }) { - if (agent.id === "claude-code") return ; - if (agent.logo) return ; +function AgentGlyph({ agent }: { agent: Pick }) { + if (agent.logo) return ; const monogram = MONOGRAMS[agent.id]; if (monogram) { return ( @@ -77,10 +57,16 @@ function AgentGlyph({ agent }: { agent: AgentRow }) { return {agent.label.charAt(0)}; } -function AgentMark({ agent }: { agent: AgentRow }) { +export function AgentMark({ agent }: { agent: Pick }) { + const Surface = CLIENT_SURFACES[agent.id]; return ( - + + {Surface && ( + + + )} ); } @@ -98,8 +84,9 @@ function AgentItem({ color: string | undefined; onOpen?: (agent: AgentRow) => void; }) { - return ( -
  • + const openRow = onOpen && agent.tags.length > 0 ? () => onOpen(agent) : undefined; + const content = ( + <> {rank}. @@ -113,15 +100,11 @@ function AgentItem({ {agent.label} - {onOpen ? ( - + ) : ( {agent.description} )} @@ -132,6 +115,27 @@ function AgentItem({ {formatCompactUsd(agent.spend)} · {share.toFixed(1)}% + + ); + 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 ( +
  • + {openRow ? ( + + ) : ( +
    {content}
    + )}
  • ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageBreakdownChart.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageBreakdownChart.integration.test.tsx new file mode 100644 index 00000000000..bedc748ab44 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageBreakdownChart.integration.test.tsx @@ -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(); + + 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(); + + 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(); + + expect(screen.getByRole("status", { name: "Loading top models" })).toBeInTheDocument(); + expect(screen.queryByText("No usage in this range")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageBreakdownChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageBreakdownChart.tsx new file mode 100644 index 00000000000..377a3895b1c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageBreakdownChart.tsx @@ -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 = { 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; + dimension?: BreakdownDimension; +} + +export default function UsageBreakdownChart({ + results, + loading, + compact = false, + colorByModel, + dimension = "model_groups", +}: UsageBreakdownChartProps) { + const [metric, setMetric] = useState("spend"); + const state: BreakdownState = { metric, dimension }; + const [granularity, setGranularity] = useState("day"); + const [scale, setScale] = useState("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 ( + + setMetric(next.metric)} showDimension={false} /> + + + + } + bodyClassName="px-0 pt-4 pb-0" + > +
    + {loading ? ( + + ) : ( + labelForDate(series, date)} + scale={scale} + format={format} + totalFor={(date) => totalsByBucket.get(date)} + className={compact ? "h-56" : undefined} + /> + )} +
    +
    +

    Top models

    + {loading ? ( +
    + +
    + ) : ( + + )} +
    +
    + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx index afd6e5ba48b..4b6fa4ee324 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx @@ -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 = { 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({ metric: "spend", dimension: "model_groups" }); - const [granularity, setGranularity] = useState("day"); - const [scale, setScale] = useState("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 (
    - - - - - - } - bodyClassName="px-0 pt-4 pb-0" - > -
    - {loading ? ( - - ) : ( - labelForDate(series, date)} - scale={scale} - format={format} - totalFor={(date) => totalsByBucket.get(date)} - /> - )} -
    -
    -

    Top models

    - -
    -
    + {topAgents} {providerBreakdown} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.integration.test.tsx new file mode 100644 index 00000000000..2e2c42cb218 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.integration.test.tsx @@ -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(); + + 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(); + + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx index 318b7758370..5d9f8d6e0e3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx @@ -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 && ( + + + View token breakdown + + + What counts toward total tokens? +
    +
    +
    Input tokens
    +
    {totals.inputTokens.toLocaleString()}
    +
    +
    +
    Cache reads
    +
    {totals.cacheReadTokens.toLocaleString()}
    +
    +
    +
    Cache writes
    +
    {totals.cacheWriteTokens.toLocaleString()}
    +
    +
    +
    Output tokens
    +
    {totals.outputTokens.toLocaleString()}
    +
    +
    +
    Total tokens
    +
    {totals.tokens.toLocaleString()}
    +
    +
    +

    + Input includes cache reads and writes. Cached input has separate pricing, so total tokens alone cannot + explain the cost. +

    +

    + A client may show a different subset of tokens. Compare the same person, client and time range before + comparing counts. +

    +
    +
    + )}
    diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.test.ts index 14ad625c79f..174539c0a1f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.test.ts @@ -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, + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.ts index 57f481c14be..e4fe94791fb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.ts @@ -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>((acc, agent) => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/clientAgentDetail.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/clientAgentDetail.test.ts new file mode 100644 index 00000000000..bf44b18e2f3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/clientAgentDetail.test.ts @@ -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 => ({ + 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): 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/clientAgentDetail.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/clientAgentDetail.ts new file mode 100644 index 00000000000..6c496ece2e9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/clientAgentDetail.ts @@ -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; + 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((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), + }; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.test.ts index eb4ad2379b7..f00d53ea1bc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.test.ts @@ -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); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.ts index 5d5386f8a3f..c103c2d7c60 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.ts @@ -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 => { + 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 => { + 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 } = 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 }; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/tokenBreakdown.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/tokenBreakdown.test.ts new file mode 100644 index 00000000000..dc5ccd30fc0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/tokenBreakdown.test.ts @@ -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); + }, + ); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/tokenBreakdown.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/tokenBreakdown.ts new file mode 100644 index 00000000000..6aa8eb797eb --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/tokenBreakdown.ts @@ -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, + }; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientAgentModelsData.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientAgentModelsData.test.ts new file mode 100644 index 00000000000..065bc2f8d01 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientAgentModelsData.test.ts @@ -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([]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientAgentModelsData.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientAgentModelsData.ts new file mode 100644 index 00000000000..61640a09de2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientAgentModelsData.ts @@ -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; +type MetricKey = Exclude; + +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 => + 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 }; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.test.ts new file mode 100644 index 00000000000..2fa261e80c2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.test.ts @@ -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 = {}) => ({ + 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[], overrides: Record = {}) => ({ + 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); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.ts new file mode 100644 index 00000000000..41e655d3e6e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageData.ts @@ -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; + +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 => + 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; + }), + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageScope.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageScope.test.ts new file mode 100644 index 00000000000..6232f96167d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageScope.test.ts @@ -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 = {}) => + 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); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageScope.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageScope.ts new file mode 100644 index 00000000000..702365fc22b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/clientUsageScope.ts @@ -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 } }; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientAgentModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientAgentModels.test.ts new file mode 100644 index 00000000000..93298f87ca6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientAgentModels.test.ts @@ -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((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); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientAgentModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientAgentModels.ts new file mode 100644 index 00000000000..9c97072c5ef --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientAgentModels.ts @@ -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 => { + 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, + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientUsage.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientUsage.test.ts new file mode 100644 index 00000000000..8d93893c092 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientUsage.test.ts @@ -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((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((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); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientUsage.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientUsage.ts new file mode 100644 index 00000000000..42d30d46018 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useClientUsage.ts @@ -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 }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useUsagePanelContext.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useUsagePanelContext.test.ts new file mode 100644 index 00000000000..24a07cb1281 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useUsagePanelContext.test.ts @@ -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); + } + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useUsagePanelContext.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useUsagePanelContext.ts new file mode 100644 index 00000000000..7db666ace3e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useUsagePanelContext.ts @@ -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(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 }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts index dbb5c44cc35..1255ea03dd9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.test.ts @@ -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"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts index a1cd04b723a..2af113412d9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/usageUrlState.ts @@ -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(Object.values(UsageClient)), team: entityIds, org: entityIds, customer: entityIds, @@ -54,12 +61,20 @@ const VIEW_ENTITY_KEYS: Readonly> = { "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; + +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), }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx index 32ce0a69aea..a719131678f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx @@ -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 ; + return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx index 9b7e60aa12a..ba3d1d0fb7d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx @@ -270,15 +270,16 @@ describe("CreateVectorStore", () => { }); it("should display S3 Vectors provider-specific fields when selected", async () => { + const user = userEvent.setup(); render(); // 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(); // 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(); }); }); diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx index 6da8e7c4a92..72d557d1f8f 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx @@ -90,6 +90,17 @@ const savedConfig = () => { return payload?.litellm_params?.complexity_router_config; }; +const renderWithSettledAvailability = async (renderModal: () => unknown) => { + vi.useFakeTimers(); + try { + renderModal(); + await act(() => vi.advanceTimersByTimeAsync(300)); + await act(() => vi.runOnlyPendingTimersAsync()); + } finally { + vi.useRealTimers(); + } +}; + describe("EditAutoRouterModal keyword matching", () => { beforeEach(() => { modelPatchUpdateCall.mockClear(); @@ -1447,7 +1458,7 @@ describe("EditAutoRouterModal prompt compression", () => { { auto_router_model_compression: "model-compressor" }, ])("should preserve the exact stored compression fields through an untouched save: %j", async (stored) => { const user = userEvent.setup(); - renderWithStoredCompression(stored); + await renderWithSettledAvailability(() => renderWithStoredCompression(stored)); await waitFor(() => expect(screen.getByRole("button", { name: /save changes/i })).toBeEnabled()); await user.click(await screen.findByRole("button", { name: /save changes/i })); @@ -1524,7 +1535,7 @@ describe("EditAutoRouterModal classifier vision", () => { it("hydrates and keeps a stored vision setting through an untouched save", async () => { const user = userEvent.setup(); - renderModal(); + await renderWithSettledAvailability(renderModal); openAutoRouterAdvanced("Classification Method"); expect(screen.getByRole("switch", { name: "Use images for classification" })).toBeChecked(); diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx index 104eed54b2f..d722b6d1ad6 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import moment from "moment"; import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; @@ -312,4 +312,48 @@ describe("AdvancedDatePicker", () => { expect(applyButton).toBeDisabled(); }); }); + + it("should label compact custom date inputs, validate their order and apply the corrected range", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { expanded: false })); + const start = screen.getByLabelText("Start date"); + const end = screen.getByLabelText("End date"); + expect(start).toHaveValue("2025-01-01"); + expect(end).toHaveValue("2025-01-31"); + + act(() => fireEvent.change(start, { target: { value: "2025-02-01" } })); + expect(screen.getByText("End date cannot be before start date")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + expect(mockOnValueChange).not.toHaveBeenCalled(); + + act(() => fireEvent.change(end, { target: { value: "2025-02-05" } })); + expect(screen.queryByText("End date cannot be before start date")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Apply" })).toBeEnabled(); + expect(mockOnValueChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Apply" })); + const expectedRange = { + from: new Date(2025, 1, 1), + to: new Date(2025, 1, 5, 23, 59, 59, 999), + }; + expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expectedRange, null); + await waitFor(() => expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expectedRange, null)); + expect(screen.queryByLabelText("Start date")).not.toBeInTheDocument(); + }); + + it("should keep compact preset selection pending until Apply is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { expanded: false })); + await user.click(screen.getByRole("button", { name: /Last 7 days/ })); + expect(screen.getByRole("button", { name: /Last 7 days/ })).toHaveAttribute("aria-pressed", "true"); + expect(mockOnValueChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Apply" })); + expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), "7d"); + await waitFor(() => expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), "7d")); + }); }); diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx index 513db07409c..27ed237720f 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/cva.config"; import type { DateRangePickerValue } from "./date_picker_types"; import moment from "moment"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useId, useRef, useState } from "react"; interface AdvancedDatePickerProps { value: DateRangePickerValue; @@ -13,6 +13,7 @@ interface AdvancedDatePickerProps { className?: string; showTimeRange?: boolean; align?: "left" | "right"; + compact?: boolean; } export interface RelativeTimeOption { @@ -84,7 +85,9 @@ const AdvancedDatePicker: React.FC = ({ className, showTimeRange = true, align = "right", + compact = false, }) => { + const inputId = useId(); const [isOpen, setIsOpen] = useState(false); const [tempValue, setTempValue] = useState(value); const [selectedOption, setSelectedOption] = useState(null); @@ -279,21 +282,28 @@ const AdvancedDatePicker: React.FC = ({ }; return ( -
    +
    {label &&

    {label}

    } -
    +
    {/* Main input display */} -
    - - {/* Model selector */} -
    - setSelectedModel(value ?? undefined)} - > - - - {isLoadingModels ? "Loading models…" : "No models found"} - - {(model: string) => ( - - {model} - - )} - - - -
    - - {/* Chat messages */} -
    - {messages.length === 0 && !streamingContent && !isLoading && ( -
    -
    - )} - - {messages.map((msg, idx) => ( -
    - {msg.role === "user" ? ( -
    -
    - {msg.content} -
    -
    - ) : ( -
    - {/* Tool calls for this message */} - {msg.toolCalls && msg.toolCalls.length > 0 && ( -
    - {msg.toolCalls.map((tc, tcIdx) => ( - - ))} -
    - )} - {/* Response */} -
    - -
    -
    - )} -
    - ))} - - {/* Active tool calls (in-progress) */} - {isLoading && activeToolCalls.length > 0 && ( -
    - {activeToolCalls.map((tc, idx) => ( - - ))} -
    - )} - - {/* Status / spinner */} - {isLoading && !streamingContent && ( -
    - - {statusMessage || "Thinking..."} -
    - )} - - {/* Streaming response */} - {streamingContent && ( -
    - -
    - )} - -
    -
    - - {/* Input area */} -
    -
    -