diff --git a/.circleci/config.yml b/.circleci/config.yml index 2edc35e985c..133a7184f9b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2036,6 +2036,7 @@ jobs: - run: python ./tests/code_coverage_tests/info_log_check.py - run: python ./tests/code_coverage_tests/test_ban_set_verbose.py - run: python ./tests/code_coverage_tests/code_qa_check_tests.py + - run: python ./tests/code_coverage_tests/check_get_model_cost_key_performance.py - run: python ./tests/code_coverage_tests/test_proxy_types_import.py - run: python ./tests/code_coverage_tests/callback_manager_test.py - run: python ./tests/code_coverage_tests/recursive_detector.py @@ -2054,39 +2055,6 @@ jobs: - run: python ./tests/code_coverage_tests/memory_test.py - run: helm lint ./deploy/charts/litellm-helm - memory_leak_tests: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: large - steps: - - setup_litellm_test_deps - - run: - name: Install Memory Test Dependencies - command: | - pip install "psutil>=5.9.0" - pip install "fastapi>=0.100.0" - pip install "httpx>=0.24.0" - pip install "uvicorn>=0.23.0" - - run: - name: Run Linear Memory Growth Tests - command: | - echo "Running memory leak tests individually to avoid baseline drift..." - echo "Running test_memory_baseline_1k..." - python -m pytest tests/load_tests/test_linear_memory_growth.py::test_memory_baseline_1k -v -s --tb=short - echo "Running test_memory_baseline_2k..." - python -m pytest tests/load_tests/test_linear_memory_growth.py::test_memory_baseline_2k -v -s --tb=short - echo "Running test_memory_baseline_4k..." - python -m pytest tests/load_tests/test_linear_memory_growth.py::test_memory_baseline_4k -v -s --tb=short - echo "Running test_memory_baseline_10k..." - python -m pytest tests/load_tests/test_linear_memory_growth.py::test_memory_baseline_10k -v -s --tb=short - echo "Running test_memory_baseline_30k..." - python -m pytest tests/load_tests/test_linear_memory_growth.py::test_memory_baseline_30k -v -s --tb=short - no_output_timeout: 60m - db_migration_disable_update_check: machine: image: ubuntu-2204:2023.10.1 @@ -3837,12 +3805,6 @@ workflows: only: - main - /litellm_.*/ - - memory_leak_tests: - filters: - branches: - only: - - main - - /litellm_.*/ - ui_build: filters: branches: diff --git a/.gitignore b/.gitignore index fafacd874a0..9d9e28dc466 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ litellm/proxy/_super_secret_config.yaml litellm/proxy/myenv/bin/activate litellm/proxy/myenv/bin/Activate.ps1 myenv/* +litellm/proxy/_experimental/out/_next/ litellm/proxy/_experimental/out/404/index.html litellm/proxy/_experimental/out/model_hub/index.html litellm/proxy/_experimental/out/onboarding/index.html diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000000..807fc85cec3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,277 @@ +# LiteLLM Architecture - LiteLLM SDK + AI Gateway + +This document helps contributors understand where to make changes in LiteLLM. + +--- + +## How It Works + +The LiteLLM AI Gateway (Proxy) uses the LiteLLM SDK internally for all LLM calls: + +``` +OpenAI SDK (client) ──▶ LiteLLM AI Gateway (proxy/) ──▶ LiteLLM SDK (litellm/) ──▶ LLM API +Anthropic SDK (client) ──▶ LiteLLMAI Gateway (proxy/) ──▶ LiteLLM SDK (litellm/) ──▶ LLM API +Any HTTP client ──▶ LiteLLMAI Gateway (proxy/) ──▶ LiteLLM SDK (litellm/) ──▶ LLM API +``` + +The **AI Gateway** adds authentication, rate limiting, budgets, and routing on top of the SDK. +The **SDK** handles the actual LLM provider calls, request/response transformations, and streaming. + +--- + +## 1. AI Gateway (Proxy) Request Flow + +The AI Gateway (`litellm/proxy/`) wraps the SDK with authentication, rate limiting, and management features. + +```mermaid +sequenceDiagram + participant Client + participant ProxyServer as proxy/proxy_server.py + participant Auth as proxy/auth/user_api_key_auth.py + participant Hooks as proxy/hooks/ + participant Router as router.py + participant Main as main.py + participant Handler as llms/custom_httpx/llm_http_handler.py + participant Transform as llms/{provider}/chat/transformation.py + participant Provider as LLM Provider API + + Client->>ProxyServer: POST /v1/chat/completions + ProxyServer->>Auth: user_api_key_auth() + ProxyServer->>Hooks: max_budget_limiter, parallel_request_limiter + ProxyServer->>Router: route_request() + Router->>Main: litellm.acompletion() + Main->>Handler: BaseLLMHTTPHandler.completion() + Handler->>Transform: ProviderConfig.transform_request() + Handler->>Provider: HTTP Request + Provider-->>Handler: Response + Handler->>Transform: ProviderConfig.transform_response() + Handler-->>Hooks: async_log_success_event() + Handler-->>Client: ModelResponse +``` + +### Proxy Components + +```mermaid +graph TD + subgraph "Incoming Request" + Client["POST /v1/chat/completions"] + end + + subgraph "proxy/proxy_server.py" + Endpoint["chat_completion()"] + end + + subgraph "proxy/auth/" + Auth["user_api_key_auth()"] + end + + subgraph "proxy/" + PreCall["litellm_pre_call_utils.py"] + RouteRequest["route_llm_request.py"] + end + + subgraph "litellm/" + Router["router.py"] + Main["main.py"] + end + + Client --> Endpoint + Endpoint --> Auth + Auth --> PreCall + PreCall --> RouteRequest + RouteRequest --> Router + Router --> Main + Main --> Client +``` + +**Key proxy files:** +- `proxy/proxy_server.py` - Main API endpoints +- `proxy/auth/` - Authentication (API keys, JWT, OAuth2) +- `proxy/hooks/` - Proxy-level callbacks +- `router.py` - Load balancing, fallbacks +- `router_strategy/` - Routing algorithms (`lowest_latency.py`, `simple_shuffle.py`, etc.) + +**LLM-specific proxy endpoints:** + +| Endpoint | Directory | Purpose | +|----------|-----------|---------| +| `/v1/messages` | `proxy/anthropic_endpoints/` | Anthropic Messages API | +| `/vertex-ai/*` | `proxy/vertex_ai_endpoints/` | Vertex AI passthrough | +| `/gemini/*` | `proxy/google_endpoints/` | Google AI Studio passthrough | +| `/v1/images/*` | `proxy/image_endpoints/` | Image generation | +| `/v1/batches` | `proxy/batches_endpoints/` | Batch processing | +| `/v1/files` | `proxy/openai_files_endpoints/` | File uploads | +| `/v1/fine_tuning` | `proxy/fine_tuning_endpoints/` | Fine-tuning jobs | +| `/v1/rerank` | `proxy/rerank_endpoints/` | Reranking | +| `/v1/responses` | `proxy/response_api_endpoints/` | OpenAI Responses API | +| `/v1/vector_stores` | `proxy/vector_store_endpoints/` | Vector stores | +| `/*` (passthrough) | `proxy/pass_through_endpoints/` | Direct provider passthrough | + +**Proxy Hooks** (`proxy/hooks/__init__.py`): + +| Hook | File | Purpose | +|------|------|---------| +| `max_budget_limiter` | `proxy/hooks/max_budget_limiter.py` | Enforce budget limits | +| `parallel_request_limiter` | `proxy/hooks/parallel_request_limiter_v3.py` | Rate limiting per key/user | +| `cache_control_check` | `proxy/hooks/cache_control_check.py` | Cache validation | +| `responses_id_security` | `proxy/hooks/responses_id_security.py` | Response ID validation | +| `litellm_skills` | `proxy/hooks/skills_injection.py` | Skills injection | + +To add a new proxy hook, implement `CustomLogger` and register in `PROXY_HOOKS`. + +--- + +## 2. SDK Request Flow + +The SDK (`litellm/`) provides the core LLM calling functionality used by both direct SDK users and the AI Gateway. + +```mermaid +graph TD + subgraph "SDK Entry Points" + Completion["litellm.completion()"] + Messages["litellm.messages()"] + end + + subgraph "main.py" + Main["completion()
acompletion()"] + end + + subgraph "utils.py" + GetProvider["get_llm_provider()"] + end + + subgraph "llms/custom_httpx/" + Handler["llm_http_handler.py
BaseLLMHTTPHandler"] + HTTP["http_handler.py
HTTPHandler / AsyncHTTPHandler"] + end + + subgraph "llms/{provider}/chat/" + TransformReq["transform_request()"] + TransformResp["transform_response()"] + end + + subgraph "litellm_core_utils/" + Streaming["streaming_handler.py"] + end + + subgraph "integrations/ (async, off main thread)" + Callbacks["custom_logger.py
Langfuse, Datadog, etc."] + end + + Completion --> Main + Messages --> Main + Main --> GetProvider + GetProvider --> Handler + Handler --> TransformReq + TransformReq --> HTTP + HTTP --> Provider["LLM Provider API"] + Provider --> HTTP + HTTP --> TransformResp + TransformResp --> Streaming + Streaming --> Response["ModelResponse"] + Response -.->|async| Callbacks +``` + +**Key SDK files:** +- `main.py` - Entry points: `completion()`, `acompletion()`, `embedding()` +- `utils.py` - `get_llm_provider()` resolves model → provider +- `llms/custom_httpx/llm_http_handler.py` - Central HTTP orchestrator +- `llms/custom_httpx/http_handler.py` - Low-level HTTP client +- `llms/{provider}/chat/transformation.py` - Provider-specific transformations +- `litellm_core_utils/streaming_handler.py` - Streaming response handling +- `integrations/` - Async callbacks (Langfuse, Datadog, etc.) + +--- + +## 3. Translation Layer + +When a request comes in, it goes through a **translation layer** that converts between API formats. +Each translation is isolated in its own file, making it easy to test and modify independently. + +### Where to find translations + +| Incoming API | Provider | Translation File | +|--------------|----------|------------------| +| `/v1/chat/completions` | Anthropic | `llms/anthropic/chat/transformation.py` | +| `/v1/chat/completions` | Bedrock Converse | `llms/bedrock/chat/converse_transformation.py` | +| `/v1/chat/completions` | Bedrock Invoke | `llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py` | +| `/v1/chat/completions` | Gemini | `llms/gemini/chat/transformation.py` | +| `/v1/chat/completions` | Vertex AI | `llms/vertex_ai/gemini/transformation.py` | +| `/v1/chat/completions` | OpenAI | `llms/openai/chat/gpt_transformation.py` | +| `/v1/messages` (passthrough) | Anthropic | `llms/anthropic/experimental_pass_through/messages/transformation.py` | +| `/v1/messages` (passthrough) | Bedrock | `llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py` | +| `/v1/messages` (passthrough) | Vertex AI | `llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py` | +| Passthrough endpoints | All | `proxy/pass_through_endpoints/llm_provider_handlers/` | + +### Example: Debugging prompt caching + +If `/v1/messages` → Bedrock Converse prompt caching isn't working but Bedrock Invoke works: + +1. **Bedrock Converse translation**: `llms/bedrock/chat/converse_transformation.py` +2. **Bedrock Invoke translation**: `llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py` +3. Compare how each handles `cache_control` in `transform_request()` + +### How translations work + +Each provider has a `Config` class that inherits from `BaseConfig` (`llms/base_llm/chat/transformation.py`): + +```python +class ProviderConfig(BaseConfig): + def transform_request(self, model, messages, optional_params, litellm_params, headers): + # Convert OpenAI format → Provider format + return {"messages": transformed_messages, ...} + + def transform_response(self, model, raw_response, model_response, logging_obj, ...): + # Convert Provider format → OpenAI format + return ModelResponse(choices=[...], usage=Usage(...)) +``` + +The `BaseLLMHTTPHandler` (`llms/custom_httpx/llm_http_handler.py`) calls these methods - you never need to modify the handler itself. + +--- + +## 4. Adding/Modifying Providers + +### To add a new provider: + +1. Create `llms/{provider}/chat/transformation.py` +2. Implement `Config` class with `transform_request()` and `transform_response()` +3. Add tests in `tests/llm_translation/test_{provider}.py` + +### To add a feature (e.g., prompt caching): + +1. Find the translation file from the table above +2. Modify `transform_request()` to handle the new parameter +3. Add unit tests that verify the transformation + +### Testing checklist + +When adding a feature, verify it works across all paths: + +| Test | File Pattern | +|------|--------------| +| OpenAI passthrough | `tests/llm_translation/test_openai*.py` | +| Anthropic direct | `tests/llm_translation/test_anthropic*.py` | +| Bedrock Invoke | `tests/llm_translation/test_bedrock*.py` | +| Bedrock Converse | `tests/llm_translation/test_bedrock*converse*.py` | +| Vertex AI | `tests/llm_translation/test_vertex*.py` | +| Gemini | `tests/llm_translation/test_gemini*.py` | + +### Unit testing translations + +Translations are designed to be unit testable without making API calls: + +```python +from litellm.llms.bedrock.chat.converse_transformation import BedrockConverseConfig + +def test_prompt_caching_transform(): + config = BedrockConverseConfig() + result = config.transform_request( + model="anthropic.claude-3-opus", + messages=[{"role": "user", "content": "test", "cache_control": {"type": "ephemeral"}}], + optional_params={}, + litellm_params={}, + headers={} + ) + assert "cachePoint" in str(result) # Verify cache_control was translated +``` diff --git a/docs/my-website/docs/mcp.md b/docs/my-website/docs/mcp.md index d845860ff01..17117b115c3 100644 --- a/docs/my-website/docs/mcp.md +++ b/docs/my-website/docs/mcp.md @@ -326,6 +326,7 @@ litellm_settings: + ## Converting OpenAPI Specs to MCP Servers LiteLLM can automatically convert OpenAPI specifications into MCP servers, allowing you to expose any REST API as MCP tools. This is useful when you have existing APIs with OpenAPI/Swagger documentation and want to make them available as MCP tools. @@ -502,7 +503,7 @@ Your OpenAPI specification should follow standard OpenAPI/Swagger conventions: LiteLLM v 1.77.6 added support for OAuth 2.0 Client Credentials for MCP servers. -This configuration is currently available on the config.yaml, with UI support coming soon. +You can configure this either in `config.yaml` or directly from the LiteLLM UI (MCP Servers → Authentication → OAuth). ```yaml mcp_servers: @@ -1473,3 +1474,13 @@ async with stdio_client(server_params) as (read, write): + +## FAQ + +**Q: How do I use OAuth2 client_credentials (machine-to-machine) with MCP servers behind LiteLLM?** + +At the moment LiteLLM only forwards whatever `Authorization` header/value you configure for the MCP server; it does not issue OAuth2 tokens by itself. If your MCP requires the Client Credentials grant, obtain the access token directly from the authorization server and set that bearer token as the MCP server’s Authorization header value. LiteLLM does not yet fetch or refresh those machine-to-machine tokens on your behalf, but we plan to add first-class client_credentials support in a future release so the proxy can manage those tokens automatically. + +**Q: When I fetch an OAuth token from the LiteLLM UI, where is it stored?** + +The UI keeps only transient state in `sessionStorage` so the OAuth redirect flow can finish; the token is not persisted in the server or database. diff --git a/docs/my-website/docs/providers/azure_ai/azure_model_router.md b/docs/my-website/docs/providers/azure_ai/azure_model_router.md new file mode 100644 index 00000000000..5e14c7283f6 --- /dev/null +++ b/docs/my-website/docs/providers/azure_ai/azure_model_router.md @@ -0,0 +1,232 @@ +# Azure Model Router + +Azure Model Router is a feature in Azure AI Foundry that automatically routes your requests to the best available model based on your requirements. This allows you to use a single endpoint that intelligently selects the optimal model for each request. + +## Key Features + +- **Automatic Model Selection**: Azure Model Router dynamically selects the best model for your request +- **Cost Tracking**: LiteLLM automatically tracks costs based on the actual model used (e.g., `gpt-4.1-nano`), not the router endpoint +- **Streaming Support**: Full support for streaming responses with accurate cost calculation + +## LiteLLM Python SDK + +### Basic Usage + +```python +import litellm +import os + +response = litellm.completion( + model="azure_ai/azure-model-router", + messages=[{"role": "user", "content": "Hello!"}], + api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/", + api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), +) + +print(response) +``` + +### Streaming with Usage Tracking + +```python +import litellm +import os + +response = await litellm.acompletion( + model="azure_ai/azure-model-router", + messages=[{"role": "user", "content": "hi"}], + api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/", + api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), + stream=True, + stream_options={"include_usage": True}, +) + +async for chunk in response: + print(chunk) +``` + +## LiteLLM Proxy (AI Gateway) + +### config.yaml + +```yaml +model_list: + - model_name: azure-model-router + litellm_params: + model: azure_ai/azure-model-router + api_base: https://your-endpoint.cognitiveservices.azure.com/openai/v1/ + api_key: os.environ/AZURE_MODEL_ROUTER_API_KEY +``` + +### Start Proxy + +```bash +litellm --config config.yaml +``` + +### Test Request + +```bash +curl -X POST http://localhost:4000/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-1234" \ + -d '{ + "model": "azure-model-router", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +## Add Azure Model Router via LiteLLM UI + +This walkthrough shows how to add an Azure Model Router endpoint to LiteLLM using the Admin Dashboard. + +### Select Provider + +Navigate to the Models page and select "Azure AI Foundry (Studio)" as the provider. + +#### Navigate to Models Page + +![Navigate to Models](./img/azure_model_router_01.jpeg) + +#### Click Provider Dropdown + +![Click Provider](./img/azure_model_router_02.jpeg) + +#### Choose Azure AI Foundry + +![Select Azure AI Foundry](./img/azure_model_router_03.jpeg) + +### Configure Model Name + +Set up the model name by entering `azure_ai/` followed by your model router deployment name from Azure. + +#### Click Model Name Field + +![Click Model Field](./img/azure_model_router_04.jpeg) + +#### Select Custom Model Name + +![Select Custom Model](./img/azure_model_router_05.jpeg) + +#### Enter LiteLLM Model Name + +![LiteLLM Model Name](./img/azure_model_router_06.jpeg) + +#### Click Custom Model Name Field + +![Enter Custom Name Field](./img/azure_model_router_07.jpeg) + +#### Type Model Prefix + +Type `azure_ai/` as the prefix. + +![Type azure_ai prefix](./img/azure_model_router_08.jpeg) + +#### Copy Model Name from Azure Portal + +Switch to Azure AI Foundry and copy your model router deployment name. + +![Azure Portal Model Name](./img/azure_model_router_09.jpeg) + +![Copy Model Name](./img/azure_model_router_10.jpeg) + +#### Paste Model Name + +Paste to get `azure_ai/azure-model-router`. + +![Paste Model Name](./img/azure_model_router_11.jpeg) + +### Configure API Base and Key + +Copy the endpoint URL and API key from Azure portal. + +#### Copy API Base URL from Azure + +![Copy API Base](./img/azure_model_router_12.jpeg) + +#### Enter API Base in LiteLLM + +![Click API Base Field](./img/azure_model_router_13.jpeg) + +![Paste API Base](./img/azure_model_router_14.jpeg) + +#### Copy API Key from Azure + +![Copy API Key](./img/azure_model_router_15.jpeg) + +#### Enter API Key in LiteLLM + +![Enter API Key](./img/azure_model_router_16.jpeg) + +### Test and Add Model + +Verify your configuration works and save the model. + +#### Test Connection + +![Test Connection](./img/azure_model_router_17.jpeg) + +#### Close Test Dialog + +![Close Dialog](./img/azure_model_router_18.jpeg) + +#### Add Model + +![Add Model](./img/azure_model_router_19.jpeg) + +### Verify in Playground + +Test your model and verify cost tracking is working. + +#### Open Playground + +![Go to Playground](./img/azure_model_router_20.jpeg) + +#### Select Model + +![Select Model](./img/azure_model_router_21.jpeg) + +#### Send Test Message + +![Send Message](./img/azure_model_router_22.jpeg) + +#### View Logs + +![View Logs](./img/azure_model_router_23.jpeg) + +#### Verify Cost Tracking + +Cost is tracked based on the actual model used (e.g., `gpt-4.1-nano`). + +![Verify Cost](./img/azure_model_router_24.jpeg) + +## Cost Tracking + +LiteLLM automatically handles cost tracking for Azure Model Router by: + +1. **Detecting the actual model**: When Azure Model Router routes your request to a specific model (e.g., `gpt-4.1-nano-2025-04-14`), LiteLLM extracts this from the response +2. **Calculating accurate costs**: Costs are calculated based on the actual model used, not the router endpoint name +3. **Streaming support**: Cost tracking works correctly for both streaming and non-streaming requests + +### Example Response with Cost + +```python +import litellm + +response = litellm.completion( + model="azure_ai/azure-model-router", + messages=[{"role": "user", "content": "Hello!"}], + api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/", + api_key="your-api-key", +) + +# The response will show the actual model used +print(f"Model used: {response.model}") # e.g., "gpt-4.1-nano-2025-04-14" + +# Get cost +from litellm import completion_cost +cost = completion_cost(completion_response=response) +print(f"Cost: ${cost}") +``` + + diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_01.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_01.jpeg new file mode 100644 index 00000000000..42654600f74 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_01.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_02.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_02.jpeg new file mode 100644 index 00000000000..b9feab050ec Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_02.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_03.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_03.jpeg new file mode 100644 index 00000000000..3f55ebf0121 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_03.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_04.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_04.jpeg new file mode 100644 index 00000000000..1626c78bd1b Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_04.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_05.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_05.jpeg new file mode 100644 index 00000000000..bef736e361d Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_05.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_06.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_06.jpeg new file mode 100644 index 00000000000..bfeb767eea7 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_06.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_07.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_07.jpeg new file mode 100644 index 00000000000..eed742a8c68 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_07.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_08.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_08.jpeg new file mode 100644 index 00000000000..e72a6e92e77 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_08.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_09.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_09.jpeg new file mode 100644 index 00000000000..5fe1421c2a4 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_09.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_10.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_10.jpeg new file mode 100644 index 00000000000..60aa80063fc Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_10.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_11.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_11.jpeg new file mode 100644 index 00000000000..98694fbb9be Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_11.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_12.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_12.jpeg new file mode 100644 index 00000000000..77922ccea01 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_12.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_13.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_13.jpeg new file mode 100644 index 00000000000..2cb80d0826a Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_13.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_14.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_14.jpeg new file mode 100644 index 00000000000..8225023658c Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_14.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_15.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_15.jpeg new file mode 100644 index 00000000000..7bd72852881 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_15.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_16.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_16.jpeg new file mode 100644 index 00000000000..e3dbd75acae Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_16.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_17.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_17.jpeg new file mode 100644 index 00000000000..ba5fd539138 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_17.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_18.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_18.jpeg new file mode 100644 index 00000000000..1ead4bee962 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_18.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_19.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_19.jpeg new file mode 100644 index 00000000000..ec7fa9c3bcb Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_19.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_20.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_20.jpeg new file mode 100644 index 00000000000..2999fcd678e Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_20.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_21.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_21.jpeg new file mode 100644 index 00000000000..1226e29d648 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_21.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_22.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_22.jpeg new file mode 100644 index 00000000000..4455b552b81 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_22.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_23.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_23.jpeg new file mode 100644 index 00000000000..4fa88bdb965 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_23.jpeg differ diff --git a/docs/my-website/docs/providers/azure_ai/img/azure_model_router_24.jpeg b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_24.jpeg new file mode 100644 index 00000000000..7fb61d1cce1 Binary files /dev/null and b/docs/my-website/docs/providers/azure_ai/img/azure_model_router_24.jpeg differ diff --git a/docs/my-website/docs/proxy/custom_pricing.md b/docs/my-website/docs/proxy/custom_pricing.md index f6762f5e45c..b5fbd0b6c2e 100644 --- a/docs/my-website/docs/proxy/custom_pricing.md +++ b/docs/my-website/docs/proxy/custom_pricing.md @@ -9,6 +9,7 @@ LiteLLM provides flexible cost tracking and pricing customization for all LLM pr - **Custom Pricing** - Override default model costs or set pricing for custom models - **Cost Per Token** - Track costs based on input/output tokens (most common) - **Cost Per Second** - Track costs based on runtime (e.g., Sagemaker) +- **Zero-Cost Models** - Bypass budget checks for free/on-premises models by setting costs to 0 - **[Provider Discounts](./provider_discounts.md)** - Apply percentage-based discounts to specific providers - **[Provider Margins](./provider_margins.md)** - Add fees/margins to LLM costs for internal billing - **Base Model Mapping** - Ensure accurate cost tracking for Azure deployments @@ -106,6 +107,51 @@ There are other keys you can use to specify costs for different scenarios and mo These keys evolve based on how new models handle multimodality. The latest version can be found at [https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json). +## Zero-Cost Models (Bypass Budget Checks) + +**Use Case**: You have on-premises or free models that should be accessible even when users exceed their budget limits. + +**Solution** ✅: Set both `input_cost_per_token` and `output_cost_per_token` to `0` (explicitly) to bypass all budget checks for that model. + +:::info + +When a model is configured with zero cost, LiteLLM will automatically skip ALL budget checks (user, team, team member, end-user, organization, and global proxy budget) for requests to that model. + +**Important**: Both costs must be **explicitly set to 0**. If costs are `null` or undefined, the model will be treated as having cost and budget checks will apply. + +::: + +### Configuration Example + +```yaml +model_list: + # On-premises model - free to use + - model_name: on-prem-llama + litellm_params: + model: ollama/llama3 + api_base: http://localhost:11434 + model_info: + input_cost_per_token: 0 # 👈 Explicitly set to 0 + output_cost_per_token: 0 # 👈 Explicitly set to 0 + + # Paid cloud model - budget checks apply + - model_name: gpt-4 + litellm_params: + model: gpt-4 + api_key: os.environ/OPENAI_API_KEY + # No model_info - uses default pricing from cost map +``` + +### Behavior + +With the above configuration: + +- **User over budget** → Can still use `on-prem-llama` ✅, but blocked from `gpt-4` ❌ +- **Team over budget** → Can still use `on-prem-llama` ✅, but blocked from `gpt-4` ❌ +- **End-user over budget** → Can still use `on-prem-llama` ✅, but blocked from `gpt-4` ❌ + +This ensures your free/on-premises models remain accessible regardless of budget constraints, while paid models are still properly governed. + ## Set 'base_model' for Cost Tracking (e.g. Azure deployments) **Problem**: Azure returns `gpt-4` in the response when `azure/gpt-4-1106-preview` is used. This leads to inaccurate cost tracking diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 2e8ea07ab75..033aef06003 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -602,6 +602,7 @@ const sidebars = { label: "Azure AI", items: [ "providers/azure_ai", + "providers/azure_ai/azure_model_router", "providers/azure_ai_agents", "providers/azure_ocr", "providers/azure_document_intelligence", diff --git a/litellm/anthropic_interface/exceptions/__init__.py b/litellm/anthropic_interface/exceptions/__init__.py new file mode 100644 index 00000000000..875b09e3da3 --- /dev/null +++ b/litellm/anthropic_interface/exceptions/__init__.py @@ -0,0 +1,19 @@ +"""Anthropic error format utilities.""" + +from .exception_mapping_utils import ( + ANTHROPIC_ERROR_TYPE_MAP, + AnthropicExceptionMapping, +) +from .exceptions import ( + AnthropicErrorDetail, + AnthropicErrorResponse, + AnthropicErrorType, +) + +__all__ = [ + "AnthropicErrorType", + "AnthropicErrorDetail", + "AnthropicErrorResponse", + "ANTHROPIC_ERROR_TYPE_MAP", + "AnthropicExceptionMapping", +] diff --git a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py new file mode 100644 index 00000000000..b8a5079a4eb --- /dev/null +++ b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py @@ -0,0 +1,168 @@ +""" +Utilities for mapping exceptions to Anthropic error format. + +Similar to litellm/litellm_core_utils/exception_mapping_utils.py but for Anthropic response format. +""" + +from litellm.litellm_core_utils.safe_json_loads import safe_json_loads +from typing import Dict, Optional + +from .exceptions import AnthropicErrorResponse, AnthropicErrorType + + +# HTTP status code -> Anthropic error type +# Source: https://docs.anthropic.com/en/api/errors +ANTHROPIC_ERROR_TYPE_MAP: Dict[int, AnthropicErrorType] = { + 400: "invalid_request_error", + 401: "authentication_error", + 403: "permission_error", + 404: "not_found_error", + 413: "request_too_large", + 429: "rate_limit_error", + 500: "api_error", + 529: "overloaded_error", +} + + +class AnthropicExceptionMapping: + """ + Helper class for mapping exceptions to Anthropic error format. + + Similar pattern to ExceptionCheckers in litellm_core_utils/exception_mapping_utils.py + """ + + @staticmethod + def get_error_type(status_code: int) -> AnthropicErrorType: + """Map HTTP status code to Anthropic error type.""" + return ANTHROPIC_ERROR_TYPE_MAP.get(status_code, "api_error") + + @staticmethod + def create_error_response( + status_code: int, + message: str, + request_id: Optional[str] = None, + ) -> AnthropicErrorResponse: + """ + Create an Anthropic-formatted error response dict. + + Anthropic error format: + { + "type": "error", + "error": {"type": "...", "message": "..."}, + "request_id": "req_..." + } + """ + error_type = AnthropicExceptionMapping.get_error_type(status_code) + + response: AnthropicErrorResponse = { + "type": "error", + "error": { + "type": error_type, + "message": message, + }, + } + + if request_id: + response["request_id"] = request_id + + return response + + @staticmethod + def extract_error_message(raw_message: str) -> str: + """ + Extract error message from various provider response formats. + + Handles: + - Bedrock: {"detail": {"message": "..."}} + - AWS: {"Message": "..."} + - Generic: {"message": "..."} + - Plain strings + """ + parsed = safe_json_loads(raw_message) + if isinstance(parsed, dict): + # Bedrock format + if "detail" in parsed and isinstance(parsed["detail"], dict): + return parsed["detail"].get("message", raw_message) + # AWS/generic format + return parsed.get("Message") or parsed.get("message") or raw_message + return raw_message + + @staticmethod + def _is_anthropic_error_dict(parsed: dict) -> bool: + """ + Check if a parsed dict is in Anthropic error format. + + Anthropic error format: + { + "type": "error", + "error": {"type": "...", "message": "..."} + } + """ + return ( + parsed.get("type") == "error" + and isinstance(parsed.get("error"), dict) + and "type" in parsed["error"] + and "message" in parsed["error"] + ) + + @staticmethod + def _extract_message_from_dict(parsed: dict, raw_message: str) -> str: + """ + Extract error message from a parsed provider-specific dict. + + Handles: + - Bedrock: {"detail": {"message": "..."}} + - AWS: {"Message": "..."} + - Generic: {"message": "..."} + """ + # Bedrock format + if "detail" in parsed and isinstance(parsed["detail"], dict): + return parsed["detail"].get("message", raw_message) + # AWS/generic format + return parsed.get("Message") or parsed.get("message") or raw_message + + @staticmethod + def transform_to_anthropic_error( + status_code: int, + raw_message: str, + request_id: Optional[str] = None, + ) -> AnthropicErrorResponse: + """ + Transform an error message to Anthropic format. + + - If already in Anthropic format: passthrough unchanged + - Otherwise: extract message and create Anthropic error + + Parses JSON only once for efficiency. + + Args: + status_code: HTTP status code + raw_message: Raw error message (may be JSON string or plain text) + request_id: Optional request ID to include + + Returns: + AnthropicErrorResponse dict + """ + # Try to parse as JSON once + parsed: Optional[dict] = safe_json_loads(raw_message) + if not isinstance(parsed, dict): + parsed = None + + # If parsed and already in Anthropic format - passthrough + if parsed is not None and AnthropicExceptionMapping._is_anthropic_error_dict(parsed): + # Optionally add request_id if provided and not present + if request_id and "request_id" not in parsed: + parsed["request_id"] = request_id + return parsed # type: ignore + + # Extract message - use parsed dict if available, otherwise raw string + if parsed is not None: + message = AnthropicExceptionMapping._extract_message_from_dict(parsed, raw_message) + else: + message = raw_message + + return AnthropicExceptionMapping.create_error_response( + status_code=status_code, + message=message, + request_id=request_id, + ) diff --git a/litellm/anthropic_interface/exceptions/exceptions.py b/litellm/anthropic_interface/exceptions/exceptions.py new file mode 100644 index 00000000000..984390fa702 --- /dev/null +++ b/litellm/anthropic_interface/exceptions/exceptions.py @@ -0,0 +1,41 @@ +"""Anthropic error format type definitions.""" + +from typing_extensions import Literal, Required, TypedDict + + +# Known Anthropic error types +# Source: https://docs.anthropic.com/en/api/errors +AnthropicErrorType = Literal[ + "invalid_request_error", + "authentication_error", + "permission_error", + "not_found_error", + "request_too_large", + "rate_limit_error", + "api_error", + "overloaded_error", +] + + +class AnthropicErrorDetail(TypedDict): + """Inner error detail in Anthropic format.""" + + type: AnthropicErrorType + message: str + + +class AnthropicErrorResponse(TypedDict, total=False): + """ + Anthropic-formatted error response. + + Format: + { + "type": "error", + "error": {"type": "...", "message": "..."}, + "request_id": "req_..." # optional + } + """ + + type: Required[Literal["error"]] + error: Required[AnthropicErrorDetail] + request_id: str diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index af7dd078107..870b97530fa 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -587,6 +587,24 @@ def _model_contains_known_llm_provider(model: str) -> bool: return _provider_prefix in LlmProvidersSet +def _get_response_model(completion_response: Any) -> Optional[str]: + """ + Extract the model name from a completion response object. + + Used as a fallback for cost calculation when the input model name + doesn't exist in model_cost (e.g., Azure Model Router). + """ + if completion_response is None: + return None + + if isinstance(completion_response, BaseModel): + return getattr(completion_response, "model", None) + elif isinstance(completion_response, dict): + return completion_response.get("model", None) + + return None + + def _get_usage_object( completion_response: Any, ) -> Optional[Usage]: @@ -933,9 +951,8 @@ def completion_cost( # noqa: PLR0915 router_model_id=router_model_id, ) - potential_model_names = [selected_model] - if model is not None: - potential_model_names.append(model) + potential_model_names = [selected_model, _get_response_model(completion_response)] + for idx, model in enumerate(potential_model_names): try: diff --git a/litellm/exceptions.py b/litellm/exceptions.py index d963cac754c..400973d8227 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -898,9 +898,15 @@ class LiteLLMUnknownProvider(BadRequestError): class GuardrailRaisedException(Exception): - def __init__(self, guardrail_name: Optional[str] = None, message: str = ""): + def __init__( + self, + guardrail_name: Optional[str] = None, + message: str = "", + should_wrap_with_default_message: bool = True, + ): + default_message = f"Guardrail raised an exception, Guardrail: {guardrail_name}, Message: {message}" self.guardrail_name = guardrail_name - self.message = f"Guardrail raised an exception, Guardrail: {guardrail_name}, Message: {message}" + self.message = default_message if should_wrap_with_default_message else message super().__init__(self.message) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 7e0cfab617b..a223925d59a 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -594,9 +594,9 @@ class OpenTelemetry(CustomLogger): def _get_dynamic_otel_headers_from_kwargs(self, kwargs) -> Optional[dict]: """Extract dynamic headers from kwargs if available.""" - standard_callback_dynamic_params: Optional[ - StandardCallbackDynamicParams - ] = kwargs.get("standard_callback_dynamic_params") + standard_callback_dynamic_params: Optional[StandardCallbackDynamicParams] = ( + kwargs.get("standard_callback_dynamic_params") + ) if not standard_callback_dynamic_params: return None @@ -797,7 +797,7 @@ class OpenTelemetry(CustomLogger): and self._token_usage_histogram ): in_attrs = {**common_attrs, "gen_ai.token.type": "input"} - out_attrs = {**common_attrs, "gen_ai.token.type": "completion"} + out_attrs = {**common_attrs, "gen_ai.token.type": "output"} self._token_usage_histogram.record( usage.get("prompt_tokens", 0), attributes=in_attrs ) @@ -1488,21 +1488,21 @@ class OpenTelemetry(CustomLogger): if usage: self.safe_set_attribute( span=span, - key=SpanAttributes.LLM_USAGE_TOTAL_TOKENS.value, + key=SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS.value, value=usage.get("total_tokens"), ) # The number of tokens used in the LLM response (completion). self.safe_set_attribute( span=span, - key=SpanAttributes.LLM_USAGE_COMPLETION_TOKENS.value, + key=SpanAttributes.GEN_AI_USAGE_OUTPUT_TOKENS.value, value=usage.get("completion_tokens"), ) # The number of tokens used in the LLM prompt. self.safe_set_attribute( span=span, - key=SpanAttributes.LLM_USAGE_PROMPT_TOKENS.value, + key=SpanAttributes.GEN_AI_USAGE_INPUT_TOKENS.value, value=usage.get("prompt_tokens"), ) @@ -1520,53 +1520,75 @@ class OpenTelemetry(CustomLogger): self.set_tools_attributes(span, tools) if kwargs.get("messages"): - for idx, prompt in enumerate(kwargs.get("messages")): - if prompt.get("role"): - self.safe_set_attribute( - span=span, - key=f"{SpanAttributes.LLM_PROMPTS.value}.{idx}.role", - value=prompt.get("role"), - ) + transformed_messages = ( + self._transform_messages_to_otel_semantic_conventions( + kwargs.get("messages") + ) + ) + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_INPUT_MESSAGES.value, + value=safe_dumps(transformed_messages), + ) - if prompt.get("content"): - if not isinstance(prompt.get("content"), str): - prompt["content"] = str(prompt.get("content")) - self.safe_set_attribute( - span=span, - key=f"{SpanAttributes.LLM_PROMPTS.value}.{idx}.content", - value=prompt.get("content"), - ) + if kwargs.get("system_instructions"): + transformed_system_instructions = ( + self._transform_messages_to_otel_semantic_conventions( + kwargs.get("system_instructions") + ) + ) + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value, + value=safe_dumps(transformed_system_instructions), + ) + + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_OPERATION_NAME.value, + value=( + "chat" + if standard_logging_payload.get("call_type") == "completion" + else standard_logging_payload.get("call_type") or "chat" + ), + ) + + if standard_logging_payload.get("request_id"): + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_REQUEST_ID.value, + value=standard_logging_payload.get("request_id"), + ) ############################################# ########## LLM Response Attributes ########## ############################################# if response_obj is not None: if response_obj.get("choices"): + transformed_choices = ( + self._transform_choices_to_otel_semantic_conventions( + response_obj.get("choices") + ) + ) + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value, + value=safe_dumps(transformed_choices), + ) + + finish_reasons = [] + for idx, choice in enumerate(response_obj.get("choices")): + if choice.get("finish_reason"): + finish_reasons.append(choice.get("finish_reason")) + + if finish_reasons: + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value, + value=safe_dumps(finish_reasons), + ) + for idx, choice in enumerate(response_obj.get("choices")): if choice.get("finish_reason"): - self.safe_set_attribute( - span=span, - key=f"{SpanAttributes.LLM_COMPLETIONS.value}.{idx}.finish_reason", - value=choice.get("finish_reason"), - ) - if choice.get("message"): - if choice.get("message").get("role"): - self.safe_set_attribute( - span=span, - key=f"{SpanAttributes.LLM_COMPLETIONS.value}.{idx}.role", - value=choice.get("message").get("role"), - ) - if choice.get("message").get("content"): - if not isinstance( - choice.get("message").get("content"), str - ): - choice["message"]["content"] = str( - choice.get("message").get("content") - ) - self.safe_set_attribute( - span=span, - key=f"{SpanAttributes.LLM_COMPLETIONS.value}.{idx}.content", - value=choice.get("message").get("content"), - ) message = choice.get("message") tool_calls = message.get("tool_calls") @@ -1608,6 +1630,66 @@ class OpenTelemetry(CustomLogger): primitive_value = self._cast_as_primitive_value_type(value) span.set_attribute(key, primitive_value) + def _transform_messages_to_otel_semantic_conventions( + self, messages: Union[List[dict], str] + ) -> List[dict]: + """ + Transforms LiteLLM/OpenAI style messages into OTEL GenAI 1.38 compliant format. + OTEL expects a 'parts' array instead of a single 'content' string. + """ + if isinstance(messages, str): + # Handle system_instructions passed as a string + return [ + {"role": "system", "parts": [{"type": "text", "content": messages}]} + ] + + transformed = [] + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + parts = [] + + if isinstance(content, str): + parts.append({"type": "text", "content": content}) + elif isinstance(content, list): + # Handle multi-modal content if necessary + for part in content: + if isinstance(part, dict): + parts.append(part) + else: + parts.append({"type": "text", "content": str(part)}) + + transformed_msg = {"role": role, "parts": parts} + if "id" in msg: + transformed_msg["id"] = msg["id"] + if "tool_calls" in msg: + transformed_msg["tool_calls"] = msg["tool_calls"] + if "tool_call_id" in msg: + transformed_msg["tool_call_id"] = msg["tool_call_id"] + transformed.append(transformed_msg) + + return transformed + + def _transform_choices_to_otel_semantic_conventions( + self, choices: List[dict] + ) -> List[dict]: + """ + Transforms choices into OTEL GenAI 1.38 compliant format for output.messages. + """ + transformed = [] + for choice in choices: + message = choice.get("message") or {} + finish_reason = choice.get("finish_reason") + + transformed_msg = self._transform_messages_to_otel_semantic_conventions( + [message] + )[0] + if finish_reason: + transformed_msg["finish_reason"] = finish_reason + + transformed.append(transformed_msg) + return transformed + def set_raw_request_attributes(self, span: Span, kwargs, response_obj): try: kwargs.get("optional_params", {}) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index e0a799d8e5c..7d036615f59 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1853,6 +1853,14 @@ class Logging(LiteLLMLoggingBaseClass): cache_hit=cache_hit, standard_logging_object=kwargs.get("standard_logging_object", None), ) + litellm_params = self.model_call_details.get("litellm_params", {}) + is_sync_request = ( + litellm_params.get(CallTypes.acompletion.value, False) is not True + and litellm_params.get(CallTypes.aresponses.value, False) is not True + and litellm_params.get(CallTypes.aembedding.value, False) is not True + and litellm_params.get(CallTypes.aimage_generation.value, False) is not True + and litellm_params.get(CallTypes.atranscription.value, False) is not True + ) try: ## BUILD COMPLETE STREAMED RESPONSE complete_streaming_response: Optional[ @@ -1915,7 +1923,6 @@ class Logging(LiteLLMLoggingBaseClass): self.has_run_logging(event_type="sync_success") for callback in callbacks: try: - litellm_params = self.model_call_details.get("litellm_params", {}) should_run = self.should_run_callback( callback=callback, litellm_params=litellm_params, @@ -2185,22 +2192,7 @@ class Logging(LiteLLMLoggingBaseClass): if ( callback == "openmeter" - and self.model_call_details.get("litellm_params", {}).get( - "acompletion", False - ) - is not True - and self.model_call_details.get("litellm_params", {}).get( - "aembedding", False - ) - is not True - and self.model_call_details.get("litellm_params", {}).get( - "aimage_generation", False - ) - is not True - and self.model_call_details.get("litellm_params", {}).get( - "atranscription", False - ) - is not True + and is_sync_request ): global openMeterLogger if openMeterLogger is None: @@ -2229,22 +2221,7 @@ class Logging(LiteLLMLoggingBaseClass): ) if ( isinstance(callback, CustomLogger) - and self.model_call_details.get("litellm_params", {}).get( - "acompletion", False - ) - is not True - and self.model_call_details.get("litellm_params", {}).get( - "aembedding", False - ) - is not True - and self.model_call_details.get("litellm_params", {}).get( - "aimage_generation", False - ) - is not True - and self.model_call_details.get("litellm_params", {}).get( - "atranscription", False - ) - is not True + and is_sync_request and self.call_type != CallTypes.pass_through.value # pass-through endpoints call async_log_success_event ): # custom logger class @@ -2272,22 +2249,7 @@ class Logging(LiteLLMLoggingBaseClass): ) if ( callable(callback) is True - and self.model_call_details.get("litellm_params", {}).get( - "acompletion", False - ) - is not True - and self.model_call_details.get("litellm_params", {}).get( - "aembedding", False - ) - is not True - and self.model_call_details.get("litellm_params", {}).get( - "aimage_generation", False - ) - is not True - and self.model_call_details.get("litellm_params", {}).get( - "atranscription", False - ) - is not True + and is_sync_request and customLogger is not None ): # custom logger functions print_verbose( @@ -2737,6 +2699,15 @@ class Logging(LiteLLMLoggingBaseClass): event_type="sync_failure" ): # prevent double logging return + litellm_params = self.model_call_details.get("litellm_params", {}) + is_sync_request = ( + litellm_params.get(CallTypes.acompletion.value, False) is not True + and litellm_params.get(CallTypes.aresponses.value, False) is not True + and litellm_params.get(CallTypes.aembedding.value, False) is not True + and litellm_params.get(CallTypes.aimage_generation.value, False) is not True + and litellm_params.get(CallTypes.atranscription.value, False) is not True + ) + try: start_time, end_time = self._failure_handler_helper_fn( exception=exception, @@ -2762,7 +2733,6 @@ class Logging(LiteLLMLoggingBaseClass): self.has_run_logging(event_type="sync_failure") for callback in callbacks: try: - litellm_params = self.model_call_details.get("litellm_params", {}) should_run = self.should_run_callback( callback=callback, litellm_params=litellm_params, @@ -2830,14 +2800,7 @@ class Logging(LiteLLMLoggingBaseClass): ) if ( isinstance(callback, CustomLogger) - and self.model_call_details.get("litellm_params", {}).get( - "acompletion", False - ) - is not True - and self.model_call_details.get("litellm_params", {}).get( - "aembedding", False - ) - is not True + and is_sync_request ): # custom logger class callback.log_failure_event( start_time=start_time, diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index c332e5f88f7..0fb6a449ab6 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -17,8 +17,8 @@ from litellm.types.utils import ( ModelResponse, ModelResponseStream, PromptTokensDetailsWrapper, + ServerToolUse, Usage, - ServerToolUse ) from litellm.utils import print_verbose, token_counter @@ -68,12 +68,31 @@ class ChunkProcessor: return chunk["id"] return "" + @staticmethod + def _get_model_from_chunks(chunks: List[Dict[str, Any]], first_chunk_model: str) -> str: + """ + Get the actual model from chunks, preferring a model that differs from the first chunk. + + For Azure Model Router, the first chunk may have the request model (e.g., 'azure-model-router') + while subsequent chunks have the actual model (e.g., 'gpt-4.1-nano-2025-04-14'). + This method finds the actual model for accurate cost calculation. + """ + # Look for a model in chunks that differs from the first chunk's model + for chunk in chunks: + chunk_model = chunk.get("model") + if chunk_model and chunk_model != first_chunk_model: + return chunk_model + # Fall back to first chunk's model if no different model found + return first_chunk_model + def build_base_response(self, chunks: List[Dict[str, Any]]) -> ModelResponse: chunk = self.first_chunk id = ChunkProcessor._get_chunk_id(chunks) object = chunk["object"] created = chunk["created"] - model = chunk["model"] + first_chunk_model = chunk["model"] + # Get the actual model - for Azure Model Router, this finds the real model from later chunks + model = ChunkProcessor._get_model_from_chunks(chunks, first_chunk_model) system_fingerprint = chunk.get("system_fingerprint", None) role = chunk["choices"][0]["delta"]["role"] diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 6baaae7ae3f..3093a37c26a 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -25,6 +25,7 @@ from litellm.types.utils import ( ) from litellm.types.utils import GenericStreamingChunk as GChunk from litellm.types.utils import ( + LlmProviders, ModelResponse, ModelResponseStream, StreamingChoices, @@ -1301,7 +1302,7 @@ class CustomStreamWrapper: if response_obj["is_finished"]: self.received_finish_reason = response_obj["finish_reason"] else: # openai / azure chat model - if self.custom_llm_provider == "azure": + if self.custom_llm_provider in [LlmProviders.AZURE.value, LlmProviders.AZURE_AI.value]: if isinstance(chunk, BaseModel) and hasattr(chunk, "model"): # for azure, we need to pass the model from the original chunk self.model = getattr(chunk, "model", self.model) diff --git a/litellm/llms/bedrock/count_tokens/bedrock_token_counter.py b/litellm/llms/bedrock/count_tokens/bedrock_token_counter.py index b680bd046ef..54f8a8dbd65 100644 --- a/litellm/llms/bedrock/count_tokens/bedrock_token_counter.py +++ b/litellm/llms/bedrock/count_tokens/bedrock_token_counter.py @@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional from litellm._logging import verbose_logger from litellm.llms.base_llm.base_utils import BaseTokenCounter -from litellm.llms.bedrock.common_utils import get_bedrock_base_model +from litellm.llms.bedrock.common_utils import BedrockError, get_bedrock_base_model from litellm.llms.bedrock.count_tokens.handler import BedrockCountTokensHandler from litellm.types.utils import LlmProviders, TokenCountResponse @@ -79,9 +79,31 @@ class BedrockTokenCounter(BaseTokenCounter): tokenizer_type="bedrock_api", original_response=result, ) + except BedrockError as e: + verbose_logger.warning( + f"Bedrock CountTokens API error: status={e.status_code}, message={e.message}" + ) + return TokenCountResponse( + total_tokens=0, + request_model=request_model, + model_used=model_to_use, + tokenizer_type="bedrock_api", + error=True, + error_message=e.message, + status_code=e.status_code, + ) except Exception as e: verbose_logger.warning( - f"Error calling Bedrock CountTokens API: {e}, falling back to default tokenizer" + f"Error calling Bedrock CountTokens API: {e}" + ) + return TokenCountResponse( + total_tokens=0, + request_model=request_model, + model_used=model_to_use, + tokenizer_type="bedrock_api", + error=True, + error_message=str(e), + status_code=500, ) return None diff --git a/litellm/llms/bedrock/count_tokens/handler.py b/litellm/llms/bedrock/count_tokens/handler.py index e8366165b65..9d2be6cca89 100644 --- a/litellm/llms/bedrock/count_tokens/handler.py +++ b/litellm/llms/bedrock/count_tokens/handler.py @@ -6,6 +6,8 @@ Simplified handler leveraging existing LiteLLM Bedrock infrastructure. from typing import Any, Dict +import httpx + import litellm from litellm._logging import verbose_logger from litellm.llms.bedrock.common_utils import BedrockError @@ -98,7 +100,7 @@ class BedrockCountTokensHandler(BedrockCountTokensConfig): verbose_logger.error(f"AWS Bedrock error: {error_text}") raise BedrockError( status_code=response.status_code, - message=f"AWS Bedrock error: {error_text}", + message=error_text, ) bedrock_response = response.json() @@ -117,6 +119,13 @@ class BedrockCountTokensHandler(BedrockCountTokensConfig): except BedrockError: # Re-raise Bedrock exceptions as-is raise + except httpx.HTTPStatusError as e: + # HTTP errors - preserve the actual status code + verbose_logger.error(f"HTTP error in CountTokens handler: {str(e)}") + raise BedrockError( + status_code=e.response.status_code, + message=e.response.text, + ) except Exception as e: verbose_logger.error(f"Error in CountTokens handler: {str(e)}") raise BedrockError( diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f818a2c82e2..39008d802be 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1644,7 +1644,7 @@ "cache_read_input_token_cost": 1.4e-07, "input_cost_per_token": 1.38e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -1944,7 +1944,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -2893,7 +2893,7 @@ "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -2964,7 +2964,7 @@ "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -2997,7 +2997,7 @@ "input_cost_per_token": 2.5e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -3092,9 +3092,9 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "source": "https://azure.microsoft.com/en-us/blog/gpt-5-in-azure-ai-foundry-the-future-of-ai-apps-and-agents-starts-here/", @@ -3377,7 +3377,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -3440,7 +3440,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -3500,7 +3500,7 @@ "cache_read_input_token_cost": 1.75e-07, "input_cost_per_token": 1.75e-06, "litellm_provider": "azure", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -3637,7 +3637,7 @@ "azure/gpt-5.2-pro": { "input_cost_per_token": 2.1e-05, "litellm_provider": "azure", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -3668,7 +3668,7 @@ "azure/gpt-5.2-pro-2025-12-11": { "input_cost_per_token": 2.1e-05, "litellm_provider": "azure", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -4684,7 +4684,7 @@ "cache_read_input_token_cost": 1.4e-07, "input_cost_per_token": 1.38e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -7813,14 +7813,14 @@ "supports_vision": true }, "deepseek-chat": { - "cache_read_input_token_cost": 6e-08, - "input_cost_per_token": 6e-07, + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 2.8e-07, "litellm_provider": "deepseek", "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 1.7e-06, + "output_cost_per_token": 4.2e-07, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" @@ -7834,14 +7834,14 @@ "supports_tool_choice": true }, "deepseek-reasoner": { - "cache_read_input_token_cost": 6e-08, - "input_cost_per_token": 6e-07, + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 2.8e-07, "litellm_provider": "deepseek", "max_input_tokens": 131072, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 1.7e-06, + "output_cost_per_token": 4.2e-07, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" @@ -10045,15 +10045,15 @@ }, "deepseek/deepseek-chat": { "cache_creation_input_token_cost": 0.0, - "cache_read_input_token_cost": 7e-08, - "input_cost_per_token": 2.7e-07, - "input_cost_per_token_cache_hit": 7e-08, + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 2.8e-07, + "input_cost_per_token_cache_hit": 2.8e-08, "litellm_provider": "deepseek", - "max_input_tokens": 65536, + "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 1.1e-06, + "output_cost_per_token": 4.2e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, @@ -10089,14 +10089,15 @@ "supports_tool_choice": true }, "deepseek/deepseek-reasoner": { - "input_cost_per_token": 5.5e-07, - "input_cost_per_token_cache_hit": 1.4e-07, + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 2.8e-07, + "input_cost_per_token_cache_hit": 2.8e-08, "litellm_provider": "deepseek", - "max_input_tokens": 65536, + "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 2.19e-06, + "output_cost_per_token": 4.2e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, @@ -12996,6 +12997,7 @@ }, "gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -13178,7 +13180,8 @@ "supports_web_search": true }, "gemini-2.5-pro-exp-03-25": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", @@ -13222,7 +13225,8 @@ }, "gemini-2.5-pro-preview-03-25": { "deprecation_date": "2025-12-02", - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -13268,7 +13272,8 @@ }, "gemini-2.5-pro-preview-05-06": { "deprecation_date": "2025-12-02", - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -13316,7 +13321,8 @@ "supports_web_search": true }, "gemini-2.5-pro-preview-06-05": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -13361,7 +13367,8 @@ "supports_web_search": true }, "gemini-2.5-pro-preview-tts": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -14850,7 +14857,8 @@ "tpm": 250000 }, "gemini/gemini-2.5-pro": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "gemini", @@ -15112,7 +15120,8 @@ }, "gemini/gemini-2.5-pro-preview-03-25": { "deprecation_date": "2025-12-02", - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -15153,7 +15162,8 @@ }, "gemini/gemini-2.5-pro-preview-05-06": { "deprecation_date": "2025-12-02", - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -15194,7 +15204,8 @@ "tpm": 10000000 }, "gemini/gemini-2.5-pro-preview-06-05": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -15235,7 +15246,8 @@ "tpm": 10000000 }, "gemini/gemini-2.5-pro-preview-tts": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -17470,7 +17482,7 @@ "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -17507,7 +17519,7 @@ "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -17543,7 +17555,7 @@ "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -17852,7 +17864,7 @@ "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -17887,9 +17899,9 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supported_endpoints": [ @@ -17951,7 +17963,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -17983,7 +17995,7 @@ "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -18046,7 +18058,7 @@ "input_cost_per_token": 2.5e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -18081,7 +18093,7 @@ "input_cost_per_token_flex": 1.25e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -18120,7 +18132,7 @@ "input_cost_per_token_flex": 1.25e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -18158,7 +18170,7 @@ "input_cost_per_token_flex": 2.5e-08, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -18193,7 +18205,7 @@ "input_cost_per_token": 5e-08, "input_cost_per_token_flex": 2.5e-08, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -23206,9 +23218,9 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supported_modalities": [ @@ -23225,7 +23237,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -23244,7 +23256,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -23263,7 +23275,7 @@ "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -23282,7 +23294,7 @@ "cache_read_input_token_cost": 5e-09, "input_cost_per_token": 5e-08, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -32552,8 +32564,8 @@ "novita/deepseek/deepseek-v3.2": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.69e-03, - "output_cost_per_token": 4e-03, + "input_cost_per_token": 2.69e-7, + "output_cost_per_token": 4e-7, "max_input_tokens": 163840, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32562,15 +32574,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 1.345e-03, - "input_cost_per_token_cache_hit": 1.345e-03, + "cache_read_input_token_cost": 1.345e-7, + "input_cost_per_token_cache_hit": 1.345e-7, "supports_reasoning": true }, "novita/minimax/minimax-m2.1": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3e-03, - "output_cost_per_token": 1.2e-02, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 1.2e-06, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_tokens": 131072, @@ -32579,15 +32591,14 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 3e-04, - "input_cost_per_token_cache_hit": 3e-04, - "supports_reasoning": true + "cache_read_input_token_cost": 3e-8, + "input_cost_per_token_cache_hit": 3e-8 }, "novita/zai-org/glm-4.7": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 6e-03, - "output_cost_per_token": 2.2e-02, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 2.2e-06, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_tokens": 131072, @@ -32596,15 +32607,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 1.1e-03, - "input_cost_per_token_cache_hit": 1.1e-03, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token_cache_hit": 1.1e-7, "supports_reasoning": true }, "novita/xiaomimimo/mimo-v2-flash": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1e-03, - "output_cost_per_token": 3e-03, + "input_cost_per_token": 1e-7, + "output_cost_per_token": 3e-7, "max_input_tokens": 262144, "max_output_tokens": 32000, "max_tokens": 32000, @@ -32613,15 +32624,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 2e-04, - "input_cost_per_token_cache_hit": 2e-04, + "cache_read_input_token_cost": 2e-8, + "input_cost_per_token_cache_hit": 2e-8, "supports_reasoning": true }, "novita/zai-org/autoglm-phone-9b-multilingual": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.5e-04, - "output_cost_per_token": 1.38e-03, + "input_cost_per_token": 3.5e-8, + "output_cost_per_token": 1.38e-7, "max_input_tokens": 65536, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32631,8 +32642,8 @@ "novita/moonshotai/kimi-k2-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.8e-03, - "output_cost_per_token": 2e-02, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 2.5e-06, "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, @@ -32646,8 +32657,8 @@ "novita/minimax/minimax-m2": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-03, - "output_cost_per_token": 9.6e-03, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 1.2e-06, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_tokens": 131072, @@ -32655,15 +32666,15 @@ "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_system_messages": true, - "cache_read_input_token_cost": 2.4e-04, - "input_cost_per_token_cache_hit": 2.4e-04, + "cache_read_input_token_cost": 3e-8, + "input_cost_per_token_cache_hit": 3e-8, "supports_reasoning": true }, "novita/paddlepaddle/paddleocr-vl": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.6e-04, - "output_cost_per_token": 1.6e-04, + "input_cost_per_token": 2e-8, + "output_cost_per_token": 2e-8, "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, @@ -32673,8 +32684,8 @@ "novita/deepseek/deepseek-v3.2-exp": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.16e-03, - "output_cost_per_token": 3.28e-03, + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 4.1e-7, "max_input_tokens": 163840, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32688,8 +32699,8 @@ "novita/qwen/qwen3-vl-235b-a22b-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 7.84e-03, - "output_cost_per_token": 3.16e-02, + "input_cost_per_token": 9.8e-7, + "output_cost_per_token": 3.95e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32700,8 +32711,8 @@ "novita/zai-org/glm-4.6v": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3e-03, - "output_cost_per_token": 9e-03, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 9e-7, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32711,15 +32722,15 @@ "supports_vision": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 5.5e-04, - "input_cost_per_token_cache_hit": 5.5e-04, + "cache_read_input_token_cost": 5.5e-8, + "input_cost_per_token_cache_hit": 5.5e-8, "supports_reasoning": true }, "novita/zai-org/glm-4.6": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.4e-03, - "output_cost_per_token": 1.76e-02, + "input_cost_per_token": 5.5e-7, + "output_cost_per_token": 2.2e-06, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_tokens": 131072, @@ -32728,15 +32739,31 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 8.8e-04, - "input_cost_per_token_cache_hit": 8.8e-04, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token_cache_hit": 1.1e-7, "supports_reasoning": true }, + "novita/kwaipilot/kat-coder-pro": { + "litellm_provider": "novita", + "mode": "chat", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 1.2e-06, + "max_input_tokens": 256000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 6e-8, + "input_cost_per_token_cache_hit": 6e-8 + }, "novita/qwen/qwen3-next-80b-a3b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.2e-03, - "output_cost_per_token": 1.2e-02, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32749,8 +32776,8 @@ "novita/qwen/qwen3-next-80b-a3b-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.2e-03, - "output_cost_per_token": 1.2e-02, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32764,8 +32791,8 @@ "novita/deepseek/deepseek-ocr": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-04, - "output_cost_per_token": 2.4e-04, + "input_cost_per_token": 3e-8, + "output_cost_per_token": 3e-8, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -32777,8 +32804,8 @@ "novita/deepseek/deepseek-v3.1-terminus": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.16e-03, - "output_cost_per_token": 8e-03, + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 1e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32787,15 +32814,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 1.08e-03, - "input_cost_per_token_cache_hit": 1.08e-03, + "cache_read_input_token_cost": 1.35e-7, + "input_cost_per_token_cache_hit": 1.35e-7, "supports_reasoning": true }, "novita/qwen/qwen3-vl-235b-a22b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-03, - "output_cost_per_token": 1.2e-02, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 1.5e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32809,8 +32836,8 @@ "novita/qwen/qwen3-max": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.688e-02, - "output_cost_per_token": 6.76e-02, + "input_cost_per_token": 2.11e-06, + "output_cost_per_token": 8.45e-06, "max_input_tokens": 262144, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32823,8 +32850,8 @@ "novita/skywork/r1v4-lite": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2e-03, - "output_cost_per_token": 6e-03, + "input_cost_per_token": 2e-7, + "output_cost_per_token": 6e-7, "max_input_tokens": 262144, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32836,8 +32863,8 @@ "novita/deepseek/deepseek-v3.1": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.16e-03, - "output_cost_per_token": 8e-03, + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 1e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32846,15 +32873,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 1.08e-03, - "input_cost_per_token_cache_hit": 1.08e-03, + "cache_read_input_token_cost": 1.35e-7, + "input_cost_per_token_cache_hit": 1.35e-7, "supports_reasoning": true }, "novita/moonshotai/kimi-k2-0905": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.8e-03, - "output_cost_per_token": 2e-02, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 2.5e-06, "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, @@ -32867,8 +32894,8 @@ "novita/qwen/qwen3-coder-480b-a35b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-03, - "output_cost_per_token": 1.04e-02, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 1.3e-06, "max_input_tokens": 262144, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32881,8 +32908,8 @@ "novita/qwen/qwen3-coder-30b-a3b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 7e-04, - "output_cost_per_token": 2.7e-03, + "input_cost_per_token": 7e-8, + "output_cost_per_token": 2.7e-7, "max_input_tokens": 160000, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32895,8 +32922,8 @@ "novita/openai/gpt-oss-120b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4e-04, - "output_cost_per_token": 2e-03, + "input_cost_per_token": 5e-8, + "output_cost_per_token": 2.5e-7, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32911,8 +32938,8 @@ "novita/moonshotai/kimi-k2-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.56e-03, - "output_cost_per_token": 1.84e-02, + "input_cost_per_token": 5.7e-7, + "output_cost_per_token": 2.3e-06, "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, @@ -32925,8 +32952,8 @@ "novita/deepseek/deepseek-v3-0324": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.16e-03, - "output_cost_per_token": 8.96e-03, + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 1.12e-06, "max_input_tokens": 163840, "max_output_tokens": 163840, "max_tokens": 163840, @@ -32935,14 +32962,14 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 1.08e-03, - "input_cost_per_token_cache_hit": 1.08e-03 + "cache_read_input_token_cost": 1.35e-7, + "input_cost_per_token_cache_hit": 1.35e-7 }, "novita/zai-org/glm-4.5": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.8e-03, - "output_cost_per_token": 1.76e-02, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 2.2e-06, "max_input_tokens": 131072, "max_output_tokens": 98304, "max_tokens": 98304, @@ -32950,15 +32977,15 @@ "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_system_messages": true, - "cache_read_input_token_cost": 8.8e-04, - "input_cost_per_token_cache_hit": 8.8e-04, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token_cache_hit": 1.1e-7, "supports_reasoning": true }, "novita/qwen/qwen3-235b-a22b-thinking-2507": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-03, - "output_cost_per_token": 2.4e-02, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 3e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32971,8 +32998,8 @@ "novita/meta-llama/llama-3.1-8b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2e-04, - "output_cost_per_token": 5e-04, + "input_cost_per_token": 2e-8, + "output_cost_per_token": 5e-8, "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, @@ -32981,8 +33008,8 @@ "novita/google/gemma-3-12b-it": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4e-04, - "output_cost_per_token": 8e-04, + "input_cost_per_token": 5e-8, + "output_cost_per_token": 1e-7, "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, @@ -32994,8 +33021,8 @@ "novita/zai-org/glm-4.5v": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.8e-03, - "output_cost_per_token": 1.44e-02, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 1.8e-06, "max_input_tokens": 65536, "max_output_tokens": 16384, "max_tokens": 16384, @@ -33005,15 +33032,15 @@ "supports_vision": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 8.8e-04, - "input_cost_per_token_cache_hit": 8.8e-04, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token_cache_hit": 1.1e-7, "supports_reasoning": true }, "novita/openai/gpt-oss-20b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.2e-04, - "output_cost_per_token": 1.2e-03, + "input_cost_per_token": 4e-8, + "output_cost_per_token": 1.5e-7, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33026,8 +33053,8 @@ "novita/qwen/qwen3-235b-a22b-instruct-2507": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 7.2e-04, - "output_cost_per_token": 4.64e-03, + "input_cost_per_token": 9e-8, + "output_cost_per_token": 5.8e-7, "max_input_tokens": 131072, "max_output_tokens": 16384, "max_tokens": 16384, @@ -33040,8 +33067,8 @@ "novita/deepseek/deepseek-r1-distill-qwen-14b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.2e-03, - "output_cost_per_token": 1.2e-03, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-7, "max_input_tokens": 32768, "max_output_tokens": 16384, "max_tokens": 16384, @@ -33053,8 +33080,8 @@ "novita/meta-llama/llama-3.3-70b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.08e-03, - "output_cost_per_token": 3.2e-03, + "input_cost_per_token": 1.35e-7, + "output_cost_per_token": 4e-7, "max_input_tokens": 131072, "max_output_tokens": 120000, "max_tokens": 120000, @@ -33066,8 +33093,8 @@ "novita/qwen/qwen-2.5-72b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.04e-03, - "output_cost_per_token": 3.2e-03, + "input_cost_per_token": 3.8e-7, + "output_cost_per_token": 4e-7, "max_input_tokens": 32000, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33080,8 +33107,8 @@ "novita/mistralai/mistral-nemo": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.2e-04, - "output_cost_per_token": 1.36e-03, + "input_cost_per_token": 4e-8, + "output_cost_per_token": 1.7e-7, "max_input_tokens": 60288, "max_output_tokens": 16000, "max_tokens": 16000, @@ -33092,8 +33119,8 @@ "novita/minimaxai/minimax-m1-80k": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.4e-03, - "output_cost_per_token": 1.76e-02, + "input_cost_per_token": 5.5e-7, + "output_cost_per_token": 2.2e-06, "max_input_tokens": 1000000, "max_output_tokens": 40000, "max_tokens": 40000, @@ -33106,8 +33133,8 @@ "novita/deepseek/deepseek-r1-0528": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-03, - "output_cost_per_token": 2e-02, + "input_cost_per_token": 7e-7, + "output_cost_per_token": 2.5e-06, "max_input_tokens": 163840, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33116,15 +33143,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 2.8e-03, - "input_cost_per_token_cache_hit": 2.8e-03, + "cache_read_input_token_cost": 3.5e-7, + "input_cost_per_token_cache_hit": 3.5e-7, "supports_reasoning": true }, "novita/deepseek/deepseek-r1-distill-qwen-32b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-03, - "output_cost_per_token": 2.4e-03, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 3e-7, "max_input_tokens": 64000, "max_output_tokens": 32000, "max_tokens": 32000, @@ -33136,8 +33163,8 @@ "novita/meta-llama/llama-3-8b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.2e-04, - "output_cost_per_token": 3.2e-04, + "input_cost_per_token": 4e-8, + "output_cost_per_token": 4e-8, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33146,8 +33173,8 @@ "novita/microsoft/wizardlm-2-8x22b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.96e-03, - "output_cost_per_token": 4.96e-03, + "input_cost_per_token": 6.2e-7, + "output_cost_per_token": 6.2e-7, "max_input_tokens": 65535, "max_output_tokens": 8000, "max_tokens": 8000, @@ -33156,8 +33183,8 @@ "novita/deepseek/deepseek-r1-0528-qwen3-8b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.8e-04, - "output_cost_per_token": 7.2e-04, + "input_cost_per_token": 6e-8, + "output_cost_per_token": 9e-8, "max_input_tokens": 128000, "max_output_tokens": 32000, "max_tokens": 32000, @@ -33167,8 +33194,8 @@ "novita/deepseek/deepseek-r1-distill-llama-70b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 6.4e-03, - "output_cost_per_token": 6.4e-03, + "input_cost_per_token": 8e-7, + "output_cost_per_token": 8e-7, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33180,8 +33207,8 @@ "novita/meta-llama/llama-3-70b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.1e-03, - "output_cost_per_token": 7.4e-03, + "input_cost_per_token": 5.1e-7, + "output_cost_per_token": 7.4e-7, "max_input_tokens": 8192, "max_output_tokens": 8000, "max_tokens": 8000, @@ -33192,8 +33219,8 @@ "novita/qwen/qwen3-235b-a22b-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.6e-03, - "output_cost_per_token": 6.4e-03, + "input_cost_per_token": 2e-7, + "output_cost_per_token": 8e-7, "max_input_tokens": 40960, "max_output_tokens": 20000, "max_tokens": 20000, @@ -33203,8 +33230,8 @@ "novita/meta-llama/llama-4-maverick-17b-128e-instruct-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.6e-03, - "output_cost_per_token": 7.2e-03, + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 8.5e-7, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33214,8 +33241,8 @@ "novita/meta-llama/llama-4-scout-17b-16e-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 8e-04, - "output_cost_per_token": 4e-03, + "input_cost_per_token": 1.8e-7, + "output_cost_per_token": 5.9e-7, "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, @@ -33225,8 +33252,8 @@ "novita/nousresearch/hermes-2-pro-llama-3-8b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.4e-03, - "output_cost_per_token": 1.4e-03, + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 1.4e-7, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33237,8 +33264,8 @@ "novita/qwen/qwen2.5-vl-72b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 6.4e-03, - "output_cost_per_token": 6.4e-03, + "input_cost_per_token": 8e-7, + "output_cost_per_token": 8e-7, "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33248,8 +33275,8 @@ "novita/sao10k/l3-70b-euryale-v2.1": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.48e-02, - "output_cost_per_token": 1.48e-02, + "input_cost_per_token": 1.48e-06, + "output_cost_per_token": 1.48e-06, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33261,8 +33288,8 @@ "novita/baidu/ernie-4.5-21B-a3b-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-04, - "output_cost_per_token": 2.24e-03, + "input_cost_per_token": 7e-8, + "output_cost_per_token": 2.8e-7, "max_input_tokens": 131072, "max_output_tokens": 65536, "max_tokens": 65536, @@ -33272,8 +33299,8 @@ "novita/sao10k/l3-8b-lunaris": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5e-04, - "output_cost_per_token": 5e-04, + "input_cost_per_token": 5e-8, + "output_cost_per_token": 5e-8, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33284,33 +33311,18 @@ "novita/baichuan/baichuan-m2-32b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-04, - "output_cost_per_token": 5.6e-04, + "input_cost_per_token": 7e-8, + "output_cost_per_token": 7e-8, "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, - "supports_tool_choice": true, - "supports_system_messages": true, - "supports_response_schema": true, - "supports_reasoning": true - }, - "novita/thudm/glm-4.1v-9b-thinking": { - "litellm_provider": "novita", - "mode": "chat", - "input_cost_per_token": 2.8e-04, - "output_cost_per_token": 1.104e-03, - "max_input_tokens": 65536, - "max_output_tokens": 8000, - "max_tokens": 8000, - "supports_vision": true, - "supports_system_messages": true, - "supports_reasoning": true + "supports_system_messages": true }, "novita/baidu/ernie-4.5-vl-424b-a47b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.36e-03, - "output_cost_per_token": 1e-02, + "input_cost_per_token": 4.2e-7, + "output_cost_per_token": 1.25e-06, "max_input_tokens": 123000, "max_output_tokens": 16000, "max_tokens": 16000, @@ -33321,8 +33333,8 @@ "novita/baidu/ernie-4.5-300b-a47b-paddle": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.24e-03, - "output_cost_per_token": 8.8e-03, + "input_cost_per_token": 2.8e-7, + "output_cost_per_token": 1.1e-06, "max_input_tokens": 123000, "max_output_tokens": 12000, "max_tokens": 12000, @@ -33333,8 +33345,8 @@ "novita/deepseek/deepseek-prover-v2-671b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-03, - "output_cost_per_token": 2e-02, + "input_cost_per_token": 7e-7, + "output_cost_per_token": 2.5e-06, "max_input_tokens": 160000, "max_output_tokens": 160000, "max_tokens": 160000, @@ -33343,8 +33355,8 @@ "novita/qwen/qwen3-32b-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 8e-04, - "output_cost_per_token": 3.6e-03, + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4.5e-7, "max_input_tokens": 40960, "max_output_tokens": 20000, "max_tokens": 20000, @@ -33354,8 +33366,8 @@ "novita/qwen/qwen3-30b-a3b-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 7.2e-04, - "output_cost_per_token": 3.6e-03, + "input_cost_per_token": 9e-8, + "output_cost_per_token": 4.5e-7, "max_input_tokens": 40960, "max_output_tokens": 20000, "max_tokens": 20000, @@ -33365,8 +33377,8 @@ "novita/google/gemma-3-27b-it": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 9.52e-04, - "output_cost_per_token": 1.6e-03, + "input_cost_per_token": 1.19e-7, + "output_cost_per_token": 2e-7, "max_input_tokens": 98304, "max_output_tokens": 16384, "max_tokens": 16384, @@ -33376,8 +33388,8 @@ "novita/deepseek/deepseek-v3-turbo": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.2e-03, - "output_cost_per_token": 1.04e-02, + "input_cost_per_token": 4e-7, + "output_cost_per_token": 1.3e-06, "max_input_tokens": 64000, "max_output_tokens": 16000, "max_tokens": 16000, @@ -33389,8 +33401,8 @@ "novita/deepseek/deepseek-r1-turbo": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-03, - "output_cost_per_token": 2e-02, + "input_cost_per_token": 7e-7, + "output_cost_per_token": 2.5e-06, "max_input_tokens": 64000, "max_output_tokens": 16000, "max_tokens": 16000, @@ -33403,8 +33415,8 @@ "novita/Sao10K/L3-8B-Stheno-v3.2": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5e-04, - "output_cost_per_token": 5e-04, + "input_cost_per_token": 5e-8, + "output_cost_per_token": 5e-8, "max_input_tokens": 8192, "max_output_tokens": 32000, "max_tokens": 32000, @@ -33416,8 +33428,8 @@ "novita/gryphe/mythomax-l2-13b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 7.2e-04, - "output_cost_per_token": 7.2e-04, + "input_cost_per_token": 9e-8, + "output_cost_per_token": 9e-8, "max_input_tokens": 4096, "max_output_tokens": 3200, "max_tokens": 3200, @@ -33426,8 +33438,8 @@ "novita/baidu/ernie-4.5-vl-28b-a3b-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.9e-03, - "output_cost_per_token": 3.9e-03, + "input_cost_per_token": 3.9e-7, + "output_cost_per_token": 3.9e-7, "max_input_tokens": 131072, "max_output_tokens": 65536, "max_tokens": 65536, @@ -33442,8 +33454,8 @@ "novita/qwen/qwen3-vl-8b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 6.4e-04, - "output_cost_per_token": 4e-03, + "input_cost_per_token": 8e-8, + "output_cost_per_token": 5e-7, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33457,8 +33469,8 @@ "novita/zai-org/glm-4.5-air": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.04e-03, - "output_cost_per_token": 6.8e-03, + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 8.5e-7, "max_input_tokens": 131072, "max_output_tokens": 98304, "max_tokens": 98304, @@ -33471,8 +33483,8 @@ "novita/qwen/qwen3-vl-30b-a3b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.6e-03, - "output_cost_per_token": 5.6e-03, + "input_cost_per_token": 2e-7, + "output_cost_per_token": 7e-7, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33486,8 +33498,8 @@ "novita/qwen/qwen3-vl-30b-a3b-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.6e-03, - "output_cost_per_token": 8e-03, + "input_cost_per_token": 2e-7, + "output_cost_per_token": 1e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33498,11 +33510,45 @@ "supports_system_messages": true, "supports_response_schema": true }, + "novita/qwen/qwen3-omni-30b-a3b-thinking": { + "litellm_provider": "novita", + "mode": "chat", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 9.7e-7, + "max_input_tokens": 65536, + "max_output_tokens": 16384, + "max_tokens": 16384, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_audio_input": true + }, + "novita/qwen/qwen3-omni-30b-a3b-instruct": { + "litellm_provider": "novita", + "mode": "chat", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 9.7e-7, + "max_input_tokens": 65536, + "max_output_tokens": 16384, + "max_tokens": 16384, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_response_schema": true, + "supports_audio_input": true, + "supports_audio_output": true + }, "novita/qwen/qwen-mt-plus": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2e-03, - "output_cost_per_token": 6e-03, + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 7.5e-7, "max_input_tokens": 16384, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33511,8 +33557,8 @@ "novita/baidu/ernie-4.5-vl-28b-a3b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.12e-03, - "output_cost_per_token": 4.48e-03, + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 5.6e-7, "max_input_tokens": 30000, "max_output_tokens": 8000, "max_tokens": 8000, @@ -33526,8 +33572,8 @@ "novita/baidu/ernie-4.5-21B-a3b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-04, - "output_cost_per_token": 2.24e-03, + "input_cost_per_token": 7e-8, + "output_cost_per_token": 2.8e-7, "max_input_tokens": 120000, "max_output_tokens": 8000, "max_tokens": 8000, @@ -33539,8 +33585,8 @@ "novita/qwen/qwen3-8b-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.8e-04, - "output_cost_per_token": 1.104e-03, + "input_cost_per_token": 3.5e-8, + "output_cost_per_token": 1.38e-7, "max_input_tokens": 128000, "max_output_tokens": 20000, "max_tokens": 20000, @@ -33550,8 +33596,8 @@ "novita/qwen/qwen3-4b-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-04, - "output_cost_per_token": 2.4e-04, + "input_cost_per_token": 3e-8, + "output_cost_per_token": 3e-8, "max_input_tokens": 128000, "max_output_tokens": 20000, "max_tokens": 20000, @@ -33561,8 +33607,8 @@ "novita/qwen/qwen2.5-7b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-04, - "output_cost_per_token": 5.6e-04, + "input_cost_per_token": 7e-8, + "output_cost_per_token": 7e-8, "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, @@ -33575,8 +33621,8 @@ "novita/meta-llama/llama-3.2-3b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-04, - "output_cost_per_token": 4e-04, + "input_cost_per_token": 3e-8, + "output_cost_per_token": 5e-8, "max_input_tokens": 32768, "max_output_tokens": 32000, "max_tokens": 32000, @@ -33588,8 +33634,8 @@ "novita/sao10k/l31-70b-euryale-v2.2": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.48e-02, - "output_cost_per_token": 1.48e-02, + "input_cost_per_token": 1.48e-06, + "output_cost_per_token": 1.48e-06, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33601,7 +33647,7 @@ "novita/qwen/qwen3-embedding-0.6b": { "litellm_provider": "novita", "mode": "embedding", - "input_cost_per_token": 5.6e-04, + "input_cost_per_token": 7e-8, "output_cost_per_token": 0, "max_input_tokens": 32768, "max_output_tokens": 32768, @@ -33610,7 +33656,7 @@ "novita/qwen/qwen3-embedding-8b": { "litellm_provider": "novita", "mode": "embedding", - "input_cost_per_token": 5.6e-04, + "input_cost_per_token": 7e-8, "output_cost_per_token": 0, "max_input_tokens": 32768, "max_output_tokens": 4096, @@ -33619,8 +33665,8 @@ "novita/baai/bge-m3": { "litellm_provider": "novita", "mode": "embedding", - "input_cost_per_token": 1e-04, - "output_cost_per_token": 1e-04, + "input_cost_per_token": 1e-8, + "output_cost_per_token": 1e-8, "max_input_tokens": 8192, "max_output_tokens": 96000, "max_tokens": 96000 @@ -33628,8 +33674,8 @@ "novita/qwen/qwen3-reranker-8b": { "litellm_provider": "novita", "mode": "rerank", - "input_cost_per_token": 4e-04, - "output_cost_per_token": 4e-04, + "input_cost_per_token": 5e-8, + "output_cost_per_token": 5e-8, "max_input_tokens": 32768, "max_output_tokens": 4096, "max_tokens": 4096 @@ -33637,8 +33683,8 @@ "novita/baai/bge-reranker-v2-m3": { "litellm_provider": "novita", "mode": "rerank", - "input_cost_per_token": 1e-04, - "output_cost_per_token": 1e-04, + "input_cost_per_token": 1e-8, + "output_cost_per_token": 1e-8, "max_input_tokens": 8000, "max_output_tokens": 8000, "max_tokens": 8000 diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index b43f4217177..49d6ac7d898 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -516,7 +516,7 @@ class MCPRequestHandler: Check if the tool is allowed for the given user/key based on permissions """ if len(allowed_mcp_servers) == 0: - return True + return False elif server_name in allowed_mcp_servers: return True return False diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 642cb0cec2d..48f7a8b0b7b 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -1,9 +1,13 @@ import importlib +from datetime import datetime from typing import Dict, List, Optional, Union -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.ui_session_utils import ( + build_effective_auth_contexts, +) from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.types.mcp import MCPAuth @@ -22,13 +26,14 @@ router = APIRouter( ) if MCP_AVAILABLE: - from litellm.experimental_mcp_client.client import MCPTool + from mcp.types import Tool as MCPTool from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) from litellm.proxy._experimental.mcp_server.server import ( ListMCPToolsRestAPIResponseObject, - call_mcp_tool, + MCPServer, + execute_mcp_tool, filter_tools_by_allowed_tools, ) @@ -134,11 +139,30 @@ if MCP_AVAILABLE: MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) ) + auth_contexts = await build_effective_auth_contexts(user_api_key_dict) + + allowed_server_ids_set = set() + for auth_context in auth_contexts: + servers = await global_mcp_server_manager.get_allowed_mcp_servers( + user_api_key_auth=auth_context + ) + allowed_server_ids_set.update(servers) + + allowed_server_ids = list(allowed_server_ids_set) + list_tools_result = [] error_message = None # If server_id is specified, only query that specific server if server_id: + if server_id not in allowed_server_ids_set: + raise HTTPException( + status_code=403, + detail={ + "error": "access_denied", + "message": f"The key is not allowed to access server {server_id}", + }, + ) server = global_mcp_server_manager.get_mcp_server_by_id(server_id) if server is None: return { @@ -165,9 +189,24 @@ if MCP_AVAILABLE: "message": f"Failed to get tools from server {server.name}: {str(e)}", } else: - # Query all servers + if not allowed_server_ids: + raise HTTPException( + status_code=403, + detail={ + "error": "access_denied", + "message": "The key is not allowed to access any MCP servers.", + }, + ) + + # Query all servers the user has access to errors = [] - for server in global_mcp_server_manager.get_registry().values(): + for allowed_server_id in allowed_server_ids: + server = global_mcp_server_manager.get_mcp_server_by_id( + allowed_server_id + ) + if server is None: + continue + server_auth_header = _get_server_auth_header( server, mcp_server_auth_headers, mcp_auth_header ) @@ -225,6 +264,30 @@ if MCP_AVAILABLE: try: data = await request.json() + + # Validate required parameters early + server_id = data.get("server_id") + if not server_id: + raise HTTPException( + status_code=400, + detail={ + "error": "missing_parameter", + "message": "server_id is required in request body", + }, + ) + + tool_name = data.get("name") + if not tool_name: + raise HTTPException( + status_code=400, + detail={ + "error": "missing_parameter", + "message": "name is required in request body", + }, + ) + + tool_arguments = data.get("arguments") + data = await add_litellm_data_to_request( data=data, request=request, @@ -252,13 +315,55 @@ if MCP_AVAILABLE: if mcp_server_auth_headers: data["mcp_server_auth_headers"] = mcp_server_auth_headers data["raw_headers"] = raw_headers_from_request - + # Extract user_api_key_auth from metadata and add to top level # call_mcp_tool expects user_api_key_auth as a top-level parameter if "metadata" in data and "user_api_key_auth" in data["metadata"]: data["user_api_key_auth"] = data["metadata"]["user_api_key_auth"] - - result = await call_mcp_tool(**data) + + # Get all auth contexts + auth_contexts = await build_effective_auth_contexts(user_api_key_dict) + + # Collect allowed server IDs from all contexts + allowed_server_ids_set = set() + for auth_context in auth_contexts: + servers = await global_mcp_server_manager.get_allowed_mcp_servers( + user_api_key_auth=auth_context + ) + allowed_server_ids_set.update(servers) + + # Check if the specified server_id is allowed + if server_id not in allowed_server_ids_set: + raise HTTPException( + status_code=403, + detail={ + "error": "access_denied", + "message": f"The key is not allowed to access server {server_id}", + }, + ) + + # Build allowed_mcp_servers list (only include allowed servers) + allowed_mcp_servers: List[MCPServer] = [] + for allowed_server_id in allowed_server_ids_set: + server = global_mcp_server_manager.get_mcp_server_by_id( + allowed_server_id + ) + if server is not None: + allowed_mcp_servers.append(server) + + # Call execute_mcp_tool directly (permission checks already done) + result = await execute_mcp_tool( + name=tool_name, + arguments=tool_arguments, + allowed_mcp_servers=allowed_mcp_servers, + start_time=datetime.now(), + user_api_key_auth=data.get("user_api_key_auth"), + mcp_auth_header=data.get("mcp_auth_header"), + mcp_server_auth_headers=data.get("mcp_server_auth_headers"), + oauth2_headers=data.get("oauth2_headers"), + raw_headers=data.get("raw_headers"), + litellm_logging_obj=data.get("litellm_logging_obj"), + ) return result except BlockedPiiEntityError as e: verbose_logger.error(f"BlockedPiiEntityError in MCP tool call: {str(e)}") @@ -301,7 +406,6 @@ if MCP_AVAILABLE: # /health/tools/list -> List tools from MCP server # For these routes users will dynamically pass the MCP connection params, they don't need to be on the MCP registry ######################################################## - from litellm.proxy._experimental.mcp_server.server import MCPServer from litellm.proxy.management_endpoints.mcp_management_endpoints import ( NewMCPServerRequest, ) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 2adfe97c611..f22040a7dd9 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -1200,47 +1200,38 @@ if MCP_AVAILABLE: return managed_resource_templates - @client - async def call_mcp_tool( + async def execute_mcp_tool( name: str, - arguments: Optional[Dict[str, Any]] = None, + arguments: Dict[str, Any], + allowed_mcp_servers: List[MCPServer], + start_time: datetime, user_api_key_auth: Optional[UserAPIKeyAuth] = None, mcp_auth_header: Optional[str] = None, - mcp_servers: Optional[List[str]] = None, mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]] = None, oauth2_headers: Optional[Dict[str, str]] = None, raw_headers: Optional[Dict[str, str]] = None, **kwargs: Any, ) -> CallToolResult: """ - Call a specific tool with the provided arguments (handles prefixed tool names) + Execute MCP tool. + + This function assumes permission checks have already been performed. + + Args: + name: Tool name (may include server prefix) + arguments: Tool arguments + allowed_mcp_servers: Pre-validated list of servers the user can access + start_time: Start time for logging + user_api_key_auth: Optional user API key auth for logging + mcp_auth_header: Optional MCP auth header + mcp_server_auth_headers: Optional server-specific auth headers + oauth2_headers: Optional OAuth2 headers + raw_headers: Optional raw HTTP headers + **kwargs: Additional arguments (e.g., litellm_logging_obj) + + Returns: + CallToolResult: Tool execution result """ - start_time = datetime.now() - if arguments is None: - raise HTTPException( - status_code=400, detail="Request arguments are required" - ) - - ## CHECK IF USER IS ALLOWED TO CALL THIS TOOL - allowed_mcp_server_ids = ( - await global_mcp_server_manager.get_allowed_mcp_servers( - user_api_key_auth=user_api_key_auth, - ) - ) - - allowed_mcp_servers: List[MCPServer] = [] - for allowed_mcp_server_id in allowed_mcp_server_ids: - allowed_server = global_mcp_server_manager.get_mcp_server_by_id( - allowed_mcp_server_id - ) - if allowed_server is not None: - allowed_mcp_servers.append(allowed_server) - - allowed_mcp_servers = await _get_allowed_mcp_servers_from_mcp_server_names( - mcp_servers=mcp_servers, - allowed_mcp_servers=allowed_mcp_servers, - ) - # Track resolved MCP server for both permission checks and dispatch mcp_server: Optional[MCPServer] = None @@ -1359,6 +1350,66 @@ if MCP_AVAILABLE: ) return response + @client + async def call_mcp_tool( + name: str, + arguments: Optional[Dict[str, Any]] = None, + user_api_key_auth: Optional[UserAPIKeyAuth] = None, + mcp_auth_header: Optional[str] = None, + mcp_servers: Optional[List[str]] = None, + mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]] = None, + oauth2_headers: Optional[Dict[str, str]] = None, + raw_headers: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> CallToolResult: + """ + Call a specific tool with the provided arguments (handles prefixed tool names). + """ + start_time = datetime.now() + if arguments is None: + raise HTTPException( + status_code=400, detail="Request arguments are required" + ) + + ## CHECK IF USER IS ALLOWED TO CALL THIS TOOL + allowed_mcp_server_ids = ( + await global_mcp_server_manager.get_allowed_mcp_servers( + user_api_key_auth=user_api_key_auth, + ) + ) + + allowed_mcp_servers: List[MCPServer] = [] + for allowed_mcp_server_id in allowed_mcp_server_ids: + allowed_server = global_mcp_server_manager.get_mcp_server_by_id( + allowed_mcp_server_id + ) + if allowed_server is not None: + allowed_mcp_servers.append(allowed_server) + + allowed_mcp_servers = await _get_allowed_mcp_servers_from_mcp_server_names( + mcp_servers=mcp_servers, + allowed_mcp_servers=allowed_mcp_servers, + ) + if not allowed_mcp_servers: + raise HTTPException( + status_code=403, + detail="User not allowed to call this tool.", + ) + + # Delegate to execute_mcp_tool for execution + return await execute_mcp_tool( + name=name, + arguments=arguments, + allowed_mcp_servers=allowed_mcp_servers, + start_time=start_time, + user_api_key_auth=user_api_key_auth, + mcp_auth_header=mcp_auth_header, + mcp_server_auth_headers=mcp_server_auth_headers, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + **kwargs, + ) + async def mcp_get_prompt( name: str, arguments: Optional[Dict[str, Any]] = None, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 559d70ab797..a1186f8e372 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -830,9 +830,9 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase): allowed_cache_controls: Optional[list] = [] config: Optional[dict] = {} permissions: Optional[dict] = {} - model_max_budget: Optional[ - dict - ] = {} # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {} + model_max_budget: Optional[dict] = ( + {} + ) # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {} model_config = ConfigDict(protected_namespaces=()) model_rpm_limit: Optional[dict] = None @@ -1366,12 +1366,12 @@ class NewCustomerRequest(BudgetNewRequest): blocked: bool = False # allow/disallow requests for this end-user budget_id: Optional[str] = None # give either a budget_id or max_budget spend: Optional[float] = None - allowed_model_region: Optional[ - AllowedModelRegion - ] = None # require all user requests to use models in this specific region - default_model: Optional[ - str - ] = None # if no equivalent model in allowed region - default all requests to this model + allowed_model_region: Optional[AllowedModelRegion] = ( + None # require all user requests to use models in this specific region + ) + default_model: Optional[str] = ( + None # if no equivalent model in allowed region - default all requests to this model + ) @model_validator(mode="before") @classmethod @@ -1393,12 +1393,12 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase): blocked: bool = False # allow/disallow requests for this end-user max_budget: Optional[float] = None budget_id: Optional[str] = None # give either a budget_id or max_budget - allowed_model_region: Optional[ - AllowedModelRegion - ] = None # require all user requests to use models in this specific region - default_model: Optional[ - str - ] = None # if no equivalent model in allowed region - default all requests to this model + allowed_model_region: Optional[AllowedModelRegion] = ( + None # require all user requests to use models in this specific region + ) + default_model: Optional[str] = ( + None # if no equivalent model in allowed region - default all requests to this model + ) class DeleteCustomerRequest(LiteLLMPydanticObjectBase): @@ -1484,15 +1484,15 @@ class NewTeamRequest(TeamBase): ] = None # raise an error if 'guaranteed_throughput' is set and we're overallocating tpm model_tpm_limit: Optional[Dict[str, int]] = None - team_member_budget: Optional[ - float - ] = None # allow user to set a budget for all team members - team_member_rpm_limit: Optional[ - int - ] = None # allow user to set RPM limit for all team members - team_member_tpm_limit: Optional[ - int - ] = None # allow user to set TPM limit for all team members + team_member_budget: Optional[float] = ( + None # allow user to set a budget for all team members + ) + team_member_rpm_limit: Optional[int] = ( + None # allow user to set RPM limit for all team members + ) + team_member_tpm_limit: Optional[int] = ( + None # allow user to set TPM limit for all team members + ) team_member_key_duration: Optional[str] = None # e.g. "1d", "1w", "1m" allowed_vector_store_indexes: Optional[List[AllowedVectorStoreIndexItem]] = None @@ -1580,9 +1580,9 @@ class BlockKeyRequest(LiteLLMPydanticObjectBase): class AddTeamCallback(LiteLLMPydanticObjectBase): callback_name: str - callback_type: Optional[ - Literal["success", "failure", "success_and_failure"] - ] = "success_and_failure" + callback_type: Optional[Literal["success", "failure", "success_and_failure"]] = ( + "success_and_failure" + ) callback_vars: Dict[str, str] @model_validator(mode="before") @@ -1895,9 +1895,9 @@ class ConfigList(LiteLLMPydanticObjectBase): stored_in_db: Optional[bool] field_default_value: Any premium_field: bool = False - nested_fields: Optional[ - List[FieldDetail] - ] = None # For nested dictionary or Pydantic fields + nested_fields: Optional[List[FieldDetail]] = ( + None # For nested dictionary or Pydantic fields + ) class UserHeaderMapping(LiteLLMPydanticObjectBase): @@ -2291,9 +2291,9 @@ class LiteLLM_OrganizationMembershipTable(LiteLLMPydanticObjectBase): budget_id: Optional[str] = None created_at: datetime updated_at: datetime - user: Optional[ - Any - ] = None # You might want to replace 'Any' with a more specific type if available + user: Optional[Any] = ( + None # You might want to replace 'Any' with a more specific type if available + ) litellm_budget_table: Optional[LiteLLM_BudgetTable] = None model_config = ConfigDict(protected_namespaces=()) @@ -2828,6 +2828,18 @@ class SpanAttributes(str, enum.Enum): LLM_RESPONSE_MODEL = "gen_ai.response.model" LLM_USAGE_COMPLETION_TOKENS = "gen_ai.usage.completion_tokens" LLM_USAGE_PROMPT_TOKENS = "gen_ai.usage.prompt_tokens" + + # OTEL 1.38 attributes + GEN_AI_INPUT_MESSAGES = "gen_ai.input.messages" + GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages" + GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens" + GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens" + GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens" + GEN_AI_OPERATION_NAME = "gen_ai.operation.name" + GEN_AI_REQUEST_ID = "gen_ai.request.id" + GEN_AI_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions" + GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons" + LLM_TOKEN_TYPE = "gen_ai.token.type" # To be added # LLM_RESPONSE_FINISH_REASON = "gen_ai.response.finish_reasons" @@ -3253,9 +3265,9 @@ class TeamModelDeleteRequest(BaseModel): # Organization Member Requests class OrganizationMemberAddRequest(OrgMemberAddRequest): organization_id: str - max_budget_in_organization: Optional[ - float - ] = None # Users max budget within the organization + max_budget_in_organization: Optional[float] = ( + None # Users max budget within the organization + ) class OrganizationMemberDeleteRequest(MemberDeleteRequest): @@ -3470,9 +3482,9 @@ class ProviderBudgetResponse(LiteLLMPydanticObjectBase): Maps provider names to their budget configs. """ - providers: Dict[ - str, ProviderBudgetResponseObject - ] = {} # Dictionary mapping provider names to their budget configurations + providers: Dict[str, ProviderBudgetResponseObject] = ( + {} + ) # Dictionary mapping provider names to their budget configurations class ProxyStateVariables(TypedDict): @@ -3615,9 +3627,9 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase): enforce_rbac: bool = False roles_jwt_field: Optional[str] = None # v2 on role mappings role_mappings: Optional[List[RoleMapping]] = None - object_id_jwt_field: Optional[ - str - ] = None # can be either user / team, inferred from the role mapping + object_id_jwt_field: Optional[str] = ( + None # can be either user / team, inferred from the role mapping + ) scope_mappings: Optional[List[ScopeMapping]] = None enforce_scope_based_access: bool = False enforce_team_based_model_access: bool = False @@ -3868,20 +3880,44 @@ class CostEstimateResponse(LiteLLMPydanticObjectBase): num_requests_per_day: Optional[int] = None num_requests_per_month: Optional[int] = None # Per-request costs - cost_per_request: float = Field(description="Total cost per request (includes margin)") - input_cost_per_request: float = Field(description="Input token cost per request (before margin)") - output_cost_per_request: float = Field(description="Output token cost per request (before margin)") - margin_cost_per_request: float = Field(default=0.0, description="Margin/fee added per request") + cost_per_request: float = Field( + description="Total cost per request (includes margin)" + ) + input_cost_per_request: float = Field( + description="Input token cost per request (before margin)" + ) + output_cost_per_request: float = Field( + description="Output token cost per request (before margin)" + ) + margin_cost_per_request: float = Field( + default=0.0, description="Margin/fee added per request" + ) # Daily costs (if num_requests_per_day provided) - daily_cost: Optional[float] = Field(default=None, description="Total daily cost (includes margin)") - daily_input_cost: Optional[float] = Field(default=None, description="Daily input token cost") - daily_output_cost: Optional[float] = Field(default=None, description="Daily output token cost") - daily_margin_cost: Optional[float] = Field(default=None, description="Daily margin/fee") + daily_cost: Optional[float] = Field( + default=None, description="Total daily cost (includes margin)" + ) + daily_input_cost: Optional[float] = Field( + default=None, description="Daily input token cost" + ) + daily_output_cost: Optional[float] = Field( + default=None, description="Daily output token cost" + ) + daily_margin_cost: Optional[float] = Field( + default=None, description="Daily margin/fee" + ) # Monthly costs (if num_requests_per_month provided) - monthly_cost: Optional[float] = Field(default=None, description="Total monthly cost (includes margin)") - monthly_input_cost: Optional[float] = Field(default=None, description="Monthly input token cost") - monthly_output_cost: Optional[float] = Field(default=None, description="Monthly output token cost") - monthly_margin_cost: Optional[float] = Field(default=None, description="Monthly margin/fee") + monthly_cost: Optional[float] = Field( + default=None, description="Total monthly cost (includes margin)" + ) + monthly_input_cost: Optional[float] = Field( + default=None, description="Monthly input token cost" + ) + monthly_output_cost: Optional[float] = Field( + default=None, description="Monthly output token cost" + ) + monthly_margin_cost: Optional[float] = Field( + default=None, description="Monthly margin/fee" + ) # Pricing info input_cost_per_token: Optional[float] = None output_cost_per_token: Optional[float] = None diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index 7de6b7fccfc..0033deb0766 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -2,13 +2,13 @@ Unified /v1/messages endpoint - (Anthropic Spec) """ - from fastapi import APIRouter, Depends, HTTPException, Request, Response from litellm._logging import verbose_proxy_logger +from litellm.anthropic_interface.exceptions import AnthropicExceptionMapping +from litellm.integrations.custom_guardrail import ModifyResponseException from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.integrations.custom_guardrail import ModifyResponseException from litellm.proxy.common_request_processing import ( ProxyBaseLLMRequestProcessing, create_response, @@ -221,6 +221,16 @@ async def count_tokens( except HTTPException: raise + except ProxyException as e: + status_code = int(e.code) if e.code and e.code.isdigit() else 500 + detail = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=status_code, + raw_message=e.message, + ) + raise HTTPException( + status_code=status_code, + detail=detail, + ) except Exception as e: verbose_proxy_logger.exception( "litellm.proxy.anthropic_endpoints.count_tokens(): Exception occurred - {}".format( diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index a741869e5fc..1879b306253 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -74,6 +74,75 @@ db_cache_expiry = DEFAULT_IN_MEMORY_TTL # refresh every 5s all_routes = LiteLLMRoutes.openai_routes.value + LiteLLMRoutes.management_routes.value +def _is_model_cost_zero( + model: Optional[Union[str, List[str]]], llm_router: Optional[Router] +) -> bool: + """ + Check if a model has zero cost (no configured pricing). + + Uses the router's get_model_group_info method to get pricing information. + + Args: + model: The model name or list of model names + llm_router: The LiteLLM router instance + + Returns: + bool: True if all costs for the model are zero, False otherwise + """ + if model is None or llm_router is None: + return False + + # Handle list of models + model_list = [model] if isinstance(model, str) else model + + for model_name in model_list: + try: + # Use router's get_model_group_info method directly for better reliability + model_group_info = llm_router.get_model_group_info(model_group=model_name) + + if model_group_info is None: + # Model not found or no pricing info available + # Conservative approach: assume it has cost + verbose_proxy_logger.debug( + f"No model group info found for {model_name}, assuming it has cost" + ) + return False + + # Check costs for this model + # Only allow bypass if BOTH costs are explicitly set to 0 (not None) + input_cost = model_group_info.input_cost_per_token + output_cost = model_group_info.output_cost_per_token + + # If costs are not explicitly configured (None), assume it has cost + if input_cost is None or output_cost is None: + verbose_proxy_logger.debug( + f"Model {model_name} has undefined cost (input: {input_cost}, output: {output_cost}), assuming it has cost" + ) + return False + + # If either cost is non-zero, return False + if input_cost > 0 or output_cost > 0: + verbose_proxy_logger.debug( + f"Model {model_name} has non-zero cost (input: {input_cost}, output: {output_cost})" + ) + return False + + # This model has zero cost explicitly configured + verbose_proxy_logger.debug( + f"Model {model_name} has zero cost explicitly configured (input: {input_cost}, output: {output_cost})" + ) + + except Exception as e: + # If we can't determine the cost, assume it has cost (conservative approach) + verbose_proxy_logger.debug( + f"Error checking cost for model {model_name}: {str(e)}, assuming it has cost" + ) + return False + + # All models checked have zero cost + return True + + async def common_checks( request_body: dict, team_object: Optional[LiteLLM_TeamTable], @@ -86,6 +155,7 @@ async def common_checks( proxy_logging_obj: ProxyLogging, valid_token: Optional[UserAPIKeyAuth], request: Request, + skip_budget_checks: bool = False, ) -> bool: """ Common checks across jwt + key-based auth. @@ -137,64 +207,66 @@ async def common_checks( user_object=user_object, ) - # 3. If team is in budget - await _team_max_budget_check( - team_object=team_object, - proxy_logging_obj=proxy_logging_obj, - valid_token=valid_token, - ) + # If this is a free model, skip all budget checks + if not skip_budget_checks: + # 3. If team is in budget + await _team_max_budget_check( + team_object=team_object, + proxy_logging_obj=proxy_logging_obj, + valid_token=valid_token, + ) - # 3.1. If organization is in budget - await _organization_max_budget_check( - valid_token=valid_token, - team_object=team_object, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - ) + # 3.1. If organization is in budget + await _organization_max_budget_check( + valid_token=valid_token, + team_object=team_object, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) - await _tag_max_budget_check( - request_body=request_body, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - valid_token=valid_token, - ) + await _tag_max_budget_check( + request_body=request_body, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + valid_token=valid_token, + ) - # 4. If user is in budget - ## 4.1 check personal budget, if personal key - if ( - (team_object is None or team_object.team_id is None) - and user_object is not None - and user_object.max_budget is not None - ): - user_budget = user_object.max_budget - if user_budget < user_object.spend: - raise litellm.BudgetExceededError( - current_cost=user_object.spend, - max_budget=user_budget, - message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_object.spend}, Budget={user_budget}", - ) + # 4. If user is in budget + ## 4.1 check personal budget, if personal key + if ( + (team_object is None or team_object.team_id is None) + and user_object is not None + and user_object.max_budget is not None + ): + user_budget = user_object.max_budget + if user_budget < user_object.spend: + raise litellm.BudgetExceededError( + current_cost=user_object.spend, + max_budget=user_budget, + message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_object.spend}, Budget={user_budget}", + ) - ## 4.2 check team member budget, if team key - await _check_team_member_budget( - team_object=team_object, - user_object=user_object, - valid_token=valid_token, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - ) + ## 4.2 check team member budget, if team key + await _check_team_member_budget( + team_object=team_object, + user_object=user_object, + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) - # 5. If end_user ('user' passed to /chat/completions, /embeddings endpoint) is in budget - if end_user_object is not None and end_user_object.litellm_budget_table is not None: - end_user_budget = end_user_object.litellm_budget_table.max_budget - if end_user_budget is not None and end_user_object.spend > end_user_budget: - raise litellm.BudgetExceededError( - current_cost=end_user_object.spend, - max_budget=end_user_budget, - message=f"ExceededBudget: End User={end_user_object.user_id} over budget. Spend={end_user_object.spend}, Budget={end_user_budget}", - ) + # 5. If end_user ('user' passed to /chat/completions, /embeddings endpoint) is in budget + if end_user_object is not None and end_user_object.litellm_budget_table is not None: + end_user_budget = end_user_object.litellm_budget_table.max_budget + if end_user_budget is not None and end_user_object.spend > end_user_budget: + raise litellm.BudgetExceededError( + current_cost=end_user_object.spend, + max_budget=end_user_budget, + message=f"ExceededBudget: End User={end_user_object.user_id} over budget. Spend={end_user_object.spend}, Budget={end_user_budget}", + ) # 6. [OPTIONAL] If 'enforce_user_param' enabled - did developer pass in 'user' param for openai endpoints if ( @@ -237,6 +309,7 @@ async def common_checks( # 7. [OPTIONAL] If 'litellm.max_budget' is set (>0), is proxy under budget if ( litellm.max_budget > 0 + and not skip_budget_checks and global_proxy_spend is not None # only run global budget checks for OpenAI routes # Reason - the Admin UI should continue working if the proxy crosses it's global budget diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 401aa7fd443..efac74219d5 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -586,6 +586,21 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 if team_object is not None else None, ) + + # Check if model has zero cost - if so, skip all budget checks + model = get_model_from_request(request_data, route) + skip_budget_checks = False + if model is not None and llm_router is not None: + from litellm.proxy.auth.auth_checks import _is_model_cost_zero + + skip_budget_checks = _is_model_cost_zero( + model=model, llm_router=llm_router + ) + if skip_budget_checks: + verbose_proxy_logger.info( + f"Skipping all budget checks for zero-cost model: {model}" + ) + # run through common checks _ = await common_checks( request=request, @@ -599,6 +614,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 llm_router=llm_router, proxy_logging_obj=proxy_logging_obj, valid_token=valid_token, + skip_budget_checks=skip_budget_checks, ) # return UserAPIKeyAuth object @@ -990,8 +1006,22 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 ) user_obj = None + # Check 2a. Check if model has zero cost - if so, skip all budget checks + model = get_model_from_request(request_data, route) + skip_budget_checks = False + if model is not None and llm_router is not None: + from litellm.proxy.auth.auth_checks import _is_model_cost_zero + + skip_budget_checks = _is_model_cost_zero( + model=model, llm_router=llm_router + ) + if skip_budget_checks: + verbose_proxy_logger.info( + f"Skipping all budget checks for zero-cost model: {model}" + ) + # Check 3. Check if user is in their team budget - if valid_token.team_member_spend is not None: + if not skip_budget_checks and valid_token.team_member_spend is not None: if prisma_client is not None: _cache_key = f"{valid_token.team_id}_{valid_token.user_id}" @@ -1055,46 +1085,47 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 param=abbreviate_api_key(api_key=api_key), ) - # Check 4. Token Spend is under budget - if RouteChecks.is_llm_api_route(route=route): - await _virtual_key_max_budget_check( + if not skip_budget_checks: + # Check 4. Token Spend is under budget + if RouteChecks.is_llm_api_route(route=route): + await _virtual_key_max_budget_check( + valid_token=valid_token, + proxy_logging_obj=proxy_logging_obj, + user_obj=user_obj, + ) + + # Check 5. Max Budget Alert Check + await _virtual_key_max_budget_alert_check( valid_token=valid_token, proxy_logging_obj=proxy_logging_obj, user_obj=user_obj, ) - # Check 5. Max Budget Alert Check - await _virtual_key_max_budget_alert_check( - valid_token=valid_token, - proxy_logging_obj=proxy_logging_obj, - user_obj=user_obj, - ) - - # Check 6. Soft Budget Check - await _virtual_key_soft_budget_check( - valid_token=valid_token, - proxy_logging_obj=proxy_logging_obj, - user_obj=user_obj, - ) - - # Check 5. Token Model Spend is under Model budget - max_budget_per_model = valid_token.model_max_budget - current_model = request_data.get("model", None) - - if ( - max_budget_per_model is not None - and isinstance(max_budget_per_model, dict) - and len(max_budget_per_model) > 0 - and prisma_client is not None - and current_model is not None - and valid_token.token is not None - ): - ## GET THE SPEND FOR THIS MODEL - await model_max_budget_limiter.is_key_within_model_budget( - user_api_key_dict=valid_token, - model=current_model, + # Check 6. Soft Budget Check + await _virtual_key_soft_budget_check( + valid_token=valid_token, + proxy_logging_obj=proxy_logging_obj, + user_obj=user_obj, ) + # Check 5. Token Model Spend is under Model budget + max_budget_per_model = valid_token.model_max_budget + current_model = request_data.get("model", None) + + if ( + max_budget_per_model is not None + and isinstance(max_budget_per_model, dict) + and len(max_budget_per_model) > 0 + and prisma_client is not None + and current_model is not None + and valid_token.token is not None + ): + ## GET THE SPEND FOR THIS MODEL + await model_max_budget_limiter.is_key_within_model_budget( + user_api_key_dict=valid_token, + model=current_model, + ) + # Check 6: Additional Common Checks across jwt + key auth if valid_token.team_id is not None: _team_obj: Optional[LiteLLM_TeamTable] = LiteLLM_TeamTable( @@ -1162,6 +1193,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 llm_router=llm_router, proxy_logging_obj=proxy_logging_obj, valid_token=valid_token, + skip_budget_checks=skip_budget_checks, ) # Token passed all checks if valid_token is None: diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 61bffde3aca..3db1620107e 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -649,9 +649,7 @@ class ProxyBaseLLMRequestProcessing: proxy_logging_obj.during_call_hook( data=self.data, user_api_key_dict=user_api_key_dict, - call_type=ProxyBaseLLMRequestProcessing._get_pre_call_type( - route_type=route_type # type: ignore - ), + call_type=route_type, # type: ignore ) ) @@ -1004,19 +1002,6 @@ class ProxyBaseLLMRequestProcessing: headers=headers, ) - @staticmethod - def _get_pre_call_type( - route_type: Literal["acompletion", "aembedding", "aresponses", "allm_passthrough_route"], - ) -> Literal["completion", "embedding", "responses", "allm_passthrough_route"]: - if route_type == "acompletion": - return "completion" - elif route_type == "aembedding": - return "embedding" - elif route_type == "aresponses": - return "responses" - elif route_type == "allm_passthrough_route": - return "allm_passthrough_route" - ######################################################### # Proxy Level Streaming Data Generator ######################################################### diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index 41e0a23b9aa..d5a62661460 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -9,6 +9,7 @@ import os from typing import TYPE_CHECKING, Any, Dict, Literal, Optional from litellm._logging import verbose_proxy_logger +from litellm.exceptions import GuardrailRaisedException from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, @@ -248,7 +249,11 @@ class GenericGuardrailAPI(CustomGuardrail): verbose_proxy_logger.warning( "Generic Guardrail API blocked request: %s", error_message ) - raise Exception(f"Content blocked by guardrail: {error_message}") + raise GuardrailRaisedException( + guardrail_name=GUARDRAIL_NAME, + message=error_message, + should_wrap_with_default_message=False, + ) # Action is NONE or no modifications needed return_inputs = GenericGuardrailAPIInputs(texts=texts) @@ -264,10 +269,10 @@ class GenericGuardrailAPI(CustomGuardrail): return_inputs["tools"] = tools return return_inputs + except GuardrailRaisedException: + # Re-raise guardrail exceptions as-is + raise except Exception as e: - # Check if it's already an exception we raised - if "Content blocked by guardrail" in str(e): - raise verbose_proxy_logger.error( "Generic Guardrail API: failed to make request: %s", str(e) ) diff --git a/litellm/proxy/guardrails/guardrail_hooks/grayswan/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/grayswan/__init__.py index 99f58f654a7..3aca9078c0a 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/grayswan/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/grayswan/__init__.py @@ -46,6 +46,10 @@ def initialize_guardrail( streaming_sampling_rate=_get_config_value( litellm_params, optional_params, "streaming_sampling_rate" ) or 5, + fail_open=_get_config_value(litellm_params, optional_params, "fail_open"), + guardrail_timeout=_get_config_value( + litellm_params, optional_params, "guardrail_timeout" + ), event_hook=litellm_params.mode, default_on=litellm_params.default_on, ) diff --git a/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py b/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py index 59e737f7d21..2a852cbda08 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py +++ b/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py @@ -1,6 +1,7 @@ """Gray Swan Cygnal guardrail integration.""" import os +import time from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional from fastapi import HTTPException @@ -28,6 +29,10 @@ class GraySwanGuardrailMissingSecrets(Exception): class GraySwanGuardrailAPIError(Exception): """Raised when the Gray Swan API returns an error.""" + def __init__(self, message: str, status_code: Optional[int] = None) -> None: + super().__init__(message) + self.status_code = status_code + class GraySwanGuardrail(CustomGuardrail): """ @@ -63,6 +68,8 @@ class GraySwanGuardrail(CustomGuardrail): policy_id: Optional[str] = None, streaming_end_of_stream_only: bool = False, streaming_sampling_rate: int = 5, + fail_open: Optional[bool] = True, + guardrail_timeout: Optional[float] = 30.0, **kwargs: Any, ) -> None: self.async_handler = get_async_httpx_client( @@ -96,6 +103,8 @@ class GraySwanGuardrail(CustomGuardrail): self.reasoning_mode = self._resolve_reasoning_mode(reasoning_mode) self.categories = categories self.policy_id = policy_id + self.fail_open = True if fail_open is None else bool(fail_open) + self.guardrail_timeout = 30.0 if guardrail_timeout is None else float(guardrail_timeout) # Streaming configuration self.streaming_end_of_stream_only = streaming_end_of_stream_only @@ -202,18 +211,36 @@ class GraySwanGuardrail(CustomGuardrail): if payload is None: return inputs - # Call GraySwan API - response_json = await self._call_grayswan_api(payload) - # Process response - is_output = input_type == "response" - result = self._process_response_internal( - response_json=response_json, - request_data=request_data, - inputs=inputs, - is_output=is_output, - ) - - return result + start_time = time.time() + try: + response_json = await self._call_grayswan_api(payload) + is_output = input_type == "response" + result = self._process_response_internal( + response_json=response_json, + request_data=request_data, + inputs=inputs, + is_output=is_output, + ) + return result + except Exception as exc: + end_time = time.time() + status_code = getattr(exc, "status_code", None) or getattr( + exc, "exception_status_code", None + ) + self._log_guardrail_failure( + exc=exc, + request_data=request_data or {}, + start_time=start_time, + end_time=end_time, + status_code=status_code, + ) + if self.fail_open: + verbose_proxy_logger.warning( + "Gray Swan Guardrail: fail_open=True. Allowing request to proceed despite error: %s", + exc, + ) + return inputs + raise GraySwanGuardrailAPIError(str(exc), status_code=status_code) from exc # ------------------------------------------------------------------ # Legacy Test Interface (for backward compatibility) @@ -348,7 +375,7 @@ class GraySwanGuardrail(CustomGuardrail): url=self.monitor_url, headers=headers, json=payload, - timeout=30.0, + timeout=self.guardrail_timeout, ) response.raise_for_status() result = response.json() @@ -356,13 +383,11 @@ class GraySwanGuardrail(CustomGuardrail): "Gray Swan Guardrail: monitor response %s", safe_dumps(result) ) return result - except HTTPException: - raise except Exception as exc: - verbose_proxy_logger.exception( - "Gray Swan Guardrail: API request failed: %s", exc + status_code = getattr(exc, "status_code", None) or getattr( + exc, "exception_status_code", None ) - raise GraySwanGuardrailAPIError(str(exc)) from exc + raise GraySwanGuardrailAPIError(str(exc), status_code=status_code) from exc def _process_response_internal( self, @@ -579,3 +604,33 @@ class GraySwanGuardrail(CustomGuardrail): if env_val and env_val.lower() in self.SUPPORTED_REASONING_MODES: return env_val.lower() return None + + def _log_guardrail_failure( + self, + exc: Exception, + request_data: dict, + start_time: float, + end_time: float, + status_code: Optional[int] = None, + ) -> None: + """Log guardrail failure and attach standard logging metadata.""" + try: + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response=str(exc), + request_data=request_data, + guardrail_status="guardrail_failed_to_respond", + start_time=start_time, + end_time=end_time, + duration=end_time - start_time, + guardrail_provider="grayswan", + ) + except Exception: + verbose_proxy_logger.exception( + "Gray Swan Guardrail: failed to log guardrail failure for error: %s", + exc, + ) + verbose_proxy_logger.error( + "Gray Swan Guardrail: API request failed%s: %s", + f" (status_code={status_code})" if status_code else "", + exc, + ) diff --git a/litellm/proxy/management_endpoints/scim/scim_transformations.py b/litellm/proxy/management_endpoints/scim/scim_transformations.py index 1bb59888405..a741ddd697a 100644 --- a/litellm/proxy/management_endpoints/scim/scim_transformations.py +++ b/litellm/proxy/management_endpoints/scim/scim_transformations.py @@ -40,7 +40,9 @@ class ScimTransformations: user_updated_at = user.updated_at.isoformat() if user.updated_at else None emails = [] - if user.user_email: + # Only add email if it's a valid email address (contains @) + # user_email can be a UUID when users are created without an email + if user.user_email and "@" in user.user_email: emails.append(SCIMUserEmail(value=user.user_email, primary=True)) return SCIMUser( @@ -126,7 +128,7 @@ class ScimTransformations: for member in team.members_with_roles or []: if isinstance(member, dict): member = Member(**member) - + scim_members.append( SCIMMember( value=ScimTransformations._get_scim_member_value(member), @@ -161,7 +163,7 @@ class ScimTransformations: elif hasattr(member, "user_id"): return member.user_id or ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE return ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE - + @staticmethod def _get_scim_member_display(member: Member) -> str: """ diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index 3ff0a7f30f9..0965198bad9 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -206,22 +206,66 @@ def _build_scim_metadata( return metadata +async def _get_scim_upsert_user_setting() -> bool: + """ + Get the scim_upsert_user setting from litellm_settings. + + Returns: + True if scim_upsert_user is not set or is True (default behavior), + False if scim_upsert_user is explicitly set to False (SCIM 2.0 strict mode) + """ + try: + from litellm.proxy.proxy_server import proxy_config + + config = await proxy_config.get_config() + litellm_settings = config.get("litellm_settings", {}) or {} + scim_upsert_user = litellm_settings.get("scim_upsert_user", True) + + # Default to True if not set (backward compatibility) + return bool(scim_upsert_user) + except Exception as e: + verbose_proxy_logger.warning( + f"Error reading scim_upsert_user setting, defaulting to True: {e}" + ) + # Default to True for backward compatibility + return True + + async def _extract_group_member_ids(group: SCIMGroup) -> GroupMemberExtractionResult: """ - Extract member IDs from SCIMGroup, creating users that don't exist. + Extract member IDs from SCIMGroup, validating that all users exist. + + Behavior depends on litellm_settings.scim_upsert_user: + - If True (default): Creates users that don't exist (backward compatible) + - If False: Rejects non-existent users per SCIM 2.0 protocol Returns: GroupMemberExtractionResult with existing members, created users, and all member IDs + + Raises: + HTTPException: If scim_upsert_user is False and any member user does not exist (400 Bad Request) """ prisma_client = await _get_prisma_client_or_raise_exception() existing_member_ids = [] created_users = [] all_member_ids = [] + + # Check the feature flag + scim_upsert_user = await _get_scim_upsert_user_setting() if group.members: for member in group.members: user_id = member.value + # Validate user_id is not empty + if not user_id or not user_id.strip(): + raise HTTPException( + status_code=400, + detail={ + "error": "Invalid member: user ID cannot be empty." + }, + ) + # Check if user exists user = await prisma_client.db.litellm_usertable.find_unique( where={"user_id": user_id} @@ -231,15 +275,26 @@ async def _extract_group_member_ids(group: SCIMGroup) -> GroupMemberExtractionRe existing_member_ids.append(user_id) all_member_ids.append(user_id) else: - # Create the user if they don't exist using our helper - created_user = await _create_user_if_not_exists( - user_id=user_id, created_via="scim_group_membership" - ) - - if created_user: - created_users.append(created_user) - all_member_ids.append(user_id) - # If creation failed, user is skipped (logged in helper) + if scim_upsert_user: + # Create the user if they don't exist (backward compatible behavior) + created_user = await _create_user_if_not_exists( + user_id=user_id, created_via="scim_group_membership" + ) + if created_user: + created_users.append(created_user) + all_member_ids.append(user_id) + # If creation failed, user is skipped (logged in helper) + else: + # User doesn't exist - reject per SCIM 2.0 protocol + # This prevents security issues where users not assigned to app + # get provisioned via group membership + raise HTTPException( + status_code=400, + detail={ + "error": f"User with ID '{user_id}' does not exist. " + "Please create the user first via POST /Users before adding to group." + }, + ) return GroupMemberExtractionResult( existing_member_ids=existing_member_ids, @@ -1039,7 +1094,7 @@ async def create_group( detail={"error": f"Group already exists with ID: {team_id}"}, ) - # Extract and process group members (creating users that don't exist) + # Extract and validate group members (all users must exist) member_result = await _extract_group_member_ids(group) members_with_roles = [ Member(user_id=member_id, role="user") @@ -1087,7 +1142,7 @@ async def update_group( prisma_client = await _get_prisma_client_or_raise_exception() existing_team = await _check_team_exists(group_id) - # Extract and process group members (creating users that don't exist) + # Extract and validate group members (all users must exist) member_result = await _extract_group_member_ids(group) verbose_proxy_logger.debug( f"SCIM PUT GROUP all_member_ids: {member_result.all_member_ids}" @@ -1204,23 +1259,43 @@ async def _process_group_patch_operations( elif path.startswith("members"): # Handle member operations member_values = _extract_group_values(value) - # Create users that don't exist and get all valid member IDs + # Check the feature flag + scim_upsert_user = await _get_scim_upsert_user_setting() + # Validate all users exist or create them based on feature flag valid_members = [] for member_id in member_values: + # Validate member_id is not empty + if not member_id or not member_id.strip(): + raise HTTPException( + status_code=400, + detail={ + "error": "Invalid member: user ID cannot be empty." + }, + ) + user = await prisma_client.db.litellm_usertable.find_unique( where={"user_id": member_id} ) if user: valid_members.append(member_id) else: - # Create the user if they don't exist using our helper - created_user = await _create_user_if_not_exists( - user_id=member_id, created_via="scim_group_patch" - ) - - if created_user: - valid_members.append(member_id) - # If creation failed, user is skipped (logged in helper) + if scim_upsert_user: + # Create the user if they don't exist (backward compatible behavior) + created_user = await _create_user_if_not_exists( + user_id=member_id, created_via="scim_group_patch" + ) + if created_user: + valid_members.append(member_id) + # If creation failed, user is skipped (logged in helper) + else: + # User doesn't exist - reject per SCIM 2.0 protocol + raise HTTPException( + status_code=400, + detail={ + "error": f"User with ID '{member_id}' does not exist. " + "Please create the user first via POST /Users before adding to group." + }, + ) if op_type == "replace": final_members = set(valid_members) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index d9798dae690..5299b30b52f 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1037,6 +1037,7 @@ async def bedrock_proxy_route( target=str(prepped.url), custom_headers=prepped.headers, # type: ignore is_streaming_request=is_streaming_request, + _forward_headers=True ) # dynamically construct pass-through endpoint based on incoming path received_value = await endpoint_func( request, diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index b2eda5a8d38..75d8253a904 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -226,12 +226,6 @@ async def chat_completion_pass_through_endpoint( # noqa: PLR0915 and data["model"] in llm_router.model_group_alias ): # model set in model_group_alias llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) - elif ( - llm_router is not None and data["model"] in llm_router.deployment_names - ): # model in router deployments, calling a specific deployment on the router - llm_response = asyncio.create_task( - llm_router.aadapter_completion(**data, specific_deployment=True) - ) elif llm_router is not None and llm_router.has_model_id( data["model"] ): # model in router model list @@ -239,9 +233,15 @@ async def chat_completion_pass_through_endpoint( # noqa: PLR0915 elif ( llm_router is not None and data["model"] not in router_model_names - and llm_router.default_deployment is not None - ): # model in router deployments, calling a specific deployment on the router + and (llm_router.default_deployment is not None or len(llm_router.pattern_router.patterns) > 0) + ): # check for wildcard routes or default deployment before checking deployment_names llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) + elif ( + llm_router is not None and data["model"] in llm_router.deployment_names + ): # model in router deployments, calling a specific deployment on the router (lowest priority) + llm_response = asyncio.create_task( + llm_router.aadapter_completion(**data, specific_deployment=True) + ) elif user_model is not None: # `litellm --model ` llm_response = asyncio.create_task(litellm.aadapter_completion(**data)) else: diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 8a8fd6794e7..576ba24aac2 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -6,3 +6,5 @@ model_list: litellm_params: model: openai/* +general_settings: + store_prompts_in_spend_logs: true \ No newline at end of file diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a0485da8495..dd7f70750ca 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -57,7 +57,10 @@ from litellm.types.utils import ( TextCompletionResponse, TokenCountResponse, ) -from litellm.utils import load_credentials_from_list +from litellm.utils import ( + _invalidate_model_cost_lowercase_map, + load_credentials_from_list, +) if TYPE_CHECKING: from aiohttp import ClientSession @@ -2473,7 +2476,12 @@ class ProxyConfig: raise Exception( f"Invalid value set for upperbound_key_generate_params - value={value}" ) - + elif key == "json_logs" and value is True: + litellm.json_logs = True + litellm._turn_on_json() + verbose_proxy_logger.debug( + f"{blue_color_code} Enabled JSON logging via config{reset_color_code}" + ) else: verbose_proxy_logger.debug( f"{blue_color_code} setting litellm.{key}={value}{reset_color_code}" @@ -3830,6 +3838,8 @@ class ProxyConfig: model_cost_map_url = litellm.model_cost_map_url new_model_cost_map = get_model_cost_map(url=model_cost_map_url) litellm.model_cost = new_model_cost_map + # Invalidate case-insensitive lookup map since model_cost was replaced + _invalidate_model_cost_lowercase_map() # Update pod's in-memory last reload time last_model_cost_map_reload = current_time.isoformat() @@ -7070,9 +7080,33 @@ async def token_counter(request: TokenCountRequest, call_endpoint: bool = False) ######################################################### # Transfrom the Response to the well known format ######################################################### - if result is not None: + if result is not None and result.error is True: + # If disable_token_counter is enabled, raise HTTP error + if litellm.disable_token_counter is True: + raise ProxyException( + message=result.error_message or "Token counting failed", + type="token_counting_error", + param="model", + code=result.status_code or 500, + ) + # Otherwise, log warning and fall back to local counter + verbose_proxy_logger.warning( + f"Provider token counting failed ({result.status_code}): {result.error_message}. " + "Falling back to local tokenizer." + ) + else: + # Success - return the result return result + # Check if token counter is disabled before fallback + if litellm.disable_token_counter is True: + raise ProxyException( + message="Token counting is disabled and no provider API result available", + type="token_counting_disabled", + param="model", + code=503, + ) + # Default LiteLLM token counting custom_tokenizer: Optional[CustomHuggingfaceTokenizer] = None if model_info is not None: @@ -10082,6 +10116,8 @@ async def reload_model_cost_map( model_cost_map_url = litellm.model_cost_map_url new_model_cost_map = get_model_cost_map(url=model_cost_map_url) litellm.model_cost = new_model_cost_map + # Invalidate case-insensitive lookup map since model_cost was replaced + _invalidate_model_cost_lowercase_map() # Update pod's in-memory last reload time global last_model_cost_map_reload diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index 4b3a65737d4..0e061a839d9 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -260,12 +260,9 @@ async def route_request( ): return getattr(llm_router, f"{route_type}")(**data) - elif data["model"] in llm_router.deployment_names: - return getattr(llm_router, f"{route_type}")( - **data, specific_deployment=True - ) - elif data["model"] not in router_model_names: + # Check wildcards before checking deployment_names + # Priority: 1. Exact model_name match, 2. Wildcard match, 3. deployment_names match if llm_router.router_general_settings.pass_through_all_models: return getattr(litellm, f"{route_type}")(**data) elif ( @@ -273,6 +270,11 @@ async def route_request( or len(llm_router.pattern_router.patterns) > 0 ): return getattr(llm_router, f"{route_type}")(**data) + elif data["model"] in llm_router.deployment_names: + # Only match deployment_names if no wildcard matched + return getattr(llm_router, f"{route_type}")( + **data, specific_deployment=True + ) elif route_type in [ "amoderation", "aget_responses", diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 07fc3cb02cc..71d94287e82 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -668,6 +668,7 @@ def responses( optional_params=dict(responses_api_request_params), litellm_params={ **responses_api_request_params, + "aresponses": _is_async, "litellm_call_id": litellm_call_id, "metadata": metadata, }, diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 49b8e123fe2..469eeb3da06 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -472,7 +472,9 @@ class ResponseAPILoggingUtils: output_tokens_details = getattr(response_api_usage, "output_tokens_details", None) if output_tokens_details: completion_tokens_details = CompletionTokensDetailsWrapper( - reasoning_tokens=getattr(output_tokens_details, "reasoning_tokens", None) + reasoning_tokens=getattr(output_tokens_details, "reasoning_tokens", None), + image_tokens=getattr(output_tokens_details, "image_tokens", None), + text_tokens=getattr(output_tokens_details, "text_tokens", None), ) chat_usage = Usage( diff --git a/litellm/router.py b/litellm/router.py index 41a08f9a1a8..3e5e8c9b8db 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1408,6 +1408,15 @@ class Router: async for item in model_response: yield item except MidStreamFallbackError as e: + # Check if fallbacks are disabled by user + if initial_kwargs.get("disable_fallbacks", False): + verbose_router_logger.info( + "Mid stream fallback disabled by user, re-raising original error" + ) + if e.original_exception is not None: + raise e.original_exception + raise e + from litellm.main import stream_chunk_builder complete_response_object = stream_chunk_builder( diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/grayswan.py b/litellm/types/proxy/guardrails/guardrail_hooks/grayswan.py index 79239e52621..24d8cd85a0b 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/grayswan.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/grayswan.py @@ -32,6 +32,14 @@ class GraySwanGuardrailConfigModelOptionalParams(BaseModel): default=None, description="Default Gray Swan category definitions to send with each request.", ) + fail_open: Optional[bool] = Field( + default=True, + description="If true (default), errors contacting Gray Swan are logged and the request proceeds. If false, errors propagate and block the request.", + ) + guardrail_timeout: Optional[float] = Field( + default=30.0, + description="Timeout in seconds for calling the Gray Swan guardrail service.", + ) class GraySwanGuardrailConfigModel( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 222324a7910..b5523385f08 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3118,6 +3118,12 @@ class TokenCountResponse(LiteLLMPydanticObjectBase): """ Original Response from upstream API call - if an API call was made for token counting """ + error: bool = False + error_message: Optional[str] = None + """ + HTTP status code from the token counting API (e.g., 200 for success, 429 for rate limit, 400 for bad request) + """ + status_code: Optional[int] = None class CustomHuggingfaceTokenizer(TypedDict): diff --git a/litellm/utils.py b/litellm/utils.py index fc8982f382f..ea29d22221d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -2639,6 +2639,10 @@ def register_model(model_cost: Union[str, dict]): # noqa: PLR0915 ## override / add new keys to the existing model cost dictionary updated_dictionary = _update_dictionary(existing_model, value) litellm.model_cost.setdefault(model_cost_key, {}).update(updated_dictionary) + + # Invalidate case-insensitive lookup map since model_cost was modified + _invalidate_model_cost_lowercase_map() + verbose_logger.debug( f"added/updated model={model_cost_key} in litellm.model_cost: {model_cost_key}" ) @@ -4993,6 +4997,109 @@ def _strip_model_name(model: str, custom_llm_provider: Optional[str]) -> str: return model +# Global case-insensitive lookup map for model_cost (built eagerly at module import) +_model_cost_lowercase_map: Optional[Dict[str, str]] = None + + +def _invalidate_model_cost_lowercase_map() -> None: + """Invalidate the case-insensitive lookup map for model_cost. + + Call this whenever litellm.model_cost is modified to ensure the map is rebuilt. + """ + global _model_cost_lowercase_map + _model_cost_lowercase_map = None + + +def _rebuild_model_cost_lowercase_map() -> Dict[str, str]: + """Rebuild the case-insensitive lookup map from the current model_cost. + + Returns: + The rebuilt map (guaranteed to be not None). + """ + global _model_cost_lowercase_map + _model_cost_lowercase_map = {k.lower(): k for k in litellm.model_cost} + return _model_cost_lowercase_map + + +def _handle_stale_map_entry_rebuild( + potential_key_lower: str, +) -> Optional[str]: + """ + Handle stale _model_cost_lowercase_map entry (key was popped). + + Rebuilds the map and retries the lookup. + + Returns: + The matched key if found after rebuild, None otherwise. + """ + global _model_cost_lowercase_map + _model_cost_lowercase_map = _rebuild_model_cost_lowercase_map() + matched_key = _model_cost_lowercase_map.get(potential_key_lower) + if matched_key is not None and matched_key in litellm.model_cost: + return matched_key + return None + + +def _handle_new_key_with_scan( + potential_key_lower: str, +) -> Optional[str]: + """ + Handle new key added to model_cost without invalidating _model_cost_lowercase_map. + + Scans model_cost for case-insensitive match and rebuilds the map if found. + + Returns: + The matched key if found, None otherwise. + """ + global _model_cost_lowercase_map + for key in litellm.model_cost: + if key.lower() == potential_key_lower: + _model_cost_lowercase_map = _rebuild_model_cost_lowercase_map() + return key + return None + + +def _get_model_cost_key(potential_key: str) -> Optional[str]: + """ + Get the actual key from model_cost, with case-insensitive fallback. + + WARNING: Only O(1) lookup operations are acceptable. O(n) lookups will cause severe + CPU overhead. This function is called frequently during router operations. + + ALLOWED HELPER FUNCTIONS (conditionally called, O(n) operations are acceptable): + - _rebuild_model_cost_lowercase_map: Rebuilds the lookup map (only when map is None) + - _handle_stale_map_entry_rebuild: Rebuilds map when stale entry detected (rare case) + + If you need to add a new helper function with O(n) operations that is conditionally + called and confirmed not to cause performance issues, add it to the allowed_helpers + list in: tests/code_coverage_tests/check_get_model_cost_key_performance.py + """ + global _model_cost_lowercase_map + + # Exact match (O(1)) + if potential_key in litellm.model_cost: + return potential_key + + # Case-insensitive lookup via map (O(1)) + if _model_cost_lowercase_map is None: + _model_cost_lowercase_map = _rebuild_model_cost_lowercase_map() + + potential_key_lower = potential_key.lower() + matched_key = _model_cost_lowercase_map.get(potential_key_lower) + + # Verify key exists (O(1) - handles model_cost.pop() case) + if matched_key is not None and matched_key in litellm.model_cost: + return matched_key + + # Rebuild map if stale entry detected (O(n) rebuild, but only when stale entry found) + if matched_key is not None: + matched_key = _handle_stale_map_entry_rebuild(potential_key_lower) + if matched_key is not None: + return matched_key + + return None + + def _get_model_info_from_model_cost(key: str) -> dict: return litellm.model_cost[key] @@ -5021,6 +5128,11 @@ def _check_provider_match(model_info: dict, custom_llm_provider: Optional[str]) custom_llm_provider == "litellm_proxy" ): # litellm_proxy is a special case, it's not a provider, it's a proxy for the provider return True + elif custom_llm_provider == "azure_ai" and model_info["litellm_provider"] in ("azure", "openai"): + # Azure AI also works with azure models + # as a last attempt if the model is not on Azure AI, Azure then fallback to OpenAI cost + # tracking the cost is better than attributing 0 cost to it. + return True else: return False @@ -5142,10 +5254,10 @@ def _is_potential_model_name_in_model_cost( potential_model_names: PotentialModelNamesAndCustomLLMProvider, ) -> bool: """ - Check if the potential model name is in the model cost. + Check if the potential model name is in the model cost (case-insensitive). """ return any( - potential_model_name in litellm.model_cost + _get_model_cost_key(str(potential_model_name)) is not None for potential_model_name in potential_model_names.values() ) @@ -5223,44 +5335,51 @@ def _get_model_info_helper( # noqa: PLR0915 _model_info: Optional[Dict[str, Any]] = None key: Optional[str] = None - if combined_model_name in litellm.model_cost: - key = combined_model_name - _model_info = _get_model_info_from_model_cost(key=cast(str, key)) - if not _check_provider_match( - model_info=_model_info, custom_llm_provider=custom_llm_provider - ): - _model_info = None - if _model_info is None and model in litellm.model_cost: - key = model - _model_info = _get_model_info_from_model_cost(key=cast(str, key)) - if not _check_provider_match( - model_info=_model_info, custom_llm_provider=custom_llm_provider - ): - _model_info = None - if ( - _model_info is None - and combined_stripped_model_name in litellm.model_cost - ): - key = combined_stripped_model_name - _model_info = _get_model_info_from_model_cost(key=cast(str, key)) - if not _check_provider_match( - model_info=_model_info, custom_llm_provider=custom_llm_provider - ): - _model_info = None - if _model_info is None and stripped_model_name in litellm.model_cost: - key = stripped_model_name - _model_info = _get_model_info_from_model_cost(key=cast(str, key)) - if not _check_provider_match( - model_info=_model_info, custom_llm_provider=custom_llm_provider - ): - _model_info = None - if _model_info is None and split_model in litellm.model_cost: - key = split_model + # Use case-insensitive lookup for all model name checks + _matched_key = _get_model_cost_key(combined_model_name) + if _matched_key is not None: + key = _matched_key _model_info = _get_model_info_from_model_cost(key=cast(str, key)) if not _check_provider_match( model_info=_model_info, custom_llm_provider=custom_llm_provider ): _model_info = None + if _model_info is None: + _matched_key = _get_model_cost_key(model) + if _matched_key is not None: + key = _matched_key + _model_info = _get_model_info_from_model_cost(key=cast(str, key)) + if not _check_provider_match( + model_info=_model_info, custom_llm_provider=custom_llm_provider + ): + _model_info = None + if _model_info is None: + _matched_key = _get_model_cost_key(combined_stripped_model_name) + if _matched_key is not None: + key = _matched_key + _model_info = _get_model_info_from_model_cost(key=cast(str, key)) + if not _check_provider_match( + model_info=_model_info, custom_llm_provider=custom_llm_provider + ): + _model_info = None + if _model_info is None: + _matched_key = _get_model_cost_key(stripped_model_name) + if _matched_key is not None: + key = _matched_key + _model_info = _get_model_info_from_model_cost(key=cast(str, key)) + if not _check_provider_match( + model_info=_model_info, custom_llm_provider=custom_llm_provider + ): + _model_info = None + if _model_info is None: + _matched_key = _get_model_cost_key(split_model) + if _matched_key is not None: + key = _matched_key + _model_info = _get_model_info_from_model_cost(key=cast(str, key)) + if not _check_provider_match( + model_info=_model_info, custom_llm_provider=custom_llm_provider + ): + _model_info = None if _model_info is None or key is None: raise ValueError( diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f818a2c82e2..cf2ee10c6ea 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1644,7 +1644,7 @@ "cache_read_input_token_cost": 1.4e-07, "input_cost_per_token": 1.38e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -1944,7 +1944,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -2893,7 +2893,7 @@ "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -2964,7 +2964,7 @@ "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -2997,7 +2997,7 @@ "input_cost_per_token": 2.5e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -3092,9 +3092,9 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "source": "https://azure.microsoft.com/en-us/blog/gpt-5-in-azure-ai-foundry-the-future-of-ai-apps-and-agents-starts-here/", @@ -3377,7 +3377,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -3440,7 +3440,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -3500,7 +3500,7 @@ "cache_read_input_token_cost": 1.75e-07, "input_cost_per_token": 1.75e-06, "litellm_provider": "azure", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -3637,7 +3637,7 @@ "azure/gpt-5.2-pro": { "input_cost_per_token": 2.1e-05, "litellm_provider": "azure", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -3668,7 +3668,7 @@ "azure/gpt-5.2-pro-2025-12-11": { "input_cost_per_token": 2.1e-05, "litellm_provider": "azure", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -4684,7 +4684,7 @@ "cache_read_input_token_cost": 1.4e-07, "input_cost_per_token": 1.38e-06, "litellm_provider": "azure", - "max_input_tokens": 272000, + "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -6616,6 +6616,20 @@ "supports_tool_choice": true }, "cerebras/zai-glm-4.6": { + "deprecation_date": "2026-01-20", + "input_cost_per_token": 2.25e-06, + "litellm_provider": "cerebras", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "source": "https://www.cerebras.ai/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "cerebras/zai-glm-4.7": { "input_cost_per_token": 2.25e-06, "litellm_provider": "cerebras", "max_input_tokens": 128000, @@ -7813,14 +7827,14 @@ "supports_vision": true }, "deepseek-chat": { - "cache_read_input_token_cost": 6e-08, - "input_cost_per_token": 6e-07, + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 2.8e-07, "litellm_provider": "deepseek", "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 1.7e-06, + "output_cost_per_token": 4.2e-07, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" @@ -7834,14 +7848,14 @@ "supports_tool_choice": true }, "deepseek-reasoner": { - "cache_read_input_token_cost": 6e-08, - "input_cost_per_token": 6e-07, + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 2.8e-07, "litellm_provider": "deepseek", "max_input_tokens": 131072, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 1.7e-06, + "output_cost_per_token": 4.2e-07, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" @@ -10045,15 +10059,15 @@ }, "deepseek/deepseek-chat": { "cache_creation_input_token_cost": 0.0, - "cache_read_input_token_cost": 7e-08, - "input_cost_per_token": 2.7e-07, - "input_cost_per_token_cache_hit": 7e-08, + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 2.8e-07, + "input_cost_per_token_cache_hit": 2.8e-08, "litellm_provider": "deepseek", - "max_input_tokens": 65536, + "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 1.1e-06, + "output_cost_per_token": 4.2e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, @@ -10089,14 +10103,15 @@ "supports_tool_choice": true }, "deepseek/deepseek-reasoner": { - "input_cost_per_token": 5.5e-07, - "input_cost_per_token_cache_hit": 1.4e-07, + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 2.8e-07, + "input_cost_per_token_cache_hit": 2.8e-08, "litellm_provider": "deepseek", - "max_input_tokens": 65536, + "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 2.19e-06, + "output_cost_per_token": 4.2e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, @@ -12996,6 +13011,7 @@ }, "gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -13178,7 +13194,8 @@ "supports_web_search": true }, "gemini-2.5-pro-exp-03-25": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", @@ -13222,7 +13239,8 @@ }, "gemini-2.5-pro-preview-03-25": { "deprecation_date": "2025-12-02", - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -13268,7 +13286,8 @@ }, "gemini-2.5-pro-preview-05-06": { "deprecation_date": "2025-12-02", - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -13316,7 +13335,8 @@ "supports_web_search": true }, "gemini-2.5-pro-preview-06-05": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -13361,7 +13381,8 @@ "supports_web_search": true }, "gemini-2.5-pro-preview-tts": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -14850,7 +14871,8 @@ "tpm": 250000 }, "gemini/gemini-2.5-pro": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "gemini", @@ -15112,7 +15134,8 @@ }, "gemini/gemini-2.5-pro-preview-03-25": { "deprecation_date": "2025-12-02", - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -15153,7 +15176,8 @@ }, "gemini/gemini-2.5-pro-preview-05-06": { "deprecation_date": "2025-12-02", - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -15194,7 +15218,8 @@ "tpm": 10000000 }, "gemini/gemini-2.5-pro-preview-06-05": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -15235,7 +15260,8 @@ "tpm": 10000000 }, "gemini/gemini-2.5-pro-preview-tts": { - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -17470,7 +17496,7 @@ "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -17507,7 +17533,7 @@ "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -17543,7 +17569,7 @@ "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -17852,7 +17878,7 @@ "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -17887,9 +17913,9 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supported_endpoints": [ @@ -17951,7 +17977,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -17983,7 +18009,7 @@ "input_cost_per_token": 1.25e-06, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -18046,7 +18072,7 @@ "input_cost_per_token": 2.5e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", @@ -18081,7 +18107,7 @@ "input_cost_per_token_flex": 1.25e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -18120,7 +18146,7 @@ "input_cost_per_token_flex": 1.25e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -18158,7 +18184,7 @@ "input_cost_per_token_flex": 2.5e-08, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -18193,7 +18219,7 @@ "input_cost_per_token": 5e-08, "input_cost_per_token_flex": 2.5e-08, "litellm_provider": "openai", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -23206,9 +23232,9 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supported_modalities": [ @@ -23225,7 +23251,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -23244,7 +23270,7 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -23263,7 +23289,7 @@ "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -23282,7 +23308,7 @@ "cache_read_input_token_cost": 5e-09, "input_cost_per_token": 5e-08, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -32552,8 +32578,8 @@ "novita/deepseek/deepseek-v3.2": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.69e-03, - "output_cost_per_token": 4e-03, + "input_cost_per_token": 2.69e-7, + "output_cost_per_token": 4e-7, "max_input_tokens": 163840, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32562,15 +32588,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 1.345e-03, - "input_cost_per_token_cache_hit": 1.345e-03, + "cache_read_input_token_cost": 1.345e-7, + "input_cost_per_token_cache_hit": 1.345e-7, "supports_reasoning": true }, "novita/minimax/minimax-m2.1": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3e-03, - "output_cost_per_token": 1.2e-02, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 1.2e-06, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_tokens": 131072, @@ -32579,15 +32605,14 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 3e-04, - "input_cost_per_token_cache_hit": 3e-04, - "supports_reasoning": true + "cache_read_input_token_cost": 3e-8, + "input_cost_per_token_cache_hit": 3e-8 }, "novita/zai-org/glm-4.7": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 6e-03, - "output_cost_per_token": 2.2e-02, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 2.2e-06, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_tokens": 131072, @@ -32596,15 +32621,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 1.1e-03, - "input_cost_per_token_cache_hit": 1.1e-03, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token_cache_hit": 1.1e-7, "supports_reasoning": true }, "novita/xiaomimimo/mimo-v2-flash": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1e-03, - "output_cost_per_token": 3e-03, + "input_cost_per_token": 1e-7, + "output_cost_per_token": 3e-7, "max_input_tokens": 262144, "max_output_tokens": 32000, "max_tokens": 32000, @@ -32613,15 +32638,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 2e-04, - "input_cost_per_token_cache_hit": 2e-04, + "cache_read_input_token_cost": 2e-8, + "input_cost_per_token_cache_hit": 2e-8, "supports_reasoning": true }, "novita/zai-org/autoglm-phone-9b-multilingual": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.5e-04, - "output_cost_per_token": 1.38e-03, + "input_cost_per_token": 3.5e-8, + "output_cost_per_token": 1.38e-7, "max_input_tokens": 65536, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32631,8 +32656,8 @@ "novita/moonshotai/kimi-k2-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.8e-03, - "output_cost_per_token": 2e-02, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 2.5e-06, "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, @@ -32646,8 +32671,8 @@ "novita/minimax/minimax-m2": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-03, - "output_cost_per_token": 9.6e-03, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 1.2e-06, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_tokens": 131072, @@ -32655,15 +32680,15 @@ "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_system_messages": true, - "cache_read_input_token_cost": 2.4e-04, - "input_cost_per_token_cache_hit": 2.4e-04, + "cache_read_input_token_cost": 3e-8, + "input_cost_per_token_cache_hit": 3e-8, "supports_reasoning": true }, "novita/paddlepaddle/paddleocr-vl": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.6e-04, - "output_cost_per_token": 1.6e-04, + "input_cost_per_token": 2e-8, + "output_cost_per_token": 2e-8, "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, @@ -32673,8 +32698,8 @@ "novita/deepseek/deepseek-v3.2-exp": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.16e-03, - "output_cost_per_token": 3.28e-03, + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 4.1e-7, "max_input_tokens": 163840, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32688,8 +32713,8 @@ "novita/qwen/qwen3-vl-235b-a22b-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 7.84e-03, - "output_cost_per_token": 3.16e-02, + "input_cost_per_token": 9.8e-7, + "output_cost_per_token": 3.95e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32700,8 +32725,8 @@ "novita/zai-org/glm-4.6v": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3e-03, - "output_cost_per_token": 9e-03, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 9e-7, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32711,15 +32736,15 @@ "supports_vision": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 5.5e-04, - "input_cost_per_token_cache_hit": 5.5e-04, + "cache_read_input_token_cost": 5.5e-8, + "input_cost_per_token_cache_hit": 5.5e-8, "supports_reasoning": true }, "novita/zai-org/glm-4.6": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.4e-03, - "output_cost_per_token": 1.76e-02, + "input_cost_per_token": 5.5e-7, + "output_cost_per_token": 2.2e-06, "max_input_tokens": 204800, "max_output_tokens": 131072, "max_tokens": 131072, @@ -32728,15 +32753,31 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 8.8e-04, - "input_cost_per_token_cache_hit": 8.8e-04, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token_cache_hit": 1.1e-7, "supports_reasoning": true }, + "novita/kwaipilot/kat-coder-pro": { + "litellm_provider": "novita", + "mode": "chat", + "input_cost_per_token": 3e-7, + "output_cost_per_token": 1.2e-06, + "max_input_tokens": 256000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_system_messages": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 6e-8, + "input_cost_per_token_cache_hit": 6e-8 + }, "novita/qwen/qwen3-next-80b-a3b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.2e-03, - "output_cost_per_token": 1.2e-02, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32749,8 +32790,8 @@ "novita/qwen/qwen3-next-80b-a3b-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.2e-03, - "output_cost_per_token": 1.2e-02, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32764,8 +32805,8 @@ "novita/deepseek/deepseek-ocr": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-04, - "output_cost_per_token": 2.4e-04, + "input_cost_per_token": 3e-8, + "output_cost_per_token": 3e-8, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -32777,8 +32818,8 @@ "novita/deepseek/deepseek-v3.1-terminus": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.16e-03, - "output_cost_per_token": 8e-03, + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 1e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32787,15 +32828,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 1.08e-03, - "input_cost_per_token_cache_hit": 1.08e-03, + "cache_read_input_token_cost": 1.35e-7, + "input_cost_per_token_cache_hit": 1.35e-7, "supports_reasoning": true }, "novita/qwen/qwen3-vl-235b-a22b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-03, - "output_cost_per_token": 1.2e-02, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 1.5e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32809,8 +32850,8 @@ "novita/qwen/qwen3-max": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.688e-02, - "output_cost_per_token": 6.76e-02, + "input_cost_per_token": 2.11e-06, + "output_cost_per_token": 8.45e-06, "max_input_tokens": 262144, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32823,8 +32864,8 @@ "novita/skywork/r1v4-lite": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2e-03, - "output_cost_per_token": 6e-03, + "input_cost_per_token": 2e-7, + "output_cost_per_token": 6e-7, "max_input_tokens": 262144, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32836,8 +32877,8 @@ "novita/deepseek/deepseek-v3.1": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.16e-03, - "output_cost_per_token": 8e-03, + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 1e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32846,15 +32887,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 1.08e-03, - "input_cost_per_token_cache_hit": 1.08e-03, + "cache_read_input_token_cost": 1.35e-7, + "input_cost_per_token_cache_hit": 1.35e-7, "supports_reasoning": true }, "novita/moonshotai/kimi-k2-0905": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.8e-03, - "output_cost_per_token": 2e-02, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 2.5e-06, "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, @@ -32867,8 +32908,8 @@ "novita/qwen/qwen3-coder-480b-a35b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-03, - "output_cost_per_token": 1.04e-02, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 1.3e-06, "max_input_tokens": 262144, "max_output_tokens": 65536, "max_tokens": 65536, @@ -32881,8 +32922,8 @@ "novita/qwen/qwen3-coder-30b-a3b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 7e-04, - "output_cost_per_token": 2.7e-03, + "input_cost_per_token": 7e-8, + "output_cost_per_token": 2.7e-7, "max_input_tokens": 160000, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32895,8 +32936,8 @@ "novita/openai/gpt-oss-120b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4e-04, - "output_cost_per_token": 2e-03, + "input_cost_per_token": 5e-8, + "output_cost_per_token": 2.5e-7, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32911,8 +32952,8 @@ "novita/moonshotai/kimi-k2-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.56e-03, - "output_cost_per_token": 1.84e-02, + "input_cost_per_token": 5.7e-7, + "output_cost_per_token": 2.3e-06, "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, @@ -32925,8 +32966,8 @@ "novita/deepseek/deepseek-v3-0324": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.16e-03, - "output_cost_per_token": 8.96e-03, + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 1.12e-06, "max_input_tokens": 163840, "max_output_tokens": 163840, "max_tokens": 163840, @@ -32935,14 +32976,14 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 1.08e-03, - "input_cost_per_token_cache_hit": 1.08e-03 + "cache_read_input_token_cost": 1.35e-7, + "input_cost_per_token_cache_hit": 1.35e-7 }, "novita/zai-org/glm-4.5": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.8e-03, - "output_cost_per_token": 1.76e-02, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 2.2e-06, "max_input_tokens": 131072, "max_output_tokens": 98304, "max_tokens": 98304, @@ -32950,15 +32991,15 @@ "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_system_messages": true, - "cache_read_input_token_cost": 8.8e-04, - "input_cost_per_token_cache_hit": 8.8e-04, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token_cache_hit": 1.1e-7, "supports_reasoning": true }, "novita/qwen/qwen3-235b-a22b-thinking-2507": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-03, - "output_cost_per_token": 2.4e-02, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 3e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -32971,8 +33012,8 @@ "novita/meta-llama/llama-3.1-8b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2e-04, - "output_cost_per_token": 5e-04, + "input_cost_per_token": 2e-8, + "output_cost_per_token": 5e-8, "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, @@ -32981,8 +33022,8 @@ "novita/google/gemma-3-12b-it": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4e-04, - "output_cost_per_token": 8e-04, + "input_cost_per_token": 5e-8, + "output_cost_per_token": 1e-7, "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, @@ -32994,8 +33035,8 @@ "novita/zai-org/glm-4.5v": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.8e-03, - "output_cost_per_token": 1.44e-02, + "input_cost_per_token": 6e-7, + "output_cost_per_token": 1.8e-06, "max_input_tokens": 65536, "max_output_tokens": 16384, "max_tokens": 16384, @@ -33005,15 +33046,15 @@ "supports_vision": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 8.8e-04, - "input_cost_per_token_cache_hit": 8.8e-04, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token_cache_hit": 1.1e-7, "supports_reasoning": true }, "novita/openai/gpt-oss-20b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.2e-04, - "output_cost_per_token": 1.2e-03, + "input_cost_per_token": 4e-8, + "output_cost_per_token": 1.5e-7, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33026,8 +33067,8 @@ "novita/qwen/qwen3-235b-a22b-instruct-2507": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 7.2e-04, - "output_cost_per_token": 4.64e-03, + "input_cost_per_token": 9e-8, + "output_cost_per_token": 5.8e-7, "max_input_tokens": 131072, "max_output_tokens": 16384, "max_tokens": 16384, @@ -33040,8 +33081,8 @@ "novita/deepseek/deepseek-r1-distill-qwen-14b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.2e-03, - "output_cost_per_token": 1.2e-03, + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 1.5e-7, "max_input_tokens": 32768, "max_output_tokens": 16384, "max_tokens": 16384, @@ -33053,8 +33094,8 @@ "novita/meta-llama/llama-3.3-70b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.08e-03, - "output_cost_per_token": 3.2e-03, + "input_cost_per_token": 1.35e-7, + "output_cost_per_token": 4e-7, "max_input_tokens": 131072, "max_output_tokens": 120000, "max_tokens": 120000, @@ -33066,8 +33107,8 @@ "novita/qwen/qwen-2.5-72b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.04e-03, - "output_cost_per_token": 3.2e-03, + "input_cost_per_token": 3.8e-7, + "output_cost_per_token": 4e-7, "max_input_tokens": 32000, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33080,8 +33121,8 @@ "novita/mistralai/mistral-nemo": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.2e-04, - "output_cost_per_token": 1.36e-03, + "input_cost_per_token": 4e-8, + "output_cost_per_token": 1.7e-7, "max_input_tokens": 60288, "max_output_tokens": 16000, "max_tokens": 16000, @@ -33092,8 +33133,8 @@ "novita/minimaxai/minimax-m1-80k": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.4e-03, - "output_cost_per_token": 1.76e-02, + "input_cost_per_token": 5.5e-7, + "output_cost_per_token": 2.2e-06, "max_input_tokens": 1000000, "max_output_tokens": 40000, "max_tokens": 40000, @@ -33106,8 +33147,8 @@ "novita/deepseek/deepseek-r1-0528": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-03, - "output_cost_per_token": 2e-02, + "input_cost_per_token": 7e-7, + "output_cost_per_token": 2.5e-06, "max_input_tokens": 163840, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33116,15 +33157,15 @@ "supports_tool_choice": true, "supports_system_messages": true, "supports_response_schema": true, - "cache_read_input_token_cost": 2.8e-03, - "input_cost_per_token_cache_hit": 2.8e-03, + "cache_read_input_token_cost": 3.5e-7, + "input_cost_per_token_cache_hit": 3.5e-7, "supports_reasoning": true }, "novita/deepseek/deepseek-r1-distill-qwen-32b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-03, - "output_cost_per_token": 2.4e-03, + "input_cost_per_token": 3e-7, + "output_cost_per_token": 3e-7, "max_input_tokens": 64000, "max_output_tokens": 32000, "max_tokens": 32000, @@ -33136,8 +33177,8 @@ "novita/meta-llama/llama-3-8b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.2e-04, - "output_cost_per_token": 3.2e-04, + "input_cost_per_token": 4e-8, + "output_cost_per_token": 4e-8, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33146,8 +33187,8 @@ "novita/microsoft/wizardlm-2-8x22b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.96e-03, - "output_cost_per_token": 4.96e-03, + "input_cost_per_token": 6.2e-7, + "output_cost_per_token": 6.2e-7, "max_input_tokens": 65535, "max_output_tokens": 8000, "max_tokens": 8000, @@ -33156,8 +33197,8 @@ "novita/deepseek/deepseek-r1-0528-qwen3-8b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 4.8e-04, - "output_cost_per_token": 7.2e-04, + "input_cost_per_token": 6e-8, + "output_cost_per_token": 9e-8, "max_input_tokens": 128000, "max_output_tokens": 32000, "max_tokens": 32000, @@ -33167,8 +33208,8 @@ "novita/deepseek/deepseek-r1-distill-llama-70b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 6.4e-03, - "output_cost_per_token": 6.4e-03, + "input_cost_per_token": 8e-7, + "output_cost_per_token": 8e-7, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33180,8 +33221,8 @@ "novita/meta-llama/llama-3-70b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.1e-03, - "output_cost_per_token": 7.4e-03, + "input_cost_per_token": 5.1e-7, + "output_cost_per_token": 7.4e-7, "max_input_tokens": 8192, "max_output_tokens": 8000, "max_tokens": 8000, @@ -33192,8 +33233,8 @@ "novita/qwen/qwen3-235b-a22b-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.6e-03, - "output_cost_per_token": 6.4e-03, + "input_cost_per_token": 2e-7, + "output_cost_per_token": 8e-7, "max_input_tokens": 40960, "max_output_tokens": 20000, "max_tokens": 20000, @@ -33203,8 +33244,8 @@ "novita/meta-llama/llama-4-maverick-17b-128e-instruct-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.6e-03, - "output_cost_per_token": 7.2e-03, + "input_cost_per_token": 2.7e-7, + "output_cost_per_token": 8.5e-7, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33214,8 +33255,8 @@ "novita/meta-llama/llama-4-scout-17b-16e-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 8e-04, - "output_cost_per_token": 4e-03, + "input_cost_per_token": 1.8e-7, + "output_cost_per_token": 5.9e-7, "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, @@ -33225,8 +33266,8 @@ "novita/nousresearch/hermes-2-pro-llama-3-8b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.4e-03, - "output_cost_per_token": 1.4e-03, + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 1.4e-7, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33237,8 +33278,8 @@ "novita/qwen/qwen2.5-vl-72b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 6.4e-03, - "output_cost_per_token": 6.4e-03, + "input_cost_per_token": 8e-7, + "output_cost_per_token": 8e-7, "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33248,8 +33289,8 @@ "novita/sao10k/l3-70b-euryale-v2.1": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.48e-02, - "output_cost_per_token": 1.48e-02, + "input_cost_per_token": 1.48e-06, + "output_cost_per_token": 1.48e-06, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33261,8 +33302,8 @@ "novita/baidu/ernie-4.5-21B-a3b-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-04, - "output_cost_per_token": 2.24e-03, + "input_cost_per_token": 7e-8, + "output_cost_per_token": 2.8e-7, "max_input_tokens": 131072, "max_output_tokens": 65536, "max_tokens": 65536, @@ -33272,8 +33313,8 @@ "novita/sao10k/l3-8b-lunaris": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5e-04, - "output_cost_per_token": 5e-04, + "input_cost_per_token": 5e-8, + "output_cost_per_token": 5e-8, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33284,33 +33325,18 @@ "novita/baichuan/baichuan-m2-32b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-04, - "output_cost_per_token": 5.6e-04, + "input_cost_per_token": 7e-8, + "output_cost_per_token": 7e-8, "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, - "supports_tool_choice": true, - "supports_system_messages": true, - "supports_response_schema": true, - "supports_reasoning": true - }, - "novita/thudm/glm-4.1v-9b-thinking": { - "litellm_provider": "novita", - "mode": "chat", - "input_cost_per_token": 2.8e-04, - "output_cost_per_token": 1.104e-03, - "max_input_tokens": 65536, - "max_output_tokens": 8000, - "max_tokens": 8000, - "supports_vision": true, - "supports_system_messages": true, - "supports_reasoning": true + "supports_system_messages": true }, "novita/baidu/ernie-4.5-vl-424b-a47b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.36e-03, - "output_cost_per_token": 1e-02, + "input_cost_per_token": 4.2e-7, + "output_cost_per_token": 1.25e-06, "max_input_tokens": 123000, "max_output_tokens": 16000, "max_tokens": 16000, @@ -33321,8 +33347,8 @@ "novita/baidu/ernie-4.5-300b-a47b-paddle": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.24e-03, - "output_cost_per_token": 8.8e-03, + "input_cost_per_token": 2.8e-7, + "output_cost_per_token": 1.1e-06, "max_input_tokens": 123000, "max_output_tokens": 12000, "max_tokens": 12000, @@ -33333,8 +33359,8 @@ "novita/deepseek/deepseek-prover-v2-671b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-03, - "output_cost_per_token": 2e-02, + "input_cost_per_token": 7e-7, + "output_cost_per_token": 2.5e-06, "max_input_tokens": 160000, "max_output_tokens": 160000, "max_tokens": 160000, @@ -33343,8 +33369,8 @@ "novita/qwen/qwen3-32b-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 8e-04, - "output_cost_per_token": 3.6e-03, + "input_cost_per_token": 1e-7, + "output_cost_per_token": 4.5e-7, "max_input_tokens": 40960, "max_output_tokens": 20000, "max_tokens": 20000, @@ -33354,8 +33380,8 @@ "novita/qwen/qwen3-30b-a3b-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 7.2e-04, - "output_cost_per_token": 3.6e-03, + "input_cost_per_token": 9e-8, + "output_cost_per_token": 4.5e-7, "max_input_tokens": 40960, "max_output_tokens": 20000, "max_tokens": 20000, @@ -33365,8 +33391,8 @@ "novita/google/gemma-3-27b-it": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 9.52e-04, - "output_cost_per_token": 1.6e-03, + "input_cost_per_token": 1.19e-7, + "output_cost_per_token": 2e-7, "max_input_tokens": 98304, "max_output_tokens": 16384, "max_tokens": 16384, @@ -33376,8 +33402,8 @@ "novita/deepseek/deepseek-v3-turbo": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.2e-03, - "output_cost_per_token": 1.04e-02, + "input_cost_per_token": 4e-7, + "output_cost_per_token": 1.3e-06, "max_input_tokens": 64000, "max_output_tokens": 16000, "max_tokens": 16000, @@ -33389,8 +33415,8 @@ "novita/deepseek/deepseek-r1-turbo": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-03, - "output_cost_per_token": 2e-02, + "input_cost_per_token": 7e-7, + "output_cost_per_token": 2.5e-06, "max_input_tokens": 64000, "max_output_tokens": 16000, "max_tokens": 16000, @@ -33403,8 +33429,8 @@ "novita/Sao10K/L3-8B-Stheno-v3.2": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5e-04, - "output_cost_per_token": 5e-04, + "input_cost_per_token": 5e-8, + "output_cost_per_token": 5e-8, "max_input_tokens": 8192, "max_output_tokens": 32000, "max_tokens": 32000, @@ -33416,8 +33442,8 @@ "novita/gryphe/mythomax-l2-13b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 7.2e-04, - "output_cost_per_token": 7.2e-04, + "input_cost_per_token": 9e-8, + "output_cost_per_token": 9e-8, "max_input_tokens": 4096, "max_output_tokens": 3200, "max_tokens": 3200, @@ -33426,8 +33452,8 @@ "novita/baidu/ernie-4.5-vl-28b-a3b-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 3.9e-03, - "output_cost_per_token": 3.9e-03, + "input_cost_per_token": 3.9e-7, + "output_cost_per_token": 3.9e-7, "max_input_tokens": 131072, "max_output_tokens": 65536, "max_tokens": 65536, @@ -33442,8 +33468,8 @@ "novita/qwen/qwen3-vl-8b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 6.4e-04, - "output_cost_per_token": 4e-03, + "input_cost_per_token": 8e-8, + "output_cost_per_token": 5e-7, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33457,8 +33483,8 @@ "novita/zai-org/glm-4.5-air": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.04e-03, - "output_cost_per_token": 6.8e-03, + "input_cost_per_token": 1.3e-7, + "output_cost_per_token": 8.5e-7, "max_input_tokens": 131072, "max_output_tokens": 98304, "max_tokens": 98304, @@ -33471,8 +33497,8 @@ "novita/qwen/qwen3-vl-30b-a3b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.6e-03, - "output_cost_per_token": 5.6e-03, + "input_cost_per_token": 2e-7, + "output_cost_per_token": 7e-7, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33486,8 +33512,8 @@ "novita/qwen/qwen3-vl-30b-a3b-thinking": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.6e-03, - "output_cost_per_token": 8e-03, + "input_cost_per_token": 2e-7, + "output_cost_per_token": 1e-06, "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, @@ -33498,11 +33524,45 @@ "supports_system_messages": true, "supports_response_schema": true }, + "novita/qwen/qwen3-omni-30b-a3b-thinking": { + "litellm_provider": "novita", + "mode": "chat", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 9.7e-7, + "max_input_tokens": 65536, + "max_output_tokens": 16384, + "max_tokens": 16384, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_audio_input": true + }, + "novita/qwen/qwen3-omni-30b-a3b-instruct": { + "litellm_provider": "novita", + "mode": "chat", + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 9.7e-7, + "max_input_tokens": 65536, + "max_output_tokens": 16384, + "max_tokens": 16384, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_response_schema": true, + "supports_audio_input": true, + "supports_audio_output": true + }, "novita/qwen/qwen-mt-plus": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2e-03, - "output_cost_per_token": 6e-03, + "input_cost_per_token": 2.5e-7, + "output_cost_per_token": 7.5e-7, "max_input_tokens": 16384, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33511,8 +33571,8 @@ "novita/baidu/ernie-4.5-vl-28b-a3b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.12e-03, - "output_cost_per_token": 4.48e-03, + "input_cost_per_token": 1.4e-7, + "output_cost_per_token": 5.6e-7, "max_input_tokens": 30000, "max_output_tokens": 8000, "max_tokens": 8000, @@ -33526,8 +33586,8 @@ "novita/baidu/ernie-4.5-21B-a3b": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-04, - "output_cost_per_token": 2.24e-03, + "input_cost_per_token": 7e-8, + "output_cost_per_token": 2.8e-7, "max_input_tokens": 120000, "max_output_tokens": 8000, "max_tokens": 8000, @@ -33539,8 +33599,8 @@ "novita/qwen/qwen3-8b-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.8e-04, - "output_cost_per_token": 1.104e-03, + "input_cost_per_token": 3.5e-8, + "output_cost_per_token": 1.38e-7, "max_input_tokens": 128000, "max_output_tokens": 20000, "max_tokens": 20000, @@ -33550,8 +33610,8 @@ "novita/qwen/qwen3-4b-fp8": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-04, - "output_cost_per_token": 2.4e-04, + "input_cost_per_token": 3e-8, + "output_cost_per_token": 3e-8, "max_input_tokens": 128000, "max_output_tokens": 20000, "max_tokens": 20000, @@ -33561,8 +33621,8 @@ "novita/qwen/qwen2.5-7b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 5.6e-04, - "output_cost_per_token": 5.6e-04, + "input_cost_per_token": 7e-8, + "output_cost_per_token": 7e-8, "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, @@ -33575,8 +33635,8 @@ "novita/meta-llama/llama-3.2-3b-instruct": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 2.4e-04, - "output_cost_per_token": 4e-04, + "input_cost_per_token": 3e-8, + "output_cost_per_token": 5e-8, "max_input_tokens": 32768, "max_output_tokens": 32000, "max_tokens": 32000, @@ -33588,8 +33648,8 @@ "novita/sao10k/l31-70b-euryale-v2.2": { "litellm_provider": "novita", "mode": "chat", - "input_cost_per_token": 1.48e-02, - "output_cost_per_token": 1.48e-02, + "input_cost_per_token": 1.48e-06, + "output_cost_per_token": 1.48e-06, "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, @@ -33601,7 +33661,7 @@ "novita/qwen/qwen3-embedding-0.6b": { "litellm_provider": "novita", "mode": "embedding", - "input_cost_per_token": 5.6e-04, + "input_cost_per_token": 7e-8, "output_cost_per_token": 0, "max_input_tokens": 32768, "max_output_tokens": 32768, @@ -33610,7 +33670,7 @@ "novita/qwen/qwen3-embedding-8b": { "litellm_provider": "novita", "mode": "embedding", - "input_cost_per_token": 5.6e-04, + "input_cost_per_token": 7e-8, "output_cost_per_token": 0, "max_input_tokens": 32768, "max_output_tokens": 4096, @@ -33619,8 +33679,8 @@ "novita/baai/bge-m3": { "litellm_provider": "novita", "mode": "embedding", - "input_cost_per_token": 1e-04, - "output_cost_per_token": 1e-04, + "input_cost_per_token": 1e-8, + "output_cost_per_token": 1e-8, "max_input_tokens": 8192, "max_output_tokens": 96000, "max_tokens": 96000 @@ -33628,8 +33688,8 @@ "novita/qwen/qwen3-reranker-8b": { "litellm_provider": "novita", "mode": "rerank", - "input_cost_per_token": 4e-04, - "output_cost_per_token": 4e-04, + "input_cost_per_token": 5e-8, + "output_cost_per_token": 5e-8, "max_input_tokens": 32768, "max_output_tokens": 4096, "max_tokens": 4096 @@ -33637,8 +33697,8 @@ "novita/baai/bge-reranker-v2-m3": { "litellm_provider": "novita", "mode": "rerank", - "input_cost_per_token": 1e-04, - "output_cost_per_token": 1e-04, + "input_cost_per_token": 1e-8, + "output_cost_per_token": 1e-8, "max_input_tokens": 8000, "max_output_tokens": 8000, "max_tokens": 8000 diff --git a/scripts/mock_grayswan_timeout_server.py b/scripts/mock_grayswan_timeout_server.py new file mode 100644 index 00000000000..bc40e13dfa5 --- /dev/null +++ b/scripts/mock_grayswan_timeout_server.py @@ -0,0 +1,85 @@ +""" +Minimal mock GraySwan monitor server that intentionally responds slowly. + +Usage: + python scripts/mock_grayswan_timeout_server.py --port 8787 --delay 35 + +Point GRAYSWAN_API_BASE at http://127.0.0.1:8787 so the guardrail hits this +endpoint and times out (the guardrail client has a 30s timeout). +""" + +from __future__ import annotations + +import argparse +import json +import logging +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Optional + +LOG = logging.getLogger("mock_grayswan") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + + +class SlowHandler(BaseHTTPRequestHandler): + delay_seconds: float = 35.0 + + def log_message(self, fmt: str, *args) -> None: # noqa: D401 + """Route handler logs through the logging module.""" + LOG.info("%s - %s", self.address_string(), fmt % args) + + def _read_body(self) -> Optional[bytes]: + content_length = self.headers.get("content-length") + if content_length is None: + return None + try: + length = int(content_length) + except ValueError: + return None + return self.rfile.read(length) + + def do_POST(self) -> None: # noqa: N802 + if self.path != "/cygnal/monitor": + self.send_error(404, "Not Found") + return + + body = self._read_body() + LOG.info("Received POST %s body=%s", self.path, body) + + LOG.info("Sleeping for %.1fs to trigger client timeout", self.delay_seconds) + time.sleep(self.delay_seconds) + + response = {"status": "ok", "delayed": self.delay_seconds} + response_bytes = json.dumps(response).encode("utf-8") + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response_bytes))) + self.end_headers() + self.wfile.write(response_bytes) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Mock GraySwan monitor server") + parser.add_argument("--port", type=int, default=8787, help="Port to listen on") + parser.add_argument( + "--delay", + type=float, + default=35.0, + help="Seconds to delay before responding (must exceed guardrail timeout)", + ) + args = parser.parse_args() + + SlowHandler.delay_seconds = args.delay + server = HTTPServer(("0.0.0.0", args.port), SlowHandler) + LOG.info("Starting mock server on port %d with delay %.1fs", args.port, args.delay) + try: + server.serve_forever() + except KeyboardInterrupt: + LOG.info("Shutting down mock server") + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tests/code_coverage_tests/check_get_model_cost_key_performance.py b/tests/code_coverage_tests/check_get_model_cost_key_performance.py new file mode 100644 index 00000000000..09a64fd71db --- /dev/null +++ b/tests/code_coverage_tests/check_get_model_cost_key_performance.py @@ -0,0 +1,200 @@ +""" +Code quality check: Ensure _get_model_cost_key only uses O(1) operations. + +Simple pattern-based check for O(n) operations in _get_model_cost_key. +""" + +import re +import os + + +def _function_has_on_operations(all_lines, func_name, visited=None): + """ + Check if a function contains O(n) operations by searching for it in the file. + Recursively checks called functions as well. + """ + if visited is None: + visited = set() + + # Prevent infinite recursion + if func_name in visited: + return False + visited.add(func_name) + + func_start = None + func_end = None + + for i, line in enumerate(all_lines): + if func_start is None and f'def {func_name}(' in line: + func_start = i + elif func_start is not None: + # Function ends when we hit next def at module level + if line.strip() and not line.startswith(' ') and not line.startswith('\t') and line.startswith('def '): + func_end = i + break + + if func_start is None or func_end is None: + return False + + # Check function body for O(n) patterns + func_lines = all_lines[func_start:func_end] + + for line in func_lines: + # Skip comments and docstrings + line_stripped = line.strip() + if line_stripped.startswith('#') or line_stripped.startswith('"""') or line_stripped.startswith("'''"): + continue + + # Check for for loops + if re.search(r'\bfor\s+\w+\s+in\s+', line): + return True + # Check for while loops + if re.search(r'\bwhile\s+', line): + return True + # Check for comprehensions + if re.search(r'\[.*\s+for\s+.*\s+in\s+', line) or re.search(r'\{.*\s+for\s+.*\s+in\s+', line): + return True + + # Recursively check called functions (check all, don't skip any in recursive checks) + func_call_match = re.search(r'\b([a-z_][a-z0-9_]*)\s*\(', line) + if func_call_match: + called_func = func_call_match.group(1) + if called_func.startswith('_'): + if _function_has_on_operations(all_lines, called_func, visited): + return True + + return False + + +def check_get_model_cost_key_performance(): + """ + Check that _get_model_cost_key doesn't contain O(n) operations. + """ + utils_file = "./litellm/utils.py" + + if not os.path.exists(utils_file): + print(f"Warning: File {utils_file} does not exist.") + return [] + + with open(utils_file, "r", encoding="utf-8") as f: + lines = f.readlines() + + # Find the _get_model_cost_key function + func_start = None + func_end = None + + for i, line in enumerate(lines): + if func_start is None and 'def _get_model_cost_key(' in line: + func_start = i + elif func_start is not None: + # Function ends when we hit next def at module level (no indentation) + if line.strip() and not line.startswith(' ') and not line.startswith('\t') and line.startswith('def '): + func_end = i + break + + if func_start is None: + print("Warning: Could not find _get_model_cost_key function") + return [] + + if func_end is None: + func_end = len(lines) + + # Extract function body + func_lines = lines[func_start:func_end] + problematic_lines = [] + + # Track if we're inside a docstring + in_docstring = False + docstring_quote = None + + # Check for O(n) patterns + for i, line in enumerate(func_lines, start=func_start + 1): + line_stripped = line.strip() + + # Track docstring state (handle both single-line and multi-line docstrings) + if not in_docstring: + if line_stripped.startswith('"""') or line_stripped.startswith("'''"): + docstring_quote = '"""' if line_stripped.startswith('"""') else "'''" + # Check if it's a single-line docstring + if line_stripped.count(docstring_quote) >= 2: + in_docstring = False # Single-line, skip this line + continue + else: + in_docstring = True + continue + else: + # Inside multi-line docstring, check for closing quote + if docstring_quote is not None and docstring_quote in line: + in_docstring = False + docstring_quote = None + continue # Skip all lines inside docstring + + # Skip comments + if line_stripped.startswith('#'): + continue + + # Check for for loops + if re.search(r'\bfor\s+\w+\s+in\s+', line): + # Allow helper function calls (they're conditional) + if not re.search(r'(_rebuild_model_cost_lowercase_map|_handle_stale_map_entry_rebuild|_handle_new_key_with_scan)', line): + problematic_lines.append((i, "for loop", line_stripped)) + + # Check for while loops + if re.search(r'\bwhile\s+', line): + problematic_lines.append((i, "while loop", line_stripped)) + + # Check for comprehensions + if re.search(r'\[.*\s+for\s+.*\s+in\s+', line) or re.search(r'\{.*\s+for\s+.*\s+in\s+', line): + problematic_lines.append((i, "comprehension", line_stripped)) + + # Check for problematic function calls + problematic_funcs = ['enumerate', 'zip', 'map', 'filter', 'sorted', 'any', 'all', 'sum', 'max', 'min'] + for func in problematic_funcs: + if re.search(rf'\b{func}\s*\(', line): + problematic_lines.append((i, f"call to {func}()", line_stripped)) + + # Check for calls to functions that might have O(n) operations + # Allow known helper functions that are conditional + allowed_helpers = [ + '_rebuild_model_cost_lowercase_map', + '_handle_stale_map_entry_rebuild', + '_handle_new_key_with_scan', + ] + + # Check for function calls (pattern: function_name(...), but not function definitions) + # Skip function definitions (def function_name(...)) + if not re.search(r'\bdef\s+', line): + func_call_match = re.search(r'\b([a-z_][a-z0-9_]*)\s*\(', line) + if func_call_match: + func_name = func_call_match.group(1) + # If it's a call to a function that might have O(n) operations, check it + if func_name not in allowed_helpers and func_name.startswith('_'): + # Check if this function has O(n) operations + if _function_has_on_operations(lines, func_name): + problematic_lines.append((i, f"call to {func_name}() which contains O(n) operations", line_stripped)) + + return problematic_lines + + +def main(): + """Main function to check _get_model_cost_key performance requirements.""" + problematic_lines = check_get_model_cost_key_performance() + + if problematic_lines: + print("\nERROR: Found O(n) operations in _get_model_cost_key:") + for line_num, operation, context in problematic_lines: + print(f" Line {line_num}: {operation} - {context}") + + print("\nWARNING: Only O(1) lookup operations are acceptable in _get_model_cost_key.") + print("Any O(n) operations will cause severe CPU overhead.") + + raise Exception( + f"Found {len(problematic_lines)} O(n) operation(s) in _get_model_cost_key. " + f"This violates the performance requirement." + ) + else: + print("OK: No O(n) operations found in _get_model_cost_key. Performance requirement satisfied.") + + +if __name__ == "__main__": + main() diff --git a/tests/llm_translation/test_azure_ai.py b/tests/llm_translation/test_azure_ai.py index 8d1ebd95904..1633fb4cc82 100644 --- a/tests/llm_translation/test_azure_ai.py +++ b/tests/llm_translation/test_azure_ai.py @@ -31,6 +31,8 @@ import pytest import litellm from litellm import completion +from litellm.integrations.custom_logger import CustomLogger +from litellm.types.utils import StandardLoggingPayload @pytest.mark.parametrize( @@ -364,4 +366,150 @@ def test_completion_azure_ai_gpt_4o_with_flexible_api_base(api_base): except litellm.Timeout as e: pass except Exception as e: - pytest.fail(f"Error occurred: {e}") \ No newline at end of file + pytest.fail(f"Error occurred: {e}") + + +@pytest.mark.asyncio +async def test_azure_ai_model_router(): + """ + Test Azure AI model router non-streaming response cost tracking. + """ + litellm._turn_on_debug() + response = await litellm.acompletion( + model="azure_ai/azure-model-router", + messages=[{"role": "user", "content": "hi who is this"}], + api_base="https://ishaa-mh6uutut-swedencentral.cognitiveservices.azure.com/openai/v1/", + api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), + ) + print("response: ", response) + + # Check response cost + tracked_cost = response._hidden_params["response_cost"] + assert tracked_cost > 0 + print("Tracked cost: ", tracked_cost) + + +@pytest.mark.asyncio +async def test_azure_ai_model_router_streaming_model_in_chunk(): + """ + Test that Azure AI model router streaming returns the actual model in each chunk. + The response should contain the actual model used (e.g., gpt-4.1-nano) not the request model (azure-model-router). + """ + litellm._turn_on_debug() + response = await litellm.acompletion( + model="azure_ai/azure-model-router", + messages=[{"role": "user", "content": "hi"}], + api_base="https://ishaa-mh6uutut-swedencentral.cognitiveservices.azure.com/openai/v1/", + api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), + stream=True, + ) + + # Collect chunks and check model field + chunks_with_model = [] + async for chunk in response: + print(f"Chunk model: {chunk.model}") + if chunk.model and chunk.model.strip(): + chunks_with_model.append(chunk.model) + + print(f"All chunk models: {chunks_with_model}") + + # At least some chunks should have a model + assert len(chunks_with_model) > 0, "No chunks had a model field set" + + # The model should NOT be azure-model-router (the request model) + # It should be the actual model from the response (e.g., gpt-4.1-nano, gpt-5-nano, etc.) + for model in chunks_with_model: + assert model != "azure-model-router", f"Chunk model should be actual model, not request model. Got: {model}" + # The actual model should be a real model name like gpt-4.1-nano, gpt-5-nano, etc. + print(f"Verified chunk has actual model: {model}") + + +class AzureModelRouterStreamingCallback(litellm.integrations.custom_logger.CustomLogger): + """ + Custom callback to capture streaming cost tracking for Azure Model Router. + """ + def __init__(self): + self.standard_logging_payload = None + self.response_cost = None + self.async_success_called = False + self.complete_streaming_response = None + super().__init__() + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + print(f"async_log_success_event called") + self.async_success_called = True + self.standard_logging_payload = kwargs.get("standard_logging_object") + self.complete_streaming_response = kwargs.get("complete_streaming_response") + + if self.standard_logging_payload: + self.response_cost = self.standard_logging_payload.get("response_cost") + print(f"standard_logging_payload model: {self.standard_logging_payload.get('model')}") + print(f"standard_logging_payload response_cost: {self.response_cost}") + + if self.complete_streaming_response: + print(f"complete_streaming_response model: {self.complete_streaming_response.model}") + print(f"complete_streaming_response usage: {self.complete_streaming_response.usage}") + + + + +@pytest.mark.asyncio +async def test_azure_ai_model_router_streaming_cost_with_stream_options(): + """ + Test Azure AI model router streaming cost tracking with stream_options include_usage=True. + This tests the specific case where cost tracking fails with stream_options. + """ + litellm.logging_callback_manager._reset_all_callbacks() + test_callback = AzureModelRouterStreamingCallback() + litellm.callbacks = [test_callback] + + try: + litellm._turn_on_debug() + response = await litellm.acompletion( + model="azure_ai/azure-model-router", + messages=[{"role": "user", "content": "hi"}], + api_base="https://ishaa-mh6uutut-swedencentral.cognitiveservices.azure.com/openai/v1/", + api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), + stream=True, + stream_options={"include_usage": True}, + ) + + # Consume the stream and check chunks + full_response = "" + chunks_with_model = [] + async for chunk in response: + print(f"Chunk: model={chunk.model}, choices={len(chunk.choices) if chunk.choices else 0}") + if chunk.model: + chunks_with_model.append(chunk.model) + if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content: + full_response += chunk.choices[0].delta.content + + print(f"Full streamed response: {full_response}") + print(f"Chunks with model: {chunks_with_model}") + + # Give async logging time to complete + import asyncio + await asyncio.sleep(1) + + # Verify callback was called + assert test_callback.async_success_called is True, "async_log_success_event was not called" + assert test_callback.standard_logging_payload is not None, "standard_logging_payload is None" + + # Check response cost + print(f"Final response_cost: {test_callback.response_cost}") + + # The first chunk may have the request model (azure-model-router) because it's created + # before the API response is received. Subsequent chunks should have the actual model. + # At least some chunks should have the actual model (not azure-model-router) + actual_model_chunks = [m for m in chunks_with_model if m != "azure-model-router"] + assert len(actual_model_chunks) > 0, "No chunks had the actual model from the API response" + print(f"Chunks with actual model: {actual_model_chunks}") + + # Verify response cost is tracked - this is the main goal of this test + assert test_callback.response_cost is not None, "response_cost is None with stream_options" + assert test_callback.response_cost > 0, f"response_cost should be > 0, got {test_callback.response_cost}" + print(f"Streaming cost tracking with stream_options passed. Cost: {test_callback.response_cost}") + + finally: + litellm.logging_callback_manager._reset_all_callbacks() + litellm.callbacks = [] \ No newline at end of file diff --git a/tests/local_testing/test_get_model_info.py b/tests/local_testing/test_get_model_info.py index 823f03185d5..b84fc22af01 100644 --- a/tests/local_testing/test_get_model_info.py +++ b/tests/local_testing/test_get_model_info.py @@ -372,3 +372,86 @@ def test_get_model_info_cost_calculator_bedrock_region_cris_stripped(model, prov print("info", info) assert info["key"] == "us.anthropic.claude-3-haiku-20240307-v1:0" assert info["litellm_provider"] == "bedrock" + + +def test_get_model_info_case_insensitive_lookup(monkeypatch): + """ + Test that model info lookup is case-insensitive. + + This ensures that users can use lowercase model names even when the model cost + map has mixed-case keys (e.g., "Qwen/Qwen3-Next-80B-A3B-Thinking"). + + Related Slack discussion: Users were getting "does not support parameters: ['tools']" + errors when using lowercase model names like "qwen/qwen3-next-80b-a3b-thinking" + because the lookup was case-sensitive. + """ + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + litellm.model_cost = litellm.get_model_cost_map(url="") + + # Register a test model with mixed-case name + litellm.register_model( + { + "together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking": { + "input_cost_per_token": 0.0001, + "output_cost_per_token": 0.0002, + "litellm_provider": "together_ai", + "supports_function_calling": True, + } + } + ) + + # Test 1: Exact case should work + info = litellm.get_model_info( + model="Qwen/Qwen3-Next-80B-A3B-Thinking", custom_llm_provider="together_ai" + ) + assert info is not None + assert info["supports_function_calling"] is True + + # Test 2: Lowercase should also work (case-insensitive lookup) + info_lower = litellm.get_model_info( + model="qwen/qwen3-next-80b-a3b-thinking", custom_llm_provider="together_ai" + ) + assert info_lower is not None + assert info_lower["supports_function_calling"] is True + + # Test 3: Mixed case should also work + info_mixed = litellm.get_model_info( + model="QWEN/qwen3-NEXT-80b-a3b-thinking", custom_llm_provider="together_ai" + ) + assert info_mixed is not None + assert info_mixed["supports_function_calling"] is True + + +def test_get_model_info_case_insensitive_supports_function_calling(monkeypatch): + """ + Test that supports_function_calling check works with case-insensitive model lookup. + """ + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + litellm.model_cost = litellm.get_model_cost_map(url="") + + # Register a model with mixed-case name that supports function calling + litellm.register_model( + { + "test_provider/TestModel-ABC": { + "input_cost_per_token": 0.0001, + "output_cost_per_token": 0.0002, + "litellm_provider": "test_provider", + "supports_function_calling": True, + } + } + ) + + # Test that supports_function_calling works with lowercase model name + from litellm.utils import supports_function_calling + + # Exact case + assert ( + supports_function_calling("TestModel-ABC", custom_llm_provider="test_provider") + is True + ) + + # Lowercase (should now work with case-insensitive lookup) + assert ( + supports_function_calling("testmodel-abc", custom_llm_provider="test_provider") + is True + ) diff --git a/tests/local_testing/test_router_pattern_matching.py b/tests/local_testing/test_router_pattern_matching.py index 5695034c7a5..7f38d14c480 100644 --- a/tests/local_testing/test_router_pattern_matching.py +++ b/tests/local_testing/test_router_pattern_matching.py @@ -314,3 +314,79 @@ def test_calculate_pattern_specificity(): assert PatternUtils.calculate_pattern_specificity("llmengine/*") == (11, 1) assert PatternUtils.calculate_pattern_specificity("*") == (1, 1) + + +def test_wildcard_priority_over_deployment_names(): + """ + Test that wildcard routes take priority over deployment_names (litellm_params.model) matching. + + Scenario: + - deployment 1: model_name="zapier-multi-provider-text-embedding-3-small", model="openai/text-embedding-3-small" + - deployment 2: model_name="*", model="openai/*" + - deployment 3: model_name="openai/*", model="openai/*" + + When calling "openai/text-embedding-3-small", it should match deployment 3 (wildcard), + NOT deployment 1 (even though deployment 1's litellm_params.model matches). + + Priority order should be: + 1. Exact model_name match + 2. Wildcard model_name match + 3. deployment_names (litellm_params.model) match + """ + router = Router( + model_list=[ + { + "model_name": "zapier-multi-provider-text-embedding-3-small", + "litellm_params": { + "model": "openai/text-embedding-3-small", + "api_base": "http://localhost:8080/openai", + "api_key": "test-key-1" + }, + "model_info": { + "id": "zapier-multi-provider-text-embedding-3-small-openai" + } + }, + { + "model_name": "*", + "litellm_params": { + "model": "openai/*", + "api_base": "http://localhost:8081/openai", + "api_key": "test-key-2" + } + }, + { + "model_name": "openai/*", + "litellm_params": { + "model": "openai/*", + "api_base": "http://localhost:8082/openai", + "api_key": "test-key-3" + } + } + ] + ) + + # Test 1: Request "openai/text-embedding-3-small" should match wildcard "openai/*", not deployment_names + deployments = router.get_model_list(model_name="openai/text-embedding-3-small") + + assert deployments is not None, "No deployments found" + assert len(deployments) == 1, f"Expected 1 deployment, got {len(deployments)}" + + # Should match the "openai/*" wildcard deployment (api_base ending in 8082) + assert deployments[0]['litellm_params']['api_base'] == "http://localhost:8082/openai", \ + f"Expected wildcard deployment (8082), got {deployments[0]['litellm_params']['api_base']}" + + # Test 2: Request exact model_name should still work + deployments = router.get_model_list(model_name="zapier-multi-provider-text-embedding-3-small") + + assert deployments is not None, "No deployments found" + assert len(deployments) == 1, f"Expected 1 deployment, got {len(deployments)}" + assert deployments[0]['litellm_params']['api_base'] == "http://localhost:8080/openai", \ + f"Expected exact match deployment (8080), got {deployments[0]['litellm_params']['api_base']}" + + # Test 3: Request with "*" wildcard should match the "*" deployment + deployments = router.get_model_list(model_name="some-random-model") + + assert deployments is not None, "No deployments found" + assert len(deployments) == 1, f"Expected 1 deployment, got {len(deployments)}" + assert deployments[0]['litellm_params']['api_base'] == "http://localhost:8081/openai", \ + f"Expected '*' wildcard deployment (8081), got {deployments[0]['litellm_params']['api_base']}" diff --git a/tests/logging_callback_tests/test_otel_logging.py b/tests/logging_callback_tests/test_otel_logging.py index 3350c6c2dbd..b53be44fe0f 100644 --- a/tests/logging_callback_tests/test_otel_logging.py +++ b/tests/logging_callback_tests/test_otel_logging.py @@ -27,6 +27,9 @@ exporter = InMemorySpanExporter() @pytest.mark.parametrize("streaming", [True, False]) async def test_async_otel_callback(streaming): litellm.set_verbose = True + + # Clear exporter at the start to ensure clean state + exporter.clear() litellm.callbacks = [OpenTelemetry(config=OpenTelemetryConfig(exporter=exporter))] @@ -83,9 +86,9 @@ def validate_litellm_request(span): "llm.user", "gen_ai.response.id", "gen_ai.response.model", - "llm.usage.total_tokens", - "gen_ai.usage.completion_tokens", - "gen_ai.usage.prompt_tokens", + "gen_ai.usage.total_tokens", + "gen_ai.usage.output_tokens", + "gen_ai.usage.input_tokens", ] # get the str of all the span attributes @@ -149,6 +152,10 @@ async def test_awesome_otel_with_message_logging_off(streaming, global_redact): tests when OpenTelemetry(message_logging=False) is set """ litellm.set_verbose = True + + # Clear exporter at the start to ensure clean state + exporter.clear() + litellm.callbacks = [OpenTelemetry(config=OpenTelemetryConfig(exporter=exporter))] if global_redact is False: otel_logger = OpenTelemetry( @@ -201,9 +208,9 @@ def validate_redacted_message_span_attributes(span): "llm.request.type", "gen_ai.response.id", "gen_ai.response.model", - "llm.usage.total_tokens", - "gen_ai.usage.completion_tokens", - "gen_ai.usage.prompt_tokens", + "gen_ai.usage.total_tokens", + "gen_ai.usage.output_tokens", + "gen_ai.usage.input_tokens", ] _all_attributes = set( @@ -230,6 +237,8 @@ def validate_redacted_message_span_attributes(span): attr.startswith("metadata.") or attr.startswith("hidden_params") or attr.startswith("gen_ai.cost.") + or attr.startswith("gen_ai.operation.") + or attr.startswith("gen_ai.request.") ), f"Non-metadata attribute found: {attr}" pass diff --git a/tests/logging_callback_tests/test_standard_logging_payload.py b/tests/logging_callback_tests/test_standard_logging_payload.py index 1c01ab91938..4ead642c462 100644 --- a/tests/logging_callback_tests/test_standard_logging_payload.py +++ b/tests/logging_callback_tests/test_standard_logging_payload.py @@ -80,6 +80,53 @@ def test_get_usage(response_obj, expected_values): assert usage.total_tokens == expected_values[2] +def test_get_usage_from_image_generation_response(): + """ + Test that image generation usage (with input_tokens/output_tokens format) + is correctly transformed to standard usage format with image_tokens preserved. + + Note: get_usage_from_response_obj() is used by multiple endpoints including + /images/generations and Response API (/responses), both of which use the + input_tokens/output_tokens format instead of prompt_tokens/completion_tokens. + + This tests the fix for the bug where image_tokens were being lost during + spend log creation for /images/generations endpoint. + """ + # Simulating image generation response usage from OpenAI + response_obj = { + "usage": { + "input_tokens": 13, + "output_tokens": 372, + "total_tokens": 385, + "input_tokens_details": { + "image_tokens": 0, + "text_tokens": 13, + }, + "output_tokens_details": { + "image_tokens": 272, + "text_tokens": 100, + }, + } + } + + usage = StandardLoggingPayloadSetup.get_usage_from_response_obj(response_obj) + + # Check basic token counts are mapped correctly + assert usage.prompt_tokens == 13 + assert usage.completion_tokens == 372 + assert usage.total_tokens == 385 + + # Check that prompt_tokens_details contains image_tokens and text_tokens + assert usage.prompt_tokens_details is not None + assert usage.prompt_tokens_details.image_tokens == 0 + assert usage.prompt_tokens_details.text_tokens == 13 + + # Check that completion_tokens_details contains image_tokens and text_tokens + assert usage.completion_tokens_details is not None + assert usage.completion_tokens_details.image_tokens == 272 + assert usage.completion_tokens_details.text_tokens == 100 + + def test_get_additional_headers(): additional_headers = { "x-ratelimit-limit-requests": "2000", diff --git a/tests/mcp_tests/test_mcp_logging.py b/tests/mcp_tests/test_mcp_logging.py index d27626dd447..aeebb2e913e 100644 --- a/tests/mcp_tests/test_mcp_logging.py +++ b/tests/mcp_tests/test_mcp_logging.py @@ -13,11 +13,14 @@ import litellm from litellm.types.utils import StandardLoggingPayload from litellm.integrations.custom_logger import CustomLogger from litellm.proxy._experimental.mcp_server.server import ( - mcp_server_tool_call, + mcp_server_tool_call, + set_auth_context, ) from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - MCPServerManager, + MCPServerManager, ) +from litellm.proxy.proxy_server import LiteLLM_ObjectPermissionTable +from litellm.proxy._types import UserAPIKeyAuth from litellm.types.mcp import MCPPostCallResponseObject from litellm.types.utils import HiddenParams from mcp.types import Tool as MCPTool, CallToolResult, TextContent @@ -34,6 +37,20 @@ class TestMCPLogger(CustomLogger): print(f"Captured standard_logging_payload: {self.standard_logging_payload}") +def _set_authorized_user(server_ids): + """Configure auth context with permission to call the specified servers.""" + server_list = list(server_ids) + user_auth = UserAPIKeyAuth( + api_key="test", + user_id="test_user", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="mcp-test-permissions", + mcp_servers=server_list, + ), + ) + set_auth_context(user_api_key_auth=user_auth, mcp_servers=server_list) + + @pytest.mark.asyncio async def test_mcp_cost_tracking(): # Create a mock tool call result @@ -87,6 +104,8 @@ async def test_mcp_cost_tracking(): with patch('litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager', local_mcp_server_manager), \ patch('litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager', local_mcp_server_manager): + _set_authorized_user(local_mcp_server_manager.get_all_mcp_server_ids()) + print("tool_name_to_mcp_server_name_mapping", local_mcp_server_manager.tool_name_to_mcp_server_name_mapping) # Manually add the tool mapping to ensure it's available (since mocking might not capture it properly) @@ -197,6 +216,8 @@ async def test_mcp_cost_tracking_per_tool(): with patch('litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager', local_mcp_server_manager), \ patch('litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager', local_mcp_server_manager): + _set_authorized_user(local_mcp_server_manager.get_all_mcp_server_ids()) + print("tool_name_to_mcp_server_name_mapping", local_mcp_server_manager.tool_name_to_mcp_server_name_mapping) # Test 1: Call expensive_tool - should cost 5.0 @@ -327,6 +348,8 @@ async def test_mcp_tool_call_hook(): with patch('litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager', local_mcp_server_manager), \ patch('litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager', local_mcp_server_manager): + _set_authorized_user(local_mcp_server_manager.get_all_mcp_server_ids()) + print("tool_name_to_mcp_server_name_mapping", local_mcp_server_manager.tool_name_to_mcp_server_name_mapping) # Call mcp tool using the correct separator format (- not /) diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py index 8fb0e80cc39..66dd2bdc6b3 100644 --- a/tests/mcp_tests/test_mcp_server.py +++ b/tests/mcp_tests/test_mcp_server.py @@ -1,6 +1,7 @@ # Create server parameters for stdio connection import os import sys +from litellm.proxy.proxy_server import LiteLLM_ObjectPermissionTable import pytest from unittest.mock import AsyncMock, MagicMock, patch from contextlib import asynccontextmanager @@ -630,8 +631,15 @@ async def test_list_tools_rest_api_server_not_found(): from fastapi import Query from litellm.proxy._types import UserAPIKeyAuth - # Mock UserAPIKeyAuth - mock_user_auth = UserAPIKeyAuth(api_key="test", user_id="test") + # Mock UserAPIKeyAuth with explicit permission to access the requested server id + mock_user_auth = UserAPIKeyAuth( + api_key="test", + user_id="test", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="dummy", + mcp_servers=["non_existent_server_id"], + ), + ) # Mock request mock_request = MagicMock() @@ -704,7 +712,16 @@ async def test_list_tools_rest_api_success(): ) # Mock UserAPIKeyAuth - mock_user_auth = UserAPIKeyAuth(api_key="test", user_id="test") + mock_user_auth = UserAPIKeyAuth( + api_key="test", + user_id="test", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="dummy", + mcp_servers=list( + global_mcp_server_manager.get_all_mcp_server_ids() + ), + ), + ) # Get the server ID server_id = list(global_mcp_server_manager.get_registry().keys())[0] @@ -1718,6 +1735,7 @@ async def test_list_tool_rest_api_with_server_specific_auth(): from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( MCPRequestHandler, ) + from litellm.proxy._types import UserAPIKeyAuth # Create mock request with server-specific auth headers mock_request = MagicMock() @@ -1727,10 +1745,6 @@ async def test_list_tool_rest_api_with_server_specific_auth(): "x-mcp-slack-authorization": "Bearer slack_token", } - # Create mock user_api_key_dict - mock_user_api_key_dict = MagicMock() - mock_user_api_key_dict.user_id = "test_user" - # Mock the MCPRequestHandler methods with patch.object( MCPRequestHandler, "_get_mcp_auth_header_from_headers" @@ -1748,6 +1762,9 @@ async def test_list_tool_rest_api_with_server_specific_auth(): with patch( "litellm.proxy._experimental.mcp_server.rest_endpoints.global_mcp_server_manager" ) as mock_manager: + mock_manager.get_allowed_mcp_servers = AsyncMock( + return_value=["test-server-123"] + ) # Create a mock server mock_server = MagicMock() mock_server.server_id = "test-server-123" @@ -1757,6 +1774,15 @@ async def test_list_tool_rest_api_with_server_specific_auth(): mock_manager.get_mcp_server_by_id.return_value = mock_server + mock_user_api_key_dict = UserAPIKeyAuth( + api_key="test", + user_id="test_user", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="dummy", + mcp_servers=[mock_server.server_id], + ), + ) + # Mock the _get_tools_for_single_server function with patch( "litellm.proxy._experimental.mcp_server.rest_endpoints._get_tools_for_single_server" @@ -1803,6 +1829,7 @@ async def test_list_tool_rest_api_with_default_auth(): from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( MCPRequestHandler, ) + from litellm.proxy._types import UserAPIKeyAuth # Create mock request with default auth header only mock_request = MagicMock() @@ -1811,10 +1838,6 @@ async def test_list_tool_rest_api_with_default_auth(): "x-mcp-authorization": "Bearer default_token", } - # Create mock user_api_key_dict - mock_user_api_key_dict = MagicMock() - mock_user_api_key_dict.user_id = "test_user" - # Mock the MCPRequestHandler methods with patch.object( MCPRequestHandler, "_get_mcp_auth_header_from_headers" @@ -1829,6 +1852,9 @@ async def test_list_tool_rest_api_with_default_auth(): with patch( "litellm.proxy._experimental.mcp_server.rest_endpoints.global_mcp_server_manager" ) as mock_manager: + mock_manager.get_allowed_mcp_servers = AsyncMock( + return_value=["test-server-123"] + ) # Create a mock server mock_server = MagicMock() mock_server.server_id = "test-server-123" @@ -1838,6 +1864,15 @@ async def test_list_tool_rest_api_with_default_auth(): mock_manager.get_mcp_server_by_id.return_value = mock_server + mock_user_api_key_dict = UserAPIKeyAuth( + api_key="test", + user_id="test_user", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="dummy", + mcp_servers=[mock_server.server_id], + ), + ) + # Mock the _get_tools_for_single_server function with patch( "litellm.proxy._experimental.mcp_server.rest_endpoints._get_tools_for_single_server" @@ -1884,6 +1919,7 @@ async def test_list_tool_rest_api_all_servers_with_auth(): from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( MCPRequestHandler, ) + from litellm.proxy._types import UserAPIKeyAuth # Create mock request with server-specific auth headers mock_request = MagicMock() @@ -1893,10 +1929,6 @@ async def test_list_tool_rest_api_all_servers_with_auth(): "x-mcp-slack-authorization": "Bearer slack_token", } - # Create mock user_api_key_dict - mock_user_api_key_dict = MagicMock() - mock_user_api_key_dict.user_id = "test_user" - # Mock the MCPRequestHandler methods with patch.object( MCPRequestHandler, "_get_mcp_auth_header_from_headers" @@ -1929,6 +1961,23 @@ async def test_list_tool_rest_api_all_servers_with_auth(): "zapier": mock_zapier_server, "slack": mock_slack_server, } + mock_manager.get_allowed_mcp_servers = AsyncMock( + return_value=["zapier", "slack"] + ) + mock_manager.get_mcp_server_by_id.side_effect = ( + lambda server_id: mock_manager.get_registry.return_value.get( + server_id + ) + ) + + mock_user_api_key_dict = UserAPIKeyAuth( + api_key="test", + user_id="test_user", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="dummy", + mcp_servers=["zapier", "slack"], + ), + ) # Mock the _get_tools_for_single_server function with patch( @@ -1971,17 +2020,15 @@ async def test_list_tool_rest_api_all_servers_with_auth(): assert result["tools"][0].name == "send_email" assert result["tools"][1].name == "send_message" - # Verify that _get_tools_for_single_server was called for both servers with correct auth headers + # Verify that _get_tools_for_single_server was called for both servers assert mock_get_tools.call_count == 2 - calls = mock_get_tools.call_args_list + server_auth_map = { + call_args[0][0]: call_args[0][1] + for call_args in mock_get_tools.call_args_list + } - # First call should be for zapier server with zapier auth - assert calls[0][0][0] == mock_zapier_server # server - assert calls[0][0][1] == "Bearer zapier_token" # server_auth_header - - # Second call should be for slack server with slack auth - assert calls[1][0][0] == mock_slack_server # server - assert calls[1][0][1] == "Bearer slack_token" # server_auth_header + assert server_auth_map.get(mock_zapier_server) == "Bearer zapier_token" + assert server_auth_map.get(mock_slack_server) == "Bearer slack_token" @pytest.mark.asyncio diff --git a/tests/proxy_unit_tests/test_proxy_config_unit_test.py b/tests/proxy_unit_tests/test_proxy_config_unit_test.py index a1586ab6bde..36cd08fa5f5 100644 --- a/tests/proxy_unit_tests/test_proxy_config_unit_test.py +++ b/tests/proxy_unit_tests/test_proxy_config_unit_test.py @@ -264,3 +264,55 @@ def test_add_callbacks_invalid_input(): # Cleanup litellm.success_callback = [] litellm.failure_callback = [] + + +@pytest.mark.asyncio +async def test_json_logs_calls_turn_on_json(): + """ + Test that json_logs: true in litellm_settings calls litellm._turn_on_json() + + This is a regression test for the bug where json_logs in config file + would only set the attribute but not actually enable JSON logging. + See: https://github.com/BerriAI/litellm/issues/XXXX + """ + import tempfile + + import yaml + + # Create a temporary config file with json_logs: true + config_content = { + "model_list": [ + { + "model_name": "test-model", + "litellm_params": {"model": "openai/gpt-4", "api_key": "test-key"}, + } + ], + "litellm_settings": {"json_logs": True}, + } + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as temp_file: + yaml.dump(config_content, temp_file) + temp_file_path = temp_file.name + + try: + proxy_config = ProxyConfig() + + # Mock _turn_on_json to track if it gets called + with mock.patch("litellm._turn_on_json") as mock_turn_on_json: + await proxy_config.load_config( + router=None, + config_file_path=temp_file_path, + ) + + # Verify _turn_on_json was called + mock_turn_on_json.assert_called_once() + + # Also verify the attribute was set + assert litellm.json_logs is True + + finally: + # Cleanup + os.unlink(temp_file_path) + litellm.json_logs = False diff --git a/tests/proxy_unit_tests/test_proxy_server.py b/tests/proxy_unit_tests/test_proxy_server.py index 0db1240544a..839d09b52d7 100644 --- a/tests/proxy_unit_tests/test_proxy_server.py +++ b/tests/proxy_unit_tests/test_proxy_server.py @@ -640,10 +640,6 @@ def test_embedding(mock_aembedding, client_no_auth): pre_call_kwargs.get("call_type") == "aembedding" ), f"expected pre_call_hook to receive call_type='aembedding', got {pre_call_kwargs.get('call_type')}" - during_call_kwargs = mock_during_hook.await_args_list[0].kwargs - assert ( - during_call_kwargs.get("call_type") == "embedding" - ), f"expected during_call_hook to receive call_type='embedding', got {during_call_kwargs.get('call_type')}" except Exception as e: pytest.fail(f"LiteLLM Proxy test failed. Exception - {str(e)}") diff --git a/tests/proxy_unit_tests/test_proxy_token_counter.py b/tests/proxy_unit_tests/test_proxy_token_counter.py index a8486358f71..8e1057bb8e1 100644 --- a/tests/proxy_unit_tests/test_proxy_token_counter.py +++ b/tests/proxy_unit_tests/test_proxy_token_counter.py @@ -2,30 +2,42 @@ # 1. Generate a Key, and use it to make a call -import sys, os +import json +import logging +import os +import sys +import tempfile +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest from dotenv import load_dotenv load_dotenv() -import os # this file is to test litellm/proxy sys.path.insert( 0, os.path.abspath("../..") ) # Adds the parent directory to the system path -import pytest, logging + +from fastapi import HTTPException, Request + import litellm -from litellm.proxy.proxy_server import token_counter +from litellm import Router from litellm._logging import verbose_proxy_logger +from litellm.llms.bedrock.common_utils import BedrockError +from litellm.llms.bedrock.count_tokens.bedrock_token_counter import BedrockTokenCounter +from litellm.llms.bedrock.count_tokens.handler import BedrockCountTokensHandler +from litellm.proxy._types import ProxyException, TokenCountRequest +from litellm.proxy.anthropic_endpoints.endpoints import ( + count_tokens as anthropic_count_tokens, +) +from litellm.proxy.proxy_server import token_counter +from litellm.types.utils import TokenCountResponse verbose_proxy_logger.setLevel(level=logging.DEBUG) -from litellm.proxy._types import TokenCountRequest -import json, tempfile - - -from litellm import Router - def get_vertex_ai_creds_json() -> dict: # Define the path to the vertex_key.json file @@ -839,4 +851,411 @@ def test_vertex_ai_partner_models_token_counting_endpoint(vertex_location): if vertex_location == "global": assert endpoint.startswith("https://aiplatform.googleapis.com") else: - assert endpoint.startswith(f"https://{vertex_location}-aiplatform.googleapis.com") \ No newline at end of file + assert endpoint.startswith(f"https://{vertex_location}-aiplatform.googleapis.com") + + +@pytest.mark.asyncio +async def test_bedrock_token_counter_error_propagation_bedrock_error(): + """ + Test that BedrockTokenCounter properly returns error response when BedrockError is raised. + Verifies that the status code and error message are preserved. + """ + counter = BedrockTokenCounter() + + # Mock the handler to raise BedrockError with specific status code + with patch.object( + counter, "count_tokens", wraps=counter.count_tokens + ) as mock_count: + # We need to patch at the handler level + with patch( + "litellm.llms.bedrock.count_tokens.bedrock_token_counter.BedrockCountTokensHandler" + ) as MockHandler: + mock_handler_instance = MockHandler.return_value + mock_handler_instance.handle_count_tokens_request = AsyncMock( + side_effect=BedrockError( + status_code=429, message="Rate limit exceeded" + ) + ) + + result = await counter.count_tokens( + model_to_use="anthropic.claude-3-sonnet", + messages=[{"role": "user", "content": "hello"}], + contents=None, + deployment={"litellm_params": {}}, + request_model="bedrock/anthropic.claude-3-sonnet", + ) + + assert result is not None + assert result.error is True + assert result.status_code == 429 + assert "Rate limit exceeded" in result.error_message + assert result.tokenizer_type == "bedrock_api" + assert result.total_tokens == 0 + + +@pytest.mark.asyncio +async def test_bedrock_token_counter_error_propagation_generic_exception(): + """ + Test that BedrockTokenCounter returns error response with 500 status for generic exceptions. + """ + counter = BedrockTokenCounter() + + with patch( + "litellm.llms.bedrock.count_tokens.bedrock_token_counter.BedrockCountTokensHandler" + ) as MockHandler: + mock_handler_instance = MockHandler.return_value + mock_handler_instance.handle_count_tokens_request = AsyncMock( + side_effect=Exception("Unexpected error") + ) + + result = await counter.count_tokens( + model_to_use="anthropic.claude-3-sonnet", + messages=[{"role": "user", "content": "hello"}], + contents=None, + deployment={"litellm_params": {}}, + request_model="bedrock/anthropic.claude-3-sonnet", + ) + + assert result is not None + assert result.error is True + assert result.status_code == 500 + assert "Unexpected error" in result.error_message + + +@pytest.mark.asyncio +async def test_bedrock_handler_httpx_error_status_code_propagation(): + """ + Test that BedrockCountTokensHandler properly extracts status code from httpx.HTTPStatusError. + """ + handler = BedrockCountTokensHandler() + + # Create a mock httpx response with 403 status + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.text = "Forbidden - Invalid credentials" + + # Create HTTPStatusError + http_error = httpx.HTTPStatusError( + message="Client error '403 Forbidden'", + request=MagicMock(), + response=mock_response, + ) + + with patch.object(handler, "validate_count_tokens_request"): + with patch.object(handler, "_get_aws_region_name", return_value="us-west-2"): + with patch.object( + handler, "transform_anthropic_to_bedrock_count_tokens", return_value={} + ): + with patch.object( + handler, + "get_bedrock_count_tokens_endpoint", + return_value="https://example.com", + ): + with patch.object(handler, "_sign_request", return_value=({}, "{}")): + with patch( + "litellm.llms.bedrock.count_tokens.handler.get_async_httpx_client" + ) as mock_client: + mock_async_client = AsyncMock() + mock_async_client.post = AsyncMock(side_effect=http_error) + mock_client.return_value = mock_async_client + + with pytest.raises(BedrockError) as exc_info: + await handler.handle_count_tokens_request( + request_data={ + "model": "test", + "messages": [ + {"role": "user", "content": "hello"} + ], + }, + litellm_params={}, + resolved_model="anthropic.claude-3-sonnet", + ) + + assert exc_info.value.status_code == 403 + # Message should be the raw response text + assert exc_info.value.message == "Forbidden - Invalid credentials" + + +@pytest.mark.asyncio +async def test_proxy_token_counter_error_raises_exception_when_disabled(): + """ + Test that proxy token_counter raises ProxyException when disable_token_counter=True + and provider returns an error response. + """ + # Create error response + error_response = TokenCountResponse( + total_tokens=0, + request_model="bedrock/anthropic.claude-3-sonnet", + model_used="anthropic.claude-3-sonnet", + tokenizer_type="bedrock_api", + error=True, + error_message="Rate limit exceeded", + status_code=429, + ) + + # Create mock router that returns a deployment + mock_deployment = { + "litellm_params": { + "model": "bedrock/anthropic.claude-3-sonnet", + }, + "model_info": {}, + } + + mock_router = MagicMock() + mock_router.async_get_available_deployment = AsyncMock(return_value=mock_deployment) + + setattr(litellm.proxy.proxy_server, "llm_router", mock_router) + + # Save original value and function + original_disable = litellm.disable_token_counter + original_get_provider_token_counter = litellm.proxy.proxy_server._get_provider_token_counter + + try: + litellm.disable_token_counter = True + + # Create a mock counter that returns an error response + mock_counter = MagicMock(spec=BedrockTokenCounter) + mock_counter.should_use_token_counting_api.return_value = True + mock_counter.count_tokens = AsyncMock(return_value=error_response) + + # Replace the function directly + def mock_get_provider_token_counter(deployment, model_to_use): + return (mock_counter, "anthropic.claude-3-sonnet", "bedrock") + + litellm.proxy.proxy_server._get_provider_token_counter = mock_get_provider_token_counter + + with pytest.raises(ProxyException) as exc_info: + await token_counter( + request=TokenCountRequest( + model="claude-bedrock", + messages=[{"role": "user", "content": "hello"}], + ), + call_endpoint=True, + ) + + assert exc_info.value.code == "429" + assert "Rate limit exceeded" in exc_info.value.message + finally: + litellm.disable_token_counter = original_disable + litellm.proxy.proxy_server._get_provider_token_counter = original_get_provider_token_counter + + +@pytest.mark.asyncio +async def test_proxy_token_counter_error_falls_back_when_enabled(): + """ + Test that proxy token_counter falls back to local tokenizer when disable_token_counter=False + and provider returns an error response. + """ + # Create error response + error_response = TokenCountResponse( + total_tokens=0, + request_model="bedrock/anthropic.claude-3-sonnet", + model_used="anthropic.claude-3-sonnet", + tokenizer_type="bedrock_api", + error=True, + error_message="Rate limit exceeded", + status_code=429, + ) + + # Create mock router that returns a deployment + mock_deployment = { + "litellm_params": { + "model": "bedrock/anthropic.claude-3-sonnet", + }, + "model_info": {}, + } + + mock_router = MagicMock() + mock_router.async_get_available_deployment = AsyncMock(return_value=mock_deployment) + + setattr(litellm.proxy.proxy_server, "llm_router", mock_router) + + # Save original value and function + original_disable = litellm.disable_token_counter + original_get_provider_token_counter = litellm.proxy.proxy_server._get_provider_token_counter + + try: + litellm.disable_token_counter = False + + # Create a mock counter that returns an error response + mock_counter = MagicMock(spec=BedrockTokenCounter) + mock_counter.should_use_token_counting_api.return_value = True + mock_counter.count_tokens = AsyncMock(return_value=error_response) + + # Replace the function directly + def mock_get_provider_token_counter(deployment, model_to_use): + return (mock_counter, "anthropic.claude-3-sonnet", "bedrock") + + litellm.proxy.proxy_server._get_provider_token_counter = mock_get_provider_token_counter + + # Should not raise, should fall back to local tokenizer + result = await token_counter( + request=TokenCountRequest( + model="claude-bedrock", + messages=[{"role": "user", "content": "hello"}], + ), + call_endpoint=True, + ) + + # Should have used the fallback tokenizer + assert result.error is False + assert result.total_tokens > 0 + assert result.tokenizer_type != "bedrock_api" + finally: + litellm.disable_token_counter = original_disable + litellm.proxy.proxy_server._get_provider_token_counter = original_get_provider_token_counter + + +@pytest.mark.asyncio +async def test_anthropic_endpoint_returns_anthropic_error_format(): + """ + Test that /v1/messages/count_tokens returns errors in Anthropic format. + """ + import litellm.proxy.anthropic_endpoints.endpoints as anthropic_endpoints + import litellm.proxy.proxy_server as proxy_server + + # Mock request object + mock_request = MagicMock(spec=Request) + mock_request_data = { + "model": "claude-bedrock", + "messages": [{"role": "user", "content": "Hello!"}], + } + + async def mock_read_request_body(request): + return mock_request_data + + mock_user_api_key_dict = MagicMock() + + original_read_request_body = anthropic_endpoints._read_request_body + anthropic_endpoints._read_request_body = mock_read_request_body + + original_token_counter = proxy_server.token_counter + + # Mock token_counter to raise ProxyException with Bedrock-style error + async def mock_token_counter_error(request, call_endpoint=False): + raise ProxyException( + message='{"detail":{"message":"Input is too long for requested model."}}', + type="token_counting_error", + param="model", + code=400, + ) + + proxy_server.token_counter = mock_token_counter_error + + try: + with pytest.raises(HTTPException) as exc_info: + await anthropic_count_tokens(mock_request, mock_user_api_key_dict) + + # Verify HTTP status code is correct + assert exc_info.value.status_code == 400 + + # Verify error is in Anthropic format + detail = exc_info.value.detail + assert detail["type"] == "error" + assert detail["error"]["type"] == "invalid_request_error" + assert detail["error"]["message"] == "Input is too long for requested model." + finally: + anthropic_endpoints._read_request_body = original_read_request_body + proxy_server.token_counter = original_token_counter + + +@pytest.mark.asyncio +async def test_anthropic_endpoint_403_permission_error_format(): + """ + Test that 403 errors are returned as permission_error in Anthropic format. + """ + import litellm.proxy.anthropic_endpoints.endpoints as anthropic_endpoints + import litellm.proxy.proxy_server as proxy_server + + mock_request = MagicMock(spec=Request) + mock_request_data = { + "model": "claude-bedrock", + "messages": [{"role": "user", "content": "Hello!"}], + } + + async def mock_read_request_body(request): + return mock_request_data + + mock_user_api_key_dict = MagicMock() + + original_read_request_body = anthropic_endpoints._read_request_body + anthropic_endpoints._read_request_body = mock_read_request_body + + original_token_counter = proxy_server.token_counter + + # Mock token_counter to raise ProxyException with 403 error + async def mock_token_counter_error(request, call_endpoint=False): + raise ProxyException( + message='{"Message":"Bearer Token has expired"}', + type="token_counting_error", + param="model", + code=403, + ) + + proxy_server.token_counter = mock_token_counter_error + + try: + with pytest.raises(HTTPException) as exc_info: + await anthropic_count_tokens(mock_request, mock_user_api_key_dict) + + assert exc_info.value.status_code == 403 + + detail = exc_info.value.detail + assert detail["type"] == "error" + assert detail["error"]["type"] == "permission_error" + assert detail["error"]["message"] == "Bearer Token has expired" + finally: + anthropic_endpoints._read_request_body = original_read_request_body + proxy_server.token_counter = original_token_counter + + +@pytest.mark.asyncio +async def test_anthropic_endpoint_429_rate_limit_error_format(): + """ + Test that 429 errors are returned as rate_limit_error in Anthropic format. + """ + import litellm.proxy.anthropic_endpoints.endpoints as anthropic_endpoints + import litellm.proxy.proxy_server as proxy_server + + mock_request = MagicMock(spec=Request) + mock_request_data = { + "model": "claude-bedrock", + "messages": [{"role": "user", "content": "Hello!"}], + } + + async def mock_read_request_body(request): + return mock_request_data + + mock_user_api_key_dict = MagicMock() + + original_read_request_body = anthropic_endpoints._read_request_body + anthropic_endpoints._read_request_body = mock_read_request_body + + original_token_counter = proxy_server.token_counter + + # Mock token_counter to raise ProxyException with 429 error + async def mock_token_counter_error(request, call_endpoint=False): + raise ProxyException( + message="Rate limit exceeded", + type="token_counting_error", + param="model", + code=429, + ) + + proxy_server.token_counter = mock_token_counter_error + + try: + with pytest.raises(HTTPException) as exc_info: + await anthropic_count_tokens(mock_request, mock_user_api_key_dict) + + assert exc_info.value.status_code == 429 + + detail = exc_info.value.detail + assert detail["type"] == "error" + assert detail["error"]["type"] == "rate_limit_error" + assert detail["error"]["message"] == "Rate limit exceeded" + finally: + anthropic_endpoints._read_request_body = original_read_request_body + proxy_server.token_counter = original_token_counter + + diff --git a/tests/proxy_unit_tests/test_zero_cost_model_budget_bypass.py b/tests/proxy_unit_tests/test_zero_cost_model_budget_bypass.py new file mode 100644 index 00000000000..bc818fc0dca --- /dev/null +++ b/tests/proxy_unit_tests/test_zero_cost_model_budget_bypass.py @@ -0,0 +1,590 @@ +""" +Tests for zero-cost model budget bypass functionality. + +When a user exceeds their budget, the system should still allow requests +to models with zero cost (e.g., on-premises models). +""" + +import asyncio +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest + +import litellm +from litellm.caching.caching import DualCache +from litellm.proxy._types import ( + LiteLLM_BudgetTable, + LiteLLM_EndUserTable, + LiteLLM_TeamMembership, + LiteLLM_TeamTable, + LiteLLM_UserTable, + UserAPIKeyAuth, +) +from litellm.proxy.auth.auth_checks import ( + _check_team_member_budget, + _is_model_cost_zero, + _team_max_budget_check, + common_checks, +) +from litellm.proxy.utils import ProxyLogging +from litellm.router import Router +from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo + + +@pytest.fixture +def mock_router_with_zero_cost_model(): + """Create a mock router with a zero-cost model.""" + router = Router( + model_list=[ + { + "model_name": "on-prem-model", + "litellm_params": { + "model": "ollama/llama2", + "api_base": "http://localhost:11434", + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + }, + "model_info": { + "id": "on-prem-model-id", + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + }, + }, + { + "model_name": "cloud-model", + "litellm_params": { + "model": "gpt-3.5-turbo", + "api_key": "sk-test", + }, + "model_info": { + "id": "cloud-model-id", + }, + }, + ] + ) + return router + + +@pytest.fixture +def mock_router_with_paid_model(): + """Create a mock router with only paid models.""" + router = Router( + model_list=[ + { + "model_name": "cloud-model", + "litellm_params": { + "model": "gpt-3.5-turbo", + "api_key": "sk-test", + }, + "model_info": { + "id": "cloud-model-id", + }, + } + ] + ) + return router + + +@pytest.fixture +def mock_proxy_logging(): + """Create a mock ProxyLogging instance.""" + proxy_logging = ProxyLogging(user_api_key_cache=None) + + async def mock_budget_alerts(*args, **kwargs): + pass + + proxy_logging.budget_alerts = mock_budget_alerts + return proxy_logging + + +class TestIsModelCostZero: + """Tests for _is_model_cost_zero helper function.""" + + def test_zero_cost_model_in_router(self, mock_router_with_zero_cost_model): + """Test that a zero-cost model in router is correctly identified.""" + result = _is_model_cost_zero( + model="on-prem-model", llm_router=mock_router_with_zero_cost_model + ) + assert result is True + + def test_paid_model_in_router(self, mock_router_with_zero_cost_model): + """Test that a paid model is correctly identified as non-zero cost.""" + with patch("litellm.get_model_info") as mock_get_model_info: + # Mock the return value for gpt-3.5-turbo + mock_get_model_info.return_value = { + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + } + result = _is_model_cost_zero( + model="cloud-model", llm_router=mock_router_with_zero_cost_model + ) + assert result is False + + def test_none_model(self, mock_router_with_zero_cost_model): + """Test that None model returns False.""" + result = _is_model_cost_zero( + model=None, llm_router=mock_router_with_zero_cost_model + ) + assert result is False + + def test_none_router(self): + """Test that None router returns False.""" + result = _is_model_cost_zero(model="some-model", llm_router=None) + assert result is False + + def test_list_of_zero_cost_models(self, mock_router_with_zero_cost_model): + """Test that a list of zero-cost models returns True.""" + result = _is_model_cost_zero( + model=["on-prem-model"], llm_router=mock_router_with_zero_cost_model + ) + assert result is True + + def test_mixed_cost_models(self, mock_router_with_zero_cost_model): + """Test that a list with mixed cost models returns False.""" + with patch("litellm.get_model_info") as mock_get_model_info: + mock_get_model_info.return_value = { + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + } + result = _is_model_cost_zero( + model=["on-prem-model", "cloud-model"], + llm_router=mock_router_with_zero_cost_model, + ) + assert result is False + + +class TestUserBudgetBypass: + """Tests for user budget bypass with zero-cost models.""" + + @pytest.mark.asyncio + async def test_user_over_budget_with_zero_cost_model_allowed( + self, mock_router_with_zero_cost_model, mock_proxy_logging + ): + """Test that user over budget can still use zero-cost models.""" + user_object = LiteLLM_UserTable( + user_id="test-user", + spend=100.0, + max_budget=50.0, + ) + + request_body = {"model": "on-prem-model"} + + # Should not raise BudgetExceededError + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=user_object, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=mock_router_with_zero_cost_model, + proxy_logging_obj=mock_proxy_logging, + valid_token=UserAPIKeyAuth( + token="test-token", + user_id="test-user", + ), + request=MagicMock(), + skip_budget_checks=True, # This is set by user_api_key_auth for zero-cost models + ) + assert result is True + + @pytest.mark.asyncio + async def test_user_over_budget_with_paid_model_blocked( + self, mock_router_with_zero_cost_model, mock_proxy_logging + ): + """Test that user over budget cannot use paid models.""" + user_object = LiteLLM_UserTable( + user_id="test-user", + spend=100.0, + max_budget=50.0, + ) + + request_body = {"model": "cloud-model"} + + with patch("litellm.get_model_info") as mock_get_model_info: + mock_get_model_info.return_value = { + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + } + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await common_checks( + request_body=request_body, + team_object=None, + user_object=user_object, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=mock_router_with_zero_cost_model, + proxy_logging_obj=mock_proxy_logging, + valid_token=UserAPIKeyAuth( + token="test-token", + user_id="test-user", + ), + request=MagicMock(), + ) + + assert exc_info.value.current_cost == 100.0 + assert exc_info.value.max_budget == 50.0 + assert "test-user" in str(exc_info.value) + + +class TestEndUserBudgetBypass: + """Tests for end user budget bypass with zero-cost models.""" + + @pytest.mark.asyncio + async def test_end_user_over_budget_with_zero_cost_model_allowed( + self, mock_router_with_zero_cost_model, mock_proxy_logging + ): + """Test that end user over budget can still use zero-cost models.""" + end_user_budget = LiteLLM_BudgetTable(max_budget=20.0) + end_user_object = LiteLLM_EndUserTable( + user_id="end-user-123", + spend=50.0, + litellm_budget_table=end_user_budget, + blocked=False, + ) + + request_body = {"model": "on-prem-model", "user": "end-user-123"} + + # In the real flow, skip_budget_checks would be set to True for zero-cost models + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=end_user_object, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=mock_router_with_zero_cost_model, + proxy_logging_obj=mock_proxy_logging, + valid_token=UserAPIKeyAuth( + token="test-token", + ), + request=MagicMock(), + skip_budget_checks=True, # This is set by user_api_key_auth for zero-cost models + ) + assert result is True + + @pytest.mark.asyncio + async def test_end_user_over_budget_with_paid_model_blocked( + self, mock_router_with_zero_cost_model, mock_proxy_logging + ): + """Test that end user over budget cannot use paid models.""" + end_user_budget = LiteLLM_BudgetTable(max_budget=20.0) + end_user_object = LiteLLM_EndUserTable( + user_id="end-user-123", + spend=50.0, + litellm_budget_table=end_user_budget, + blocked=False, + ) + + request_body = {"model": "cloud-model", "user": "end-user-123"} + + with patch("litellm.get_model_info") as mock_get_model_info: + mock_get_model_info.return_value = { + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + } + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=end_user_object, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=mock_router_with_zero_cost_model, + proxy_logging_obj=mock_proxy_logging, + valid_token=UserAPIKeyAuth( + token="test-token", + ), + request=MagicMock(), + ) + + assert exc_info.value.current_cost == 50.0 + assert exc_info.value.max_budget == 20.0 + assert "end-user-123" in str(exc_info.value) + + +class TestTeamBudgetBypass: + """Tests for team budget bypass with zero-cost models.""" + + @pytest.mark.asyncio + async def test_team_over_budget_with_zero_cost_model_allowed( + self, mock_router_with_zero_cost_model, mock_proxy_logging + ): + """Test that team over budget can still use zero-cost models.""" + team_object = LiteLLM_TeamTable( + team_id="test-team", + spend=150.0, + max_budget=100.0, + ) + + valid_token = UserAPIKeyAuth( + token="test-token", + team_id="test-team", + ) + + request_body = {"model": "on-prem-model"} + + # In the real flow, skip_budget_checks would be set to True for zero-cost models + result = await common_checks( + request_body=request_body, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=mock_router_with_zero_cost_model, + proxy_logging_obj=mock_proxy_logging, + valid_token=valid_token, + request=MagicMock(), + skip_budget_checks=True, # This is set by user_api_key_auth for zero-cost models + ) + assert result is True + + @pytest.mark.asyncio + async def test_team_over_budget_with_paid_model_blocked( + self, mock_router_with_zero_cost_model, mock_proxy_logging + ): + """Test that team over budget cannot use paid models.""" + team_object = LiteLLM_TeamTable( + team_id="test-team", + spend=150.0, + max_budget=100.0, + ) + + valid_token = UserAPIKeyAuth( + token="test-token", + team_id="test-team", + ) + + request_body = {"model": "cloud-model"} + + with patch("litellm.get_model_info") as mock_get_model_info: + mock_get_model_info.return_value = { + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + } + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await common_checks( + request_body=request_body, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=mock_router_with_zero_cost_model, + proxy_logging_obj=mock_proxy_logging, + valid_token=valid_token, + request=MagicMock(), + ) + + assert exc_info.value.current_cost == 150.0 + assert exc_info.value.max_budget == 100.0 + assert "test-team" in str(exc_info.value) + + +class TestTeamMemberBudgetBypass: + """Tests for team member budget bypass with zero-cost models.""" + + @pytest.mark.asyncio + async def test_team_member_over_budget_with_zero_cost_model_allowed( + self, mock_router_with_zero_cost_model, mock_proxy_logging + ): + """Test that team member over budget can still use zero-cost models.""" + team_object = LiteLLM_TeamTable( + team_id="test-team", + ) + + user_object = LiteLLM_UserTable( + user_id="test-user", + ) + + valid_token = UserAPIKeyAuth( + token="test-token", + user_id="test-user", + team_id="test-team", + ) + + member_budget = LiteLLM_BudgetTable(max_budget=30.0) + team_membership = LiteLLM_TeamMembership( + user_id="test-user", + team_id="test-team", + spend=60.0, + litellm_budget_table=member_budget, + ) + + request_body = {"model": "on-prem-model"} + + # Mock get_team_membership + with patch( + "litellm.proxy.auth.auth_checks.get_team_membership" + ) as mock_get_membership: + mock_get_membership.return_value = team_membership + + # In the real flow, skip_budget_checks would be set to True for zero-cost models + result = await common_checks( + request_body=request_body, + team_object=team_object, + user_object=user_object, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=mock_router_with_zero_cost_model, + proxy_logging_obj=mock_proxy_logging, + valid_token=valid_token, + request=MagicMock(), + skip_budget_checks=True, # This is set by user_api_key_auth for zero-cost models + ) + assert result is True + + @pytest.mark.asyncio + async def test_team_member_over_budget_with_paid_model_blocked( + self, mock_router_with_zero_cost_model, mock_proxy_logging + ): + """Test that team member over budget cannot use paid models.""" + team_object = LiteLLM_TeamTable( + team_id="test-team", + ) + + user_object = LiteLLM_UserTable( + user_id="test-user", + ) + + valid_token = UserAPIKeyAuth( + token="test-token", + user_id="test-user", + team_id="test-team", + ) + + member_budget = LiteLLM_BudgetTable(max_budget=30.0) + team_membership = LiteLLM_TeamMembership( + user_id="test-user", + team_id="test-team", + spend=60.0, + litellm_budget_table=member_budget, + ) + + request_body = {"model": "cloud-model"} + + with patch( + "litellm.proxy.auth.auth_checks.get_team_membership" + ) as mock_get_membership: + mock_get_membership.return_value = team_membership + + with patch("litellm.get_model_info") as mock_get_model_info: + mock_get_model_info.return_value = { + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + } + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await common_checks( + request_body=request_body, + team_object=team_object, + user_object=user_object, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=mock_router_with_zero_cost_model, + proxy_logging_obj=mock_proxy_logging, + valid_token=valid_token, + request=MagicMock(), + ) + + assert exc_info.value.current_cost == 60.0 + assert exc_info.value.max_budget == 30.0 + assert "test-user" in str(exc_info.value) + assert "test-team" in str(exc_info.value) + + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + def test_model_not_in_router(self, mock_router_with_zero_cost_model): + """Test behavior when model is not found in router.""" + with patch("litellm.get_model_info") as mock_get_model_info: + # Simulate model not found + mock_get_model_info.side_effect = Exception("Model not found") + result = _is_model_cost_zero( + model="nonexistent-model", llm_router=mock_router_with_zero_cost_model + ) + # Should return False (conservative approach) + assert result is False + + @pytest.mark.asyncio + async def test_user_under_budget_with_paid_model_allowed( + self, mock_router_with_zero_cost_model, mock_proxy_logging + ): + """Test that user under budget can use paid models normally.""" + user_object = LiteLLM_UserTable( + user_id="test-user", + spend=30.0, + max_budget=100.0, + ) + + request_body = {"model": "cloud-model"} + + with patch("litellm.get_model_info") as mock_get_model_info: + mock_get_model_info.return_value = { + "input_cost_per_token": 0.0000015, + "output_cost_per_token": 0.000002, + } + # Should not raise BudgetExceededError + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=user_object, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=mock_router_with_zero_cost_model, + proxy_logging_obj=mock_proxy_logging, + valid_token=UserAPIKeyAuth( + token="test-token", + user_id="test-user", + ), + request=MagicMock(), + ) + assert result is True + + @pytest.mark.asyncio + async def test_user_under_budget_with_zero_cost_model_allowed( + self, mock_router_with_zero_cost_model, mock_proxy_logging + ): + """Test that user under budget can use zero-cost models normally.""" + user_object = LiteLLM_UserTable( + user_id="test-user", + spend=30.0, + max_budget=100.0, + ) + + request_body = {"model": "on-prem-model"} + + # Should not raise BudgetExceededError + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=user_object, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=mock_router_with_zero_cost_model, + proxy_logging_obj=mock_proxy_logging, + valid_token=UserAPIKeyAuth( + token="test-token", + user_id="test-user", + ), + request=MagicMock(), + ) + assert result is True diff --git a/tests/test_litellm/anthropic_interface/exceptions/test_exception_mapping_utils.py b/tests/test_litellm/anthropic_interface/exceptions/test_exception_mapping_utils.py new file mode 100644 index 00000000000..6f69a54b5d5 --- /dev/null +++ b/tests/test_litellm/anthropic_interface/exceptions/test_exception_mapping_utils.py @@ -0,0 +1,185 @@ +""" +Tests for AnthropicExceptionMapping class in litellm/anthropic_interface/exceptions/exception_mapping_utils.py +""" + +import json + +from litellm.anthropic_interface.exceptions import AnthropicExceptionMapping + + +class TestCreateErrorResponse: + """Tests for AnthropicExceptionMapping.create_error_response()""" + + def test_400_invalid_request_error(self): + """Test 400 maps to invalid_request_error.""" + response = AnthropicExceptionMapping.create_error_response(400, "Invalid request") + assert response["type"] == "error" + assert response["error"]["type"] == "invalid_request_error" + assert response["error"]["message"] == "Invalid request" + assert "request_id" not in response + + def test_401_authentication_error(self): + """Test 401 maps to authentication_error.""" + response = AnthropicExceptionMapping.create_error_response(401, "Unauthorized") + assert response["error"]["type"] == "authentication_error" + + def test_403_permission_error(self): + """Test 403 maps to permission_error.""" + response = AnthropicExceptionMapping.create_error_response(403, "Forbidden") + assert response["error"]["type"] == "permission_error" + + def test_404_not_found_error(self): + """Test 404 maps to not_found_error.""" + response = AnthropicExceptionMapping.create_error_response(404, "Not found") + assert response["error"]["type"] == "not_found_error" + + def test_429_rate_limit_error(self): + """Test 429 maps to rate_limit_error.""" + response = AnthropicExceptionMapping.create_error_response(429, "Rate limit exceeded") + assert response["error"]["type"] == "rate_limit_error" + + def test_500_api_error(self): + """Test 500 maps to api_error.""" + response = AnthropicExceptionMapping.create_error_response(500, "Internal error") + assert response["error"]["type"] == "api_error" + + def test_with_request_id(self): + """Test request_id is included when provided.""" + response = AnthropicExceptionMapping.create_error_response(400, "Error", request_id="req_123") + assert response["request_id"] == "req_123" + + def test_unknown_status_defaults_to_api_error(self): + """Test unknown status code defaults to api_error.""" + response = AnthropicExceptionMapping.create_error_response(418, "I'm a teapot") + assert response["error"]["type"] == "api_error" + + +class TestExtractErrorMessage: + """Tests for AnthropicExceptionMapping.extract_error_message()""" + + def test_bedrock_format(self): + """Test extraction from Bedrock format: {"detail": {"message": "..."}}""" + bedrock_msg = '{"detail":{"message":"Input is too long for requested model."}}' + assert AnthropicExceptionMapping.extract_error_message(bedrock_msg) == "Input is too long for requested model." + + def test_aws_message_format(self): + """Test extraction from AWS format: {"Message": "..."}""" + msg = '{"Message":"Bearer Token has expired"}' + assert AnthropicExceptionMapping.extract_error_message(msg) == "Bearer Token has expired" + + def test_generic_message_format(self): + """Test extraction from generic format: {"message": "..."}""" + msg = '{"message":"Some error occurred"}' + assert AnthropicExceptionMapping.extract_error_message(msg) == "Some error occurred" + + def test_plain_string(self): + """Test plain string is returned as-is.""" + assert AnthropicExceptionMapping.extract_error_message("Plain error message") == "Plain error message" + + def test_invalid_json(self): + """Test invalid JSON is returned as-is.""" + assert AnthropicExceptionMapping.extract_error_message("Not JSON {invalid}") == "Not JSON {invalid}" + + def test_empty_dict(self): + """Test empty dict returns original string.""" + assert AnthropicExceptionMapping.extract_error_message("{}") == "{}" + + +class TestTransformToAnthropicError: + """Tests for AnthropicExceptionMapping.transform_to_anthropic_error()""" + + def test_passthrough_anthropic_error(self): + """Test that Anthropic errors pass through unchanged.""" + anthropic_error = { + "type": "error", + "error": {"type": "rate_limit_error", "message": "Rate limited"} + } + raw = json.dumps(anthropic_error) + result = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=429, + raw_message=raw, + ) + assert result["type"] == "error" + assert result["error"]["type"] == "rate_limit_error" + assert result["error"]["message"] == "Rate limited" + + def test_passthrough_preserves_existing_request_id(self): + """Test that existing request_id in Anthropic error is preserved.""" + anthropic_error = { + "type": "error", + "error": {"type": "api_error", "message": "Server error"}, + "request_id": "req_existing" + } + raw = json.dumps(anthropic_error) + result = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=500, + raw_message=raw, + request_id="req_new", # Should not override existing + ) + assert result["request_id"] == "req_existing" + + def test_passthrough_adds_request_id_if_missing(self): + """Test that request_id is added to Anthropic error if missing.""" + anthropic_error = { + "type": "error", + "error": {"type": "api_error", "message": "Server error"} + } + raw = json.dumps(anthropic_error) + result = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=500, + raw_message=raw, + request_id="req_123", + ) + assert result["request_id"] == "req_123" + + def test_translates_bedrock_error(self): + """Test that Bedrock errors are translated to Anthropic format.""" + bedrock_error = json.dumps({"detail": {"message": "Access denied"}}) + result = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=403, + raw_message=bedrock_error, + ) + assert result["type"] == "error" + assert result["error"]["type"] == "permission_error" + assert result["error"]["message"] == "Access denied" + + def test_translates_aws_error(self): + """Test that AWS errors are translated to Anthropic format.""" + aws_error = json.dumps({"Message": "Resource not found"}) + result = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=404, + raw_message=aws_error, + ) + assert result["type"] == "error" + assert result["error"]["type"] == "not_found_error" + assert result["error"]["message"] == "Resource not found" + + def test_handles_plain_string(self): + """Test that plain string errors are wrapped in Anthropic format.""" + result = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=400, + raw_message="Invalid request parameters", + ) + assert result["type"] == "error" + assert result["error"]["type"] == "invalid_request_error" + assert result["error"]["message"] == "Invalid request parameters" + + def test_handles_generic_message_json(self): + """Test that generic {"message": "..."} JSON is translated.""" + generic_error = json.dumps({"message": "Something went wrong"}) + result = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=500, + raw_message=generic_error, + ) + assert result["type"] == "error" + assert result["error"]["type"] == "api_error" + assert result["error"]["message"] == "Something went wrong" + + def test_handles_non_dict_json(self): + """Test that non-dict JSON (e.g., array) is treated as plain string.""" + result = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=400, + raw_message='["error1", "error2"]', + ) + assert result["type"] == "error" + assert result["error"]["message"] == '["error1", "error2"]' diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index d7d7720ff43..00557c10b5d 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -492,51 +492,6 @@ class TestCustomGuardrailPassthroughSupport: assert result is True -class TestPassthroughCallTypeHandling: - """Tests for passthrough call type handling in common_request_processing.""" - - def test_get_pre_call_type_with_allm_passthrough_route(self): - """ - Test that _get_pre_call_type correctly maps allm_passthrough_route. - - This tests Fix #1: allm_passthrough_route was not being handled, causing call_type to be None. - """ - from litellm.proxy.common_request_processing import ( - ProxyBaseLLMRequestProcessing, - ) - - # Test the mapping - result = ProxyBaseLLMRequestProcessing._get_pre_call_type( - route_type="allm_passthrough_route" - ) - - # Should return allm_passthrough_route, not None - assert result == "allm_passthrough_route" - - def test_get_pre_call_type_preserves_standard_mappings(self): - """ - Test that _get_pre_call_type still correctly maps standard route types. - - Ensures Fix #1 didn't break existing functionality. - """ - from litellm.proxy.common_request_processing import ( - ProxyBaseLLMRequestProcessing, - ) - - # Test standard mappings are preserved - assert ( - ProxyBaseLLMRequestProcessing._get_pre_call_type(route_type="acompletion") - == "completion" - ) - assert ( - ProxyBaseLLMRequestProcessing._get_pre_call_type(route_type="aembedding") - == "embedding" - ) - assert ( - ProxyBaseLLMRequestProcessing._get_pre_call_type(route_type="aresponses") - == "responses" - ) - class TestEventTypeLogging: """Tests for event_type logging in guardrail information.""" diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 55b65fbb92a..95fa6ed8f60 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -1869,3 +1869,237 @@ class TestOpenTelemetryExternalSpan(unittest.TestCase): parent_span_finished.attributes, "Parent span should have model attribute from LiteLLM even on failure" ) + + +class TestOpenTelemetrySemanticConventions138(unittest.TestCase): + """ + Test suite for OpenTelemetry 1.38 Semantic Conventions compliance. + + These tests verify that LiteLLM emits span attributes following the + OpenTelemetry GenAI semantic conventions v1.38, including: + - gen_ai.input.messages (JSON string with parts array) + - gen_ai.output.messages (JSON string with parts array) + - gen_ai.usage.input_tokens / output_tokens (new naming) + - gen_ai.response.finish_reasons (JSON array) + + See: https://github.com/BerriAI/litellm/issues/17794 + """ + + def test_input_messages_uses_parts_structure(self): + """ + Test that gen_ai.input.messages uses the OTEL 1.38 parts array structure. + + Expected format: + [{"role": "user", "parts": [{"type": "text", "content": "Hello"}]}] + """ + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello world"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + }, + } + + response_obj = { + "id": "test-response-id", + "model": "gpt-4", + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "Hi there!"}, + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + # Find the call that set gen_ai.input.messages + input_messages_calls = [ + call for call in mock_span.set_attribute.call_args_list + if call[0][0] == "gen_ai.input.messages" + ] + self.assertEqual(len(input_messages_calls), 1, "Should have exactly one gen_ai.input.messages attribute") + + input_messages_value = input_messages_calls[0][0][1] + parsed = json.loads(input_messages_value) + + # Verify structure + self.assertIsInstance(parsed, list) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0]["role"], "user") + self.assertIn("parts", parsed[0]) + self.assertEqual(parsed[0]["parts"][0]["type"], "text") + self.assertEqual(parsed[0]["parts"][0]["content"], "Hello world") + + def test_output_messages_uses_parts_structure(self): + """ + Test that gen_ai.output.messages uses the OTEL 1.38 parts array structure. + + Expected format: + [{"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}], "finish_reason": "stop"}] + """ + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + }, + } + + response_obj = { + "id": "test-response-id", + "model": "gpt-4", + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "Hello back!"}, + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + # Find the call that set gen_ai.output.messages + output_messages_calls = [ + call for call in mock_span.set_attribute.call_args_list + if call[0][0] == "gen_ai.output.messages" + ] + self.assertEqual(len(output_messages_calls), 1, "Should have exactly one gen_ai.output.messages attribute") + + output_messages_value = output_messages_calls[0][0][1] + parsed = json.loads(output_messages_value) + + # Verify structure + self.assertIsInstance(parsed, list) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0]["role"], "assistant") + self.assertIn("parts", parsed[0]) + self.assertEqual(parsed[0]["parts"][0]["type"], "text") + self.assertEqual(parsed[0]["parts"][0]["content"], "Hello back!") + self.assertEqual(parsed[0]["finish_reason"], "stop") + + def test_usage_tokens_use_new_naming_convention(self): + """ + Test that token usage uses the OTEL 1.38 naming convention: + - gen_ai.usage.input_tokens (not prompt_tokens) + - gen_ai.usage.output_tokens (not completion_tokens) + """ + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + }, + } + + response_obj = { + "id": "test-response-id", + "model": "gpt-4", + "choices": [], + "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + # Verify new naming convention is used + mock_span.set_attribute.assert_any_call("gen_ai.usage.input_tokens", 100) + mock_span.set_attribute.assert_any_call("gen_ai.usage.output_tokens", 50) + mock_span.set_attribute.assert_any_call("gen_ai.usage.total_tokens", 150) + + def test_finish_reasons_is_json_array(self): + """ + Test that gen_ai.response.finish_reasons is a proper JSON array. + + Expected: '["stop"]' (not "['stop']") + """ + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + }, + } + + response_obj = { + "id": "test-response-id", + "model": "gpt-4", + "choices": [ + {"finish_reason": "stop", "message": {"role": "assistant", "content": "Hi"}}, + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + # Find the call that set gen_ai.response.finish_reasons + finish_reasons_calls = [ + call for call in mock_span.set_attribute.call_args_list + if call[0][0] == "gen_ai.response.finish_reasons" + ] + self.assertEqual(len(finish_reasons_calls), 1, "Should have exactly one gen_ai.response.finish_reasons attribute") + + finish_reasons_value = finish_reasons_calls[0][0][1] + + # Verify it's valid JSON (not Python repr) + parsed = json.loads(finish_reasons_value) + self.assertEqual(parsed, ["stop"]) + + def test_operation_name_is_chat_for_completion(self): + """ + Test that gen_ai.operation.name is 'chat' for completion calls. + """ + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + }, + } + + response_obj = { + "id": "test-response-id", + "model": "gpt-4", + "choices": [], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + mock_span.set_attribute.assert_any_call("gen_ai.operation.name", "chat") diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index bae0e5bbb4f..a4d3206fdc7 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -14,6 +14,7 @@ import time from litellm.constants import SENTRY_DENYLIST, SENTRY_PII_DENYLIST from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging from litellm.litellm_core_utils.litellm_logging import set_callbacks +from litellm.types.utils import ModelResponse @pytest.fixture @@ -277,6 +278,87 @@ async def test_logging_non_streaming_request(): litellm.callbacks = original_callbacks +@pytest.mark.parametrize("async_flag", ["acompletion", "aresponses"]) +def test_success_handler_skips_sync_callbacks_for_async_requests(logging_obj, async_flag): + """Ensure sync success callbacks are skipped when async call type flags are set.""" + from litellm.integrations.custom_logger import CustomLogger + + class DummyLogger(CustomLogger): + pass + + logging_obj.stream = False # simulate non-streaming request where sync callbacks would normally run + logging_obj.model_call_details["litellm_params"] = {async_flag: True} + logging_obj.litellm_params = logging_obj.model_call_details["litellm_params"] + + dummy_logger = DummyLogger() + dummy_logger.log_success_event = MagicMock() + dummy_logger.log_stream_event = MagicMock() + + model_response = ModelResponse( + id="resp-123", + model="gpt-4o-mini", + choices=[ + { + "message": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + "index": 0, + } + ], + usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + ) + + with patch.object( + logging_obj, + "get_combined_callback_list", + return_value=[dummy_logger], + ): + logging_obj.success_handler(result=model_response) + + dummy_logger.log_success_event.assert_not_called() + dummy_logger.log_stream_event.assert_not_called() + + +@pytest.mark.parametrize("call_type", ["completion", "responses"]) +def test_success_handler_runs_sync_callbacks_for_sync_requests(logging_obj, call_type): + """Ensure sync success callbacks execute when call type is sync (completion/responses).""" + from litellm.integrations.custom_logger import CustomLogger + + class DummyLogger(CustomLogger): + pass + + logging_obj.stream = False + logging_obj.call_type = call_type + logging_obj.model_call_details["litellm_params"] = {} + logging_obj.litellm_params = {} + + dummy_logger = DummyLogger() + dummy_logger.log_success_event = MagicMock() + dummy_logger.log_stream_event = MagicMock() + + model_response = ModelResponse( + id="resp-123", + model="gpt-4o-mini", + choices=[ + { + "message": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + "index": 0, + } + ], + usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + ) + + with patch.object( + logging_obj, + "get_combined_callback_list", + return_value=[dummy_logger], + ): + logging_obj.success_handler(result=model_response) + + dummy_logger.log_success_event.assert_called_once() + dummy_logger.log_stream_event.assert_not_called() + + def test_get_user_agent_tags(): from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py index 2164a3b82e3..eef206ca667 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py @@ -328,6 +328,42 @@ def test_stream_chunk_builder_litellm_usage_chunks(): assert usage.total_tokens == 77 +def test_get_model_from_chunks_azure_model_router(): + """ + Test that _get_model_from_chunks finds the actual model from Azure Model Router chunks. + + Azure Model Router returns the request model (e.g., 'azure-model-router') in the first chunk, + but subsequent chunks contain the actual model (e.g., 'gpt-4.1-nano-2025-04-14'). + This is important for accurate cost calculation. + """ + # First chunk has request model, subsequent chunks have actual model + chunks = [ + {"model": "azure-model-router", "id": "chatcmpl-123", "choices": []}, + {"model": "gpt-4.1-nano-2025-04-14", "id": "chatcmpl-123", "choices": []}, + {"model": "gpt-4.1-nano-2025-04-14", "id": "chatcmpl-123", "choices": []}, + ] + + result = ChunkProcessor._get_model_from_chunks( + chunks=chunks, first_chunk_model="azure-model-router" + ) + + # Should return the actual model, not the request model + assert result == "gpt-4.1-nano-2025-04-14" + + # Test when all chunks have the same model (non-router case) + chunks_same_model = [ + {"model": "gpt-4", "id": "chatcmpl-456", "choices": []}, + {"model": "gpt-4", "id": "chatcmpl-456", "choices": []}, + ] + + result_same = ChunkProcessor._get_model_from_chunks( + chunks=chunks_same_model, first_chunk_model="gpt-4" + ) + + # Should return the first chunk's model when all are the same + assert result_same == "gpt-4" + + def test_stream_chunk_builder_anthropic_web_search(): # Prepare two mocked streaming chunks with usage split across them chunk1 = ModelResponseStream( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index 0c6d0921952..c026ea232b7 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -1,6 +1,8 @@ -from typing import Dict, Optional +import json +from typing import Any, Dict, Optional import pytest +from fastapi import HTTPException from starlette.requests import Request from litellm.proxy._experimental.mcp_server import rest_endpoints @@ -12,8 +14,21 @@ from litellm.proxy._types import NewMCPServerRequest, UserAPIKeyAuth from litellm.types.mcp import MCPAuth -def _build_request(headers: Optional[Dict[str, str]] = None) -> Request: +def _build_request( + headers: Optional[Dict[str, str]] = None, + *, + path: str = "/mcp-rest/test/tools/list", + method: str = "POST", + json_body: Optional[Any] = None, + body: Optional[bytes] = None, +) -> Request: headers = headers or {} + if json_body is not None: + body_bytes = json.dumps(json_body).encode("utf-8") + elif body is not None: + body_bytes = body + else: + body_bytes = b"" raw_headers = [ (key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers.items() @@ -21,13 +36,18 @@ def _build_request(headers: Optional[Dict[str, str]] = None) -> Request: scope = { "type": "http", "http_version": "1.1", - "method": "POST", - "path": "/mcp-rest/test/tools/list", + "method": method, + "path": path, "headers": raw_headers, } + state = {"sent": False} + async def receive(): - return {"type": "http.request", "body": b"", "more_body": False} + if state["sent"]: + return {"type": "http.request", "body": b"", "more_body": False} + state["sent"] = True + return {"type": "http.request", "body": body_bytes, "more_body": False} return Request(scope, receive=receive) @@ -53,146 +73,380 @@ def _route_has_dependency(route, dependency) -> bool: return any(getattr(dep, "call", None) == dependency for dep in dependant.dependencies) -@pytest.mark.asyncio -async def test_execute_with_mcp_client_redacts_stack_trace(monkeypatch): - def fake_create_client(*args, **kwargs): - return object() +class TestExecuteWithMcpClient: + @pytest.mark.asyncio + async def test_redacts_stack_trace(self, monkeypatch): + def fake_create_client(*args, **kwargs): + return object() - monkeypatch.setattr( - rest_endpoints.global_mcp_server_manager, - "_create_mcp_client", - fake_create_client, - ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "_create_mcp_client", + fake_create_client, + ) - async def failing_operation(client): - raise RuntimeError("boom") + async def failing_operation(client): + raise RuntimeError("boom") - payload = NewMCPServerRequest( - server_name="example", - url="https://example.com", - auth_type=MCPAuth.none, - ) + payload = NewMCPServerRequest( + server_name="example", + url="https://example.com", + auth_type=MCPAuth.none, + ) - result = await rest_endpoints._execute_with_mcp_client( - payload, failing_operation - ) + result = await rest_endpoints._execute_with_mcp_client( + payload, failing_operation + ) - assert result["status"] == "error" - assert "stack_trace" not in result + assert result["status"] == "error" + assert "stack_trace" not in result -def test_test_connection_requires_auth_dependency(): - route = _get_route("/mcp-rest/test/connection", "POST") - assert _route_has_dependency(route, user_api_key_auth) +class TestTestConnection: + def test_requires_auth_dependency(self): + route = _get_route("/mcp-rest/test/connection", "POST") + assert _route_has_dependency(route, user_api_key_auth) -@pytest.mark.asyncio -async def test_test_tools_list_forwards_mcp_auth_header(monkeypatch): - """Ensure credential-based auth forwards the auth_value to the MCP client.""" +class TestTestToolsList: + pytestmark = pytest.mark.asyncio - captured: dict = {} + async def test_forwards_mcp_auth_header(self, monkeypatch): + """Ensure credential-based auth forwards the auth_value to the MCP client.""" - async def fake_execute( - request, - operation, - mcp_auth_header=None, - oauth2_headers=None, - raw_headers=None, - ): - captured["mcp_auth_header"] = mcp_auth_header - captured["oauth2_headers"] = oauth2_headers - return { - "tools": [], - "error": None, - "message": "Successfully retrieved tools", + captured: dict = {} + + async def fake_execute( + request, + operation, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers=None, + ): + captured["mcp_auth_header"] = mcp_auth_header + captured["oauth2_headers"] = oauth2_headers + return { + "tools": [], + "error": None, + "message": "Successfully retrieved tools", + } + + monkeypatch.setattr( + rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False + ) + + oauth_call_counter = {"count": 0} + + def fake_oauth(headers): + oauth_call_counter["count"] += 1 + return {"Authorization": "Bearer oauth"} + + monkeypatch.setattr( + auth_mcp.MCPRequestHandler, + "_get_oauth2_headers_from_headers", + staticmethod(fake_oauth), + raising=False, + ) + + request = _build_request() + payload = NewMCPServerRequest( + server_name="example", + url="https://example.com", + auth_type=MCPAuth.api_key, + credentials={"auth_value": "secret-key"}, + ) + + result = await rest_endpoints.test_tools_list( + request, payload, user_api_key_dict=UserAPIKeyAuth() + ) + + assert result["message"] == "Successfully retrieved tools" + assert captured["mcp_auth_header"] == "secret-key" + assert captured["oauth2_headers"] is None + assert oauth_call_counter["count"] == 0 + + async def test_extracts_oauth2_headers(self, monkeypatch): + """Ensure oauth2 auth type pulls oauth headers and omits MCP auth header.""" + + captured: dict = {} + + async def fake_execute( + request, + operation, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers=None, + ): + captured["mcp_auth_header"] = mcp_auth_header + captured["oauth2_headers"] = oauth2_headers + return { + "tools": [], + "error": None, + "message": "Successfully retrieved tools", + } + + monkeypatch.setattr( + rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False + ) + + oauth_headers = {"Authorization": "Bearer oauth"} + oauth_call_counter = {"count": 0} + + def fake_oauth(headers): + oauth_call_counter["count"] += 1 + return oauth_headers + + monkeypatch.setattr( + auth_mcp.MCPRequestHandler, + "_get_oauth2_headers_from_headers", + staticmethod(fake_oauth), + raising=False, + ) + + request = _build_request({"authorization": "Bearer incoming"}) + payload = NewMCPServerRequest( + server_name="example", + url="https://example.com", + auth_type=MCPAuth.oauth2, + ) + + result = await rest_endpoints.test_tools_list( + request, payload, user_api_key_dict=UserAPIKeyAuth() + ) + + assert result["message"] == "Successfully retrieved tools" + assert captured["mcp_auth_header"] is None + assert captured["oauth2_headers"] == oauth_headers + assert oauth_call_counter["count"] == 1 + + +class TestListToolsRestAPI: + pytestmark = pytest.mark.asyncio + + async def test_rejects_disallowed_server(self, monkeypatch): + async def fake_contexts(user_api_key_auth): + return [user_api_key_auth] + + async def fake_get_allowed_mcp_servers(*args, **kwargs): + return [] + + monkeypatch.setattr( + rest_endpoints, + "build_effective_auth_contexts", + fake_contexts, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_allowed_mcp_servers", + fake_get_allowed_mcp_servers, + raising=False, + ) + + request = _build_request(path="/mcp-rest/tools/list", method="GET") + result = await rest_endpoints.list_tool_rest_api( + request, + server_id="server-1", + user_api_key_dict=UserAPIKeyAuth(), + ) + + assert result["tools"] == [] + assert result["error"] == "unexpected_error" + assert "access_denied" in result["message"] + assert "server server-1" in result["message"] + + async def test_lists_tools_for_allowed_server(self, monkeypatch): + async def fake_contexts(user_api_key_auth): + return [user_api_key_auth] + + async def fake_get_allowed_mcp_servers(*args, **kwargs): + return ["server-1"] + + class StubServer: + alias = "server-1" + server_name = "server-1" + name = "stub" + allowed_tools = None + mcp_info = {"server_name": "stub"} + + stub_server = StubServer() + + captured = {"called": False} + + async def fake_get_tools(server, server_auth_header, raw_headers=None): + captured["called"] = True + captured["server"] = server + captured["auth_header"] = server_auth_header + return ["tool-1"] + + monkeypatch.setattr( + rest_endpoints, + "build_effective_auth_contexts", + fake_contexts, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_allowed_mcp_servers", + fake_get_allowed_mcp_servers, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_mcp_server_by_id", + lambda server_id: stub_server if server_id == "server-1" else None, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints, + "_get_tools_for_single_server", + fake_get_tools, + raising=False, + ) + + request = _build_request(path="/mcp-rest/tools/list", method="GET") + result = await rest_endpoints.list_tool_rest_api( + request, + server_id="server-1", + user_api_key_dict=UserAPIKeyAuth(), + ) + + assert captured["called"] is True + assert captured["server"] is stub_server + assert result["tools"] == ["tool-1"] + assert result["error"] is None + assert result["message"] == "Successfully retrieved tools" + + +class TestCallToolRestAPI: + pytestmark = pytest.mark.asyncio + + async def test_rejects_disallowed_server(self, monkeypatch): + async def fake_contexts(user_api_key_auth): + return [user_api_key_auth] + + async def fake_get_allowed_mcp_servers(*args, **kwargs): + return [] + + async def fake_add_litellm_data_to_request(**kwargs): + return kwargs.get("data", {}) + + monkeypatch.setattr( + rest_endpoints, + "build_effective_auth_contexts", + fake_contexts, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_allowed_mcp_servers", + fake_get_allowed_mcp_servers, + raising=False, + ) + monkeypatch.setattr( + "litellm.proxy.proxy_server.add_litellm_data_to_request", + fake_add_litellm_data_to_request, + raising=False, + ) + + request_payload = { + "server_id": "server-1", + "name": "demo-tool", + "arguments": {"foo": "bar"}, } + request = _build_request( + path="/mcp-rest/tools/call", + method="POST", + json_body=request_payload, + ) - monkeypatch.setattr( - rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False - ) + with pytest.raises(HTTPException) as exc_info: + await rest_endpoints.call_tool_rest_api( + request, + user_api_key_dict=UserAPIKeyAuth(), + ) - oauth_call_counter = {"count": 0} + assert exc_info.value.status_code == 403 + assert exc_info.value.detail["error"] == "access_denied" + assert "server server-1" in exc_info.value.detail["message"] - def fake_oauth(headers): - oauth_call_counter["count"] += 1 - return {"Authorization": "Bearer oauth"} + async def test_executes_tool_when_allowed(self, monkeypatch): + async def fake_contexts(user_api_key_auth): + return [user_api_key_auth] - monkeypatch.setattr( - auth_mcp.MCPRequestHandler, - "_get_oauth2_headers_from_headers", - staticmethod(fake_oauth), - raising=False, - ) + async def fake_get_allowed_mcp_servers(*args, **kwargs): + return ["server-1"] - request = _build_request() - payload = NewMCPServerRequest( - server_name="example", - url="https://example.com", - auth_type=MCPAuth.api_key, - credentials={"auth_value": "secret-key"}, - ) + class StubServer: + alias = "server-1" + server_name = "server-1" + name = "stub" + allowed_tools = None + mcp_info = {"server_name": "stub"} - result = await rest_endpoints.test_tools_list( - request, payload, user_api_key_dict=UserAPIKeyAuth() - ) + stub_server = StubServer() - assert result["message"] == "Successfully retrieved tools" - assert captured["mcp_auth_header"] == "secret-key" - assert captured["oauth2_headers"] is None - assert oauth_call_counter["count"] == 0 + async def fake_add_litellm_data_to_request(**kwargs): + return kwargs.get("data", {}) + captured = {} -@pytest.mark.asyncio -async def test_test_tools_list_extracts_oauth2_headers(monkeypatch): - """Ensure oauth2 auth type pulls oauth headers and omits MCP auth header.""" + async def fake_execute_mcp_tool(**kwargs): + captured.update(kwargs) + return {"result": "ok"} - captured: dict = {} + monkeypatch.setattr( + rest_endpoints, + "build_effective_auth_contexts", + fake_contexts, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_allowed_mcp_servers", + fake_get_allowed_mcp_servers, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_mcp_server_by_id", + lambda server_id: stub_server if server_id == "server-1" else None, + raising=False, + ) + monkeypatch.setattr( + "litellm.proxy.proxy_server.add_litellm_data_to_request", + fake_add_litellm_data_to_request, + raising=False, + ) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_config", + {}, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints, + "execute_mcp_tool", + fake_execute_mcp_tool, + raising=False, + ) - async def fake_execute( - request, - operation, - mcp_auth_header=None, - oauth2_headers=None, - raw_headers=None, - ): - captured["mcp_auth_header"] = mcp_auth_header - captured["oauth2_headers"] = oauth2_headers - return { - "tools": [], - "error": None, - "message": "Successfully retrieved tools", + request_payload = { + "server_id": "server-1", + "name": "demo-tool", + "arguments": {"foo": "bar"}, } + request = _build_request( + path="/mcp-rest/tools/call", + method="POST", + json_body=request_payload, + ) - monkeypatch.setattr( - rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False - ) + result = await rest_endpoints.call_tool_rest_api( + request, + user_api_key_dict=UserAPIKeyAuth(), + ) - oauth_headers = {"Authorization": "Bearer oauth"} - oauth_call_counter = {"count": 0} - - def fake_oauth(headers): - oauth_call_counter["count"] += 1 - return oauth_headers - - monkeypatch.setattr( - auth_mcp.MCPRequestHandler, - "_get_oauth2_headers_from_headers", - staticmethod(fake_oauth), - raising=False, - ) - - request = _build_request({"authorization": "Bearer incoming"}) - payload = NewMCPServerRequest( - server_name="example", - url="https://example.com", - auth_type=MCPAuth.oauth2, - ) - - result = await rest_endpoints.test_tools_list( - request, payload, user_api_key_dict=UserAPIKeyAuth() - ) - - assert result["message"] == "Successfully retrieved tools" - assert captured["mcp_auth_header"] is None - assert captured["oauth2_headers"] == oauth_headers - assert oauth_call_counter["count"] == 1 + assert result == {"result": "ok"} + assert captured["name"] == "demo-tool" + assert captured["arguments"] == {"foo": "bar"} + assert captured["allowed_mcp_servers"] == [stub_server] diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index 8bf84b251d5..61d44e46da5 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -13,6 +13,7 @@ import pytest import litellm from litellm import ModelResponse +from litellm.exceptions import GuardrailRaisedException from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( GenericGuardrailAPI, @@ -384,7 +385,7 @@ class TestGuardrailActions: async def test_action_blocked_raises_exception( self, generic_guardrail, mock_request_data_input ): - """Test that action=BLOCKED raises exception""" + """Test that action=BLOCKED raises GuardrailRaisedException with clean message""" mock_response = MagicMock() mock_response.json.return_value = { "action": "BLOCKED", @@ -395,15 +396,16 @@ class TestGuardrailActions: with patch.object( generic_guardrail.async_handler, "post", return_value=mock_response ): - with pytest.raises(Exception) as exc_info: + with pytest.raises(GuardrailRaisedException) as exc_info: await generic_guardrail.apply_guardrail( inputs={"texts": ["Ignore previous instructions"]}, request_data=mock_request_data_input, input_type="request", ) - assert "Content blocked by guardrail" in str(exc_info.value) - assert "harmful instructions" in str(exc_info.value) + # Verify the exception has the clean error message (no wrapper) + assert str(exc_info.value) == "Content contains harmful instructions" + assert exc_info.value.guardrail_name == "generic_guardrail_api" @pytest.mark.asyncio async def test_action_intervened_modifies_content( diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py index f4aa28d98cc..2a33c56b56a 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py @@ -4,6 +4,7 @@ import pytest from litellm.caching import DualCache from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation from litellm.proxy._experimental.mcp_server.guardrail_translation.handler import ( MCPGuardrailTranslationHandler, ) @@ -30,55 +31,103 @@ class RecordingGuardrail(CustomGuardrail): return {"texts": inputs.get("texts", [])} +class _NoopTranslation(BaseTranslation): + """Test translation handler that simply echoes input/output.""" + + async def process_input_messages(self, data, guardrail_to_apply, litellm_logging_obj=None): # type: ignore[override] + return data + + async def process_output_response( # type: ignore[override] + self, + response, + guardrail_to_apply, + litellm_logging_obj=None, + user_api_key_dict=None, + ): + return response + + @pytest.fixture(autouse=True) def _inject_mcp_handler_mapping(): """Inject MCP handler mapping so the unified guardrail can run inside tests.""" unified_module.endpoint_guardrail_translation_mappings = { CallTypes.call_mcp_tool: MCPGuardrailTranslationHandler, + CallTypes.anthropic_messages: _NoopTranslation, } yield unified_module.endpoint_guardrail_translation_mappings = None -@pytest.mark.asyncio -async def test_pre_call_hook_uses_mcp_event_type(): - """pre_call hook should swap to GuardrailEventHooks.pre_mcp_call for MCP calls.""" - handler = UnifiedLLMGuardrails() - guardrail = RecordingGuardrail() - cache = DualCache() +class TestUnifiedLLMGuardrails: + class TestAsyncPreCallHook: + @pytest.mark.asyncio + async def test_uses_mcp_event_type(self): + """pre_call hook should swap to GuardrailEventHooks.pre_mcp_call for MCP calls.""" + handler = UnifiedLLMGuardrails() + guardrail = RecordingGuardrail() + cache = DualCache() - data = { - "guardrail_to_apply": guardrail, - "messages": [{"role": "user", "content": "Tool: test\nArguments: {}"}], - "model": "mcp-tool-call", - } + data = { + "guardrail_to_apply": guardrail, + "messages": [ + {"role": "user", "content": "Tool: test\nArguments: {}"} + ], + "model": "mcp-tool-call", + } - await handler.async_pre_call_hook( - user_api_key_dict=None, - cache=cache, - data=data, - call_type=CallTypes.call_mcp_tool.value, - ) + await handler.async_pre_call_hook( + user_api_key_dict=None, + cache=cache, + data=data, + call_type=CallTypes.call_mcp_tool.value, + ) - assert guardrail.event_history == [GuardrailEventHooks.pre_mcp_call] + assert guardrail.event_history == [GuardrailEventHooks.pre_mcp_call] + class TestAsyncModerationHook: + @pytest.mark.asyncio + async def test_uses_mcp_event_type(self): + """moderation hook should request GuardrailEventHooks.during_mcp_call for MCP calls.""" + handler = UnifiedLLMGuardrails() + guardrail = RecordingGuardrail() -@pytest.mark.asyncio -async def test_moderation_hook_uses_mcp_event_type(): - """moderation hook should request GuardrailEventHooks.during_mcp_call for MCP calls.""" - handler = UnifiedLLMGuardrails() - guardrail = RecordingGuardrail() + data = { + "guardrail_to_apply": guardrail, + "messages": [ + {"role": "user", "content": "Tool: test\nArguments: {}"} + ], + "model": "mcp-tool-call", + } - data = { - "guardrail_to_apply": guardrail, - "messages": [{"role": "user", "content": "Tool: test\nArguments: {}"}], - "model": "mcp-tool-call", - } + await handler.async_moderation_hook( + data=data, + user_api_key_dict=None, + call_type=CallTypes.call_mcp_tool.value, + ) - await handler.async_moderation_hook( - data=data, - user_api_key_dict=None, - call_type=CallTypes.call_mcp_tool.value, - ) + assert guardrail.event_history == [GuardrailEventHooks.during_mcp_call] - assert guardrail.event_history == [GuardrailEventHooks.during_mcp_call] + @pytest.mark.asyncio + async def test_runs_for_anthropic_messages(self): + """Ensure anthropic_messages requests still trigger guardrail moderation.""" + handler = UnifiedLLMGuardrails() + guardrail = RecordingGuardrail() + + data = { + "guardrail_to_apply": guardrail, + "messages": [ + { + "role": "user", + "content": "Hello Anthropics", + } + ], + "model": "anthropic.claude-3", + } + + await handler.async_moderation_hook( + data=data, + user_api_key_dict=None, + call_type=CallTypes.anthropic_messages.value, + ) + + assert guardrail.event_history == [GuardrailEventHooks.during_call] diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_transformations.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_transformations.py index bacdfb225fb..e5857a10967 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_transformations.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_transformations.py @@ -1,14 +1,8 @@ -import asyncio -import json import os import sys -from litellm._uuid import uuid -from typing import Optional, cast from unittest.mock import AsyncMock, MagicMock, patch import pytest -from fastapi import HTTPException -from fastapi.testclient import TestClient sys.path.insert( 0, os.path.abspath("../../../") @@ -19,10 +13,7 @@ from litellm.proxy.management_endpoints.scim.scim_transformations import ( ScimTransformations, ) from litellm.types.proxy.management_endpoints.scim_v2 import ( - SCIMGroup, - SCIMPatchOp, SCIMPatchOperation, - SCIMUser, ) @@ -229,6 +220,63 @@ class TestScimTransformations: result = ScimTransformations._get_scim_member_value(member_without_email) assert result == member_without_email.user_id + @pytest.mark.asyncio + async def test_transform_user_with_uuid_as_email(self, mock_prisma_client): + """ + Test that users with UUID in user_email don't cause validation errors. + This tests the defensive fix that validates email contains '@' before creating SCIMUserEmail. + """ + mock_client, mock_find_unique = mock_prisma_client + + user_with_uuid_email = LiteLLM_UserTable( + user_id="21df4e37-2f38-4f2e-a21b-c33cb939ff5b", + user_email="21df4e37-2f38-4f2e-a21b-c33cb939ff5b", # UUID as email (bug scenario) + user_alias=None, + teams=[], + created_at=None, + updated_at=None, + metadata={}, + ) + + mock_find_unique.return_value = None + + with patch("litellm.proxy.proxy_server.prisma_client", mock_client): + scim_user = await ScimTransformations.transform_litellm_user_to_scim_user( + user_with_uuid_email + ) + + assert scim_user.id == user_with_uuid_email.user_id + assert scim_user.emails is None or len(scim_user.emails) == 0 + + @pytest.mark.asyncio + async def test_transform_user_with_none_email(self, mock_prisma_client): + """ + Test that users with user_email=None are transformed correctly. + This tests the root cause fix. + """ + mock_client, mock_find_unique = mock_prisma_client + + user_with_none_email = LiteLLM_UserTable( + user_id="user-from-group", + user_email=None, + user_alias=None, + teams=[], + created_at=None, + updated_at=None, + metadata={}, + ) + + mock_find_unique.return_value = None + + with patch("litellm.proxy.proxy_server.prisma_client", mock_client): + scim_user = await ScimTransformations.transform_litellm_user_to_scim_user( + user_with_none_email + ) + + assert scim_user.id == user_with_none_email.user_id + assert scim_user.emails is None or len(scim_user.emails) == 0 + + class TestSCIMPatchOperations: """Test SCIM PATCH operation validation and case-insensitive handling""" diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index 6a8b1a9e2fb..f8affda25d6 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -3,10 +3,12 @@ from unittest.mock import AsyncMock import pytest from fastapi import HTTPException -from litellm.proxy._types import LitellmUserRoles, NewUserRequest, ProxyException +from litellm.proxy._types import LitellmUserRoles, NewUserRequest, NewUserResponse, ProxyException from litellm.proxy.management_endpoints.scim.scim_v2 import ( UserProvisionerHelpers, + _extract_group_member_ids, _handle_team_membership_changes, + _process_group_patch_operations, create_group, create_user, get_service_provider_config, @@ -16,7 +18,6 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import ( update_user, ) from litellm.types.proxy.management_endpoints.scim_v2 import ( - SCIMFeature, SCIMGroup, SCIMMember, SCIMPatchOp, @@ -429,7 +430,7 @@ async def test_update_user_success(mocker): "litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes", AsyncMock() ) - mock_transform = mocker.patch( + mocker.patch( "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user", AsyncMock(return_value=response_scim_user) ) @@ -525,7 +526,7 @@ async def test_patch_user_success(mocker): "litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes", AsyncMock() ) - mock_transform = mocker.patch( + mocker.patch( "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user", AsyncMock(return_value=response_scim_user) ) @@ -661,7 +662,7 @@ async def test_update_group_metadata_serialization_issue(mocker): ) # Call the function that had the bug - result = await update_group(group_id=group_id, group=scim_group) + await update_group(group_id=group_id, group=scim_group) # Verify the team update was called mock_prisma_client.db.litellm_teamtable.update.assert_called_once() @@ -697,7 +698,6 @@ async def test_team_membership_management(mocker): from litellm.proxy.management_endpoints.scim.scim_v2 import ( _get_team_member_user_ids_from_team, _handle_group_membership_changes, - patch_team_membership, ) # Mock team with members_with_roles as source of truth @@ -773,7 +773,6 @@ async def test_update_group_e2e(mocker): from litellm.proxy.management_endpoints.scim.scim_transformations import ( ScimTransformations, ) - from litellm.proxy.utils import safe_dumps # Setup test data group_id = "test-team-123" @@ -916,11 +915,23 @@ async def test_update_group_e2e(mocker): @pytest.mark.asyncio -async def test_create_group_with_nonexistent_users_creates_users(mocker): +async def test_create_group_with_nonexistent_users_rejects(mocker, monkeypatch): """ - Test that creating a group with non-existent users creates those users. - This tests the scenario: Group Push ['new user', existing users...] + Test that creating a group with non-existent users is rejected when scim_upsert_user is False. + Per SCIM 2.0 protocol, users must exist before being added to groups. + This prevents security issues where users not assigned to app get provisioned via group membership. """ + # Mock the feature flag to False (SCIM 2.0 strict mode) + async def mock_get_config(): + return { + "litellm_settings": { + "scim_upsert_user": False + } + } + + from litellm.proxy.proxy_server import proxy_config + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + # Test data group_id = "test-group-123" scim_group = SCIMGroup( @@ -935,7 +946,7 @@ async def test_create_group_with_nonexistent_users_creates_users(mocker): ) ######################################################### - # We expect new-user-1 and new-user-2 to be created + # We expect the request to be rejected with 400 error ######################################################### # Mock prisma client @@ -964,96 +975,33 @@ async def test_create_group_with_nonexistent_users_creates_users(mocker): AsyncMock(return_value=mock_prisma_client) ) - # Mock new_user function to track user creation - mock_new_user = mocker.patch( - "litellm.proxy.management_endpoints.internal_user_endpoints.new_user", - AsyncMock() - ) + # Execute the create_group function - should raise ProxyException + with pytest.raises(ProxyException) as exc_info: + await create_group(group=scim_group) - # Mock created users return values - def mock_new_user_side_effect(data): - from litellm.proxy._types import NewUserResponse - return NewUserResponse( - key="sk-test-key-" + data.user_id, # Required field from GenerateKeyResponse - user_id=data.user_id, - user_email=data.user_email, - metadata=data.metadata, - teams=data.teams, - user_role=data.user_role - ) - - mock_new_user.side_effect = mock_new_user_side_effect - - # Mock new_team function - mock_created_team = mocker.MagicMock() - mock_created_team.team_id = group_id - mock_created_team.team_alias = "Test Group" - - mock_new_team = mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2.new_team", - AsyncMock(return_value=mock_created_team) - ) - - # Mock SCIM transformation - expected_scim_response = SCIMGroup( - schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], - id=group_id, - displayName="Test Group", - members=[ - SCIMMember(value="existing-user", display="existing-user"), - SCIMMember(value="new-user-1", display="new-user-1"), - SCIMMember(value="new-user-2", display="new-user-2") - ] - ) - mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group", - AsyncMock(return_value=expected_scim_response) - ) - - # Execute the create_group function - result = await create_group(group=scim_group) - - ######################################################### - # Assert that new-user-1 and new-user-2 were created - ######################################################### - - # Verify that new_user was called exactly twice (for new-user-1 and new-user-2) - assert mock_new_user.call_count == 2 - - # Check the user creation calls - created_user_ids = set() - for call in mock_new_user.call_args_list: - user_request = call.kwargs["data"] - created_user_ids.add(user_request.user_id) - assert user_request.metadata["created_via"] == "scim_group_membership" - assert user_request.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY - assert user_request.auto_create_key is False - assert user_request.teams == [] # Teams added separately - - assert created_user_ids == {"new-user-1", "new-user-2"} - - # Verify team creation was called with all members (existing + created) - mock_new_team.assert_called_once() - team_request = mock_new_team.call_args.kwargs["data"] - assert team_request.team_id == group_id - assert team_request.team_alias == "Test Group" - - # Verify all members are in the team (existing + newly created) - member_user_ids = {member.user_id for member in team_request.members_with_roles} - assert member_user_ids == {"existing-user", "new-user-1", "new-user-2"} - - # Verify response - assert result.id == group_id - assert result.displayName == "Test Group" - assert len(result.members) == 3 + # Verify it's a 400 Bad Request + assert int(exc_info.value.code) == 400 + assert "does not exist" in str(exc_info.value.message) + assert "new-user-1" in str(exc_info.value.message) or "new-user-2" in str(exc_info.value.message) @pytest.mark.asyncio -async def test_update_group_with_nonexistent_users_creates_users(mocker): +async def test_update_group_with_nonexistent_users_rejects(mocker, monkeypatch): """ - Test that updating a group with non-existent users creates those users. - This tests the scenario where a group is updated with members that don't exist in user table. + Test that updating a group with non-existent users is rejected when scim_upsert_user is False. + Per SCIM 2.0 protocol, users must exist before being added to groups. """ + # Mock the feature flag to False (SCIM 2.0 strict mode) + async def mock_get_config(): + return { + "litellm_settings": { + "scim_upsert_user": False + } + } + + from litellm.proxy.proxy_server import proxy_config + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + # Test data group_id = "existing-group-456" @@ -1115,156 +1063,43 @@ async def test_update_group_with_nonexistent_users_creates_users(mocker): AsyncMock(return_value=mock_existing_team) ) - # Mock new_user function to track user creation - mock_new_user = mocker.patch( - "litellm.proxy.management_endpoints.internal_user_endpoints.new_user", - AsyncMock() - ) + # Execute the update_group function - should raise ProxyException + with pytest.raises(ProxyException) as exc_info: + await update_group(group_id=group_id, group=scim_group_update) - # Mock created users return values - def mock_new_user_side_effect(data): - from litellm.proxy._types import NewUserResponse - return NewUserResponse( - key="sk-test-key-" + data.user_id, # Required field from GenerateKeyResponse - user_id=data.user_id, - user_email=data.user_email, - metadata=data.metadata, - teams=data.teams, - user_role=data.user_role - ) - - mock_new_user.side_effect = mock_new_user_side_effect - - # Mock group membership changes - mock_handle_group_membership_changes = mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2._handle_group_membership_changes", - AsyncMock() - ) - - # Mock SCIM transformation - expected_scim_response = SCIMGroup( - schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], - id=group_id, - displayName="Updated Group Name", - members=[ - SCIMMember(value="existing-user", display="existing-user"), - SCIMMember(value="new-user-3", display="new-user-3"), - SCIMMember(value="new-user-4", display="new-user-4") - ] - ) - mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group", - AsyncMock(return_value=expected_scim_response) - ) - - # Execute the update_group function - result = await update_group(group_id=group_id, group=scim_group_update) - - # Verify that new_user was called exactly twice (for new-user-3 and new-user-4) - assert mock_new_user.call_count == 2 - - # Check the user creation calls - created_user_ids = set() - for call in mock_new_user.call_args_list: - user_request = call.kwargs["data"] - created_user_ids.add(user_request.user_id) - assert user_request.metadata["created_via"] == "scim_group_membership" - assert user_request.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY - assert user_request.auto_create_key is False - assert user_request.teams == [] # Teams added separately - - assert created_user_ids == {"new-user-3", "new-user-4"} - - # Verify team update was called - mock_prisma_client.db.litellm_teamtable.update.assert_called_once() - update_call = mock_prisma_client.db.litellm_teamtable.update.call_args - assert update_call[1]["where"]["team_id"] == group_id - assert update_call[1]["data"]["team_alias"] == "Updated Group Name" - - # Verify group membership changes were handled with all members (existing + created) - mock_handle_group_membership_changes.assert_called_once() - membership_call = mock_handle_group_membership_changes.call_args - assert membership_call[1]["group_id"] == group_id - assert membership_call[1]["final_members"] == {"existing-user", "new-user-3", "new-user-4"} - - # Verify response - assert result.id == group_id - assert result.displayName == "Updated Group Name" - assert len(result.members) == 3 + # Verify it's a 400 Bad Request + assert int(exc_info.value.code) == 400 + assert "does not exist" in str(exc_info.value.message) + assert "new-user-3" in str(exc_info.value.message) or "new-user-4" in str(exc_info.value.message) @pytest.mark.asyncio -async def test_patch_group_refreshes_team_data_to_prevent_race_conditions(mocker): +async def test_create_group_with_nonexistent_users_creates_when_flag_true(mocker, monkeypatch): """ - Test that patch_group refreshes team data from database: - 1. After applying updates (to get latest state before membership changes) - 2. After membership changes (to get final state for response) - - This prevents race conditions when multiple PATCH requests come in simultaneously. + Test that creating a group with non-existent users creates them when scim_upsert_user is True. + This preserves backward compatible behavior. """ - from litellm.proxy._types import LiteLLM_TeamTable, Member + # Mock the feature flag to True (backward compatible mode) + async def mock_get_config(): + return { + "litellm_settings": { + "scim_upsert_user": True + } + } + from litellm.proxy.proxy_server import proxy_config + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + + # Test data group_id = "test-group-123" - - # Mock existing team - existing_team = LiteLLM_TeamTable( - team_id=group_id, - team_alias="Original Team", - members=["user1", "user2"], - members_with_roles=[ - Member(user_id="user1", role="user"), - Member(user_id="user2", role="user") - ], - metadata={} - ) - - # Mock team after applying updates (simulating what _apply_group_patch_updates returns) - updated_team_after_patch = LiteLLM_TeamTable( - team_id=group_id, - team_alias="Updated Team", - members=["user1", "user2", "user3"], # user3 added in patch - members_with_roles=[ - Member(user_id="user1", role="user"), - Member(user_id="user2", role="user"), - Member(user_id="user3", role="user") - ], - metadata={} - ) - - # Mock refreshed team (simulating concurrent update - user4 was added by another request) - refreshed_team_before_membership = LiteLLM_TeamTable( - team_id=group_id, - team_alias="Updated Team", - members=["user1", "user2", "user3", "user4"], # user4 added concurrently - members_with_roles=[ - Member(user_id="user1", role="user"), - Member(user_id="user2", role="user"), - Member(user_id="user3", role="user"), - Member(user_id="user4", role="user") # Concurrent addition - ], - metadata={} - ) - - # Mock final refreshed team after membership changes - final_refreshed_team = LiteLLM_TeamTable( - team_id=group_id, - team_alias="Updated Team", - members=["user1", "user2", "user3", "user4", "user5"], # user5 added via membership change - members_with_roles=[ - Member(user_id="user1", role="user"), - Member(user_id="user2", role="user"), - Member(user_id="user3", role="user"), - Member(user_id="user4", role="user"), - Member(user_id="user5", role="user") # Added via membership change - ], - metadata={} - ) - - # Mock SCIM patch operations - adding user3 and user5 - patch_ops = SCIMPatchOp( - schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - Operations=[ - SCIMPatchOperation(op="add", path="members", value=[{"value": "user3"}, {"value": "user5"}]) + scim_group = SCIMGroup( + schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], + id=group_id, + displayName="Test Group", + members=[ + SCIMMember(value="existing-user", display="Existing User"), # This user exists + SCIMMember(value="new-user-1", display="New User 1"), # This user doesn't exist - should be created + SCIMMember(value="new-user-2", display="New User 2"), # This user doesn't exist - should be created ] ) @@ -1274,120 +1109,312 @@ async def test_patch_group_refreshes_team_data_to_prevent_race_conditions(mocker mock_prisma_client.db.litellm_teamtable = mocker.MagicMock() mock_prisma_client.db.litellm_usertable = mocker.MagicMock() - # Mock user lookups (all users exist) - mock_user = mocker.MagicMock() - mock_user.user_id = "test-user" - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user) + # Mock team operations - team doesn't exist yet + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None) + + # Mock user lookup - only existing-user exists initially + def mock_user_lookup(where): + user_id = where["user_id"] + if user_id == "existing-user": + mock_user = mocker.MagicMock() + mock_user.user_id = user_id + return mock_user + return None # new-user-1 and new-user-2 don't exist + + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup) + + # Mock user creation + created_user_1 = NewUserResponse(user_id="new-user-1", key="test-key-1") + created_user_2 = NewUserResponse(user_id="new-user-2", key="test-key-2") + mock_create_user = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._create_user_if_not_exists", + AsyncMock(side_effect=[created_user_1, created_user_2]) + ) + + # Mock new_team + mock_team = mocker.MagicMock() + mock_team.team_id = group_id + mock_new_team = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.new_team", + AsyncMock(return_value=mock_team) + ) + + # Mock transformation + mock_scim_group = SCIMGroup( + schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], + id=group_id, + displayName="Test Group", + members=[] + ) + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group", + AsyncMock(return_value=mock_scim_group) + ) # Mock dependencies mocker.patch( "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", AsyncMock(return_value=mock_prisma_client) ) - mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2._check_team_exists", - AsyncMock(return_value=existing_team) - ) - # Mock _process_group_patch_operations - mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2._process_group_patch_operations", - AsyncMock(return_value=( - {"team_alias": "Updated Team"}, - {"user1", "user2", "user3", "user5"} # final_members after processing patch - )) - ) + # Execute the create_group function - should succeed + result = await create_group(group=scim_group) - # Mock _apply_group_patch_updates to return updated_team_after_patch - mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2._apply_group_patch_updates", - AsyncMock(return_value=updated_team_after_patch) - ) + # Verify users were created + assert mock_create_user.call_count == 2 + assert mock_create_user.call_args_list[0].kwargs['user_id'] == "new-user-1" + assert mock_create_user.call_args_list[1].kwargs['user_id'] == "new-user-2" - # Mock find_unique calls for refresh operations - # First refresh (after applying updates) - returns team with concurrent update (user4) - # Second refresh (after membership changes) - returns final team (with user5) - # Need to add model_dump() method to mock Prisma model objects - mock_refreshed_team_before_membership = mocker.MagicMock() - # model_dump() should return a dict that can be used to construct LiteLLM_TeamTable - mock_refreshed_team_before_membership.model_dump = mocker.Mock(return_value={ - "team_id": refreshed_team_before_membership.team_id, - "team_alias": refreshed_team_before_membership.team_alias, - "members": refreshed_team_before_membership.members, - "members_with_roles": refreshed_team_before_membership.members_with_roles, - "metadata": refreshed_team_before_membership.metadata, - }) + # Verify team was created + mock_new_team.assert_called_once() + + +@pytest.mark.asyncio +async def test_extract_group_member_ids_with_flag_true_creates_users(mocker, monkeypatch): + """ + Test that _extract_group_member_ids creates users when scim_upsert_user is True. + """ + # Mock the feature flag to True (backward compatible mode) + async def mock_get_config(): + return { + "litellm_settings": { + "scim_upsert_user": True + } + } - mock_final_refreshed_team = mocker.MagicMock() - mock_final_refreshed_team.model_dump = mocker.Mock(return_value={ - "team_id": final_refreshed_team.team_id, - "team_alias": final_refreshed_team.team_alias, - "members": final_refreshed_team.members, - "members_with_roles": final_refreshed_team.members_with_roles, - "metadata": final_refreshed_team.metadata, - }) + from litellm.proxy.proxy_server import proxy_config + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) - refresh_calls = [mock_refreshed_team_before_membership, mock_final_refreshed_team] - mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(side_effect=refresh_calls) - - # Mock _handle_group_membership_changes - mock_handle_group_membership_changes = mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2._handle_group_membership_changes", - AsyncMock() - ) - - # Mock SCIM transformation - expected_scim_response = SCIMGroup( + # Test data + scim_group = SCIMGroup( schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], - id=group_id, - displayName="Updated Team", + id="test-group", + displayName="Test Group", members=[ - SCIMMember(value="user1", display="user1"), - SCIMMember(value="user2", display="user2"), - SCIMMember(value="user3", display="user3"), - SCIMMember(value="user4", display="user4"), - SCIMMember(value="user5", display="user5") + SCIMMember(value="existing-user", display="Existing User"), # This user exists + SCIMMember(value="new-user-1", display="New User 1"), # This user doesn't exist - should be created ] ) - mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group", - AsyncMock(return_value=expected_scim_response) + + # Mock prisma client + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + + # Mock user lookup - only existing-user exists initially + def mock_user_lookup(where): + user_id = where["user_id"] + if user_id == "existing-user": + mock_user = mocker.MagicMock() + mock_user.user_id = user_id + return mock_user + return None # new-user-1 doesn't exist + + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup) + + # Mock user creation + created_user = NewUserResponse(user_id="new-user-1", key="test-key-1") + mock_create_user = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._create_user_if_not_exists", + AsyncMock(return_value=created_user) ) - # Execute patch_group - result = await patch_group(group_id=group_id, patch_ops=patch_ops) + # Mock dependencies + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=mock_prisma_client) + ) - # Verify that find_unique was called twice (for the two refreshes) - assert mock_prisma_client.db.litellm_teamtable.find_unique.call_count == 2 + # Execute the function + result = await _extract_group_member_ids(scim_group) - # Verify first refresh was called after applying updates - first_refresh_call = mock_prisma_client.db.litellm_teamtable.find_unique.call_args_list[0] - assert first_refresh_call[1]["where"]["team_id"] == group_id + # Verify result + assert "existing-user" in result.existing_member_ids + assert "existing-user" in result.all_member_ids + assert "new-user-1" in result.all_member_ids + assert len(result.created_users) == 1 - # Verify that _handle_group_membership_changes was called with refreshed members - # It should use refreshed_current_members (user1, user2, user3, user4) not updated_team_after_patch members - mock_handle_group_membership_changes.assert_called_once() - membership_call = mock_handle_group_membership_changes.call_args - # _handle_group_membership_changes is called with positional arguments: (group_id, current_members, final_members) - assert membership_call[0][0] == group_id - # current_members should be from refreshed_team_before_membership (includes user4 from concurrent update) - assert membership_call[0][1] == {"user1", "user2", "user3", "user4"} - # final_members should be from patch operations (user1, user2, user3, user5) - assert membership_call[0][2] == {"user1", "user2", "user3", "user5"} + # Verify user was created + mock_create_user.assert_called_once_with( + user_id="new-user-1", + created_via="scim_group_membership" + ) + + +@pytest.mark.asyncio +async def test_extract_group_member_ids_with_flag_false_rejects(mocker, monkeypatch): + """ + Test that _extract_group_member_ids rejects non-existent users when scim_upsert_user is False. + """ + # Mock the feature flag to False (SCIM 2.0 strict mode) + async def mock_get_config(): + return { + "litellm_settings": { + "scim_upsert_user": False + } + } - # Verify second refresh was called after membership changes - second_refresh_call = mock_prisma_client.db.litellm_teamtable.find_unique.call_args_list[1] - assert second_refresh_call[1]["where"]["team_id"] == group_id + from litellm.proxy.proxy_server import proxy_config + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) - # Verify SCIM transformation was called with final_refreshed_team (not updated_team_after_patch) - from litellm.proxy.management_endpoints.scim.scim_v2 import ScimTransformations - ScimTransformations.transform_litellm_team_to_scim_group.assert_called_once() - transform_call = ScimTransformations.transform_litellm_team_to_scim_group.call_args[0][0] - # Verify it was called with final_refreshed_team (has user5) - assert isinstance(transform_call, LiteLLM_TeamTable) - member_ids = {member.user_id for member in transform_call.members_with_roles} - assert member_ids == {"user1", "user2", "user3", "user4", "user5"} + # Test data + scim_group = SCIMGroup( + schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], + id="test-group", + displayName="Test Group", + members=[ + SCIMMember(value="existing-user", display="Existing User"), # This user exists + SCIMMember(value="new-user-1", display="New User 1"), # This user doesn't exist - should be rejected + ] + ) - # Verify response - assert result.id == group_id - assert result.displayName == "Updated Team" \ No newline at end of file + # Mock prisma client + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + + # Mock user lookup - only existing-user exists + def mock_user_lookup(where): + user_id = where["user_id"] + if user_id == "existing-user": + mock_user = mocker.MagicMock() + mock_user.user_id = user_id + return mock_user + return None # new-user-1 doesn't exist + + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup) + + # Mock dependencies + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=mock_prisma_client) + ) + + # Execute the function - should raise HTTPException + with pytest.raises(HTTPException) as exc_info: + await _extract_group_member_ids(scim_group) + + # Verify it's a 400 Bad Request + assert exc_info.value.status_code == 400 + assert "does not exist" in str(exc_info.value.detail) + assert "new-user-1" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_process_group_patch_operations_with_flag_true_creates_users(mocker, monkeypatch): + """ + Test that _process_group_patch_operations creates users when scim_upsert_user is True. + """ + # Mock the feature flag to True (backward compatible mode) + async def mock_get_config(): + return { + "litellm_settings": { + "scim_upsert_user": True + } + } + + from litellm.proxy.proxy_server import proxy_config + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + + # Test data + patch_ops = SCIMPatchOp( + schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations=[ + SCIMPatchOperation( + op="add", + path="members", + value=[{"value": "new-user-1"}] + ) + ] + ) + + # Mock existing team + mock_existing_team = mocker.MagicMock() + mock_existing_team.members = [] + mock_existing_team.metadata = {} + + # Mock prisma client + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + + # Mock user lookup - new-user-1 doesn't exist + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + + # Mock user creation + created_user = NewUserResponse(user_id="new-user-1", key="test-key-1") + mock_create_user = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._create_user_if_not_exists", + AsyncMock(return_value=created_user) + ) + + # Execute the function + update_data, final_members = await _process_group_patch_operations( + patch_ops=patch_ops, + existing_team=mock_existing_team, + prisma_client=mock_prisma_client + ) + + # Verify result + assert "new-user-1" in final_members + + # Verify user was created + mock_create_user.assert_called_once_with( + user_id="new-user-1", + created_via="scim_group_patch" + ) + + +@pytest.mark.asyncio +async def test_process_group_patch_operations_with_flag_false_rejects(mocker, monkeypatch): + """ + Test that _process_group_patch_operations rejects non-existent users when scim_upsert_user is False. + """ + # Mock the feature flag to False (SCIM 2.0 strict mode) + async def mock_get_config(): + return { + "litellm_settings": { + "scim_upsert_user": False + } + } + + from litellm.proxy.proxy_server import proxy_config + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + + # Test data + patch_ops = SCIMPatchOp( + schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations=[ + SCIMPatchOperation( + op="add", + path="members", + value=[{"value": "new-user-1"}] + ) + ] + ) + + # Mock existing team + mock_existing_team = mocker.MagicMock() + mock_existing_team.members = [] + mock_existing_team.metadata = {} + + # Mock prisma client + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + + # Mock user lookup - new-user-1 doesn't exist + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + + # Execute the function - should raise HTTPException + with pytest.raises(HTTPException) as exc_info: + await _process_group_patch_operations( + patch_ops=patch_ops, + existing_team=mock_existing_team, + prisma_client=mock_prisma_client + ) + + # Verify it's a 400 Bad Request + assert exc_info.value.status_code == 400 + assert "does not exist" in str(exc_info.value.detail) + assert "new-user-1" in str(exc_info.value.detail) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 0bb9924af82..368e2103590 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -1188,11 +1188,11 @@ class TestBedrockLLMProxyRoute: This test verifies the fix for the bug where passthrough endpoints were using environment variables instead of model-specific credentials from config.yaml. """ + from litellm import Router + from litellm.litellm_core_utils.get_litellm_params import get_litellm_params from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( handle_bedrock_passthrough_router_model, ) - from litellm import Router - from litellm.litellm_core_utils.get_litellm_params import get_litellm_params # Model-specific credentials (different from env vars) model_access_key = "MODEL_SPECIFIC_ACCESS_KEY" @@ -1453,6 +1453,294 @@ class TestVLLMProxyRoute: mock_factory_route.assert_awaited_once() +class TestForwardHeaders: + """ + Test cases for _forward_headers parameter in passthrough endpoints + """ + + @pytest.mark.asyncio + async def test_pass_through_request_with_forward_headers_true(self): + """ + Test that when forward_headers=True, user headers from the main request + are forwarded to the target endpoint (except content-length and host) + """ + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + pass_through_request, + ) + + # Create a mock request with custom headers + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.url = MagicMock() + mock_request.url.path = "/test/endpoint" + + # User headers that should be forwarded + user_headers = { + "x-custom-header": "custom-value", + "x-api-key": "user-api-key", + "authorization": "Bearer user-token", + "user-agent": "test-client/1.0", + "content-type": "application/json", + # These should NOT be forwarded + "content-length": "123", + "host": "original-host.com", + } + mock_request.headers = user_headers + mock_request.query_params = {} + + # Mock the request body + mock_request_body = {"test": "data"} + + mock_user_api_key_dict = MagicMock() + + # Custom headers that should be merged with user headers + custom_headers = { + "x-litellm-header": "litellm-value", + } + + target_url = "https://api.example.com/v1/test" + + # Mock the httpx client and response + mock_httpx_response = MagicMock() + mock_httpx_response.status_code = 200 + mock_httpx_response.headers = {"content-type": "application/json"} + mock_httpx_response.aiter_bytes = AsyncMock(return_value=[b'{"result": "success"}']) + mock_httpx_response.aread = AsyncMock(return_value=b'{"result": "success"}') + + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints._read_request_body", + return_value=mock_request_body, + ), patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_async_httpx_client" + ) as mock_get_client, patch( + "litellm.proxy.proxy_server.proxy_logging_obj" + ) as mock_logging_obj: + # Setup mock httpx client + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=mock_httpx_response) + mock_client_obj = MagicMock() + mock_client_obj.client = mock_client + mock_get_client.return_value = mock_client_obj + + # Setup mock logging object + mock_logging_obj.pre_call_hook = AsyncMock(return_value=mock_request_body) + mock_logging_obj.post_call_success_hook = AsyncMock() + mock_logging_obj.post_call_failure_hook = AsyncMock() + + # Call pass_through_request with forward_headers=True + result = await pass_through_request( + request=mock_request, + target=target_url, + custom_headers=custom_headers, + user_api_key_dict=mock_user_api_key_dict, + forward_headers=True, # Enable header forwarding + stream=False, + ) + + # Verify the httpx client was called + assert mock_client.request.called + + # Get the headers that were sent to the target + call_args = mock_client.request.call_args + sent_headers = call_args[1]["headers"] + + # Verify user headers were forwarded (except content-length and host) + assert sent_headers["x-custom-header"] == "custom-value" + assert sent_headers["x-api-key"] == "user-api-key" + assert sent_headers["authorization"] == "Bearer user-token" + assert sent_headers["user-agent"] == "test-client/1.0" + assert sent_headers["content-type"] == "application/json" + + # Verify custom headers were included + assert sent_headers["x-litellm-header"] == "litellm-value" + + # Verify content-length and host were NOT forwarded + assert "content-length" not in sent_headers + assert "host" not in sent_headers + + @pytest.mark.asyncio + async def test_pass_through_request_with_forward_headers_false(self): + """ + Test that when forward_headers=False (default), user headers are NOT forwarded, + only custom_headers are sent + """ + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + pass_through_request, + ) + + # Create a mock request with custom headers + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.url = MagicMock() + mock_request.url.path = "/test/endpoint" + + # User headers that should NOT be forwarded + user_headers = { + "x-custom-header": "custom-value", + "x-api-key": "user-api-key", + "authorization": "Bearer user-token", + } + mock_request.headers = user_headers + mock_request.query_params = {} + + mock_request_body = {"test": "data"} + mock_user_api_key_dict = MagicMock() + + # Only these custom headers should be sent + custom_headers = { + "x-litellm-header": "litellm-value", + "authorization": "Bearer litellm-token", + } + + target_url = "https://api.example.com/v1/test" + + # Mock the httpx client and response + mock_httpx_response = MagicMock() + mock_httpx_response.status_code = 200 + mock_httpx_response.headers = {"content-type": "application/json"} + mock_httpx_response.aiter_bytes = AsyncMock(return_value=[b'{"result": "success"}']) + mock_httpx_response.aread = AsyncMock(return_value=b'{"result": "success"}') + + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints._read_request_body", + return_value=mock_request_body, + ), patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_async_httpx_client" + ) as mock_get_client, patch( + "litellm.proxy.proxy_server.proxy_logging_obj" + ) as mock_logging_obj: + # Setup mock httpx client + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=mock_httpx_response) + mock_client_obj = MagicMock() + mock_client_obj.client = mock_client + mock_get_client.return_value = mock_client_obj + + # Setup mock logging object + mock_logging_obj.pre_call_hook = AsyncMock(return_value=mock_request_body) + mock_logging_obj.post_call_success_hook = AsyncMock() + mock_logging_obj.post_call_failure_hook = AsyncMock() + + # Call pass_through_request with forward_headers=False (default) + result = await pass_through_request( + request=mock_request, + target=target_url, + custom_headers=custom_headers, + user_api_key_dict=mock_user_api_key_dict, + forward_headers=False, # Explicitly set to False + stream=False, + ) + + # Verify the httpx client was called + assert mock_client.request.called + + # Get the headers that were sent to the target + call_args = mock_client.request.call_args + sent_headers = call_args[1]["headers"] + + # Verify only custom headers were sent + assert sent_headers["x-litellm-header"] == "litellm-value" + assert sent_headers["authorization"] == "Bearer litellm-token" + + # Verify user headers were NOT forwarded + assert "x-custom-header" not in sent_headers + assert "x-api-key" not in sent_headers + # Authorization is present but should be from custom_headers, not user headers + assert sent_headers["authorization"] == "Bearer litellm-token" + + @pytest.mark.asyncio + async def test_llm_passthrough_factory_with_forward_headers(self): + """ + Test that _forward_headers works correctly in llm_passthrough_factory_proxy_route + which is used in the code snippet provided by the user + """ + from litellm.types.utils import LlmProviders + + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.url = MagicMock() + mock_request.url.path = "/openai/chat/completions" + + # User headers to be forwarded + user_headers = { + "x-custom-tracking-id": "tracking-123", + "x-request-id": "req-456", + "user-agent": "my-app/2.0", + } + mock_request.headers = user_headers + mock_request.json = AsyncMock(return_value={"stream": False}) + + mock_fastapi_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + # Mock the httpx response + mock_httpx_response = MagicMock() + mock_httpx_response.status_code = 200 + mock_httpx_response.headers = {"content-type": "application/json"} + mock_httpx_response.aiter_bytes = AsyncMock(return_value=[b'{"result": "success"}']) + mock_httpx_response.aread = AsyncMock(return_value=b'{"result": "success"}') + + with patch( + "litellm.utils.ProviderConfigManager.get_provider_model_info" + ) as mock_get_provider, patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials" + ) as mock_get_creds, patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints._read_request_body", + return_value={"messages": [{"role": "user", "content": "test"}]}, + ), patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_async_httpx_client" + ) as mock_get_client, patch( + "litellm.proxy.proxy_server.proxy_logging_obj" + ) as mock_logging_obj: + # Setup provider config + mock_provider_config = MagicMock() + mock_provider_config.get_api_base.return_value = "https://api.openai.com/v1" + mock_provider_config.validate_environment.return_value = { + "authorization": "Bearer sk-test" + } + mock_get_provider.return_value = mock_provider_config + mock_get_creds.return_value = "sk-test" + + # Setup mock httpx client + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=mock_httpx_response) + mock_client_obj = MagicMock() + mock_client_obj.client = mock_client + mock_get_client.return_value = mock_client_obj + + # Setup mock logging object + mock_logging_obj.pre_call_hook = AsyncMock( + return_value={"messages": [{"role": "user", "content": "test"}]} + ) + mock_logging_obj.post_call_success_hook = AsyncMock() + + # This is the key part - when create_pass_through_route is called with _forward_headers=True + # it should forward the user headers + with patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route" + ) as mock_create_route: + mock_endpoint_func = AsyncMock(return_value="success") + mock_create_route.return_value = mock_endpoint_func + + result = await llm_passthrough_factory_proxy_route( + custom_llm_provider=LlmProviders.OPENAI, + endpoint="/chat/completions", + request=mock_request, + fastapi_response=mock_fastapi_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + # Verify create_pass_through_route was called + mock_create_route.assert_called_once() + + # Get the call arguments to verify _forward_headers parameter + call_kwargs = mock_create_route.call_args[1] + + # Note: The current implementation doesn't explicitly pass _forward_headers + # This test documents the current behavior. If _forward_headers should be + # configurable in llm_passthrough_factory_proxy_route, it would need to be added + + class TestMilvusProxyRoute: """ Test cases for Milvus passthrough endpoint diff --git a/tests/test_litellm/responses/test_responses_utils.py b/tests/test_litellm/responses/test_responses_utils.py index 09628cd4a76..3eb9c63e1be 100644 --- a/tests/test_litellm/responses/test_responses_utils.py +++ b/tests/test_litellm/responses/test_responses_utils.py @@ -222,3 +222,90 @@ class TestResponseAPILoggingUtils: assert result.prompt_tokens == 15 assert result.completion_tokens == 25 assert result.total_tokens == 40 # 15 + 25 + + def test_transform_response_api_usage_with_image_tokens(self): + """Test transformation handles image_tokens from image generation responses. + + Note: _transform_response_api_usage_to_chat_usage() is used by multiple + endpoints including /images/generations and Response API (/responses), + both of which use the input_tokens/output_tokens format. + + This tests the fix for image generation responses that include image_tokens + in both input_tokens_details and output_tokens_details. + + Example from gpt-image-1.5: + - input: text prompt with 13 tokens + - output: generated image with 272 image tokens + 100 text tokens + """ + # Setup - simulating image generation usage from OpenAI + usage = { + "input_tokens": 13, + "output_tokens": 372, + "total_tokens": 385, + "input_tokens_details": { + "image_tokens": 0, + "text_tokens": 13, + }, + "output_tokens_details": { + "image_tokens": 272, + "text_tokens": 100, + }, + } + + # Execute + result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( + usage + ) + + # Assert - verify basic token counts + assert isinstance(result, Usage) + assert result.prompt_tokens == 13 + assert result.completion_tokens == 372 + assert result.total_tokens == 385 + + # Assert - verify prompt_tokens_details includes image_tokens and text_tokens + assert result.prompt_tokens_details is not None + assert result.prompt_tokens_details.image_tokens == 0 + assert result.prompt_tokens_details.text_tokens == 13 + + # Assert - verify completion_tokens_details includes image_tokens and text_tokens + assert result.completion_tokens_details is not None + assert result.completion_tokens_details.image_tokens == 272 + assert result.completion_tokens_details.text_tokens == 100 + + def test_transform_response_api_usage_mixed_details(self): + """Test transformation handles mixed token details (cached + image + audio).""" + # Setup - hypothetical usage with mixed token types + usage = { + "input_tokens": 100, + "output_tokens": 200, + "total_tokens": 300, + "input_tokens_details": { + "cached_tokens": 50, + "audio_tokens": 10, + "image_tokens": 20, + "text_tokens": 20, + }, + "output_tokens_details": { + "reasoning_tokens": 30, + "image_tokens": 100, + "text_tokens": 70, + }, + } + + # Execute + result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( + usage + ) + + # Assert - all token detail types should be preserved + assert result.prompt_tokens_details is not None + assert result.prompt_tokens_details.cached_tokens == 50 + assert result.prompt_tokens_details.audio_tokens == 10 + assert result.prompt_tokens_details.image_tokens == 20 + assert result.prompt_tokens_details.text_tokens == 20 + + assert result.completion_tokens_details is not None + assert result.completion_tokens_details.reasoning_tokens == 30 + assert result.completion_tokens_details.image_tokens == 100 + assert result.completion_tokens_details.text_tokens == 70 diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 0f618aeadbd..4d6599fc1b5 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -14,6 +14,7 @@ from pydantic import BaseModel import litellm from litellm.cost_calculator import ( + completion_cost, handle_realtime_stream_cost_calculation, response_cost_calculator, ) @@ -22,6 +23,33 @@ from litellm.types.utils import ModelResponse, PromptTokensDetailsWrapper, Usage from litellm.utils import TranscriptionResponse +def test_completion_cost_uses_response_model_for_dynamic_routing(): + """ + Test that completion_cost uses the model from the response object + when the input model (e.g., azure-model-router) is not in model_cost. + This supports Azure Model Router and similar dynamic routing scenarios. + """ + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + # Simulate Azure Model Router: input is generic router, response has actual model + response = ModelResponse( + id="test-id", + model="azure_ai/gpt-4o-2024-08-06", # Response contains actual model used + choices=[], + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + ) + + # Should calculate cost using the response model, not the input model + cost = completion_cost( + completion_response=response, + model="azure_ai/azure-model-router", # Input model doesn't exist in model_cost + custom_llm_provider="azure_ai", + ) + + assert cost > 0, "Cost should be calculated using response model" + + def test_cost_calculator_with_response_cost_in_additional_headers(): class MockResponse(BaseModel): _hidden_params = { diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 08ae804ea80..ad023cf6b1f 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -1171,6 +1171,193 @@ async def test_acompletion_streaming_iterator_edge_cases(): print("✓ Edge case tests passed!") +@pytest.mark.asyncio +async def test_acompletion_streaming_disable_fallbacks_midstream(): + """Test that disable_fallbacks=True prevents mid-stream fallback attempts.""" + from unittest.mock import MagicMock + + from litellm.exceptions import MidStreamFallbackError + + # Set up router with fallback configuration + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4", + "litellm_params": {"model": "gpt-4", "api_key": "fake-key-1"}, + }, + { + "model_name": "gpt-3.5-turbo", + "litellm_params": {"model": "gpt-3.5-turbo", "api_key": "fake-key-2"}, + }, + ], + fallbacks=[{"gpt-4": ["gpt-3.5-turbo"]}], + set_verbose=True, + ) + + messages = [{"role": "user", "content": "Hello"}] + + # Test 1: disable_fallbacks=True with original_exception + print("\n=== Test 1: disable_fallbacks=True with original_exception ===") + + # Create an original exception to wrap + from litellm.llms.anthropic.chat.anthropic_chat_transformation import ( + AnthropicError, + ) + + original_error = AnthropicError( + status_code=500, + message="An unexpected error occurred while processing the response", + ) + + # Create MidStreamFallbackError with original_exception + error_with_original = MidStreamFallbackError( + message="Connection lost", + model="gpt-4", + llm_provider="openai", + generated_content="Hello", + original_exception=original_error, + ) + + class AsyncIteratorWithError: + def __init__(self, items, error_after_index, error): + self.items = items + self.index = 0 + self.error_after_index = error_after_index + self.error = error + self.chunks = [] + self.model = "gpt-4" + self.custom_llm_provider = "openai" + self.logging_obj = MagicMock() + + def __aiter__(self): + return self + + async def __anext__(self): + if self.index >= len(self.items): + raise StopAsyncIteration + if self.index == self.error_after_index: + raise self.error + item = self.items[self.index] + self.index += 1 + self.chunks.append(item) + return item + + mock_chunks = [ + MagicMock(choices=[MagicMock(delta=MagicMock(content="Hello"))]), + ] + + mock_error_response = AsyncIteratorWithError( + mock_chunks, 1, error_with_original + ) # Error after first chunk + + initial_kwargs = {"model": "gpt-4", "stream": True, "disable_fallbacks": True} + + # Mock the fallback function to ensure it's NOT called + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=MagicMock(), + ) as mock_fallback_utils: + with pytest.raises(AnthropicError, match="An unexpected error occurred"): + result = await router._acompletion_streaming_iterator( + model_response=mock_error_response, + messages=messages, + initial_kwargs=initial_kwargs, + ) + + async for chunk in result: + pass # Should not reach here; exception should be raised + + # Verify fallback was NOT called + mock_fallback_utils.assert_not_called() + print("✓ Original exception raised correctly when disable_fallbacks=True") + + # Test 2: disable_fallbacks=True without original_exception + print("\n=== Test 2: disable_fallbacks=True without original_exception ===") + + error_without_original = MidStreamFallbackError( + message="Connection lost", + model="gpt-4", + llm_provider="openai", + generated_content="Hello", + original_exception=None, + ) + + mock_error_response_2 = AsyncIteratorWithError( + mock_chunks, 1, error_without_original + ) + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=MagicMock(), + ) as mock_fallback_utils: + with pytest.raises(MidStreamFallbackError, match="Connection lost"): + result = await router._acompletion_streaming_iterator( + model_response=mock_error_response_2, + messages=messages, + initial_kwargs=initial_kwargs, + ) + + async for chunk in result: + pass # Should not reach here + + # Verify fallback was NOT called + mock_fallback_utils.assert_not_called() + print( + "✓ MidStreamFallbackError raised correctly when no original_exception and disable_fallbacks=True" + ) + + # Test 3: disable_fallbacks=False (default behavior - fallback should work) + print("\n=== Test 3: disable_fallbacks=False (fallback enabled) ===") + + error_for_fallback = MidStreamFallbackError( + message="Connection lost", + model="gpt-4", + llm_provider="openai", + generated_content="Hello", + ) + + mock_error_response_3 = AsyncIteratorWithError(mock_chunks, 1, error_for_fallback) + + # Mock successful fallback response + class EmptyAsyncIterator: + def __aiter__(self): + return self + + async def __anext__(self): + raise StopAsyncIteration + + mock_fallback_response = EmptyAsyncIterator() + + initial_kwargs_fallback_enabled = { + "model": "gpt-4", + "stream": True, + "disable_fallbacks": False, + } + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=mock_fallback_response, + ) as mock_fallback_utils: + collected_chunks = [] + result = await router._acompletion_streaming_iterator( + model_response=mock_error_response_3, + messages=messages, + initial_kwargs=initial_kwargs_fallback_enabled, + ) + + async for chunk in result: + collected_chunks.append(chunk) + + # Verify fallback WAS called + assert mock_fallback_utils.called + print("✓ Fallback called correctly when disable_fallbacks=False") + + print("\n=== All disable_fallbacks tests passed! ===") + + @pytest.mark.asyncio async def test_async_function_with_fallbacks_common_utils(): """Test the async_function_with_fallbacks_common_utils method""" diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index da52b7bcb39..6c9c1f31c09 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -21,6 +21,7 @@ from litellm.types.utils import ( from litellm.utils import ( ProviderConfigManager, TextCompletionStreamWrapper, + _check_provider_match, get_llm_provider, get_optional_params_image_gen, is_cached_message, @@ -29,6 +30,30 @@ from litellm.utils import ( # Adds the parent directory to the system path +def test_check_provider_match_azure_ai_allows_openai_and_azure(): + """ + Test that azure_ai provider can match openai and azure models. + This is needed for Azure Model Router which can route to OpenAI models. + """ + # azure_ai should match openai models + assert _check_provider_match( + model_info={"litellm_provider": "openai"}, + custom_llm_provider="azure_ai" + ) is True + + # azure_ai should match azure models + assert _check_provider_match( + model_info={"litellm_provider": "azure"}, + custom_llm_provider="azure_ai" + ) is True + + # azure_ai should NOT match other providers + assert _check_provider_match( + model_info={"litellm_provider": "anthropic"}, + custom_llm_provider="azure_ai" + ) is False + + def test_get_optional_params_image_gen(): from litellm.llms.azure.image_generation import AzureGPTImageGenerationConfig diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx index d881e225273..820adfcdd55 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx @@ -10,7 +10,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render } from "@testing-library/react"; +import { renderWithProviders } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import EntityUsageExportModal from "./EntityUsageExportModal"; @@ -35,6 +35,16 @@ vi.mock("../molecules/notifications_manager", () => { }; }); +// Mock useTeams hook +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useTeams: vi.fn(() => ({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), + })), +})); + // JSDOM stubs for download flow used by the modal // @ts-ignore global.URL.createObjectURL = vi.fn(() => "blob:mock"); @@ -74,7 +84,7 @@ describe("EntityUsageExportModal", () => { const user = userEvent.setup(); const { handleExportCSV } = await import("./utils"); - const { getByRole } = render(); + const { getByRole } = renderWithProviders(); // Default primary action reflects CSV export expect(getByRole("button", { name: /Export CSV/i })).toBeInTheDocument(); @@ -83,7 +93,7 @@ describe("EntityUsageExportModal", () => { await user.click(getByRole("button", { name: /Export CSV/i })); // Verifies export function was invoked with correct parameters - expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily", "Tag", "tag"); + expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily", "Tag", "tag", {}); // Modal closes after export expect(baseProps.onClose).toHaveBeenCalled(); @@ -98,7 +108,7 @@ describe("EntityUsageExportModal", () => { const user = userEvent.setup(); const { handleExportCSV } = await import("./utils"); - const { getByText, getByRole } = render(); + const { getByText, getByRole } = renderWithProviders(); // Choose the alternate export type - click the label to trigger radio const dailyModelLabel = getByText(/Day-by-day by tag and model/i); @@ -109,7 +119,7 @@ describe("EntityUsageExportModal", () => { await user.click(exportBtn); // Ensure the selected scope flowed through - expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily_with_models", "Tag", "tag"); + expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily_with_models", "Tag", "tag", {}); // Modal closes after export expect(baseProps.onClose).toHaveBeenCalled(); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx index 5b40d761983..4c934bbf1d3 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx @@ -1,12 +1,13 @@ -import React, { useState } from "react"; -import { Button } from "@tremor/react"; -import { Modal } from "antd"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { createTeamAliasMap } from "@/utils/teamUtils"; +import { Button, Modal, Skeleton } from "antd"; +import React, { useMemo, useState } from "react"; import NotificationsManager from "../molecules/notifications_manager"; +import ExportFormatSelector from "./ExportFormatSelector"; import ExportSummary from "./ExportSummary"; import ExportTypeSelector from "./ExportTypeSelector"; -import ExportFormatSelector from "./ExportFormatSelector"; -import { handleExportCSV, handleExportJSON } from "./utils"; import type { EntityUsageExportModalProps, ExportFormat, ExportScope } from "./types"; +import { handleExportCSV, handleExportJSON } from "./utils"; const EntityUsageExportModal: React.FC = ({ isOpen, @@ -20,19 +21,22 @@ const EntityUsageExportModal: React.FC = ({ const [exportFormat, setExportFormat] = useState("csv"); const [exportScope, setExportScope] = useState("daily"); const [isExporting, setIsExporting] = useState(false); + const { data: teams, isLoading: isLoadingTeams } = useTeams(); const entityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1); const modalTitle = customTitle || `Export ${entityLabel} Usage`; + // Cache team alias map using useMemo + const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); const handleExport = async (format?: ExportFormat) => { const formatToUse = format || exportFormat; setIsExporting(true); try { if (formatToUse === "csv") { - handleExportCSV(spendData, exportScope, entityLabel, entityType); + handleExportCSV(spendData, exportScope, entityLabel, entityType, teamAliasMap); NotificationsManager.success(`${entityLabel} usage data exported successfully as CSV`); } else { - handleExportJSON(spendData, exportScope, entityLabel, entityType, dateRange, selectedFilters); + handleExportJSON(spendData, exportScope, entityLabel, entityType, dateRange, selectedFilters, teamAliasMap); NotificationsManager.success(`${entityLabel} usage data exported successfully as JSON`); } onClose(); @@ -51,23 +55,37 @@ const EntityUsageExportModal: React.FC = ({ onCancel={onClose} footer={null} width={480} - destroyOnClose >
- - - - - - -
- - -
+ {isLoadingTeams ? ( + + ) : ( + <> + + + + + )} + {isLoadingTeams ? ( +
+ + +
+ ) : ( +
+ + +
+ )}
); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index b390c19df16..989ff8703e8 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -4,6 +4,7 @@ import { Select } from "antd"; import React, { useState } from "react"; import EntityUsageExportModal from "./EntityUsageExportModal"; import type { EntitySpendData, EntityType } from "./types"; +import type { Team } from "@/components/key_team_helpers/key_list"; interface UsageExportHeaderProps { dateValue: DateRangePickerValue; @@ -18,6 +19,7 @@ interface UsageExportHeaderProps { filterOptions?: Array<{ label: string; value: string }>; customTitle?: string; compactLayout?: boolean; + teams?: Team[]; } const UsageExportHeader: React.FC = ({ @@ -32,6 +34,7 @@ const UsageExportHeader: React.FC = ({ filterOptions = [], customTitle, compactLayout = false, + teams = [], }) => { const [isExportModalOpen, setIsExportModalOpen] = useState(false); @@ -95,6 +98,7 @@ const UsageExportHeader: React.FC = ({ dateRange={dateValue} selectedFilters={selectedFilters} customTitle={customTitle} + teams={teams} /> ); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts index 81b0307c712..84a20da7cba 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -1,4 +1,5 @@ import type { DateRangePickerValue } from "@tremor/react"; +import type { Team } from "@/components/key_team_helpers/key_list"; export type ExportFormat = "csv" | "json"; export type ExportScope = "daily" | "daily_with_models"; @@ -23,6 +24,7 @@ export interface EntityUsageExportModalProps { dateRange: DateRangePickerValue; selectedFilters: string[]; customTitle?: string; + teams?: Team[]; } export interface ExportMetadata { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts new file mode 100644 index 00000000000..127e3fc208f --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -0,0 +1,1004 @@ +import type { DateRangePickerValue } from "@tremor/react"; +import Papa from "papaparse"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { EntitySpendData, ExportScope } from "./types"; +import { + generateDailyData, + generateDailyWithModelsData, + generateExportData, + generateMetadata, + getEntityBreakdown, + handleExportCSV, + handleExportJSON, +} from "./utils"; + +vi.mock("@/utils/dataUtils", () => ({ + formatNumberWithCommas: vi.fn((value: number, decimals: number = 0) => { + if (value === null || value === undefined || !Number.isFinite(value)) { + return "-"; + } + return value.toFixed(decimals); + }), +})); + +vi.mock("papaparse", () => ({ + default: { + unparse: vi.fn((data: any[]) => "mocked-csv-data"), + }, +})); + +describe("EntityUsageExport utils", () => { + const mockSpendData: EntitySpendData = { + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + key_alias: "alias-1", + }, + }, + }, + }, + entity2: { + metrics: { + spend: 20.3, + api_requests: 200, + successful_requests: 190, + failed_requests: 10, + total_tokens: 2000, + prompt_tokens: 1200, + completion_tokens: 800, + cache_read_input_tokens: 100, + cache_creation_input_tokens: 60, + }, + api_key_breakdown: { + key2: { + metrics: { + spend: 20.3, + api_requests: 200, + successful_requests: 190, + failed_requests: 10, + total_tokens: 2000, + }, + metadata: { + team_id: "team-2", + key_alias: "alias-2", + }, + }, + }, + }, + }, + }, + }, + { + date: "2025-01-02", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 15.2, + api_requests: 150, + successful_requests: 145, + failed_requests: 5, + total_tokens: 1500, + prompt_tokens: 900, + completion_tokens: 600, + cache_read_input_tokens: 75, + cache_creation_input_tokens: 45, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 15.2, + api_requests: 150, + successful_requests: 145, + failed_requests: 5, + total_tokens: 1500, + }, + metadata: { + team_id: "team-1", + key_alias: "alias-1", + }, + }, + }, + }, + }, + }, + }, + ], + metadata: { + total_spend: 46.0, + total_api_requests: 450, + total_successful_requests: 430, + total_failed_requests: 20, + total_tokens: 4500, + }, + }; + + const mockTeamAliasMap: Record = { + "team-1": "Team One", + "team-2": "Team Two", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getEntityBreakdown", () => { + it("should aggregate entity spend data across multiple days", () => { + const result = getEntityBreakdown(mockSpendData); + + expect(result).toHaveLength(2); + expect(result[0].metadata.id).toBe("team-1"); + expect(result[0].metrics.spend).toBe(25.7); + expect(result[1].metadata.id).toBe("team-2"); + expect(result[1].metrics.spend).toBe(20.3); + }); + + it("should sort entities by spend descending", () => { + const result = getEntityBreakdown(mockSpendData); + + expect(result[0].metrics.spend).toBeGreaterThan(result[1].metrics.spend); + }); + + it("should aggregate all metrics correctly", () => { + const result = getEntityBreakdown(mockSpendData); + const entity1 = result.find((e) => e.metadata.id === "team-1"); + + expect(entity1?.metrics.api_requests).toBe(250); + expect(entity1?.metrics.successful_requests).toBe(240); + expect(entity1?.metrics.failed_requests).toBe(10); + expect(entity1?.metrics.total_tokens).toBe(2500); + expect(entity1?.metrics.prompt_tokens).toBe(1500); + expect(entity1?.metrics.completion_tokens).toBe(1000); + expect(entity1?.metrics.cache_read_input_tokens).toBe(125); + expect(entity1?.metrics.cache_creation_input_tokens).toBe(75); + }); + + it("should use key alias when available", () => { + const result = getEntityBreakdown(mockSpendData); + const entity1 = result.find((e) => e.metadata.id === "team-1"); + + expect(entity1?.metadata.alias).toBe("alias-1"); + }); + + it("should use team alias map when key alias is not available", () => { + const spendDataWithoutAlias: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = getEntityBreakdown(spendDataWithoutAlias, mockTeamAliasMap); + const entity1 = result.find((e) => e.metadata.id === "team-1"); + + expect(entity1?.metadata.alias).toBe("Team One"); + }); + + it("should use entity id when team alias is not available", () => { + const spendDataWithoutTeamId: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: {}, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = getEntityBreakdown(spendDataWithoutTeamId); + const entity1 = result.find((e) => e.metadata.id === "entity1"); + + expect(entity1?.metadata.alias).toBe("entity1"); + }); + + it("should handle empty spend data", () => { + const emptySpendData: EntitySpendData = { + results: [], + metadata: { + total_spend: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, + }, + }; + + const result = getEntityBreakdown(emptySpendData); + + expect(result).toHaveLength(0); + }); + + it("should handle missing optional token fields", () => { + const spendDataWithMissingTokens: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = getEntityBreakdown(spendDataWithMissingTokens); + const entity1 = result.find((e) => e.metadata.id === "team-1"); + + expect(entity1?.metrics.prompt_tokens).toBe(0); + expect(entity1?.metrics.completion_tokens).toBe(0); + }); + }); + + describe("generateDailyData", () => { + it("should generate daily breakdown data with correct structure", () => { + const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); + + expect(result).toHaveLength(3); + expect(result[0]).toHaveProperty("Date"); + expect(result[0]).toHaveProperty("Team"); + expect(result[0]).toHaveProperty("Team ID"); + expect(result[0]).toHaveProperty("Spend ($)"); + expect(result[0]).toHaveProperty("Requests"); + expect(result[0]).toHaveProperty("Successful Requests"); + expect(result[0]).toHaveProperty("Failed Requests"); + expect(result[0]).toHaveProperty("Total Tokens"); + expect(result[0]).toHaveProperty("Prompt Tokens"); + expect(result[0]).toHaveProperty("Completion Tokens"); + }); + + it("should sort data by date ascending", () => { + const result = generateDailyData(mockSpendData, "Team"); + + const dates = result.map((r) => new Date(r.Date).getTime()); + for (let i = 0; i < dates.length - 1; i++) { + expect(dates[i]).toBeLessThanOrEqual(dates[i + 1]); + } + }); + + it("should use team alias when available", () => { + const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); + const team1Entry = result.find((r) => r["Team ID"] === "team-1"); + + expect(team1Entry?.["Team"]).toBe("Team One"); + }); + + it("should use dash when team alias is not available", () => { + const result = generateDailyData(mockSpendData, "Team"); + const entryWithoutTeamId = result.find((r) => !r["Team ID"] || r["Team ID"] === "-"); + + if (entryWithoutTeamId) { + expect(entryWithoutTeamId["Team"]).toBe("-"); + } + }); + + it("should use dash when team id is not available", () => { + const spendDataWithoutTeamId: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + }, + api_key_breakdown: {}, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = generateDailyData(spendDataWithoutTeamId, "Team"); + const entry = result[0]; + + expect(entry["Team ID"]).toBe("-"); + expect(entry["Team"]).toBe("-"); + }); + + it("should format spend values correctly", () => { + const result = generateDailyData(mockSpendData, "Team"); + + expect(result[0]["Spend ($)"]).toBeDefined(); + }); + + it("should handle missing optional token fields", () => { + const spendDataWithMissingTokens: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = generateDailyData(spendDataWithMissingTokens, "Team"); + + expect(result[0]["Prompt Tokens"]).toBe(0); + expect(result[0]["Completion Tokens"]).toBe(0); + }); + }); + + describe("generateDailyWithModelsData", () => { + const mockSpendDataWithModels: EntitySpendData = { + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 5.0, + api_requests: 50, + successful_requests: 48, + failed_requests: 2, + total_tokens: 500, + }, + metadata: { + team_id: "team-1", + }, + }, + key2: { + metrics: { + spend: 5.5, + api_requests: 50, + successful_requests: 47, + failed_requests: 3, + total_tokens: 500, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + models: { + "gpt-4": { + metrics: { + spend: 5.0, + api_requests: 50, + successful_requests: 48, + failed_requests: 2, + total_tokens: 500, + }, + }, + "gpt-3.5-turbo": { + metrics: { + spend: 5.5, + api_requests: 50, + successful_requests: 47, + failed_requests: 3, + total_tokens: 500, + }, + }, + }, + }, + }, + ], + metadata: { + total_spend: 10.5, + total_api_requests: 100, + total_successful_requests: 95, + total_failed_requests: 5, + total_tokens: 1000, + }, + }; + + it("should generate daily breakdown with model data", () => { + const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team", mockTeamAliasMap); + + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty("Date"); + expect(result[0]).toHaveProperty("Team"); + expect(result[0]).toHaveProperty("Team ID"); + expect(result[0]).toHaveProperty("Model"); + expect(result[0]).toHaveProperty("Spend ($)"); + expect(result[0]).toHaveProperty("Requests"); + expect(result[0]).toHaveProperty("Successful"); + expect(result[0]).toHaveProperty("Failed"); + expect(result[0]).toHaveProperty("Total Tokens"); + }); + + it("should sort data by date ascending", () => { + const multiDayData: EntitySpendData = { + results: [ + { + date: "2025-01-02", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + models: { + "gpt-4": { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + }, + }, + }, + }, + ...mockSpendDataWithModels.results, + ], + metadata: mockSpendDataWithModels.metadata, + }; + + const result = generateDailyWithModelsData(multiDayData, "Team"); + + expect(new Date(result[0].Date).getTime()).toBeLessThanOrEqual( + new Date(result[result.length - 1].Date).getTime(), + ); + }); + + it("should aggregate model metrics from api key breakdown", () => { + const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team"); + + const gpt4Entry = result.find((r) => r.Model === "gpt-4"); + expect(gpt4Entry).toBeDefined(); + expect(gpt4Entry?.Requests).toBeGreaterThan(0); + }); + + it("should use team alias when available", () => { + const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team", mockTeamAliasMap); + const team1Entry = result.find((r) => r["Team ID"] === "team-1"); + + expect(team1Entry?.["Team"]).toBe("Team One"); + }); + + it("should use dash when team alias is not available", () => { + const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team"); + const entryWithoutTeamId = result.find((r) => !r["Team ID"] || r["Team ID"] === "-"); + + if (entryWithoutTeamId) { + expect(entryWithoutTeamId["Team"]).toBe("-"); + } + }); + + it("should handle empty models breakdown", () => { + const spendDataWithoutModels: EntitySpendData = { + ...mockSpendDataWithModels, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + models: {}, + }, + }, + ], + metadata: mockSpendDataWithModels.metadata, + }; + + const result = generateDailyWithModelsData(spendDataWithoutModels, "Team"); + + expect(result).toHaveLength(0); + }); + }); + + describe("generateExportData", () => { + it("should return daily data when scope is daily", () => { + const result = generateExportData(mockSpendData, "daily", "Team", mockTeamAliasMap); + + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty("Date"); + expect(result[0]).not.toHaveProperty("Model"); + }); + + it("should return daily with models data when scope is daily_with_models", () => { + const mockDataWithModels: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + models: { + "gpt-4": { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = generateExportData(mockDataWithModels, "daily_with_models", "Team", mockTeamAliasMap); + + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty("Model"); + }); + + it("should default to daily data for unknown scope", () => { + const result = generateExportData(mockSpendData, "unknown" as ExportScope, "Team", mockTeamAliasMap); + + expect(result.length).toBeGreaterThan(0); + expect(result[0]).not.toHaveProperty("Model"); + }); + }); + + describe("generateMetadata", () => { + const mockDateRange: DateRangePickerValue = { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }; + + it("should generate metadata with correct structure", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result).toHaveProperty("export_date"); + expect(result).toHaveProperty("entity_type"); + expect(result).toHaveProperty("date_range"); + expect(result).toHaveProperty("filters_applied"); + expect(result).toHaveProperty("export_scope"); + expect(result).toHaveProperty("summary"); + }); + + it("should include export date as ISO string", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result.export_date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it("should include entity type", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result.entity_type).toBe("team"); + }); + + it("should format date range correctly", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result.date_range.from).toBe("2025-01-01T00:00:00.000Z"); + expect(result.date_range.to).toBe("2025-01-31T00:00:00.000Z"); + }); + + it("should handle missing date range values", () => { + const incompleteDateRange: DateRangePickerValue = { + from: undefined, + to: undefined, + }; + + const result = generateMetadata("team", incompleteDateRange, [], "daily", mockSpendData); + + expect(result.date_range.from).toBeUndefined(); + expect(result.date_range.to).toBeUndefined(); + }); + + it("should set filters_applied to None when empty", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result.filters_applied).toBe("None"); + }); + + it("should include filters when provided", () => { + const result = generateMetadata("team", mockDateRange, ["filter1", "filter2"], "daily", mockSpendData); + + expect(result.filters_applied).toEqual(["filter1", "filter2"]); + }); + + it("should include export scope", () => { + const result = generateMetadata("team", mockDateRange, [], "daily_with_models", mockSpendData); + + expect(result.export_scope).toBe("daily_with_models"); + }); + + it("should include summary metrics from spend data", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result.summary.total_spend).toBe(46.0); + expect(result.summary.total_requests).toBe(450); + expect(result.summary.successful_requests).toBe(430); + expect(result.summary.failed_requests).toBe(20); + expect(result.summary.total_tokens).toBe(4500); + }); + }); + + describe("handleExportCSV", () => { + beforeEach(() => { + document.body.innerHTML = ""; + window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); + window.URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should create CSV file and trigger download", () => { + const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:mock-url"); + const revokeObjectURLSpy = vi.spyOn(window.URL, "revokeObjectURL"); + const createElementSpy = vi.spyOn(document, "createElement"); + const appendChildSpy = vi.spyOn(document.body, "appendChild"); + const removeChildSpy = vi.spyOn(document.body, "removeChild"); + + handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); + + expect(Papa.unparse).toHaveBeenCalled(); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(appendChildSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + }); + + it("should generate correct filename", () => { + const anchorElement = document.createElement("a"); + const createElementSpy = vi.spyOn(document, "createElement").mockReturnValue(anchorElement); + + const today = new Date().toISOString().split("T")[0]; + + handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); + + expect(anchorElement.download).toBe(`team_usage_daily_${today}.csv`); + }); + + it("should create blob with correct type", () => { + let blobType = ""; + const originalBlob = window.Blob; + + window.Blob = class extends Blob { + constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { + super(parts, options); + if (options?.type) { + blobType = options.type; + } + } + } as any; + + handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); + + expect(blobType).toBe("text/csv;charset=utf-8;"); + + window.Blob = originalBlob; + }); + }); + + describe("handleExportJSON", () => { + beforeEach(() => { + document.body.innerHTML = ""; + window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); + window.URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should create JSON file and trigger download", () => { + const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:mock-url"); + const revokeObjectURLSpy = vi.spyOn(window.URL, "revokeObjectURL"); + const createElementSpy = vi.spyOn(document, "createElement"); + const appendChildSpy = vi.spyOn(document.body, "appendChild"); + const removeChildSpy = vi.spyOn(document.body, "removeChild"); + + const mockDateRange: DateRangePickerValue = { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }; + + handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); + + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(appendChildSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + }); + + it("should generate correct filename", () => { + const anchorElement = document.createElement("a"); + const createElementSpy = vi.spyOn(document, "createElement").mockReturnValue(anchorElement); + + const today = new Date().toISOString().split("T")[0]; + const mockDateRange: DateRangePickerValue = { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }; + + handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); + + expect(anchorElement.download).toBe(`team_usage_daily_${today}.json`); + }); + + it("should create blob with correct type", () => { + let blobType = ""; + const originalBlob = window.Blob; + + window.Blob = class extends Blob { + constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { + super(parts, options); + if (options?.type) { + blobType = options.type; + } + } + } as any; + + const mockDateRange: DateRangePickerValue = { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }; + + handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); + + expect(blobType).toBe("application/json"); + + window.Blob = originalBlob; + }); + + it("should include metadata and data in JSON export", () => { + let jsonString = ""; + const originalBlob = window.Blob; + + window.Blob = class extends Blob { + constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { + super(parts, options); + if (parts && parts[0]) { + jsonString = parts[0] as string; + } + } + } as any; + + const mockDateRange: DateRangePickerValue = { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }; + + handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, ["filter1"], mockTeamAliasMap); + + const exportObject = JSON.parse(jsonString); + expect(exportObject).toHaveProperty("metadata"); + expect(exportObject).toHaveProperty("data"); + expect(exportObject.metadata.entity_type).toBe("team"); + expect(exportObject.metadata.filters_applied).toEqual(["filter1"]); + + window.Blob = originalBlob; + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index c93155feb23..cdb5d011af4 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -1,13 +1,43 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; -import Papa from "papaparse"; -import type { EntitySpendData, EntityBreakdown, ExportMetadata, ExportScope, EntityType } from "./types"; import type { DateRangePickerValue } from "@tremor/react"; +import Papa from "papaparse"; +import type { EntityBreakdown, EntitySpendData, EntityType, ExportMetadata, ExportScope } from "./types"; -export const getEntityBreakdown = (spendData: EntitySpendData): EntityBreakdown[] => { +// Helper function to extract team_id from api_key_breakdown +const extractTeamIdFromApiKeyBreakdown = (apiKeyBreakdown: Record | undefined): string | null => { + if (!apiKeyBreakdown) return null; + + // Look through all API keys to find the first non-null team_id + for (const apiKeyData of Object.values(apiKeyBreakdown)) { + const teamId = (apiKeyData as any)?.metadata?.team_id; + if (teamId) { + return teamId; + } + } + return null; +}; + +export const getEntityBreakdown = ( + spendData: EntitySpendData, + teamAliasMap: Record = {}, +): EntityBreakdown[] => { const entitySpend: { [key: string]: EntityBreakdown } = {}; spendData.results.forEach((day) => { Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => { + // Extract team_id from api_key_breakdown metadata (not data.metadata which is empty) + const teamId = extractTeamIdFromApiKeyBreakdown(data.api_key_breakdown) || entity; + // Extract key_alias from the first API key that has one + const apiKeyBreakdown = data.api_key_breakdown || {}; + let keyAlias: string | null = null; + for (const apiKeyData of Object.values(apiKeyBreakdown)) { + const alias = (apiKeyData as any)?.metadata?.key_alias; + if (alias) { + keyAlias = alias; + break; + } + } + if (!entitySpend[entity]) { entitySpend[entity] = { metrics: { @@ -22,8 +52,8 @@ export const getEntityBreakdown = (spendData: EntitySpendData): EntityBreakdown[ cache_creation_input_tokens: 0, }, metadata: { - alias: data.metadata?.team_alias || entity, - id: entity, + alias: keyAlias || teamAliasMap[teamId] || entity, + id: teamId, }, }; } @@ -42,15 +72,23 @@ export const getEntityBreakdown = (spendData: EntitySpendData): EntityBreakdown[ return Object.values(entitySpend).sort((a, b) => b.metrics.spend - a.metrics.spend); }; -export const generateDailyData = (spendData: EntitySpendData, entityLabel: string): any[] => { +export const generateDailyData = ( + spendData: EntitySpendData, + entityLabel: string, + teamAliasMap: Record = {}, +): any[] => { const dailyBreakdown: any[] = []; spendData.results.forEach((day) => { Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => { + // Extract team_id from api_key_breakdown metadata (not data.metadata which is empty) + const teamId = extractTeamIdFromApiKeyBreakdown(data.api_key_breakdown); + const teamAlias = teamId ? teamAliasMap[teamId] || null : null; + dailyBreakdown.push({ Date: day.date, - [entityLabel]: data.metadata?.team_alias || entity, - [`${entityLabel} ID`]: entity, + [entityLabel]: teamAlias || "-", + [`${entityLabel} ID`]: teamId || "-", "Spend ($)": formatNumberWithCommas(data.metrics.spend, 4), Requests: data.metrics.api_requests, "Successful Requests": data.metrics.successful_requests, @@ -65,15 +103,17 @@ export const generateDailyData = (spendData: EntitySpendData, entityLabel: strin return dailyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); }; -export const generateDailyWithModelsData = (spendData: EntitySpendData, entityLabel: string): any[] => { +export const generateDailyWithModelsData = ( + spendData: EntitySpendData, + entityLabel: string, + teamAliasMap: Record = {}, +): any[] => { const dailyModelBreakdown: any[] = []; spendData.results.forEach((day) => { const dailyEntityModels: { [key: string]: { [key: string]: any } } = {}; Object.entries(day.breakdown.entities || {}).forEach(([entity, entityData]: [string, any]) => { - const entityName = entityData.metadata?.team_alias || entity; - if (!dailyEntityModels[entity]) { dailyEntityModels[entity] = {}; } @@ -102,13 +142,15 @@ export const generateDailyWithModelsData = (spendData: EntitySpendData, entityLa Object.entries(dailyEntityModels).forEach(([entity, models]) => { const entityData = day.breakdown.entities?.[entity]; - const entityName = entityData?.metadata?.team_alias || entity; + // Extract team_id from api_key_breakdown metadata (not entityData.metadata which is empty) + const teamId = extractTeamIdFromApiKeyBreakdown(entityData?.api_key_breakdown); + const teamAlias = teamId ? teamAliasMap[teamId] || null : null; Object.entries(models).forEach(([model, metrics]: [string, any]) => { dailyModelBreakdown.push({ Date: day.date, - [entityLabel]: entityName, - [`${entityLabel} ID`]: entity, + [entityLabel]: teamAlias || "-", + [`${entityLabel} ID`]: teamId || "-", Model: model, "Spend ($)": formatNumberWithCommas(metrics.spend, 4), Requests: metrics.requests, @@ -127,14 +169,15 @@ export const generateExportData = ( spendData: EntitySpendData, exportScope: ExportScope, entityLabel: string, + teamAliasMap: Record = {}, ): any[] => { switch (exportScope) { case "daily": - return generateDailyData(spendData, entityLabel); + return generateDailyData(spendData, entityLabel, teamAliasMap); case "daily_with_models": - return generateDailyWithModelsData(spendData, entityLabel); + return generateDailyWithModelsData(spendData, entityLabel, teamAliasMap); default: - return generateDailyData(spendData, entityLabel); + return generateDailyData(spendData, entityLabel, teamAliasMap); } }; @@ -167,8 +210,9 @@ export const handleExportCSV = ( exportScope: ExportScope, entityLabel: string, entityType: EntityType, + teamAliasMap: Record = {}, ): void => { - const data = generateExportData(spendData, exportScope, entityLabel); + const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap); const csv = Papa.unparse(data); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = window.URL.createObjectURL(blob); @@ -189,8 +233,9 @@ export const handleExportJSON = ( entityType: EntityType, dateRange: DateRangePickerValue, selectedFilters: string[], + teamAliasMap: Record = {}, ): void => { - const data = generateExportData(spendData, exportScope, entityLabel); + const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap); const metadata = generateMetadata(entityType, dateRange, selectedFilters, exportScope, spendData); const exportObject = { metadata, diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index 10a38f0285c..5679788d30d 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -5,6 +5,10 @@ import TeamSSOSettings from "@/components/TeamSSOSettings"; import { isProxyAdminRole } from "@/utils/roles"; import { InfoCircleOutlined } from "@ant-design/icons"; import { ChevronDownIcon, ChevronRightIcon, RefreshIcon } from "@heroicons/react/outline"; +import { FilterInput } from "@/components/common_components/Filters/FilterInput"; +import { FiltersButton } from "@/components/common_components/Filters/FiltersButton"; +import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton"; +import { Search, User } from "lucide-react"; import { Accordion, AccordionBody, @@ -681,91 +685,34 @@ const Teams: React.FC = ({ {/* Search and Filter Controls */}
{/* Team Alias Search */} -
- handleFilterChange("team_alias", e.target.value)} - /> - - - -
+ handleFilterChange("team_alias", value)} + icon={Search} + /> {/* Filter Button */} - + active={showFilters} + hasActiveFilters={!!(filters.team_id || filters.team_alias || filters.organization_id)} + /> {/* Reset Filters Button */} - +
{/* Additional Filters */} {showFilters && (
{/* Team ID Search */} -
- handleFilterChange("team_id", e.target.value)} - /> - - - -
+ handleFilterChange("team_id", value)} + icon={User} + /> {/* Organization Dropdown */}
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx index 46714857c1f..60674a7c332 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx @@ -36,7 +36,11 @@ vi.mock("./TopModelView", () => ({ default: () =>
Top Models
, })); -vi.mock("./EntityUsageExport", () => ({ +vi.mock("../../../EntityUsageExport/EntityUsageExportModal", () => ({ + default: () =>
Entity Usage Export Modal
, +})); + +vi.mock("../../../EntityUsageExport", () => ({ UsageExportHeader: () =>
Usage Export Header
, })); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index 52e75005ccc..7b8fcc4896f 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -1,3 +1,4 @@ +import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { BarChart, @@ -23,6 +24,7 @@ import { } from "@tremor/react"; import React, { useEffect, useState } from "react"; import { ActivityMetrics, processActivityData } from "../../../activity_metrics"; +import NewBadge from "../../../common_components/NewBadge"; import { UsageExportHeader } from "../../../EntityUsageExport"; import type { EntityType } from "../../../EntityUsageExport/types"; import { @@ -35,11 +37,9 @@ import { import { getProviderLogoAndName } from "../../../provider_info_helpers"; import { BreakdownMetrics, DailyData, EntityMetricWithMetadata, KeyMetricWithMetadata, TagUsage } from "../../types"; import { valueFormatterSpend } from "../../utils/value_formatters"; +import EndpointUsage from "../EndpointUsage/EndpointUsage"; import TopKeyView from "./TopKeyView"; import TopModelView from "./TopModelView"; -import useTeams from "@/app/(dashboard)/hooks/useTeams"; -import EndpointUsage from "../EndpointUsage/EndpointUsage"; -import NewBadge from "../../../common_components/NewBadge"; interface EntityMetrics { metrics: { @@ -87,16 +87,7 @@ interface EntityUsageProps { dateValue: DateRangePickerValue; } -const EntityUsage: React.FC = ({ - accessToken, - entityType, - entityId, - userID, - userRole, - entityList, - premiumUser, - dateValue, -}) => { +const EntityUsage: React.FC = ({ accessToken, entityType, entityId, entityList, dateValue }) => { const [spendData, setSpendData] = useState({ results: [], metadata: { @@ -112,6 +103,8 @@ const EntityUsage: React.FC = ({ const modelMetrics = processActivityData(spendData, "models", teams || []); const keyMetrics = processActivityData(spendData, "api_keys", teams || []); const [selectedTags, setSelectedTags] = useState([]); + const [topKeysLimit, setTopKeysLimit] = useState(5); + const [topModelsLimit, setTopModelsLimit] = useState(5); const fetchSpendData = async () => { if (!accessToken || !dateValue.from || !dateValue.to) return; @@ -204,7 +197,7 @@ const EntityUsage: React.FC = ({ ...metrics, })) .sort((a, b) => b.spend - a.spend) - .slice(0, 5); + .slice(0, topModelsLimit); }; const getTopAPIKeys = () => { @@ -269,7 +262,7 @@ const EntityUsage: React.FC = ({ spend: metrics.metrics.spend, })) .sort((a, b) => b.spend - a.spend) - .slice(0, 5); + .slice(0, topKeysLimit); }; const getProviderSpend = () => { @@ -399,6 +392,7 @@ const EntityUsage: React.FC = ({ selectedFilters={selectedTags} onFiltersChange={setSelectedTags} filterOptions={getAllTags() || undefined} + teams={teams || []} /> @@ -597,7 +591,13 @@ const EntityUsage: React.FC = ({ Top Virtual Keys - + @@ -605,7 +605,11 @@ const EntityUsage: React.FC = ({ {entityType === "agent" ? "Top Agents" : "Top Models"} - + diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx index 6c074623139..126bf51bd36 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx @@ -1,15 +1,39 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import TopKeyView from "./TopKeyView"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { KeyResponse } from "../../../key_team_helpers/key_list"; +import * as transformKeyInfo from "../../../key_team_helpers/transform_key_info"; +import * as networking from "../../../networking"; +import TopKeyView from "./TopKeyView"; vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ __esModule: true, default: vi.fn(), })); +vi.mock("../../../networking", () => ({ + keyInfoV1Call: vi.fn(), +})); + +vi.mock("../../../key_team_helpers/transform_key_info", () => ({ + transformKeyInfo: vi.fn(), +})); + +vi.mock("../../../templates/key_info_view", () => ({ + default: ({ keyId, onClose }: { keyId: string; onClose: () => void }) => ( +
+
Key Info View for {keyId}
+ +
+ ), +})); + describe("TopKeyView", () => { const mockUseAuthorized = vi.mocked(useAuthorized); + const mockKeyInfoV1Call = vi.mocked(networking.keyInfoV1Call); + const mockTransformKeyInfo = vi.mocked(transformKeyInfo.transformKeyInfo); + const mockAuth = { token: "mock-token", accessToken: "test-token", @@ -20,46 +44,60 @@ describe("TopKeyView", () => { disabledPersonalKeyCreation: false, showSSOBanner: false, }; + + const mockSetTopKeysLimit = vi.fn(); + const baseProps = { topKeys: [], teams: null, showTags: false, + topKeysLimit: 5, + setTopKeysLimit: mockSetTopKeysLimit, }; beforeEach(() => { mockUseAuthorized.mockReturnValue(mockAuth); + mockSetTopKeysLimit.mockClear(); + mockKeyInfoV1Call.mockClear(); + mockTransformKeyInfo.mockClear(); }); it("should render", () => { render(); - expect(screen.getByText("Table View")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument(); }); - it("should have a table view button", () => { + it("should display table view button", () => { render(); - expect(screen.getByText("Table View")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument(); }); - it("should have a chart view", () => { + it("should display chart view button", () => { render(); - expect(screen.getByText("Chart View")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument(); }); - ["Key ID", "Key Alias", "Spend (USD)"].forEach((header) => { - it(`should have a ${header} column`, () => { - render(); - expect(screen.getByText(header)).toBeInTheDocument(); - }); + it("should display base table column headers", () => { + render(); + expect(screen.getByText("Key ID")).toBeInTheDocument(); + expect(screen.getByText("Key Alias")).toBeInTheDocument(); + expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); }); - it("should have a Tags column when showTags is true", () => { + it("should display Tags column when showTags is true", () => { render(); expect(screen.getByText("Tags")).toBeInTheDocument(); }); - it("should show the key's information on the table", () => { + it("should not display Tags column when showTags is false", () => { + render(); + expect(screen.queryByText("Tags")).not.toBeInTheDocument(); + }); + + it("should display key information in table view", () => { render( { ], }, ]} - teams={null} showTags={true} />, ); @@ -80,4 +117,519 @@ describe("TopKeyView", () => { expect(screen.getByText(/tag-2/)).toBeInTheDocument(); expect(screen.getByText("$100.00")).toBeInTheDocument(); }); + + it("should switch to chart view when chart view button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const chartViewButton = screen.getByRole("button", { name: "Chart View" }); + await user.click(chartViewButton); + + expect(chartViewButton).toHaveClass("bg-blue-100"); + }); + + it("should switch to table view when table view button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const chartViewButton = screen.getByRole("button", { name: "Chart View" }); + const tableViewButton = screen.getByRole("button", { name: "Table View" }); + + await user.click(chartViewButton); + await user.click(tableViewButton); + + expect(tableViewButton).toHaveClass("bg-blue-100"); + }); + + it("should call setTopKeysLimit when limit is changed via Segmented control", async () => { + const user = userEvent.setup(); + render(); + + const limit10Radio = screen.getByRole("radio", { name: "10" }); + const limit10Label = limit10Radio.closest("label"); + if (limit10Label) { + await user.click(limit10Label); + } else { + // Fallback: click the div with title="10" + const limit10Div = screen.getByTitle("10"); + await user.click(limit10Div); + } + + expect(mockSetTopKeysLimit).toHaveBeenCalledWith(10); + }); + + it("should display truncated key ID in table", () => { + render( + , + ); + expect(screen.getByText(/sk-1234\.\.\./)).toBeInTheDocument(); + }); + + it("should display dash for missing key alias", () => { + render( + , + ); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("should format spend values with two decimal places", () => { + render( + , + ); + expect(screen.getByText("$123.46")).toBeInTheDocument(); + }); + + it("should display less than 0.01 spend as <$0.01", () => { + render( + , + ); + expect(screen.getByText("<$0.01")).toBeInTheDocument(); + }); + + it("should display zero spend correctly", () => { + render( + , + ); + expect(screen.getByText("$0.00")).toBeInTheDocument(); + }); + + it("should display dash for empty tags", () => { + render( + , + ); + expect(screen.getAllByText("-").length).toBeGreaterThan(0); + }); + + it("should display dash for missing tags", () => { + render( + , + ); + expect(screen.getAllByText("-").length).toBeGreaterThan(0); + }); + + it("should display first two tags by default and show expand button", () => { + render( + , + ); + expect(screen.getByText(/tag-1/)).toBeInTheDocument(); + expect(screen.getByText(/tag-2/)).toBeInTheDocument(); + expect(screen.queryByText(/tag-3/)).not.toBeInTheDocument(); + }); + + it("should expand tags when expand button is clicked", async () => { + const user = userEvent.setup(); + render( + , + ); + + const expandButton = screen.getByTitle("Show all tags"); + await user.click(expandButton); + + expect(screen.getByText(/tag-3/)).toBeInTheDocument(); + }); + + it("should collapse tags when collapse button is clicked", async () => { + const user = userEvent.setup(); + render( + , + ); + + const expandButton = screen.getByTitle("Show all tags"); + await user.click(expandButton); + + expect(screen.getByText(/tag-3/)).toBeInTheDocument(); + + const collapseButton = screen.getByTitle("Show fewer tags"); + await user.click(collapseButton); + + expect(screen.queryByText(/tag-3/)).not.toBeInTheDocument(); + }); + + it("should open modal when key ID is clicked", async () => { + const mockKeyInfo = { key: "info" }; + const mockTransformedData = { transformed: "data" } as unknown as KeyResponse; + mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo); + mockTransformKeyInfo.mockReturnValue(mockTransformedData); + + const user = userEvent.setup(); + render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(mockKeyInfoV1Call).toHaveBeenCalledWith("test-token", "key-123"); + }); + + await waitFor(() => { + expect(screen.getByTestId("key-info-view")).toBeInTheDocument(); + }); + }); + + it("should close modal when close button is clicked", async () => { + const mockKeyInfo = { key: "info" }; + const mockTransformedData = { transformed: "data" } as unknown as KeyResponse; + mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo); + mockTransformKeyInfo.mockReturnValue(mockTransformedData); + + const user = userEvent.setup(); + render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(screen.getByTestId("key-info-view")).toBeInTheDocument(); + }); + + const closeButton = screen.getByLabelText("Close"); + await user.click(closeButton); + + await waitFor(() => { + expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument(); + }); + }); + + it("should close modal when escape key is pressed", async () => { + const mockKeyInfo = { key: "info" }; + const mockTransformedData = { transformed: "data" } as unknown as KeyResponse; + mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo); + mockTransformKeyInfo.mockReturnValue(mockTransformedData); + + const user = userEvent.setup(); + render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(screen.getByTestId("key-info-view")).toBeInTheDocument(); + }); + + await user.keyboard("{Escape}"); + + await waitFor(() => { + expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument(); + }); + }); + + it("should close modal when clicking outside modal", async () => { + const mockKeyInfo = { key: "info" }; + const mockTransformedData = { transformed: "data" } as unknown as KeyResponse; + mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo); + mockTransformKeyInfo.mockReturnValue(mockTransformedData); + + const user = userEvent.setup(); + const { container } = render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(screen.getByTestId("key-info-view")).toBeInTheDocument(); + }); + + const modalBackdrop = container.querySelector(".fixed.inset-0"); + if (modalBackdrop) { + await user.click(modalBackdrop); + } + + await waitFor(() => { + expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument(); + }); + }); + + it("should not open modal when accessToken is missing", async () => { + mockUseAuthorized.mockReturnValue({ + ...mockAuth, + accessToken: "", + }); + + const user = userEvent.setup(); + render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(mockKeyInfoV1Call).not.toHaveBeenCalled(); + }); + + expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument(); + }); + + it("should handle error when fetching key info", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockKeyInfoV1Call.mockRejectedValue(new Error("Network error")); + + const user = userEvent.setup(); + render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(mockKeyInfoV1Call).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + consoleErrorSpy.mockRestore(); + }); + + it("should sort tags by usage descending", () => { + render( + , + ); + + const tagElements = screen.getAllByText(/tag-/); + const tagTexts = tagElements.map((el) => el.textContent); + // Tags are truncated to 7 characters + "...", so "tag-high" becomes "tag-hig..." + expect(tagTexts[0]).toMatch(/^tag-hig/); + expect(tagTexts[1]).toMatch(/^tag-med/); + }); + + it("should handle empty key list", () => { + render(); + expect(screen.getByText("Key ID")).toBeInTheDocument(); + expect(screen.getByText("Key Alias")).toBeInTheDocument(); + }); + + it("should display full key alias in table view", () => { + render( + , + ); + expect(screen.getByText("This is a very long key alias")).toBeInTheDocument(); + }); + + it("should handle keys with no alias", () => { + render( + , + ); + expect(screen.getByText("-")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index db268286007..8903f74f232 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -1,23 +1,24 @@ -import React, { useState } from "react"; -import { BarChart } from "@tremor/react"; -import KeyInfoView from "../../../templates/key_info_view"; -import { keyInfoV1Call } from "../../../networking"; -import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info"; -import { DataTable } from "../../../view_logs/table"; -import { Tooltip } from "antd"; -import { Button } from "@tremor/react"; -import { formatNumberWithCommas } from "../../../../utils/dataUtils"; -import { TagUsage } from "../../types"; -import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline"; +import { BarChart, Button } from "@tremor/react"; +import { Segmented, Tooltip } from "antd"; +import React, { useState } from "react"; +import { formatNumberWithCommas } from "../../../../utils/dataUtils"; +import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info"; +import { keyInfoV1Call } from "../../../networking"; +import KeyInfoView from "../../../templates/key_info_view"; +import { DataTable } from "../../../view_logs/table"; +import { TagUsage } from "../../types"; interface TopKeyViewProps { topKeys: any[]; teams: any[] | null; showTags?: boolean; + topKeysLimit: number; + setTopKeysLimit: (limit: number) => void; } -const TopKeyView: React.FC = ({ topKeys, teams, showTags = false }) => { +const TopKeyView: React.FC = ({ topKeys, teams, showTags = false, topKeysLimit, setTopKeysLimit }) => { const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedKey, setSelectedKey] = useState(null); @@ -178,7 +179,17 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals return ( <> -
+
+ setTopKeysLimit(value as number)} + />
{viewMode === "chart" ? ( -
+
= ({ topKeys, teams, showTags = fals />
) : ( -
+
{ + const mockSetTopModelsLimit = vi.fn(); + + beforeEach(() => { + mockSetTopModelsLimit.mockClear(); + }); + it("should render", () => { - const { container } = render(); - expect(container).toBeTruthy(); + render(); + expect(screen.getByText("Table View")).toBeInTheDocument(); }); - it("should have a table view button", () => { - const { getByText } = render(); - expect(getByText("Table View")).toBeInTheDocument(); + it("should display table view button", () => { + render(); + expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument(); }); - it("should have a chart view", () => { - const { getByText } = render(); - expect(getByText("Chart View")).toBeInTheDocument(); + it("should display chart view button", () => { + render(); + expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument(); }); - ["Model", "Spend (USD)", "Successful", "Failed", "Tokens"].forEach((header) => { - it(`should have a ${header} column`, () => { - const { getByText } = render(); - expect(getByText(header)).toBeInTheDocument(); - }); + it("should display all table column headers", () => { + render(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); + expect(screen.getByText("Successful")).toBeInTheDocument(); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect(screen.getByText("Tokens")).toBeInTheDocument(); }); - it("should show the model's information on the table", () => { - const { getByText } = render( + it("should display model data in table view", () => { + render( { tokens: 50000, }, ]} + topModelsLimit={5} + setTopModelsLimit={mockSetTopModelsLimit} />, ); - expect(getByText("gpt-4")).toBeInTheDocument(); - expect(getByText("$150.50")).toBeInTheDocument(); - expect(getByText("100")).toBeInTheDocument(); - expect(getByText("5")).toBeInTheDocument(); - expect(getByText("50,000")).toBeInTheDocument(); + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByText("$150.50")).toBeInTheDocument(); + expect(screen.getByText("100")).toBeInTheDocument(); + const failedRequestsCell = screen + .getAllByText("5") + .find((el) => el.closest("span")?.classList.contains("text-red-600")); + expect(failedRequestsCell).toBeDefined(); + expect(screen.getByText("50,000")).toBeInTheDocument(); + }); + + it("should switch to chart view when chart view button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const chartViewButton = screen.getByRole("button", { name: "Chart View" }); + await user.click(chartViewButton); + + expect(chartViewButton).toHaveClass("bg-blue-100"); + }); + + it("should switch to table view when table view button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const chartViewButton = screen.getByRole("button", { name: "Chart View" }); + const tableViewButton = screen.getByRole("button", { name: "Table View" }); + + await user.click(chartViewButton); + await user.click(tableViewButton); + + expect(tableViewButton).toHaveClass("bg-blue-100"); + }); + + it("should call setTopModelsLimit when limit is changed via Segmented control", async () => { + const user = userEvent.setup(); + render(); + + const limit10Radio = screen.getByRole("radio", { name: "10" }); + const limit10Label = limit10Radio.closest("label"); + if (limit10Label) { + await user.click(limit10Label); + } else { + // Fallback: click the div with title="10" + const limit10Div = screen.getByTitle("10"); + await user.click(limit10Div); + } + + expect(mockSetTopModelsLimit).toHaveBeenCalledWith(10); + }); + + it("should display only top N models based on limit", () => { + const manyModels = Array.from({ length: 10 }, (_, i) => ({ + key: `model-${i + 1}`, + spend: 100 + i, + successful_requests: 50 + i, + failed_requests: 5 + i, + tokens: 10000 + i * 1000, + })); + + render(); + + expect(screen.getByText("model-1")).toBeInTheDocument(); + expect(screen.getByText("model-5")).toBeInTheDocument(); + expect(screen.queryByText("model-6")).not.toBeInTheDocument(); + }); + + it("should display all models when limit is greater than model count", () => { + const models = [ + { + key: "model-1", + spend: 100, + successful_requests: 50, + failed_requests: 5, + tokens: 10000, + }, + { + key: "model-2", + spend: 200, + successful_requests: 60, + failed_requests: 6, + tokens: 20000, + }, + ]; + + render(); + + expect(screen.getByText("model-1")).toBeInTheDocument(); + expect(screen.getByText("model-2")).toBeInTheDocument(); + }); + + it("should format spend values with two decimal places", () => { + render( + , + ); + expect(screen.getByText("$123.46")).toBeInTheDocument(); + }); + + it("should display zero values correctly", () => { + render( + , + ); + expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.getAllByText("0").length).toBeGreaterThan(0); + }); + + it("should display successful requests with green styling", () => { + render( + , + ); + const successfulCell = screen + .getAllByText("50") + .find((el) => el.closest("span")?.classList.contains("text-green-600")); + expect(successfulCell).toBeDefined(); + }); + + it("should display failed requests with red styling", () => { + render( + , + ); + const failedCell = screen.getAllByText("5").find((el) => el.closest("span")?.classList.contains("text-red-600")); + expect(failedCell).toBeDefined(); + }); + + it("should format large token numbers with commas", () => { + render( + , + ); + expect(screen.getByText("1,234,567")).toBeInTheDocument(); + }); + + it("should handle empty model list", () => { + render(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); + }); + + it("should display dash for missing model key", () => { + render( + , + ); + expect(screen.getByText("-")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx index 15ae660d4be..c69ba42f182 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx @@ -1,6 +1,7 @@ import { BarChart } from "@tremor/react"; -import { formatNumberWithCommas } from "../../../../utils/dataUtils"; +import { Segmented } from "antd"; import { useState } from "react"; +import { formatNumberWithCommas } from "../../../../utils/dataUtils"; import { DataTable } from "../../../view_logs/table"; interface TopModel { @@ -13,9 +14,11 @@ interface TopModel { interface TopModelViewProps { topModels: TopModel[]; + topModelsLimit: number; + setTopModelsLimit: (limit: number) => void; } -export default function TopModelView({ topModels }: TopModelViewProps) { +export default function TopModelView({ topModels, topModelsLimit, setTopModelsLimit }: TopModelViewProps) { const [modelViewMode, setModelViewMode] = useState<"chart" | "table">("table"); const columns = [ @@ -48,9 +51,21 @@ export default function TopModelView({ topModels }: TopModelViewProps) { cell: (info: any) => info.getValue()?.toLocaleString() || 0, }, ]; + const processedTopModels = topModels.slice(0, topModelsLimit); + return ( <> -
+
+ setTopModelsLimit(value as number)} + />
{modelViewMode === "chart" ? ( -
+
`$${formatNumberWithCommas(value, 2)}`} layout="vertical" yAxisWidth={200} + tickGap={5} showLegend={false} />
) : ( -
+
<>} getRowCanExpand={() => false} + isLoading={false} />
)} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index d891fcf604c..aaecbd063b5 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -27,17 +27,19 @@ import { Text, Title, } from "@tremor/react"; -import { Alert } from "antd"; +import { Alert, Segmented } from "antd"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { Button } from "@tremor/react"; import { all_admin_roles } from "../../../utils/roles"; import { ActivityMetrics, processActivityData } from "../../activity_metrics"; import CloudZeroExportModal from "../../cloudzero_export_modal"; +import NewBadge from "../../common_components/NewBadge"; import EntityUsageExportModal from "../../EntityUsageExport"; import { Team } from "../../key_team_helpers/key_list"; import { Organization, tagListCall, userDailyActivityAggregatedCall, userDailyActivityCall } from "../../networking"; @@ -49,12 +51,10 @@ import UserAgentActivity from "../../user_agent_activity"; import ViewUserSpend from "../../view_user_spend"; import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "../types"; import { valueFormatterSpend } from "../utils/value_formatters"; +import EndpointUsage from "./EndpointUsage/EndpointUsage"; import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage"; import TopKeyView from "./EntityUsage/TopKeyView"; import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; -import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; -import EndpointUsage from "./EndpointUsage/EndpointUsage"; -import NewBadge from "../../common_components/NewBadge"; interface UsagePageProps { teams: Team[]; @@ -95,6 +95,8 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [showCustomerBanner, setShowCustomerBanner] = useState(true); const [usageView, setUsageView] = useState("global"); const [showAgentBanner, setShowAgentBanner] = useState(true); + const [topKeysLimit, setTopKeysLimit] = useState(5); + const [topModelsLimit, setTopModelsLimit] = useState(5); const getAllTags = async () => { if (!accessToken) { return; @@ -116,7 +118,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const totalSpend = userSpendData.metadata?.total_spend || 0; // Calculate top models from the breakdown data - const getTopModels = () => { + const getTopModels = (limit: number = 5) => { const modelSpend: { [key: string]: MetricWithMetadata } = {}; userSpendData.results.forEach((day) => { Object.entries(day.breakdown.models || {}).forEach(([model, metrics]) => { @@ -159,10 +161,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { tokens: metrics.metrics.total_tokens, })) .sort((a, b) => b.spend - a.spend) - .slice(0, 5); + .slice(0, limit); }; - const getTopModelGroups = () => { + const getTopModelGroups = (limit: number = 5) => { const modelGroupSpend: { [key: string]: MetricWithMetadata } = {}; userSpendData.results.forEach((day) => { Object.entries(day.breakdown.model_groups || {}).forEach(([modelGroup, metrics]) => { @@ -206,7 +208,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { tokens: metrics.metrics.total_tokens, })) .sort((a, b) => b.spend - a.spend) - .slice(0, 5); + .slice(0, limit); }; // Calculate provider spend from the breakdown data @@ -254,7 +256,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }; // Calculate top API keys from the breakdown data - const getTopKeys = () => { + const getTopKeys = (limit: number = 5) => { const keySpend: { [key: string]: KeyMetricWithMetadata } = {}; userSpendData.results.forEach((day) => { Object.entries(day.breakdown.api_keys || {}).forEach(([key, metrics]) => { @@ -300,7 +302,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { spend: metrics.metrics.spend, })) .sort((a, b) => b.spend - a.spend) - .slice(0, 5); + .slice(0, limit); }; const fetchUserSpendData = useCallback(async () => { @@ -576,15 +578,30 @@ const UsagePage: React.FC = ({ teams, organizations }) => { Top Virtual Keys - + {/* Top Models */} + {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}
- {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"} + setTopModelsLimit(value as number)} + />
+ active={showFilters} + hasActiveFilters={!!(filters.user_id || filters.user_role || filters.team)} + /> {/* Reset Filters Button */} - + />
{/* Additional Filters */} {showFilters && (
{/* User ID Search */} -
- updateFilters({ user_id: e.target.value })} - /> - - - -
+ updateFilters({ user_id: value })} + icon={User} + /> + + updateFilters({ sso_user_id: value })} + icon={CircleUserRound} + /> {/* Role Dropdown */}
@@ -333,17 +289,6 @@ export function UserDataTable({ ))}
- - {/* SSO ID Search */} -
- updateFilters({ sso_user_id: e.target.value })} - /> -
)}