merge: update sidebars for mcp docs

Made-with: Cursor
This commit is contained in:
Arindam200 2026-03-18 20:05:07 +05:30
commit ca71028798
87 changed files with 3136 additions and 170 deletions

View file

@ -3218,6 +3218,7 @@ jobs:
-e DD_API_KEY=$DD_API_KEY \
-e DD_SITE=$DD_SITE \
-e LITELLM_LICENSE=$LITELLM_LICENSE \
-e LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES=true \
--add-host host.docker.internal:host-gateway \
--name my-app \
-v $(pwd)/litellm/proxy/example_config_yaml/pass_through_config.yaml:/app/config.yaml \

View file

@ -369,7 +369,8 @@ jobs:
release:
name: "New LiteLLM Release"
needs: [docker-hub-deploy, build-and-push-image, build-and-push-image-database]
permissions:
contents: write
runs-on: "ubuntu-latest"
steps:

View file

@ -156,4 +156,4 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components:
**Fix options:**
1. **Create a Prisma migration** (permanent) — run `prisma migrate dev --name <description>` in the worktree. The generated file will be picked up by `prisma migrate deploy` on next startup.
2. **Apply manually for local dev**`psql -d litellm -c "ALTER TABLE ... ADD COLUMN IF NOT EXISTS ..."` after each proxy start. Fine for dev, not for production.
3. **Update litellm-proxy-extras** — if the package is installed from PyPI, its migration directory must include the new file. Either update the package or run the migration manually until the next release ships it.
3. **Update litellm-proxy-extras** — if the package is installed from PyPI, its migration directory must include the new file. Either update the package or run the migration manually until the next release ships it.

View file

@ -11,7 +11,7 @@ echo "Starting security scans for LiteLLM..."
install_trivy() {
echo "Installing Trivy and required tools..."
sudo apt-get update
sudo apt-get install -y wget apt-transport-https gnupg lsb-release jq curl
sudo apt-get install -y wget apt-transport-https gnupg lsb-release jq curl bsdmainutils
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt-get update

View file

@ -0,0 +1,294 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# MCP Zero Trust Auth (JWT Signer)
![Zero Trust MCP Gateway](/img/mcp_zero_trust_gateway.png)
MCP servers have no built-in way to verify that a request actually came through LiteLLM. Without this guardrail, any client that can reach your MCP server directly can call tools — bypassing your access controls entirely.
`MCPJWTSigner` fixes this. It signs every outbound tool call with a short-lived RS256 JWT. Your MCP server verifies the signature against LiteLLM's public key. Requests that didn't go through LiteLLM have no valid signature and are rejected.
---
## Basic setup
Add the guardrail to your config and point your MCP server at LiteLLM's JWKS endpoint. Every tool call gets a signed JWT automatically — no changes needed on the client side.
```yaml title="config.yaml"
mcp_servers:
- server_name: weather
url: http://localhost:8000/mcp
transport: http
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
issuer: "https://my-litellm.example.com" # defaults to request base URL
audience: "mcp" # default: "mcp"
ttl_seconds: 300 # default: 300
```
**Bring your own signing key** — recommended for production. Auto-generated keys are lost on restart.
```bash
export MCP_JWT_SIGNING_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
# or point to a file
export MCP_JWT_SIGNING_KEY="file:///secrets/mcp-signing-key.pem"
```
**Build a verified MCP server with [FastMCP](https://gofastmcp.com):**
```python title="weather_server.py"
from fastmcp import FastMCP, Context
from fastmcp.server.auth.providers.jwt import JWTVerifier
auth = JWTVerifier(
jwks_uri="https://my-litellm.example.com/.well-known/jwks.json",
issuer="https://my-litellm.example.com",
audience="mcp",
algorithm="RS256",
)
mcp = FastMCP("weather-server", auth=auth)
@mcp.tool()
async def get_weather(city: str, ctx: Context) -> str:
caller = ctx.client_id # JWT `sub` — the verified user identity
return f"Weather in {city}: sunny, 72°F (requested by {caller})"
if __name__ == "__main__":
mcp.run(transport="http", host="0.0.0.0", port=8000)
```
FastMCP fetches the JWKS automatically and re-fetches when the signing key changes.
LiteLLM publishes OIDC discovery so MCP servers find the key without any manual configuration:
```
GET /.well-known/openid-configuration → { "jwks_uri": "https://<litellm>/.well-known/jwks.json" }
GET /.well-known/jwks.json → { "keys": [{ "kty": "RSA", "alg": "RS256", ... }] }
```
> **Read further only if you need to:** thread a corporate IdP identity into the JWT, enforce specific claims on callers, add custom metadata, use AWS Bedrock AgentCore Gateway, or debug JWT rejections.
---
## Thread IdP identity into MCP JWTs
By default the outbound JWT `sub` is LiteLLM's internal `user_id`. If your users authenticate with Okta, Azure AD, or another IdP, the MCP server sees a LiteLLM-internal ID — not the user's email or employee ID.
With verify+re-sign, LiteLLM validates the incoming IdP token first, then builds the outbound JWT using the real identity claims from that token. The MCP server gets the user's actual identity without ever having to trust the original IdP directly.
```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
issuer: "https://my-litellm.example.com"
# Validate the incoming Bearer token against the IdP
access_token_discovery_uri: "https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration"
verify_issuer: "https://login.microsoftonline.com/{tenant}/v2.0"
verify_audience: "api://my-app"
# Which claim to use for `sub` in the outbound JWT — first non-empty value wins
end_user_claim_sources:
- "token:sub" # from the verified incoming JWT
- "token:email" # fallback to email
- "litellm:user_id" # last resort: LiteLLM's internal user_id
```
If the incoming token is **opaque** (not a JWT — some IdPs issue these), add an introspection endpoint. LiteLLM will POST the token to it (RFC 7662) and use the returned claims:
```yaml
token_introspection_endpoint: "https://idp.example.com/oauth2/introspect"
```
**Supported `end_user_claim_sources` values:**
| Source | Resolves to |
|--------|-------------|
| `token:<claim>` | Any claim from the verified incoming JWT (e.g. `token:sub`, `token:email`, `token:oid`) |
| `litellm:user_id` | LiteLLM's internal user ID |
| `litellm:email` | User email from LiteLLM auth context |
| `litellm:end_user_id` | End-user ID if set separately |
| `litellm:team_id` | Team ID from LiteLLM auth context |
---
## Block callers missing required attributes
Some MCP servers expose sensitive operations that should only be reachable by verified employees — not service accounts, not external API keys. You can enforce this at the LiteLLM layer so the MCP server never receives the request at all.
`required_claims` rejects with `403` if the incoming token is missing any listed claim. `optional_claims` forwards claims that are useful but not mandatory.
```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
access_token_discovery_uri: "https://idp.example.com/.well-known/openid-configuration"
# Service accounts without `employee_id` are blocked before the tool runs
required_claims:
- "sub"
- "employee_id"
# Forward these into the outbound JWT when present — skipped silently if absent
optional_claims:
- "groups"
- "department"
```
**What the client sees when blocked:**
```json
HTTP 403
{ "error": "MCPJWTSigner: incoming token is missing required claims: ['employee_id']. Configure the IdP to include these claims." }
```
---
## Add custom metadata to every JWT
Your MCP server may need context that LiteLLM doesn't carry natively — which deployment sent the request, a tenant ID, an environment tag. Use claim operations to inject, override, or strip claims from the outbound JWT.
```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
# add: insert only when the key is not already in the JWT
add_claims:
deployment_id: "prod-us-east-1"
tenant_id: "acme-corp"
# set: always override — even if the claim came from the incoming token
set_claims:
env: "production"
# remove: strip claims the MCP server shouldn't see
remove_claims:
- "nbf" # some validators reject nbf; remove it if yours does
```
Operations run in order — `add_claims``set_claims``remove_claims`. `set_claims` always wins over `add_claims`; `remove_claims` beats both.
---
## AWS Bedrock AgentCore Gateway
Bedrock AgentCore Gateway uses two separate JWTs: one to authenticate the transport connection and another to authorize tool calls. They need different `aud` values and TTLs — a single JWT won't work for both.
LiteLLM can issue both in one hook and inject them into separate headers:
```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
issuer: "https://my-litellm.example.com"
audience: "mcp-resource" # for the MCP resource layer
ttl_seconds: 300
# Second JWT for the transport channel — same sub/act/scope, different aud + TTL
channel_token_audience: "bedrock-agentcore-gateway"
channel_token_ttl: 60 # transport tokens should be short-lived
```
LiteLLM injects two headers on every tool call:
- `Authorization: Bearer <resource-token>` — audience `mcp-resource`, TTL 300s
- `x-mcp-channel-token: Bearer <channel-token>` — audience `bedrock-agentcore-gateway`, TTL 60s
Both tokens are signed with the same LiteLLM key, so your MCP server only needs to trust one JWKS endpoint.
---
## Control which scopes go into the JWT
By default LiteLLM generates least-privilege scopes per request:
- Tool call → `mcp:tools/call mcp:tools/{name}:call`
- List tools → `mcp:tools/call mcp:tools/list`
If your MCP server does its own scope enforcement and needs a specific format, set `allowed_scopes` to replace auto-generation entirely:
```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
allowed_scopes:
- "mcp:tools/call"
- "mcp:tools/list"
- "mcp:admin"
```
Every JWT carries exactly those scopes regardless of which tool is being called.
---
## Debug JWT rejections
Your MCP server is returning 401 and you're not sure what's in the JWT. Enable `debug_headers` and LiteLLM adds a `x-litellm-mcp-debug` response header with the key claims that were signed:
```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
debug_headers: true
```
Response header:
```
x-litellm-mcp-debug: v=1; kid=a3f1b2c4d5e6f708; sub=alice@corp.com; iss=https://my-litellm.example.com; exp=1712345678; scope=mcp:tools/call mcp:tools/get_weather:call
```
Check that `kid` matches what the MCP server fetched from JWKS, `iss`/`aud` match your server's expected values, and `exp` hasn't passed. Disable in production — the header leaks claim metadata.
---
## JWT claims reference
| Claim | Value |
|-------|-------|
| `iss` | `issuer` config value (or request base URL) |
| `aud` | `audience` config value (default: `"mcp"`) |
| `sub` | Resolved via `end_user_claim_sources` (default: `user_id` → api-key hash → `"litellm-proxy"`) |
| `act.sub` | `team_id``org_id``"litellm-proxy"` (RFC 8693 delegation) |
| `email` | `user_email` from LiteLLM auth context (when available) |
| `scope` | Auto-generated per tool call, or `allowed_scopes` when set |
| `iat`, `exp`, `nbf` | Standard timing claims (RFC 7519) |
---
## Limitations
- **OpenAPI-backed MCP servers** (`spec_path` set) do not support JWT injection. LiteLLM logs a warning and skips the header. Use SSE/HTTP transport servers to get full JWT injection.
- The keypair is **in-memory by default** and rotated on each restart unless `MCP_JWT_SIGNING_KEY` is set. FastMCP's `JWTVerifier` handles key rotation transparently via JWKS key ID matching.
---
## Related
- [MCP Guardrails](./mcp_guardrail) — PII masking and blocking for MCP calls
- [MCP OAuth](./mcp_oauth) — upstream OAuth2 for MCP server access
- [MCP AWS SigV4](./mcp_aws_sigv4) — AWS-signed requests to MCP servers

View file

@ -0,0 +1,374 @@
---
title: "v1.82.3 - Nebius AI, gpt-5.4, Gemini 3.x, FLUX Kontext, and 116 New Models"
slug: "v1-82-3"
date: 2026-03-16T00:00:00
authors:
- name: Krrish Dholakia
title: CEO, LiteLLM
url: https://www.linkedin.com/in/krish-d/
image_url: https://pbs.twimg.com/profile_images/1298587542745358340/DZv3Oj-h_400x400.jpg
- name: Ishaan Jaff
title: CTO, LiteLLM
url: https://www.linkedin.com/in/reffajnaahsi/
image_url: https://pbs.twimg.com/profile_images/1613813310264340481/lz54oEiB_400x400.jpg
hide_table_of_contents: false
---
## Deploy this version
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
<Tabs>
<TabItem value="docker" label="Docker">
``` showLineNumbers title="docker run litellm"
docker run \
-e STORE_MODEL_IN_DB=True \
-p 4000:4000 \
ghcr.io/berriai/litellm:main-1.82.3-stable
```
</TabItem>
<TabItem value="pip" label="Pip">
``` showLineNumbers title="pip install litellm"
pip install litellm==1.82.3
```
</TabItem>
</Tabs>
## Key Highlights
- **Nebius AI — new provider** — [30 models across DeepSeek, Qwen, Llama, Mistral, NVIDIA, and BAAI available via Nebius AI cloud](../../docs/providers/nebius) - [PR #22614](https://github.com/BerriAI/litellm/pull/22614)
- **OpenAI gpt-5.4 / gpt-5.4-pro — day 0** — Full pricing and routing support for `gpt-5.4` (1M context, $2.50/$15.00) and `gpt-5.4-pro` ($30.00/$180.00) on OpenAI and Azure
- **Gemini 3.x models**`gemini-3-flash-preview`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-image-preview`, and `gemini-embedding-2-preview` added to cost map for Google AI and Vertex AI
- **FLUX Kontext image editing**`flux-kontext-pro` and `flux-kontext-max` added to Black Forest Labs, alongside `flux-pro-1.0-fill` and `flux-pro-1.0-expand` for inpainting and outpainting
- **116 new models, 132 deprecated models cleaned up** — Major model map refresh including Mistral Magistral, Dashscope Qwen3 VL, xAI Grok via Azure AI, ZAI GLM-5, Serper Search; removal of OpenAI GPT-3.5/GPT-4 legacy variants, Gemini 1.5, and Vertex AI PaLM2
- **SageMaker Nova provider** — [New `sagemaker_nova` provider for Amazon Nova models on SageMaker](../../docs/providers/aws_sagemaker) - [PR #21542](https://github.com/BerriAI/litellm/pull/21542)
- **Secret redaction in logs** — API keys, tokens, and credentials automatically scrubbed from all proxy log output. Enabled by default; opt out with `LITELLM_DISABLE_REDACT_SECRETS=true` - [PR #23668](https://github.com/BerriAI/litellm/pull/23668)
- **Streaming stability fix** — Critical fix for `RuntimeError: Cannot send a request, as the client has been closed.` crashes after ~1 hour in production - [PR #22926](https://github.com/BerriAI/litellm/pull/22926)
---
## New Providers and Endpoints
### New Providers (5 new providers)
| Provider | Supported LiteLLM Endpoints | Description |
| -------- | --------------------------- | ----------- |
| [Nebius AI](../../docs/providers/nebius) (`nebius/`) | `/chat/completions`, `/embeddings` | EU-based AI cloud with 30+ open models — DeepSeek, Qwen3, Llama 3.1/3.3, NVIDIA Nemotron, BAAI embeddings |
| [ZAI](../../docs/providers/zai) (`zai/`) | `/chat/completions` | ZhipuAI GLM-5 models via ZAI cloud |
| [Black Forest Labs](../../docs/providers/black_forest_labs) (`black_forest_labs/`) | `/images/generations`, `/images/edits` | FLUX image generation and editing — Kontext Pro/Max, Pro 1.0 Fill/Expand |
| [Serper](../../docs/providers/serper) (`serper/`) | `/search` | Web search via Serper API |
| [SageMaker Nova](../../docs/providers/aws_sagemaker) (`sagemaker_nova/`) | `/chat/completions` | Amazon Nova models via SageMaker endpoint |
---
## New Models / Updated Models
#### New Model Support (116 new models)
| Provider | Model | Context Window | Input ($/1M tokens) | Output ($/1M tokens) | Features |
| -------- | ----- | -------------- | ------------------- | -------------------- | -------- |
| OpenAI | `gpt-5.4` | 1.05M | $2.50 | $15.00 | chat, vision, tools, reasoning |
| OpenAI | `gpt-5.4-pro` | 1.05M | $30.00 | $180.00 | responses, vision, tools, reasoning |
| OpenAI | `gpt-5.3-chat-latest` | 128K | $1.75 | $14.00 | chat, vision, tools, reasoning |
| Azure OpenAI | `azure/gpt-5.4` | 1.05M | $2.50 | $15.00 | chat, vision, tools, reasoning |
| Azure OpenAI | `azure/gpt-5.4-pro` | 1.05M | $30.00 | $180.00 | responses, vision, tools, reasoning |
| Azure OpenAI | `azure/gpt-5.3-chat` | 128K | $1.75 | $14.00 | chat, vision, tools, reasoning |
| Google Gemini | `gemini/gemini-3-flash-preview` | 1M | $0.50 | $3.00 | chat, vision, tools, reasoning |
| Google Gemini | `gemini/gemini-3.1-pro-preview` | 1M | $2.00 | $12.00 | chat, vision, tools, reasoning |
| Google Gemini | `gemini/gemini-3.1-flash-image-preview` | 65K | $0.25 | $1.50 | image generation, vision |
| Google Gemini | `gemini/gemini-3.1-flash-lite-preview` | - | - | - | chat |
| Google Gemini | `gemini/gemini-3-pro-image-preview` | - | - | - | image generation |
| Google Gemini | `gemini/gemini-embedding-2-preview` | 8K | $0.20 | - | embeddings |
| Google Vertex AI | `vertex_ai/gemini-3-flash-preview` | - | - | - | chat |
| Google Vertex AI | `vertex_ai/gemini-3.1-pro-preview` | - | - | - | chat |
| Google Vertex AI | `vertex_ai/gemini-3.1-flash-lite-preview` | - | - | - | chat |
| Google Vertex AI | `vertex_ai/gemini-embedding-2-preview` | - | $0.20 | - | embeddings |
| Mistral | `mistral/magistral-medium-1-2-2509` | 40K | $2.00 | $5.00 | chat, tools, reasoning |
| Mistral | `mistral/magistral-small-1-2-2509` | 40K | $0.50 | $1.50 | chat, tools, reasoning |
| Mistral | `mistral/mistral-large-2512` | 262K | $0.50 | $1.50 | chat, vision, tools |
| Mistral | `mistral/mistral-medium-3-1-2508` | - | - | - | chat |
| Mistral | `mistral/mistral-small-3-2-2506` | - | - | - | chat |
| Mistral | `mistral/ministral-3-3b-2512` | - | - | - | chat |
| Mistral | `mistral/ministral-3-8b-2512` | - | - | - | chat |
| Mistral | `mistral/ministral-3-14b-2512` | - | - | - | chat |
| Black Forest Labs | `black_forest_labs/flux-kontext-pro` | - | - | - | image editing |
| Black Forest Labs | `black_forest_labs/flux-kontext-max` | - | - | - | image editing |
| Black Forest Labs | `black_forest_labs/flux-pro-1.0-fill` | - | - | - | image editing (inpaint) |
| Black Forest Labs | `black_forest_labs/flux-pro-1.0-expand` | - | - | - | image editing (outpaint) |
| Black Forest Labs | `black_forest_labs/flux-pro-1.1` | - | - | - | image generation |
| Black Forest Labs | `black_forest_labs/flux-pro-1.1-ultra` | - | - | - | image generation |
| Black Forest Labs | `black_forest_labs/flux-dev` | - | - | - | image generation |
| Black Forest Labs | `black_forest_labs/flux-pro` | - | - | - | image generation |
| Azure AI | `azure_ai/grok-4-1-fast-non-reasoning` | 131K | $0.20 | $0.50 | chat, tools |
| Azure AI | `azure_ai/grok-4-1-fast-reasoning` | 131K | $0.20 | $0.50 | chat, tools, reasoning |
| Azure AI | `azure_ai/mistral-document-ai-2512` | - | - | - | OCR |
| Dashscope | `dashscope/qwen3-next-80b-a3b-instruct` | 262K | $0.15 | $1.20 | chat |
| Dashscope | `dashscope/qwen3-next-80b-a3b-thinking` | 262K | $0.15 | $1.20 | chat, reasoning |
| Dashscope | `dashscope/qwen3-vl-235b-a22b-instruct` | 131K | $0.40 | $1.60 | chat, vision |
| Dashscope | `dashscope/qwen3-vl-235b-a22b-thinking` | 131K | $0.40 | $4.00 | chat, vision, reasoning |
| Dashscope | `dashscope/qwen3-vl-32b-instruct` | 131K | $0.16 | $0.64 | chat, vision |
| Dashscope | `dashscope/qwen3-vl-32b-thinking` | 131K | $0.16 | $2.87 | chat, vision, reasoning |
| Dashscope | `dashscope/qwen3-vl-plus` | 260K | - | - | chat, vision |
| Dashscope | `dashscope/qwen3.5-plus` | 992K | - | - | chat |
| Dashscope | `dashscope/qwen3-max-2026-01-23` | 258K | - | - | chat |
| Nebius AI | `nebius/deepseek-ai/DeepSeek-R1` | 128K | $0.80 | $2.40 | chat, reasoning |
| Nebius AI | `nebius/deepseek-ai/DeepSeek-R1-0528` | 164K | $0.80 | $2.40 | chat, reasoning |
| Nebius AI | `nebius/deepseek-ai/DeepSeek-V3` | 128K | $0.50 | $1.50 | chat |
| Nebius AI | `nebius/deepseek-ai/DeepSeek-V3-0324` | 128K | $0.50 | $1.50 | chat |
| Nebius AI | `nebius/deepseek-ai/DeepSeek-R1-Distill-Llama-70B` | 128K | $0.25 | $0.75 | chat |
| Nebius AI | `nebius/Qwen/Qwen3-235B-A22B` | 262K | $0.20 | $0.60 | chat |
| Nebius AI | `nebius/Qwen/Qwen3-32B` | 32K | $0.10 | $0.30 | chat |
| Nebius AI | `nebius/Qwen/Qwen3-30B-A3B` | 32K | $0.10 | $0.30 | chat |
| Nebius AI | `nebius/Qwen/Qwen3-14B` | 32K | $0.08 | $0.24 | chat |
| Nebius AI | `nebius/Qwen/Qwen3-4B` | 32K | $0.08 | $0.24 | chat |
| Nebius AI | `nebius/Qwen/QwQ-32B` | 32K | $0.15 | $0.45 | chat |
| Nebius AI | `nebius/Qwen/Qwen2.5-72B-Instruct` | 128K | $0.13 | $0.40 | chat |
| Nebius AI | `nebius/Qwen/Qwen2.5-32B-Instruct` | 128K | $0.06 | $0.20 | chat |
| Nebius AI | `nebius/Qwen/Qwen2.5-VL-72B-Instruct` | 131K | $0.13 | $0.40 | chat, vision |
| Nebius AI | `nebius/Qwen/Qwen2-VL-72B-Instruct` | 131K | $0.13 | $0.40 | chat, vision |
| Nebius AI | `nebius/Qwen/Qwen2-VL-7B-Instruct` | 131K | $0.02 | $0.06 | chat, vision |
| Nebius AI | `nebius/meta-llama/Meta-Llama-3.1-405B-Instruct` | 128K | $1.00 | $3.00 | chat |
| Nebius AI | `nebius/meta-llama/Meta-Llama-3.1-70B-Instruct` | 128K | $0.13 | $0.40 | chat |
| Nebius AI | `nebius/meta-llama/Meta-Llama-3.1-8B-Instruct` | 128K | $0.02 | $0.06 | chat |
| Nebius AI | `nebius/meta-llama/Llama-3.3-70B-Instruct` | 128K | $0.13 | $0.40 | chat |
| Nebius AI | `nebius/meta-llama/Llama-Guard-3-8B` | 128K | $0.02 | $0.06 | chat |
| Nebius AI | `nebius/nvidia/Llama-3.1-Nemotron-Ultra-253B-v1` | 128K | $0.60 | $1.80 | chat |
| Nebius AI | `nebius/nvidia/Llama-3.3-Nemotron-Super-49B-v1` | 131K | $0.10 | $0.40 | chat |
| Nebius AI | `nebius/NousResearch/Hermes-3-Llama-3.1-405B` | 128K | $1.00 | $3.00 | chat |
| Nebius AI | `nebius/google/gemma-3-27b-it` | 128K | $0.06 | $0.20 | chat |
| Nebius AI | `nebius/mistralai/Mistral-Nemo-Instruct-2407` | 128K | $0.04 | $0.12 | chat |
| Nebius AI | `nebius/Qwen/Qwen2.5-Coder-7B` | 32K | $0.01 | $0.03 | chat |
| Nebius AI | `nebius/BAAI/bge-en-icl` | 32K | $0.01 | - | embeddings |
| Nebius AI | `nebius/BAAI/bge-multilingual-gemma2` | 8K | $0.01 | - | embeddings |
| Nebius AI | `nebius/intfloat/e5-mistral-7b-instruct` | 32K | $0.01 | - | embeddings |
| AWS Bedrock | `mistral.devstral-2-123b` | 256K | $0.40 | $2.00 | chat, tools |
| AWS Bedrock | `zai.glm-4.7-flash` | 200K | $0.07 | $0.40 | chat, tools, reasoning |
| ZAI | `zai/glm-5` | 200K | $1.00 | $3.20 | chat, tools, reasoning |
| ZAI | `zai/glm-5-code` | 200K | $1.20 | $5.00 | chat, tools, reasoning |
| OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` | - | - | - | chat |
| OpenRouter | `openrouter/google/gemini-3.1-pro-preview` | - | - | - | chat |
| OpenRouter | `openrouter/openai/gpt-5.1-codex-max` | - | - | - | chat |
| OpenRouter | `openrouter/qwen/qwen3-coder-plus` | - | - | - | chat |
| OpenRouter | `openrouter/qwen/qwen3.5-*` (5 models) | - | - | - | chat |
| OpenRouter | `openrouter/z-ai/glm-5` | - | - | - | chat |
| Together AI | `together_ai/Qwen/Qwen3.5-397B-A17B` | - | - | - | chat |
| Perplexity | `perplexity/pplx-embed-v1-0.6b` | 32K | $0.00 | - | embeddings |
| Perplexity | `perplexity/pplx-embed-v1-4b` | 32K | $0.03 | - | embeddings |
| Serper | `serper/search` | - | - | - | search |
#### Updated Models
- **[AWS Bedrock](../../docs/providers/bedrock)**
- Add `cache_read_input_token_cost` and `cache_creation_input_token_cost` to Bedrock-hosted Anthropic models (`claude-3-opus`, `claude-3-sonnet`, `claude-3-haiku`, and APAC/EU variants) — prompt caching is now tracked for cost estimation
- Rename `apac.anthropic.claude-sonnet-4-6``au.anthropic.claude-sonnet-4-6` to reflect correct regional identifier
- **[Azure OpenAI](../../docs/providers/azure)**
- Add `supports_none_reasoning_effort` to all `gpt-5.1-chat`, `gpt-5.1-codex`, and `gpt-5.4` variants (global, EU, standard deployments) — allows passing `reasoning_effort: null` to disable reasoning
- **[Azure OpenAI](../../docs/providers/azure)** — Removed deprecated models
- Remove `azure/gpt-35-turbo-0301` (deprecated 2025-02-13)
- Remove `azure/gpt-35-turbo-0613` (deprecated 2025-02-13)
#### Features
- **[OpenAI](../../docs/providers/openai)**
- Day 0 support for `gpt-5.4` and `gpt-5.4-pro` on OpenAI and Azure
- **[Google Gemini](../../docs/providers/gemini)**
- Add Gemini 3.x model cost map entries — `gemini-3-flash-preview`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite-preview`, `gemini-3-pro-image-preview`, `gemini-embedding-2-preview`
- Add Gemini 2.0 Flash and Flash Lite to cost map (re-added with updated pricing)
- **[Google Vertex AI](../../docs/providers/vertex)**
- Add `gemini-3-flash-preview`, `gemini-3.1-flash-lite-preview`, `gemini-flash-experimental`, and `gemini-embedding-2-preview` to Vertex AI model cost map
- **[Mistral](../../docs/providers/mistral)**
- Add Magistral reasoning models (`magistral-medium-1-2-2509`, `magistral-small-1-2-2509`)
- Add `mistral-large-2512`, `mistral-medium-3-1-2508`, `mistral-small-3-2-2506`, `ministral-3-*` variants
- **[Dashscope / Qwen](../../docs/providers/dashscope)**
- Add Qwen3 VL multimodal models (`qwen3-vl-235b`, `qwen3-vl-32b` — instruct and thinking variants)
- Add `qwen3-next-80b-a3b` (instruct + thinking), `qwen3.5-plus`, `qwen3-max-2026-01-23`
- **[Black Forest Labs](../../docs/providers/black_forest_labs)**
- Add FLUX Kontext image editing models (`flux-kontext-pro`, `flux-kontext-max`)
- Add FLUX Pro 1.0 Fill (inpainting) and Expand (outpainting)
- Add `flux-pro-1.1`, `flux-pro-1.1-ultra`, `flux-dev`, `flux-pro`
- **[Azure AI](../../docs/providers/azure_ai)**
- Add xAI Grok models via Azure AI Foundry (`grok-4-1-fast-non-reasoning`, `grok-4-1-fast-reasoning`)
- Add Mistral Document AI (`mistral-document-ai-2512`) — OCR mode
- **[AWS Bedrock](../../docs/providers/bedrock)**
- Add `mistral.devstral-2-123b` (256K context, tools)
- Add `zai.glm-4.7-flash` via Bedrock Converse (200K context, tools, reasoning)
- **[SageMaker](../../docs/providers/aws_sagemaker)**
- Add `sagemaker_nova` provider for Amazon Nova models on SageMaker - [PR #21542](https://github.com/BerriAI/litellm/pull/21542)
#### Deprecated / Removed Models
**OpenAI** — Legacy models removed from cost map:
- `gpt-3.5-turbo-0301`, `gpt-3.5-turbo-0613`, `gpt-3.5-turbo-16k-0613`
- `gpt-4-0314`, `gpt-4-32k`, `gpt-4-32k-0314`, `gpt-4-32k-0613`, `gpt-4-1106-vision-preview`, `gpt-4-vision-preview`
- `gpt-4.5-preview`, `gpt-4.5-preview-2025-02-27`
- `gpt-4o-audio-preview-2024-10-01`, `gpt-4o-realtime-preview-2024-10-01`
- `o1-mini`, `o1-mini-2024-09-12`, `o1-preview`, `o1-preview-2024-09-12`
**Google Gemini** — Gemini 1.5 and legacy 2.0 variants removed:
- All `gemini-1.5-*` variants (flash, flash-8b, pro, and dated versions)
- `gemini-2.0-flash-exp`, `gemini-2.0-pro-exp-02-05`, `gemini-2.5-flash-preview-04-17`, `gemini-2.5-flash-preview-05-20`
**Google Vertex AI** — PaLM 2 / legacy models removed:
- All `chat-bison`, `text-bison`, `codechat-bison`, `code-bison`, `code-gecko` variants
- Gemini 1.0 Pro, 1.5 Flash/Pro, 2.0 Flash experimental, and preview variants
**Perplexity** — Legacy Llama-sonar models removed:
- `llama-3.1-sonar-huge-128k-online`, `llama-3.1-sonar-large/small-128k-chat/online`
---
## LLM API Endpoints
#### Features
- **[Responses API](../../docs/response_api)**
- Handle `response.failed`, `response.incomplete`, and `response.cancelled` terminal event types in background streaming — previously only `response.completed` was handled - [PR #23492](https://github.com/BerriAI/litellm/pull/23492)
#### Bug Fixes
- **[Anthropic](../../docs/providers/anthropic)**
- Preserve native tool format (web_search, bash, tool_search, etc.) when guardrails convert tools for the Anthropic Messages API - [PR #23526](https://github.com/BerriAI/litellm/pull/23526)
- **[Moonshot / Kimi](../../docs/providers/openai_compatible)**
- Auto-fill `reasoning_content` for Moonshot Kimi reasoning models - [PR #23580](https://github.com/BerriAI/litellm/pull/23580)
- **[HuggingFace](../../docs/providers/huggingface)**
- Forward `extra_headers` to HuggingFace embedding API - [PR #23525](https://github.com/BerriAI/litellm/pull/23525)
- **General**
- Normalize `content_filtered` finish reason across providers - [PR #23564](https://github.com/BerriAI/litellm/pull/23564)
- Fix custom cost tracking on deployments for `/v1/messages` and `/v1/responses` - [PR #23647](https://github.com/BerriAI/litellm/pull/23647)
- Fix per-request custom pricing when `router_model_id` has no pricing data — now falls back to model name
---
## Management Endpoints / UI
#### Features
- **Virtual Keys**
- Add Organization dropdown to Create/Edit Key form — `organization_id` is now a first-class field in Key Ownership - [PR #23595](https://github.com/BerriAI/litellm/pull/23595)
- Allow setting `organization_id` on `/key/update` — keys can be assigned or moved to a different organization after creation - [PR #23557](https://github.com/BerriAI/litellm/pull/23557)
- **Internal Users**
- Add/Remove Team Membership directly from the Internal Users info page — includes searchable dropdown and role selector; no longer requires navigating to each team - [PR #23638](https://github.com/BerriAI/litellm/pull/23638)
- **Default Team Settings**
- Modernize page to antd (consistent with rest of app) - [PR #23614](https://github.com/BerriAI/litellm/pull/23614)
- Fix: default team params (budget, duration, tpm, rpm, permissions) now correctly applied on `/team/new` - [PR #23614](https://github.com/BerriAI/litellm/pull/23614)
- Fix: settings persist across proxy restarts (`default_team_params` added to `LITELLM_SETTINGS_SAFE_DB_OVERRIDES`) - [PR #23614](https://github.com/BerriAI/litellm/pull/23614)
- Fix: resolved race condition in `_update_litellm_setting` where `get_config()` could overwrite freshly saved values - [PR #23614](https://github.com/BerriAI/litellm/pull/23614)
- **Usage**
- Auto-paginate daily spend data — all entity views (teams, orgs, customers, tags, agents, users) fetch pages progressively with charts updating after each page - [PR #23622](https://github.com/BerriAI/litellm/pull/23622)
- **Models / Cost**
- Azure Model Router cost breakdown in UI — show per-sub-model `additional_costs` from `hidden_params` in `CostBreakdownViewer` - [PR #23550](https://github.com/BerriAI/litellm/pull/23550)
- **User Management**
- New `/user/info/v2` endpoint — scoped, paginated replacement for the existing god endpoint that caused memory and stability issues on large installs - [PR #23437](https://github.com/BerriAI/litellm/pull/23437)
#### Bugs
- Fix Tag list endpoint returning 500 due to invalid Prisma `group_by` kwargs - [PR #23606](https://github.com/BerriAI/litellm/pull/23606)
- Fix Team Admin getting 403 on `/user/filter/ui` when `scope_user_search_to_org` is enabled - [PR #23671](https://github.com/BerriAI/litellm/pull/23671)
- Fix Public Model Hub not showing config-defined models after save - [PR #23501](https://github.com/BerriAI/litellm/pull/23501)
- Fix fallback popup model dropdown z-index issue - [PR #23516](https://github.com/BerriAI/litellm/pull/23516)
- Fix double-counting bug in org/team key limit checks on `/key/update`
---
## AI Integrations
### Logging
- **[Vantage](https://vantage.sh)**
- Add Vantage integration for FOCUS 1.2 CSV export — export LiteLLM proxy spend data as FinOps Open Cost & Usage Specification reports, with time-windowed filenames to prevent overwrites - [PR #23333](https://github.com/BerriAI/litellm/pull/23333)
- **General**
- Fix silent metrics race condition causing metric collision across experiments - [PR #23542](https://github.com/BerriAI/litellm/pull/23542)
### Guardrails
No major guardrail changes in this release.
### Prompt Management
No major prompt management changes in this release.
### Secret Managers
No major secret manager changes in this release.
---
## Performance / Loadbalancing / Reliability improvements
- **Fix streaming crashes after ~1 hour**`LLMClientCache._remove_key()` no longer calls `close()`/`aclose()` on evicted HTTP/SDK clients. In-flight requests were crashing with `RuntimeError: Cannot send a request, as the client has been closed.` after the 1-hour TTL expired. Cleanup now happens only at shutdown via `close_litellm_async_clients()` - [PR #22926](https://github.com/BerriAI/litellm/pull/22926)
- **Fix OOM / Prisma connection loss** on large installs — unbounded managed-object poll was exhausting Prisma connections after ~6070 minutes on instances with 336K+ queued response rows - [PR #23472](https://github.com/BerriAI/litellm/pull/23472)
- **Centralize logging kwarg updates** — root cause fix migrating all logging updates to a single function, eliminating kwarg inconsistencies across logging paths - [PR #23659](https://github.com/BerriAI/litellm/pull/23659)
- **Fix tiktoken cache for non-root offline containers** — tiktoken cache now works correctly in offline environments running as non-root users - [PR #23498](https://github.com/BerriAI/litellm/pull/23498)
- **Add CodSpeed continuous performance benchmarks** — automated performance regression tracking on CI - [PR #23676](https://github.com/BerriAI/litellm/pull/23676)
---
## Security
- **Secret redaction in proxy logs** — Adds a `SecretRedactionFilter` to all LiteLLM loggers that scrubs API keys, tokens, and credentials from log messages, format args, exception tracebacks, and extra fields. Enabled by default; opt out with `LITELLM_DISABLE_REDACT_SECRETS=true` - [PR #23668](https://github.com/BerriAI/litellm/pull/23668), [PR #23667](https://github.com/BerriAI/litellm/pull/23667)
- **Bump PyJWT to `^2.12.0`** — addresses security vulnerability in `^2.10.1` - [PR #23678](https://github.com/BerriAI/litellm/pull/23678)
- **Bump `tar` to 7.5.11 and `tornado` to 6.5.5** — addresses CVEs in transitive dependencies - [PR #23602](https://github.com/BerriAI/litellm/pull/23602)
---
## Database / Proxy Operations
- **Fix Prisma migrate deploy on pre-existing instances** — resolved multiple bugs in migration recovery logic: missing return in the P3018 idempotent error handler and unhandled exceptions in `_roll_back_migration` that caused silent failures even after successful recovery - [PR #23655](https://github.com/BerriAI/litellm/pull/23655)
- **Make DB migration failure exit opt-in** — proxy no longer exits on `prisma migrate deploy` failure by default; enable with `--enforce_prisma_migration_check` - [PR #23675](https://github.com/BerriAI/litellm/pull/23675)
---
## New Contributors
* @ryanh-ai made their first contribution in [PR #21542](https://github.com/BerriAI/litellm/pull/21542)
* @ryan-crabbe made their first contribution in [PR #23668](https://github.com/BerriAI/litellm/pull/23668)
* @Jah-yee made their first contribution in [PR #23525](https://github.com/BerriAI/litellm/pull/23525)
* @gambletan made their first contribution in [PR #23516](https://github.com/BerriAI/litellm/pull/23516)
* @awais786 made their first contribution in [PR #23183](https://github.com/BerriAI/litellm/pull/23183)
* @pradyyadav made their first contribution in [PR #23580](https://github.com/BerriAI/litellm/pull/23580)
* @xianzongxie-stripe made their first contribution in [PR #23492](https://github.com/BerriAI/litellm/pull/23492)
* @Harshit28j made their first contribution in [PR #23333](https://github.com/BerriAI/litellm/pull/23333)
* @codspeed-hq[bot] made their first contribution in [PR #23676](https://github.com/BerriAI/litellm/pull/23676)
---
## Diff Summary
## 03/16/2026
* New Providers: 5
* New Models / Updated Models: 116 new, 132 removed
* LLM API Endpoints: 5
* Management Endpoints / UI: 11
* AI Integrations: 2
* Performance / Reliability: 5
* Security: 3
* Database / Proxy Operations: 2
---
## Full Changelog
[v1.82.0-stable...v1.82.3-stable](https://github.com/BerriAI/litellm/compare/v1.82.0-stable...v1.82.3-stable)

View file

@ -658,9 +658,22 @@ const sidebars = {
"vector_stores/create",
"vector_stores/search",
{
type: "link",
type: "category",
label: "/mcp - Model Context Protocol",
href: "/docs/mcp",
items: [
"mcp",
"mcp_usage",
"mcp_openapi",
"mcp_oauth",
"mcp_aws_sigv4",
"mcp_zero_trust",
"mcp_public_internet",
"mcp_semantic_filter",
"mcp_control",
"mcp_cost",
"mcp_guardrail",
"mcp_troubleshoot",
]
},
{
type: "category",

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm-proxy-extras"
version = "0.4.56"
version = "0.4.57"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
authors = ["BerriAI"]
readme = "README.md"
@ -22,7 +22,7 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.commitizen]
version = "0.4.56"
version = "0.4.57"
version_files = [
"pyproject.toml:version",
"../requirements.txt:litellm-proxy-extras==",

View file

@ -358,7 +358,7 @@ model_cost_map_url: str = os.getenv(
)
blog_posts_url: str = os.getenv(
"LITELLM_BLOG_POSTS_URL",
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/blog_posts.json",
"https://docs.litellm.ai/blog/rss.xml",
)
anthropic_beta_headers_url: str = os.getenv(
"LITELLM_ANTHROPIC_BETA_HEADERS_URL",

View file

@ -660,7 +660,14 @@ def _select_model_name_for_cost_calc(
if custom_pricing is True:
if router_model_id is not None and router_model_id in litellm.model_cost:
return_model = router_model_id
entry = litellm.model_cost[router_model_id]
if (
entry.get("input_cost_per_token") is not None
or entry.get("input_cost_per_second") is not None
):
return_model = router_model_id
else:
return_model = model
else:
return_model = model

View file

@ -1,8 +1,8 @@
"""
Pulls the latest LiteLLM blog posts from GitHub.
Pulls the latest LiteLLM blog posts from the docs RSS feed.
Falls back to the bundled local backup on any failure.
GitHub JSON URL is configured via litellm.blog_posts_url (or LITELLM_BLOG_POSTS_URL env var).
RSS URL is configured via litellm.blog_posts_url (or LITELLM_BLOG_POSTS_URL env var).
Disable remote fetching entirely:
export LITELLM_LOCAL_BLOG_POSTS=True
@ -11,8 +11,10 @@ Disable remote fetching entirely:
import json
import os
import time
import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from importlib.resources import files
from typing import Any, Dict, List, Optional
from typing import Dict, List, Optional
import httpx
from pydantic import BaseModel
@ -37,9 +39,8 @@ class GetBlogPosts:
"""
Fetches, validates, and caches LiteLLM blog posts.
Mirrors the structure of GetModelCostMap:
- Fetches from GitHub with a 5-second timeout
- Validates the response has a non-empty ``posts`` list
- Fetches RSS feed from docs site with a 5-second timeout
- Parses the XML and extracts the latest blog post
- Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour)
- Falls back to the bundled local backup on any failure
"""
@ -56,30 +57,67 @@ class GetBlogPosts:
return content.get("posts", [])
@staticmethod
def fetch_remote_blog_posts(url: str, timeout: int = 5) -> dict:
def fetch_rss_feed(url: str, timeout: int = 5) -> str:
"""
Fetch blog posts JSON from a remote URL.
Fetch RSS XML from a remote URL.
Returns the parsed response. Raises on network/parse errors.
Returns the raw XML text. Raises on network errors.
"""
response = httpx.get(url, timeout=timeout)
response.raise_for_status()
return response.json()
return response.text
@staticmethod
def validate_blog_posts(data: Any) -> bool:
"""Return True if data is a dict with a non-empty ``posts`` list."""
if not isinstance(data, dict):
verbose_logger.warning(
"LiteLLM: Blog posts response is not a dict (type=%s). "
"Falling back to local backup.",
type(data).__name__,
def parse_rss_to_posts(xml_text: str, max_posts: int = 1) -> List[Dict[str, str]]:
"""
Parse RSS XML and return a list of blog post dicts.
Extracts title, description, date (YYYY-MM-DD), and url from each <item>.
"""
root = ET.fromstring(xml_text)
channel = root.find("channel")
if channel is None:
raise ValueError("RSS feed missing <channel> element")
posts: List[Dict[str, str]] = []
for item in channel.findall("item"):
if len(posts) >= max_posts:
break
title_el = item.find("title")
link_el = item.find("link")
desc_el = item.find("description")
pub_date_el = item.find("pubDate")
if title_el is None or link_el is None:
continue
# Parse RFC 2822 date to YYYY-MM-DD
date_str = ""
if pub_date_el is not None and pub_date_el.text:
try:
dt = parsedate_to_datetime(pub_date_el.text)
date_str = dt.strftime("%Y-%m-%d")
except Exception:
date_str = pub_date_el.text
posts.append(
{
"title": title_el.text or "",
"description": desc_el.text or "" if desc_el is not None else "",
"date": date_str,
"url": link_el.text or "",
}
)
return False
posts = data.get("posts")
return posts
@staticmethod
def validate_blog_posts(posts: List[Dict[str, str]]) -> bool:
"""Return True if posts is a non-empty list."""
if not isinstance(posts, list) or len(posts) == 0:
verbose_logger.warning(
"LiteLLM: Blog posts response has no valid 'posts' list. "
"LiteLLM: Parsed RSS feed has no valid posts. "
"Falling back to local backup.",
)
return False
@ -102,7 +140,8 @@ class GetBlogPosts:
return cached
try:
data = cls.fetch_remote_blog_posts(url)
xml_text = cls.fetch_rss_feed(url)
posts = cls.parse_rss_to_posts(xml_text)
except Exception as e:
verbose_logger.warning(
"LiteLLM: Failed to fetch blog posts from %s: %s. "
@ -112,10 +151,9 @@ class GetBlogPosts:
)
return cls.load_local_blog_posts()
if not cls.validate_blog_posts(data):
if not cls.validate_blog_posts(posts):
return cls.load_local_blog_posts()
posts = data["posts"]
cls._cached_posts = posts
cls._last_fetch_time = now
return posts

View file

@ -2439,13 +2439,25 @@ def anthropic_messages_pt( # noqa: PLR0915
user_content.append(_content_element)
elif m.get("type", "") == "document":
user_content.append(cast(AnthropicMessagesDocumentParam, m))
_document_content_element = cast(
AnthropicMessagesDocumentParam,
add_cache_control_to_content(
anthropic_content_element=cast(AnthropicMessagesDocumentParam, m),
original_content_element=dict(m),
),
)
user_content.append(_document_content_element)
elif m.get("type", "") == "file":
user_content.append(
_file_content_element = (
anthropic_process_openai_file_message(
cast(ChatCompletionFileObject, m)
)
)
_file_content_element = add_cache_control_to_content(
anthropic_content_element=cast(AnthropicMessagesDocumentParam, _file_content_element),
original_content_element=dict(m),
)
user_content.append(cast(AnthropicMessagesDocumentParam,_file_content_element))
elif isinstance(user_message_types_block["content"], str):
_anthropic_content_text_element: AnthropicMessagesTextParam = {
"type": "text",

View file

@ -903,13 +903,17 @@ if MCP_AVAILABLE:
try:
client_id, client_secret, scopes = _extract_credentials(request)
_oauth2_flow: Optional[
Literal["client_credentials", "authorization_code"]
] = (
"client_credentials"
if client_id and client_secret and request.token_url
else None
_oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = (
request.oauth2_flow or (
"client_credentials"
if client_id and client_secret and request.token_url
else None
)
)
# client_credentials requires token_url to fetch a token; without it the
# incoming auth header would be dropped with nothing to replace it.
if _oauth2_flow == "client_credentials" and not request.token_url:
_oauth2_flow = None
server_model = MCPServer(
server_id=request.server_id or "",

View file

@ -1123,6 +1123,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
authorization_url: Optional[str] = None
token_url: Optional[str] = None
registration_url: Optional[str] = None
oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None
allow_all_keys: bool = False
available_on_public_internet: bool = True
is_byok: bool = False
@ -4262,7 +4263,7 @@ class DefaultInternalUserParams(LiteLLMPydanticObjectBase):
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
]
] = Field(
default=LitellmUserRoles.INTERNAL_USER,
default=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
description="Default role assigned to new users created",
)
max_budget: Optional[float] = Field(

View file

@ -1,6 +1,6 @@
from fastapi import Request
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
async def user_api_key_auth(request: Request, api_key: str) -> UserAPIKeyAuth:
@ -9,6 +9,7 @@ async def user_api_key_auth(request: Request, api_key: str) -> UserAPIKeyAuth:
api_key="best-api-key-ever",
user_id="best-user-id-ever",
team_id="best-team-id-ever",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
except Exception:
raise Exception

View file

@ -1,3 +1,33 @@
from typing import TYPE_CHECKING
from litellm.types.guardrails import SupportedGuardrailIntegrations
from .dynamoai import DynamoAIGuardrails
__all__ = ["DynamoAIGuardrails"]
if TYPE_CHECKING:
from litellm.types.guardrails import Guardrail, LitellmParams
def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"):
import litellm
_dynamoai_callback = DynamoAIGuardrails(
api_base=litellm_params.api_base,
api_key=litellm_params.api_key,
guardrail_name=guardrail.get("guardrail_name", ""),
event_hook=litellm_params.mode,
default_on=litellm_params.default_on,
)
litellm.logging_callback_manager.add_litellm_callback(_dynamoai_callback)
return _dynamoai_callback
guardrail_initializer_registry = {
SupportedGuardrailIntegrations.DYNAMOAI.value: initialize_guardrail,
}
guardrail_class_registry = {
SupportedGuardrailIntegrations.DYNAMOAI.value: DynamoAIGuardrails,
}

View file

@ -54,6 +54,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
_is_user_team_admin,
_set_object_metadata_field,
)
@ -71,6 +72,9 @@ from litellm.proxy.management_helpers.team_member_permission_checks import (
)
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
from litellm.proxy.spend_tracking.spend_tracking_utils import _is_master_key
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
get_ui_settings_cached,
)
from litellm.proxy.utils import (
PrismaClient,
ProxyLogging,
@ -95,6 +99,24 @@ from litellm.types.utils import (
)
async def _check_custom_key_allowed(custom_key_value: Optional[str]) -> None:
"""Raise 403 if custom API keys are disabled and a custom key was provided."""
if custom_key_value is None:
return
ui_settings = await get_ui_settings_cached()
if ui_settings.get("disable_custom_api_keys", False) is True:
verbose_proxy_logger.warning(
"Custom API key rejected: disable_custom_api_keys is enabled"
)
raise HTTPException(
status_code=403,
detail={
"error": "Custom API key values are disabled by your administrator. Keys must be auto-generated."
},
)
def _is_team_key(data: Union[GenerateKeyRequest, LiteLLM_VerificationToken]):
return data.team_id is not None
@ -353,6 +375,10 @@ def key_generation_check(
## check if key is for team or individual
is_team_key = _is_team_key(data=data)
_is_admin = (
user_api_key_dict.user_role is not None
and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
if is_team_key:
if team_table is None and litellm.key_generation_settings is not None:
raise HTTPException(
@ -360,7 +386,13 @@ def key_generation_check(
detail=f"Unable to find team object in database. Team ID: {data.team_id}",
)
elif team_table is None:
return True # assume user is assigning team_id without using the team table
if _is_admin:
return True # admins can assign team_id without team table
# Non-admin callers must have a valid team (LIT-1884)
raise HTTPException(
status_code=400,
detail=f"Unable to find team object in database. Team ID: {data.team_id}",
)
return _team_key_generation_check(
team_table=team_table,
user_api_key_dict=user_api_key_dict,
@ -660,6 +692,9 @@ async def _common_key_generation_helper( # noqa: PLR0915
prisma_client=prisma_client,
)
# Reject custom key values if disabled by admin
await _check_custom_key_allowed(data.key)
# Validate user-provided key format
if data.key is not None and not data.key.startswith("sk-"):
_masked = (
@ -1213,6 +1248,19 @@ async def generate_key_fn(
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=message
)
# For non-admin internal users: auto-assign caller's user_id if not provided
# This prevents creating unbound keys with no user association (LIT-1884)
_is_proxy_admin = (
user_api_key_dict.user_role is not None
and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
if not _is_proxy_admin and data.user_id is None:
data.user_id = user_api_key_dict.user_id
verbose_proxy_logger.warning(
"key/generate: auto-assigning user_id=%s for non-admin caller",
user_api_key_dict.user_id,
)
team_table: Optional[LiteLLM_TeamTableCachedObj] = None
if data.team_id is not None:
try:
@ -1227,6 +1275,12 @@ async def generate_key_fn(
verbose_proxy_logger.debug(
f"Error getting team object in `/key/generate`: {e}"
)
# For non-admin callers, team must exist (LIT-1884)
if not _is_proxy_admin:
raise HTTPException(
status_code=400,
detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot create keys for non-existent teams.",
)
key_generation_check(
team_table=team_table,
@ -1809,11 +1863,26 @@ async def _validate_update_key_data(
user_api_key_cache: Any,
) -> None:
"""Validate permissions and constraints for key update."""
_is_proxy_admin = (
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
# Prevent non-admin from removing user_id (setting to empty string) (LIT-1884)
if (
data.user_id is not None
and data.user_id == ""
and not _is_proxy_admin
):
raise HTTPException(
status_code=403,
detail="Non-admin users cannot remove the user_id from a key.",
)
# sanity check - prevent non-proxy admin user from updating key to belong to a different user
if (
data.user_id is not None
and data.user_id != existing_key_row.user_id
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not _is_proxy_admin
):
raise HTTPException(
status_code=403,
@ -1836,6 +1905,18 @@ async def _validate_update_key_data(
user_api_key_cache=user_api_key_cache,
)
# Admin-only: only proxy admins, team admins, or org admins can modify max_budget
if data.max_budget is not None and data.max_budget != existing_key_row.max_budget:
if prisma_client is not None:
hashed_key = existing_key_row.token
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
hashed_token=hashed_key,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
route="/key/update (max_budget)",
)
# Check team limits if key has a team_id (from request or existing key)
team_obj: Optional[LiteLLM_TeamTableCachedObj] = None
_team_id_to_check = data.team_id or getattr(existing_key_row, "team_id", None)
@ -1847,6 +1928,13 @@ async def _validate_update_key_data(
check_db_only=True,
)
# Validate team exists when non-admin sets a new team_id (LIT-1884)
if team_obj is None and data.team_id is not None and not _is_proxy_admin:
raise HTTPException(
status_code=400,
detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot set keys to non-existent teams.",
)
if team_obj is not None:
await _check_team_key_limits(
team_table=team_obj,
@ -2056,7 +2144,10 @@ async def update_key_fn(
data=data, existing_key_row=existing_key_row
)
_validate_key_alias_format(key_alias=non_default_values.get("key_alias", None))
# Only validate key_alias format if it's actually being changed
new_key_alias = non_default_values.get("key_alias", None)
if new_key_alias != existing_key_row.key_alias:
_validate_key_alias_format(key_alias=new_key_alias)
await _enforce_unique_key_alias(
key_alias=non_default_values.get("key_alias", None),
@ -3412,8 +3503,10 @@ async def _rotate_master_key( # noqa: PLR0915
)
def get_new_token(data: Optional[RegenerateKeyRequest]) -> str:
async def get_new_token(data: Optional[RegenerateKeyRequest]) -> str:
if data and data.new_key is not None:
# Reject custom key values if disabled by admin
await _check_custom_key_allowed(data.new_key)
new_token = data.new_key
if not data.new_key.startswith("sk-"):
raise HTTPException(
@ -3505,7 +3598,7 @@ async def _execute_virtual_key_regeneration(
"""Generate new token, update DB, invalidate cache, and return response."""
from litellm.proxy.proxy_server import hash_token
new_token = get_new_token(data=data)
new_token = await get_new_token(data=data)
new_token_hash = hash_token(new_token)
new_token_key_name = f"sk-...{new_token[-4:]}"
update_data = {"token": new_token_hash, "key_name": new_token_key_name}
@ -3515,7 +3608,10 @@ async def _execute_virtual_key_regeneration(
non_default_values = await prepare_key_update_data(
data=data, existing_key_row=key_in_db
)
_validate_key_alias_format(key_alias=non_default_values.get("key_alias"))
# Only validate key_alias format if it's actually being changed
new_key_alias = non_default_values.get("key_alias")
if new_key_alias != key_in_db.key_alias:
_validate_key_alias_format(key_alias=new_key_alias)
verbose_proxy_logger.debug("non_default_values: %s", non_default_values)
update_data.update(non_default_values)
update_data = prisma_client.jsonify_object(data=update_data)
@ -4733,6 +4829,64 @@ def _get_condition_to_filter_out_ui_session_tokens() -> Dict[str, Any]:
}
async def _check_key_admin_access(
user_api_key_dict: UserAPIKeyAuth,
hashed_token: str,
prisma_client: Any,
user_api_key_cache: DualCache,
route: str,
) -> None:
"""
Check that the caller has admin privileges for the target key.
Allowed callers:
- Proxy admin
- Team admin for the key's team
- Org admin for the key's team's organization
Raises HTTPException(403) if the caller is not authorized.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return
# Look up the target key to find its team
target_key_row = await prisma_client.db.litellm_verificationtoken.find_unique(
where={"token": hashed_token}
)
if target_key_row is None:
raise HTTPException(
status_code=404,
detail={"error": f"Key not found: {hashed_token}"},
)
# If the key belongs to a team, check team admin / org admin
if target_key_row.team_id:
team_obj = await get_team_object(
team_id=target_key_row.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
if team_obj is not None:
if _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
):
return
if await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
):
return
raise HTTPException(
status_code=403,
detail={
"error": f"Only proxy admins, team admins, or org admins can call {route}. "
f"user_role={user_api_key_dict.user_role}, user_id={user_api_key_dict.user_id}"
},
)
@router.post(
"/key/block", tags=["key management"], dependencies=[Depends(user_api_key_auth)]
)
@ -4762,7 +4916,7 @@ async def block_key(
}'
```
Note: This is an admin-only endpoint. Only proxy admins can block keys.
Note: This is an admin-only endpoint. Only proxy admins, team admins, or org admins can block keys.
"""
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
@ -4788,6 +4942,15 @@ async def block_key(
else:
hashed_token = data.key
# Admin-only: only proxy admins, team admins, or org admins can block keys
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
hashed_token=hashed_token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
route="/key/block",
)
if litellm.store_audit_logs is True:
# make an audit log for key update
record = await prisma_client.db.litellm_verificationtoken.find_unique(
@ -4876,7 +5039,7 @@ async def unblock_key(
}'
```
Note: This is an admin-only endpoint. Only proxy admins can unblock keys.
Note: This is an admin-only endpoint. Only proxy admins, team admins, or org admins can unblock keys.
"""
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
@ -4902,6 +5065,15 @@ async def unblock_key(
else:
hashed_token = data.key
# Admin-only: only proxy admins, team admins, or org admins can unblock keys
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
hashed_token=hashed_token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
route="/key/unblock",
)
if litellm.store_audit_logs is True:
# make an audit log for key update
record = await prisma_client.db.litellm_verificationtoken.find_unique(

View file

@ -340,9 +340,16 @@ class ProxyInitializationHelpers:
return
# Check if prometheus is in any callback list
# Each setting can be a list or a single string; normalize to list
callbacks = litellm_settings.get("callbacks") or []
success_callbacks = litellm_settings.get("success_callback") or []
failure_callbacks = litellm_settings.get("failure_callback") or []
if isinstance(callbacks, str):
callbacks = [callbacks]
if isinstance(success_callbacks, str):
success_callbacks = [success_callbacks]
if isinstance(failure_callbacks, str):
failure_callbacks = [failure_callbacks]
all_callbacks = callbacks + success_callbacks + failure_callbacks
if "prometheus" not in all_callbacks:
return

View file

@ -258,8 +258,8 @@ async def background_streaming_task( # noqa: PLR0915
),
)
# Extract error for failed responses
if event_type == "response.failed":
# Extract error for failed and incomplete responses
if event_type == "response.failed" or event_type == "response.incomplete":
terminal_error = response_data.get("error")
# Core response fields
@ -337,7 +337,7 @@ async def background_streaming_task( # noqa: PLR0915
)
verbose_proxy_logger.info(
f"Finished background streaming for {polling_id}, status={final_status}, output_items={len(output_items)}"
f"Finished background streaming for {polling_id}, status={final_status}, error={terminal_error}, incomplete_details={incomplete_details_data}, output_items={len(output_items)}"
)
except Exception as e:

View file

@ -1,3 +1,4 @@
import asyncio
from typing import TYPE_CHECKING, Any, Literal, Optional
from fastapi import HTTPException, status
@ -123,30 +124,99 @@ def get_team_id_from_data(data: dict) -> Optional[str]:
return None
def add_shared_session_to_data(data: dict) -> None:
_shared_session_lock: Optional[asyncio.Lock] = None
def _get_shared_session_lock() -> asyncio.Lock:
"""Lazily create the shared session lock (must be called within a running event loop).
WARNING: Do not reset _shared_session_lock to None while any coroutine may be
executing the session-recovery path; doing so breaks the double-checked locking
guarantee and can cause duplicate session creation.
"""
global _shared_session_lock
if _shared_session_lock is None:
_shared_session_lock = asyncio.Lock()
return _shared_session_lock
async def add_shared_session_to_data(data: dict) -> None:
"""
Add shared aiohttp session for connection reuse (prevents cold starts).
If the session was closed (e.g. due to network interruption or idle timeout),
automatically recreates it so connection pooling is restored.
Uses an asyncio.Lock to prevent race conditions where multiple concurrent
requests could each create a new session, leaking intermediate ones.
Silently continues without session reuse if import fails or session is unavailable.
Args:
data: Dictionary to add the shared session to
"""
try:
import litellm.proxy.proxy_server as proxy_server
from litellm._logging import verbose_proxy_logger
from litellm.proxy.proxy_server import shared_aiohttp_session
if shared_aiohttp_session is not None and not shared_aiohttp_session.closed:
data["shared_session"] = shared_aiohttp_session
session = proxy_server.shared_aiohttp_session
if session is not None and not session.closed:
data["shared_session"] = session
verbose_proxy_logger.info(
f"SESSION REUSE: Attached shared aiohttp session to request (ID: {id(shared_aiohttp_session)})"
f"SESSION REUSE: Attached shared aiohttp session to request (ID: {id(session)})"
)
elif session is not None and session.closed:
# Session was created at startup but has since closed — recreate it
# Use lock to prevent concurrent recreation (avoids session/connector leak)
lock = _get_shared_session_lock()
async with lock:
# Double-check under lock — another coroutine may have already recreated it
session = proxy_server.shared_aiohttp_session
if session is not None and not session.closed:
data["shared_session"] = session
return
# session could be None here (if another coroutine set it to None)
# or closed — either way we need to recreate
if session is not None:
verbose_proxy_logger.warning(
f"SESSION REUSE: Shared aiohttp session is closed (ID: {id(session)}), recreating..."
)
else:
verbose_proxy_logger.warning(
"SESSION REUSE: Shared aiohttp session is None after re-check, recreating..."
)
try:
new_session = (
await proxy_server._initialize_shared_aiohttp_session()
)
except Exception:
verbose_proxy_logger.exception(
"SESSION REUSE: Exception during shared session recreation"
)
new_session = None
if new_session is not None:
proxy_server.shared_aiohttp_session = new_session
data["shared_session"] = new_session
else:
verbose_proxy_logger.info(
"SESSION REUSE: Failed to recreate shared session, continuing without session reuse"
)
else:
verbose_proxy_logger.info(
"SESSION REUSE: No shared session available for this request"
)
except Exception:
# Silently continue without session reuse if import fails or session unavailable
pass
# Continue without session reuse — this outer handler covers import failures
# and other unexpected errors to avoid breaking the request path.
# Inner recovery logic has its own specific exception handling.
try:
from litellm._logging import verbose_proxy_logger
verbose_proxy_logger.debug(
"SESSION REUSE: Unexpected error in session setup, continuing without reuse",
exc_info=True,
)
except Exception:
pass
async def route_request( # noqa: PLR0915 - Complex routing function, refactoring tracked separately
@ -248,7 +318,7 @@ async def route_request( # noqa: PLR0915 - Complex routing function, refactorin
"""
Common helper to route the request
"""
add_shared_session_to_data(data)
await add_shared_session_to_data(data)
team_id = get_team_id_from_data(data)
router_model_names = llm_router.model_names if llm_router is not None else []

View file

@ -129,6 +129,11 @@ class UISettings(BaseModel):
description="If enabled, the user search endpoint (/user/filter/ui) restricts results by organization. When off, any authenticated user can search all users.",
)
disable_custom_api_keys: bool = Field(
default=False,
description="If true, users cannot specify custom key values. All keys must be auto-generated.",
)
class UISettingsResponse(SettingsResponse):
"""Response model for UI settings"""
@ -149,6 +154,7 @@ ALLOWED_UI_SETTINGS_FIELDS = {
"disable_vector_stores_for_internal_users",
"allow_vector_stores_for_team_admins",
"scope_user_search_to_org",
"disable_custom_api_keys",
}
# Flags that must be synced from the persisted UISettings into

View file

@ -44,6 +44,7 @@ guardrails:
class SupportedGuardrailIntegrations(Enum):
APORIA = "aporia"
BEDROCK = "bedrock"
DYNAMOAI = "dynamoai"
GUARDRAILS_AI = "guardrails_ai"
LAKERA = "lakera"
LAKERA_V2 = "lakera_v2"

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm"
version = "1.82.2"
version = "1.82.4"
description = "Library to easily interface with LLM API providers"
authors = ["BerriAI"]
license = "MIT"
@ -61,7 +61,7 @@ boto3 = { version = "^1.40.76", optional = true }
redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"}
mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"}
a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"}
litellm-proxy-extras = {version = "^0.4.56", optional = true}
litellm-proxy-extras = {version = "^0.4.57", optional = true}
rich = {version = "^13.7.1", optional = true}
litellm-enterprise = {version = "^0.1.33", optional = true}
diskcache = {version = "^5.6.1", optional = true}
@ -183,7 +183,7 @@ requires = ["poetry-core", "wheel"]
build-backend = "poetry.core.masonry.api"
[tool.commitizen]
version = "1.82.2"
version = "1.82.4"
version_files = [
"pyproject.toml:^version"
]

View file

@ -57,7 +57,7 @@ grpcio>=1.75.0; python_version >= "3.14"
sentry_sdk==2.21.0 # for sentry error handling
detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests
tzdata==2025.1 # IANA time zone database
litellm-proxy-extras==0.4.56 # for proxy extras - e.g. prisma migrations
litellm-proxy-extras==0.4.57 # for proxy extras - e.g. prisma migrations
llm-sandbox==0.3.31 # for skill execution in sandbox
### LITELLM PACKAGE DEPENDENCIES
python-dotenv==1.0.1 # for env

View file

@ -199,6 +199,7 @@ def test_router_get_model_info_wildcard_routes():
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_router_get_model_group_usage_wildcard_routes():
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
@ -219,7 +220,7 @@ async def test_router_get_model_group_usage_wildcard_routes():
)
print(resp)
await asyncio.sleep(1)
await asyncio.sleep(2)
tpm, rpm = await router.get_model_group_usage(model_group="gemini/gemini-1.5-flash")

View file

@ -395,6 +395,7 @@ async def test_mcp_http_transport_tool_not_found():
@pytest.mark.asyncio
async def test_streamable_http_mcp_handler_mock():
"""Test the streamable HTTP MCP handler functionality"""
from litellm.proxy._types import UserAPIKeyAuth
# Mock the session manager and its methods
mock_session_manager = AsyncMock()
@ -425,6 +426,8 @@ async def test_streamable_http_mcp_handler_mock():
), patch(
"litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context",
AsyncMock(return_value=mock_auth_context),
), patch(
"litellm.proxy._experimental.mcp_server.server.set_auth_context",
):
from litellm.proxy._experimental.mcp_server.server import (
handle_streamable_http_mcp,

View file

@ -1414,6 +1414,11 @@ class TestBackgroundStreamingTerminalEvents:
background_streaming_task,
)
error_payload = {
"type": "incomplete_response",
"message": "The model stopped before producing a complete response",
"code": "max_output_tokens",
}
events = [
{"type": "response.in_progress"},
{
@ -1421,6 +1426,7 @@ class TestBackgroundStreamingTerminalEvents:
"response": {
"id": "resp_123",
"status": "incomplete",
"error": error_payload,
"incomplete_details": {"reason": "max_output_tokens"},
"usage": {"input_tokens": 10, "output_tokens": 4096},
"model": "gpt-4o",
@ -1442,6 +1448,7 @@ class TestBackgroundStreamingTerminalEvents:
final_call = handler.update_state.call_args_list[-1]
assert final_call.kwargs["status"] == "incomplete"
assert final_call.kwargs["error"] == error_payload
assert final_call.kwargs["incomplete_details"] == {"reason": "max_output_tokens"}
assert final_call.kwargs["usage"] == {"input_tokens": 10, "output_tokens": 4096}

View file

@ -1,4 +1,4 @@
import json
import base64
from unittest.mock import MagicMock, patch
import pytest
@ -8,6 +8,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
BAD_MESSAGE_ERROR_STR,
BedrockConverseMessagesProcessor,
BedrockImageProcessor,
anthropic_messages_pt,
_convert_to_bedrock_tool_call_invoke,
ollama_pt,
sanitize_messages_for_tool_calling,
@ -1594,6 +1595,92 @@ def test_bedrock_tools_unpack_defs_no_oom_with_nested_refs():
assert "$defs" not in tool_schema, "$defs should be removed after expansion"
def test_anthropic_messages_pt_file_block_preserves_cache_control():
"""
Test that cache_control on file-type content blocks is preserved
when translating to Anthropic message format.
Regression test for https://github.com/BerriAI/litellm/issues/23873
"""
pdf_b64 = base64.b64encode(b"%PDF-1.4 fake pdf content").decode()
messages = [
{
"role": "user",
"content": [
{
"type": "file",
"file": {
"filename": "document.pdf",
"file_data": f"data:application/pdf;base64,{pdf_b64}",
},
"cache_control": {"type": "ephemeral"},
},
{
"type": "text",
"text": "Summarize this document.",
"cache_control": {"type": "ephemeral"},
},
],
}
]
result = anthropic_messages_pt(
messages=messages,
model="claude-sonnet-4-20250514",
llm_provider="anthropic",
)
assert len(result) == 1
content_blocks = result[0]["content"]
assert len(content_blocks) == 2
file_block = content_blocks[0]
assert file_block["type"] == "document"
assert "cache_control" in file_block, (
"cache_control should be preserved on file/document content blocks"
)
assert file_block["cache_control"]["type"] == "ephemeral"
text_block = content_blocks[1]
assert text_block["type"] == "text"
assert "cache_control" in text_block
assert text_block["cache_control"]["type"] == "ephemeral"
def test_anthropic_messages_pt_file_block_without_cache_control():
"""
Test that file blocks without cache_control still work correctly.
"""
import base64
pdf_b64 = base64.b64encode(b"%PDF-1.4 fake").decode()
messages = [
{
"role": "user",
"content": [
{
"type": "file",
"file": {
"filename": "doc.pdf",
"file_data": f"data:application/pdf;base64,{pdf_b64}",
},
},
],
}
]
result = anthropic_messages_pt(
messages=messages,
model="claude-sonnet-4-20250514",
llm_provider="anthropic",
)
assert len(result) == 1
file_block = result[0]["content"][0]
assert file_block["type"] == "document"
assert "cache_control" not in file_block
# ── _convert_to_bedrock_tool_call_invoke tests ──

View file

@ -158,7 +158,6 @@ class TestExecuteWithMcpClient:
@pytest.mark.asyncio
@pytest.mark.skip(reason="PR #23187 changed has_client_credentials to require explicit oauth2_flow opt-in, but NewMCPServerRequest and _execute_with_mcp_client were not updated - needs fix")
async def test_m2m_credentials_forwarded_to_server_model(self, monkeypatch):
"""M2M OAuth credentials (client_id, client_secret) from the nested
``credentials`` dict must be forwarded to the MCPServer model so that
@ -213,7 +212,6 @@ class TestExecuteWithMcpClient:
assert server.has_client_credentials is True
@pytest.mark.asyncio
@pytest.mark.skip(reason="PR #23187 changed has_client_credentials to require explicit oauth2_flow opt-in, but NewMCPServerRequest and _execute_with_mcp_client were not updated - needs fix")
async def test_m2m_drops_incoming_oauth2_headers(self, monkeypatch):
"""For M2M OAuth servers the incoming Authorization header (which carries
the litellm API key) must NOT be forwarded as extra_headers otherwise

View file

@ -0,0 +1,81 @@
"""
Tests for DynamoAI guardrail registration and initialization.
"""
import os
from unittest.mock import patch
import pytest
class TestDynamoAIGuardrailRegistration:
"""Tests for DynamoAI guardrail registration in the guardrail system."""
def test_supported_guardrail_enum_entry(self):
"""Test that DYNAMOAI is in SupportedGuardrailIntegrations enum."""
from litellm.types.guardrails import SupportedGuardrailIntegrations
assert hasattr(SupportedGuardrailIntegrations, "DYNAMOAI")
assert SupportedGuardrailIntegrations.DYNAMOAI.value == "dynamoai"
def test_initialize_guardrail_function_exists(self):
"""Test that initialize_guardrail function is properly exported."""
from litellm.proxy.guardrails.guardrail_hooks.dynamoai import (
guardrail_initializer_registry,
initialize_guardrail,
)
assert initialize_guardrail is not None
assert "dynamoai" in guardrail_initializer_registry
def test_guardrail_class_registry_exists(self):
"""Test that guardrail_class_registry is properly exported."""
from litellm.proxy.guardrails.guardrail_hooks.dynamoai import (
guardrail_class_registry,
)
from litellm.proxy.guardrails.guardrail_hooks.dynamoai.dynamoai import (
DynamoAIGuardrails,
)
assert "dynamoai" in guardrail_class_registry
assert guardrail_class_registry["dynamoai"] == DynamoAIGuardrails
def test_initialize_guardrail_creates_instance(self):
"""Test that initialize_guardrail creates a DynamoAIGuardrails instance."""
from litellm.proxy.guardrails.guardrail_hooks.dynamoai import (
initialize_guardrail,
)
from litellm.proxy.guardrails.guardrail_hooks.dynamoai.dynamoai import (
DynamoAIGuardrails,
)
from litellm.types.guardrails import LitellmParams
litellm_params = LitellmParams(
guardrail="dynamoai",
mode="pre_call",
api_key="test-key",
api_base="https://test.dynamo.ai",
)
guardrail = {
"guardrail_name": "test-dynamoai-guard",
}
with patch(
"litellm.logging_callback_manager.add_litellm_callback"
) as mock_add:
result = initialize_guardrail(litellm_params, guardrail)
assert isinstance(result, DynamoAIGuardrails)
assert result.api_key == "test-key"
assert result.api_base == "https://test.dynamo.ai"
assert result.guardrail_name == "test-dynamoai-guard"
mock_add.assert_called_once_with(result)
def test_dynamoai_in_global_registry(self):
"""Test that dynamoai is discoverable in the global guardrail registry."""
from litellm.proxy.guardrails.guardrail_registry import (
guardrail_initializer_registry,
)
assert "dynamoai" in guardrail_initializer_registry

View file

@ -41,12 +41,15 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
_transform_verification_tokens_to_deleted_records,
_validate_max_budget,
_validate_reset_spend_value,
_validate_update_key_data,
can_modify_verification_token,
check_org_key_model_specific_limits,
check_team_key_model_specific_limits,
delete_verification_tokens,
generate_key_fn,
generate_key_helper_fn,
key_aliases,
key_generation_check,
list_keys,
prepare_key_update_data,
reset_key_spend_fn,
@ -957,22 +960,34 @@ async def test_key_info_returns_object_permission(monkeypatch):
)
def test_get_new_token_with_valid_key():
@pytest.mark.asyncio
async def test_get_new_token_with_valid_key(monkeypatch):
"""Test get_new_token function when provided with a valid key that starts with 'sk-'"""
from unittest.mock import AsyncMock
from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
get_new_token,
)
# Mock get_ui_settings_cached to return setting disabled (custom keys allowed)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={}),
)
# Test with valid new_key
data = RegenerateKeyRequest(new_key="sk-test123456789")
result = get_new_token(data)
result = await get_new_token(data)
assert result == "sk-test123456789"
def test_get_new_token_with_invalid_key():
@pytest.mark.asyncio
async def test_get_new_token_with_invalid_key(monkeypatch):
"""Test get_new_token function when provided with an invalid key that doesn't start with 'sk-'"""
from unittest.mock import AsyncMock
from fastapi import HTTPException
from litellm.proxy._types import RegenerateKeyRequest
@ -980,16 +995,145 @@ def test_get_new_token_with_invalid_key():
get_new_token,
)
# Mock get_ui_settings_cached to return setting disabled (custom keys allowed)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={}),
)
# Test with invalid new_key (doesn't start with 'sk-')
data = RegenerateKeyRequest(new_key="invalid-key-123")
with pytest.raises(HTTPException) as exc_info:
get_new_token(data)
await get_new_token(data)
assert exc_info.value.status_code == 400
assert "New key must start with 'sk-'" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_check_custom_key_allowed_when_disabled(monkeypatch):
"""_check_custom_key_allowed raises 403 when disable_custom_api_keys is true."""
from unittest.mock import AsyncMock
from fastapi import HTTPException
from litellm.proxy.management_endpoints.key_management_endpoints import (
_check_custom_key_allowed,
)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={"disable_custom_api_keys": True}),
)
with pytest.raises(HTTPException) as exc_info:
await _check_custom_key_allowed("sk-custom-key-123")
assert exc_info.value.status_code == 403
assert "disabled" in str(exc_info.value.detail).lower()
@pytest.mark.asyncio
async def test_check_custom_key_allowed_when_enabled(monkeypatch):
"""_check_custom_key_allowed does nothing when disable_custom_api_keys is false."""
from unittest.mock import AsyncMock
from litellm.proxy.management_endpoints.key_management_endpoints import (
_check_custom_key_allowed,
)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={"disable_custom_api_keys": False}),
)
# Should not raise
await _check_custom_key_allowed("sk-custom-key-123")
@pytest.mark.asyncio
async def test_check_custom_key_allowed_when_unset(monkeypatch):
"""_check_custom_key_allowed does nothing when setting is not present."""
from unittest.mock import AsyncMock
from litellm.proxy.management_endpoints.key_management_endpoints import (
_check_custom_key_allowed,
)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={}),
)
# Should not raise
await _check_custom_key_allowed("sk-custom-key-123")
@pytest.mark.asyncio
async def test_check_custom_key_allowed_none_key_always_passes(monkeypatch):
"""_check_custom_key_allowed does nothing when key is None, even if setting is on."""
from unittest.mock import AsyncMock
from litellm.proxy.management_endpoints.key_management_endpoints import (
_check_custom_key_allowed,
)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={"disable_custom_api_keys": True}),
)
# Should not raise — None means auto-generate
await _check_custom_key_allowed(None)
@pytest.mark.asyncio
async def test_get_new_token_rejected_when_custom_keys_disabled(monkeypatch):
"""get_new_token raises 403 when new_key is set and disable_custom_api_keys is true."""
from unittest.mock import AsyncMock
from fastapi import HTTPException
from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
get_new_token,
)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={"disable_custom_api_keys": True}),
)
data = RegenerateKeyRequest(new_key="sk-custom-regen-key")
with pytest.raises(HTTPException) as exc_info:
await get_new_token(data)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_get_new_token_auto_generates_when_custom_keys_disabled(monkeypatch):
"""get_new_token auto-generates a key when new_key is None, even if setting is on."""
from unittest.mock import AsyncMock
from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
get_new_token,
)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={"disable_custom_api_keys": True}),
)
data = RegenerateKeyRequest() # no new_key
result = await get_new_token(data)
assert result.startswith("sk-")
@pytest.mark.asyncio
async def test_generate_service_account_requires_team_id():
with pytest.raises(HTTPException):
@ -7185,3 +7329,773 @@ def test_update_key_request_has_organization_id():
# Also verify it defaults to None
data_no_org = UpdateKeyRequest(key="sk-test-key")
assert data_no_org.organization_id is None
# ============================================================================
# Tests for admin-only access on /key/block, /key/unblock, /key/update max_budget
# ============================================================================
def _setup_block_unblock_mocks(monkeypatch, mock_key_team_id=None):
"""Helper to set up common mocks for block/unblock tests."""
mock_prisma_client = AsyncMock()
mock_user_api_key_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
test_hashed_token = (
"a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"
)
mock_key_record = MagicMock()
mock_key_record.token = test_hashed_token
mock_key_record.blocked = False
mock_key_record.team_id = mock_key_team_id
mock_key_record.model_dump_json.return_value = (
f'{{"token": "{test_hashed_token}", "blocked": false}}'
)
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
return_value=mock_key_record
)
mock_prisma_client.db.litellm_verificationtoken.update = AsyncMock(
return_value=mock_key_record
)
mock_key_object = MagicMock()
mock_key_object.blocked = True
def mock_hash_token(token):
if token.startswith("sk-"):
return test_hashed_token
return token
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr(
"litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache
)
monkeypatch.setattr(
"litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj
)
monkeypatch.setattr("litellm.proxy.proxy_server.hash_token", mock_hash_token)
monkeypatch.setattr("litellm.store_audit_logs", False)
async def mock_get_key_object(**kwargs):
return mock_key_object
async def mock_cache_key_object(**kwargs):
pass
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_key_object",
mock_get_key_object,
)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints._cache_key_object",
mock_cache_key_object,
)
return mock_prisma_client, test_hashed_token
@pytest.mark.asyncio
async def test_block_key_rejected_for_internal_user(monkeypatch):
"""Internal users should not be able to block keys."""
from litellm.proxy._types import BlockKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import block_key
_setup_block_unblock_mocks(monkeypatch)
mock_request = MagicMock()
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-internal",
user_id="internal_user",
)
with pytest.raises(HTTPException) as exc:
await block_key(
data=BlockKeyRequest(key="sk-test123456789"),
http_request=mock_request,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert exc.value.status_code == 403
assert "Only proxy admins, team admins, or org admins" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_unblock_key_rejected_for_internal_user(monkeypatch):
"""Internal users should not be able to unblock keys."""
from litellm.proxy._types import BlockKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import unblock_key
_setup_block_unblock_mocks(monkeypatch)
mock_request = MagicMock()
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-internal",
user_id="internal_user",
)
with pytest.raises(HTTPException) as exc:
await unblock_key(
data=BlockKeyRequest(key="sk-test123456789"),
http_request=mock_request,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert exc.value.status_code == 403
assert "Only proxy admins, team admins, or org admins" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_block_key_allowed_for_proxy_admin(monkeypatch):
"""Proxy admins should be able to block keys."""
from litellm.proxy._types import BlockKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import block_key
_setup_block_unblock_mocks(monkeypatch)
mock_request = MagicMock()
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-admin",
user_id="admin_user",
)
result = await block_key(
data=BlockKeyRequest(key="sk-test123456789"),
http_request=mock_request,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert result is not None
@pytest.mark.asyncio
async def test_block_key_allowed_for_team_admin(monkeypatch):
"""Team admins should be able to block keys belonging to their team."""
from litellm.proxy._types import BlockKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import block_key
team_id = "team-123"
_setup_block_unblock_mocks(monkeypatch, mock_key_team_id=team_id)
# Mock get_team_object to return a team where the user is admin
team_obj = LiteLLM_TeamTableCachedObj(
team_id=team_id,
members_with_roles=[
Member(user_id="team_admin_user", role="admin"),
],
)
async def mock_get_team_object(**kwargs):
return team_obj
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_team_object",
mock_get_team_object,
)
mock_request = MagicMock()
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-teamadmin",
user_id="team_admin_user",
)
result = await block_key(
data=BlockKeyRequest(key="sk-test123456789"),
http_request=mock_request,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert result is not None
@pytest.mark.asyncio
async def test_update_key_max_budget_rejected_for_internal_user(monkeypatch):
"""Internal users should not be able to modify max_budget on keys."""
from litellm.proxy.management_endpoints.key_management_endpoints import (
update_key_fn,
)
mock_prisma_client = AsyncMock()
mock_user_api_key_cache = AsyncMock()
mock_proxy_logging_obj = MagicMock()
test_hashed_token = (
"a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"
)
# Mock existing key row
mock_existing_key = MagicMock()
mock_existing_key.token = test_hashed_token
mock_existing_key.user_id = "internal_user"
mock_existing_key.team_id = None
mock_existing_key.project_id = None
mock_existing_key.max_budget = 10.0
mock_existing_key.models = []
mock_existing_key.model_dump.return_value = {
"token": test_hashed_token,
"user_id": "internal_user",
"team_id": None,
"max_budget": 10.0,
}
mock_prisma_client.get_data = AsyncMock(return_value=mock_existing_key)
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
return_value=mock_existing_key
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr(
"litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache
)
monkeypatch.setattr(
"litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj
)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
mock_request = MagicMock()
mock_request.query_params = {}
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-internal",
user_id="internal_user",
)
with pytest.raises(ProxyException) as exc:
await update_key_fn(
request=mock_request,
data=UpdateKeyRequest(key=test_hashed_token, max_budget=999999),
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert str(exc.value.code) == "403"
assert "Only proxy admins, team admins, or org admins" in str(exc.value.message)
@pytest.mark.asyncio
async def test_update_key_non_budget_fields_allowed_for_internal_user(monkeypatch):
"""Internal users should still be able to update non-budget fields on their own keys."""
from litellm.proxy.management_endpoints.key_management_endpoints import (
update_key_fn,
)
mock_prisma_client = AsyncMock()
mock_user_api_key_cache = AsyncMock()
mock_proxy_logging_obj = MagicMock()
test_hashed_token = (
"a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"
)
# Mock existing key row
mock_existing_key = MagicMock()
mock_existing_key.token = test_hashed_token
mock_existing_key.user_id = "internal_user"
mock_existing_key.team_id = None
mock_existing_key.project_id = None
mock_existing_key.max_budget = 10.0
mock_existing_key.key_alias = None
mock_existing_key.models = []
mock_existing_key.model_dump.return_value = {
"token": test_hashed_token,
"user_id": "internal_user",
"team_id": None,
"max_budget": 10.0,
}
mock_updated_key = MagicMock()
mock_updated_key.token = test_hashed_token
mock_updated_key.key_alias = "my-alias"
mock_prisma_client.get_data = AsyncMock(return_value=mock_existing_key)
mock_prisma_client.update_data = AsyncMock(return_value=mock_updated_key)
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
return_value=mock_existing_key
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr(
"litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache
)
monkeypatch.setattr(
"litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj
)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
monkeypatch.setattr("litellm.store_audit_logs", False)
def mock_hash_token(token):
return test_hashed_token
monkeypatch.setattr("litellm.proxy.proxy_server.hash_token", mock_hash_token)
async def mock_cache_key_object(**kwargs):
pass
async def mock_delete_cache_key_object(**kwargs):
pass
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints._cache_key_object",
mock_cache_key_object,
)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
mock_delete_cache_key_object,
)
# Mock _enforce_unique_key_alias to avoid DB call
async def mock_enforce_unique_key_alias(**kwargs):
pass
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints._enforce_unique_key_alias",
mock_enforce_unique_key_alias,
)
mock_request = MagicMock()
mock_request.query_params = {}
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-internal",
user_id="internal_user",
)
# Updating key_alias (non-budget field) should succeed
result = await update_key_fn(
request=mock_request,
data=UpdateKeyRequest(key=test_hashed_token, key_alias="my-alias"),
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert result is not None
# ============================================================================
# LIT-1884: Internal users cannot create invalid keys
# ============================================================================
class TestLIT1884KeyGenerateValidation:
"""Tests for LIT-1884: internal users should not be able to generate invalid keys."""
@pytest.mark.asyncio
async def test_internal_user_generate_key_no_user_id_auto_assigns(self):
"""
When an internal_user calls /key/generate without user_id,
the caller's user_id should be auto-assigned before reaching
_common_key_generation_helper.
"""
mock_prisma_client = AsyncMock()
data = GenerateKeyRequest(key_alias="test-alias")
assert data.user_id is None
user_api_key_dict = UserAPIKeyAuth(
user_id="internal-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)
# Patch _common_key_generation_helper to avoid needing full DB mocks.
# We just want to verify user_id is set before we reach this point.
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), \
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), \
patch("litellm.proxy.proxy_server.user_custom_key_generate", None), \
patch(
"litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper",
new_callable=AsyncMock,
return_value=MagicMock(),
):
await generate_key_fn(
data=data,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
# The data object should have been mutated to include the caller's user_id
assert data.user_id == "internal-user-123"
@pytest.mark.asyncio
async def test_internal_user_generate_key_invalid_team_id_rejected(self):
"""
When an internal_user provides a non-existent team_id,
key/generate should raise ProxyException with status 400.
"""
mock_prisma_client = AsyncMock()
data = GenerateKeyRequest(
key_alias="test-alias",
team_id="nonexistent-team-id",
)
user_api_key_dict = UserAPIKeyAuth(
user_id="internal-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), \
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), \
patch("litellm.proxy.proxy_server.user_custom_key_generate", None), \
patch(
"litellm.proxy.management_endpoints.key_management_endpoints.get_team_object",
AsyncMock(side_effect=Exception("Team not found")),
):
with pytest.raises(ProxyException) as exc_info:
await generate_key_fn(
data=data,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert str(exc_info.value.code) == "400"
assert "Team not found" in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_admin_generate_key_invalid_team_id_allowed(self):
"""
Admin callers should be allowed to create keys with any team_id,
even if the team doesn't exist (team_table=None is OK for admins).
"""
data = GenerateKeyRequest(
key_alias="admin-key",
team_id="nonexistent-team-id",
user_id="admin-user",
)
user_api_key_dict = UserAPIKeyAuth(
user_id="admin-user",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
mock_prisma_client = AsyncMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), \
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), \
patch("litellm.proxy.proxy_server.user_custom_key_generate", None), \
patch(
"litellm.proxy.management_endpoints.key_management_endpoints.get_team_object",
AsyncMock(side_effect=Exception("Team not found")),
), \
patch(
"litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper",
new_callable=AsyncMock,
return_value=MagicMock(),
):
# Should NOT raise — admin bypasses team validation
result = await generate_key_fn(
data=data,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert result is not None
@pytest.mark.asyncio
async def test_admin_generate_key_no_user_id_not_auto_assigned(self):
"""
Admin callers should NOT have user_id auto-assigned they may
intentionally create keys without a user_id.
"""
data = GenerateKeyRequest(key_alias="admin-unbound-key")
assert data.user_id is None
user_api_key_dict = UserAPIKeyAuth(
user_id="admin-user",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
mock_prisma_client = AsyncMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), \
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), \
patch("litellm.proxy.proxy_server.user_custom_key_generate", None), \
patch(
"litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper",
new_callable=AsyncMock,
return_value=MagicMock(),
):
await generate_key_fn(
data=data,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
# user_id should remain None for admin
assert data.user_id is None
def test_key_generation_check_non_admin_no_team_table_raises(self):
"""
key_generation_check should raise 400 for non-admin when team_table is None
and key_generation_settings is not set.
"""
data = GenerateKeyRequest(team_id="some-team-id")
user_api_key_dict = UserAPIKeyAuth(
user_id="internal-user",
user_role=LitellmUserRoles.INTERNAL_USER,
)
with patch.object(litellm, "key_generation_settings", None):
with pytest.raises(HTTPException) as exc_info:
key_generation_check(
team_table=None,
user_api_key_dict=user_api_key_dict,
data=data,
route="key_generate",
)
assert exc_info.value.status_code == 400
assert "Unable to find team object" in str(exc_info.value.detail)
def test_key_generation_check_admin_no_team_table_allowed(self):
"""
key_generation_check should allow admin to proceed even when team_table is None.
"""
data = GenerateKeyRequest(team_id="some-team-id")
user_api_key_dict = UserAPIKeyAuth(
user_id="admin-user",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
with patch.object(litellm, "key_generation_settings", None):
result = key_generation_check(
team_table=None,
user_api_key_dict=user_api_key_dict,
data=data,
route="key_generate",
)
assert result is True
class TestLIT1884KeyUpdateValidation:
"""Tests for LIT-1884: internal users should not be able to update keys to remove user_id or set invalid team."""
@pytest.mark.asyncio
async def test_internal_user_cannot_remove_user_id(self):
"""
Non-admin users should not be able to set user_id to empty string (remove it).
"""
data = UpdateKeyRequest(key="sk-test-key", user_id="")
existing_key_row = MagicMock()
existing_key_row.user_id = "internal-user-123"
existing_key_row.token = "hashed_token"
existing_key_row.team_id = None
user_api_key_dict = UserAPIKeyAuth(
user_id="internal-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)
with pytest.raises(HTTPException) as exc_info:
await _validate_update_key_data(
data=data,
existing_key_row=existing_key_row,
user_api_key_dict=user_api_key_dict,
llm_router=None,
premium_user=False,
prisma_client=AsyncMock(),
user_api_key_cache=MagicMock(),
)
assert exc_info.value.status_code == 403
assert "cannot remove the user_id" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_internal_user_cannot_set_invalid_team_id(self):
"""
Non-admin users should not be able to update a key to a non-existent team.
get_team_object raises HTTPException(404) when team doesn't exist in DB.
"""
data = UpdateKeyRequest(key="sk-test-key", team_id="nonexistent-team")
existing_key_row = MagicMock()
existing_key_row.user_id = "internal-user-123"
existing_key_row.token = "hashed_token"
existing_key_row.team_id = None
existing_key_row.organization_id = None
existing_key_row.project_id = None
user_api_key_dict = UserAPIKeyAuth(
user_id="internal-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)
with patch(
"litellm.proxy.management_endpoints.key_management_endpoints.get_team_object",
AsyncMock(side_effect=HTTPException(
status_code=404,
detail="Team doesn't exist in db. Team=nonexistent-team.",
)),
):
with pytest.raises(HTTPException) as exc_info:
await _validate_update_key_data(
data=data,
existing_key_row=existing_key_row,
user_api_key_dict=user_api_key_dict,
llm_router=None,
premium_user=False,
prisma_client=AsyncMock(),
user_api_key_cache=MagicMock(),
)
assert exc_info.value.status_code == 404
assert "Team doesn't exist" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_admin_can_remove_user_id(self):
"""
Admin users should be allowed to set user_id to empty string.
"""
data = UpdateKeyRequest(key="sk-test-key", user_id="")
existing_key_row = MagicMock()
existing_key_row.user_id = "some-user"
existing_key_row.token = "hashed_token"
existing_key_row.team_id = None
existing_key_row.organization_id = None
existing_key_row.project_id = None
user_api_key_dict = UserAPIKeyAuth(
user_id="admin-user",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
mock_prisma_client = AsyncMock()
# Should NOT raise
await _validate_update_key_data(
data=data,
existing_key_row=existing_key_row,
user_api_key_dict=user_api_key_dict,
llm_router=None,
premium_user=False,
prisma_client=mock_prisma_client,
user_api_key_cache=MagicMock(),
)
class TestKeyAliasSkipValidationOnUnchanged:
"""
Test that updating/regenerating a key without changing its key_alias
does NOT re-validate the alias. This prevents legacy aliases (created
before stricter validation rules) from blocking edits to other fields.
"""
@pytest.fixture(autouse=True)
def enable_validation(self):
litellm.enable_key_alias_format_validation = True
yield
litellm.enable_key_alias_format_validation = False
@pytest.fixture
def mock_prisma(self):
prisma = MagicMock()
prisma.db = MagicMock()
prisma.db.litellm_verificationtoken = MagicMock()
prisma.get_data = AsyncMock(return_value=None) # no duplicate alias
prisma.update_data = AsyncMock(return_value=None)
prisma.jsonify_object = MagicMock(side_effect=lambda data: data)
return prisma
@pytest.fixture
def existing_key_with_legacy_alias(self):
"""A key whose alias contains '@' — valid now, but simulates a legacy alias."""
return LiteLLM_VerificationToken(
token="hashed_token_123",
key_alias="user@domain.com",
team_id="team-1",
models=[],
max_budget=100.0,
)
@pytest.mark.asyncio
async def test_update_key_unchanged_legacy_alias_passes(
self, mock_prisma, existing_key_with_legacy_alias
):
"""
Updating a key without changing its key_alias should skip format
validation even if the alias wouldn't pass current rules.
"""
from litellm.proxy.management_endpoints.key_management_endpoints import (
_validate_key_alias_format,
)
# Temporarily make the regex reject '@' to simulate stricter rules
import re
from litellm.proxy.management_endpoints import key_management_endpoints as mod
original_pattern = mod._KEY_ALIAS_PATTERN
mod._KEY_ALIAS_PATTERN = re.compile(
r"^[a-zA-Z0-9][a-zA-Z0-9_\-/\.]{0,253}[a-zA-Z0-9]$"
)
try:
# Confirm the alias WOULD fail validation directly
with pytest.raises(ProxyException):
_validate_key_alias_format("user@domain.com")
# But prepare_key_update_data + the skip logic should allow it
# Simulate what update_key_fn does: alias is in non_default_values
# but matches existing_key_row.key_alias => skip validation
existing_alias = existing_key_with_legacy_alias.key_alias
new_alias = "user@domain.com" # same as existing
assert new_alias == existing_alias # unchanged
# This is the core logic from update_key_fn:
if new_alias != existing_alias:
_validate_key_alias_format(new_alias)
# No exception raised — test passes
finally:
mod._KEY_ALIAS_PATTERN = original_pattern
@pytest.mark.asyncio
async def test_update_key_changed_alias_still_validated(
self, mock_prisma, existing_key_with_legacy_alias
):
"""
When the alias IS being changed, validation should still run.
"""
from litellm.proxy.management_endpoints.key_management_endpoints import (
_validate_key_alias_format,
)
existing_alias = existing_key_with_legacy_alias.key_alias
new_alias = "!invalid!"
assert new_alias != existing_alias
with pytest.raises(ProxyException):
if new_alias != existing_alias:
_validate_key_alias_format(new_alias)
@pytest.mark.asyncio
async def test_update_key_changed_to_valid_alias_passes(
self, mock_prisma, existing_key_with_legacy_alias
):
"""
Changing the alias to a new valid value should pass validation.
"""
from litellm.proxy.management_endpoints.key_management_endpoints import (
_validate_key_alias_format,
)
existing_alias = existing_key_with_legacy_alias.key_alias
new_alias = "new-valid-alias"
assert new_alias != existing_alias
# Should not raise
if new_alias != existing_alias:
_validate_key_alias_format(new_alias)
@pytest.mark.asyncio
async def test_update_key_alias_none_skips_validation(self):
"""
When key_alias is not in the update payload (None), validation
should be skipped regardless.
"""
from litellm.proxy.management_endpoints.key_management_endpoints import (
_validate_key_alias_format,
)
# None alias should always pass
_validate_key_alias_format(None)

View file

@ -0,0 +1,182 @@
"""
Tests for shared aiohttp session auto-recovery.
When the shared session closes (e.g. network interruption, idle timeout),
add_shared_session_to_data should recreate it instead of permanently
falling back to per-request connections.
Fixes: https://github.com/BerriAI/litellm/issues/23806
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.mark.asyncio
async def test_add_shared_session_attaches_open_session():
"""When the shared session is open, it should be attached to data."""
from litellm.proxy.route_llm_request import add_shared_session_to_data
mock_session = MagicMock()
mock_session.closed = False
with patch("litellm.proxy.proxy_server.shared_aiohttp_session", mock_session):
data = {}
await add_shared_session_to_data(data)
assert data["shared_session"] is mock_session
@pytest.mark.asyncio
async def test_add_shared_session_recreates_closed_session():
"""When the shared session is closed, it should be recreated."""
import litellm.proxy.route_llm_request as route_module
from litellm.proxy import proxy_server as proxy_server_module
from litellm.proxy.route_llm_request import add_shared_session_to_data
# Reset the module-level lock so each test uses the current event loop
route_module._shared_session_lock = None
closed_session = MagicMock()
closed_session.closed = True
new_session = MagicMock()
new_session.closed = False
with patch.object(
proxy_server_module,
"shared_aiohttp_session",
closed_session,
):
with patch.object(
proxy_server_module,
"_initialize_shared_aiohttp_session",
new_callable=AsyncMock,
return_value=new_session,
) as mock_init:
data = {}
await add_shared_session_to_data(data)
mock_init.assert_called_once()
assert data["shared_session"] is new_session
assert proxy_server_module.shared_aiohttp_session is new_session
@pytest.mark.asyncio
async def test_add_shared_session_handles_recreation_failure():
"""When recreation fails, data should not contain shared_session."""
import litellm.proxy.route_llm_request as route_module
from litellm.proxy import proxy_server as proxy_server_module
from litellm.proxy.route_llm_request import add_shared_session_to_data
# Reset the module-level lock so each test uses the current event loop
route_module._shared_session_lock = None
closed_session = MagicMock()
closed_session.closed = True
with patch.object(
proxy_server_module,
"shared_aiohttp_session",
closed_session,
):
with patch.object(
proxy_server_module,
"_initialize_shared_aiohttp_session",
new_callable=AsyncMock,
return_value=None,
):
data = {}
await add_shared_session_to_data(data)
assert "shared_session" not in data
@pytest.mark.asyncio
async def test_add_shared_session_handles_recreation_exception():
"""When _initialize_shared_aiohttp_session raises, data should not contain shared_session."""
import litellm.proxy.route_llm_request as route_module
from litellm.proxy import proxy_server as proxy_server_module
from litellm.proxy.route_llm_request import add_shared_session_to_data
# Reset the module-level lock so each test uses the current event loop
route_module._shared_session_lock = None
closed_session = MagicMock()
closed_session.closed = True
with patch.object(
proxy_server_module,
"shared_aiohttp_session",
closed_session,
):
with patch.object(
proxy_server_module,
"_initialize_shared_aiohttp_session",
new_callable=AsyncMock,
side_effect=RuntimeError("connection pool exhausted"),
):
data = {}
await add_shared_session_to_data(data)
# Should gracefully handle exception — no shared_session attached
assert "shared_session" not in data
@pytest.mark.asyncio
async def test_add_shared_session_no_session_available():
"""When no session was ever created, data should not contain shared_session."""
from litellm.proxy.route_llm_request import add_shared_session_to_data
with patch("litellm.proxy.proxy_server.shared_aiohttp_session", None):
data = {}
await add_shared_session_to_data(data)
assert "shared_session" not in data
@pytest.mark.asyncio
async def test_add_shared_session_concurrent_recreation_uses_lock():
"""When multiple coroutines detect a closed session concurrently,
only one should recreate it (double-checked locking via asyncio.Lock)."""
import litellm.proxy.route_llm_request as route_module
from litellm.proxy import proxy_server as proxy_server_module
from litellm.proxy.route_llm_request import add_shared_session_to_data
# Reset the module-level lock so each test is isolated
route_module._shared_session_lock = None
closed_session = MagicMock()
closed_session.closed = True
new_session = MagicMock()
new_session.closed = False
call_count = 0
async def mock_init():
nonlocal call_count
call_count += 1
# Simulate some async work
await asyncio.sleep(0.01)
return new_session
with patch.object(
proxy_server_module,
"shared_aiohttp_session",
closed_session,
):
with patch.object(
proxy_server_module,
"_initialize_shared_aiohttp_session",
new_callable=AsyncMock,
side_effect=mock_init,
):
# Launch 5 concurrent calls
results = [{} for _ in range(5)]
await asyncio.gather(*(add_shared_session_to_data(d) for d in results))
# Only 1 coroutine should have called _initialize (the rest see the
# re-checked session as open under the lock)
assert call_count == 1, f"Expected 1 init call, got {call_count}"
# All should have the new session
for d in results:
assert d.get("shared_session") is new_session

View file

@ -67,6 +67,30 @@ class TestMaybeSetupPrometheusMultiprocDir:
assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == custom_dir
assert os.path.isdir(custom_dir)
@pytest.mark.parametrize(
"litellm_settings",
[
{"callbacks": "prometheus"},
{"success_callback": "prometheus"},
{"failure_callback": "prometheus"},
{"callbacks": "custom_callback"}, # string but not prometheus
],
)
def test_handles_string_callbacks(self, litellm_settings):
"""When callbacks are specified as a string instead of a list, should not crash."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
os.environ.pop("prometheus_multiproc_dir", None)
# Should not raise TypeError
ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir(
num_workers=4,
litellm_settings=litellm_settings,
)
# Cleanup
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
@pytest.mark.parametrize(
"num_workers, litellm_settings",
[

View file

@ -677,7 +677,7 @@ class TestHealthAppFactory:
mock_atexit_register,
mock_subprocess_run,
):
"""Test that proxy exits with code 1 when PrismaManager.setup_database returns False"""
"""Test that proxy exits with code 1 when PrismaManager.setup_database returns False and --enforce_prisma_migration_check is set"""
from litellm.proxy.proxy_cli import run_server
mock_subprocess_run.return_value = MagicMock(returncode=0)
@ -717,7 +717,7 @@ class TestHealthAppFactory:
with pytest.raises(SystemExit) as exc_info:
run_server.main(
["--local", "--skip_server_startup"], standalone_mode=False
["--local", "--skip_server_startup", "--enforce_prisma_migration_check"], standalone_mode=False
)
assert exc_info.value.code == 1
mock_setup_database.assert_called_once_with(use_migrate=True)

View file

@ -111,6 +111,37 @@ class TestProxySettingEndpoints:
assert "user_role" in data["field_schema"]["properties"]
assert "description" in data["field_schema"]["properties"]["user_role"]
def test_get_internal_user_settings_fresh_db_defaults_to_viewer(
self, mock_auth, monkeypatch
):
"""
On a fresh DB with no saved settings, the GET endpoint should return
INTERNAL_USER_VIEW_ONLY as the default role matching the runtime
fallback in SSO/SCIM/JWT provisioning paths.
"""
# Simulate fresh DB: no default_internal_user_params in config
empty_config = {
"litellm_settings": {},
"general_settings": {},
"environment_variables": {},
}
from litellm.proxy.proxy_server import proxy_config
async def mock_get_config():
return empty_config
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
response = client.get("/get/internal_user_settings")
assert response.status_code == 200
values = response.json()["values"]
assert values["user_role"] == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, (
f"Fresh DB should default to INTERNAL_USER_VIEW_ONLY, got {values['user_role']}. "
"The Pydantic default must match the runtime fallback."
)
def test_update_internal_user_settings(
self, mock_proxy_config, mock_auth, monkeypatch
):

View file

@ -388,6 +388,65 @@ def test_custom_pricing_cost_calc_uses_router_model_id_from_litellm_metadata():
assert custom_model_id not in (selected_model_no_custom or "")
def test_per_request_custom_pricing_with_router():
"""When custom pricing is passed as per-request kwargs (not in model_list),
_select_model_name_for_cost_calc should fall back to the model name
(where register_model stored the pricing) instead of the router_model_id
(which has no pricing data).
Regression test for the bug where response._hidden_params["response_cost"]
returned 0.0 for per-request custom pricing via Router.
"""
from litellm import Router
from litellm.cost_calculator import _select_model_name_for_cost_calc
router = Router(
model_list=[
{
"model_name": "openai/gpt-3.5-turbo",
"litellm_params": {
"model": "openai/gpt-3.5-turbo",
"api_key": "test_api_key",
},
},
]
)
# Get the deployment's model_id (hash) that the router registered
deployment = router.model_list[0]
router_model_id = deployment["model_info"]["id"]
# The router registered this hash in model_cost but without custom pricing
assert router_model_id in litellm.model_cost
entry = litellm.model_cost[router_model_id]
# No custom pricing was set in model_list, so these should be None
assert entry.get("input_cost_per_token") is None
# Now simulate what completion() does: register custom pricing under the model name
litellm.register_model(
{
"openai/gpt-3.5-turbo": {
"input_cost_per_token": 2.0,
"output_cost_per_token": 2.0,
"litellm_provider": "openai",
}
}
)
# _select_model_name_for_cost_calc should pick the model name (which has pricing),
# NOT the router_model_id (which has no pricing)
selected = _select_model_name_for_cost_calc(
model="openai/gpt-3.5-turbo",
completion_response=None,
custom_pricing=True,
custom_llm_provider="openai",
router_model_id=router_model_id,
)
assert selected is not None
assert router_model_id not in selected
assert "gpt-3.5-turbo" in selected
def test_azure_realtime_cost_calculator():
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")

View file

@ -1,5 +1,4 @@
"""Tests for GetBlogPosts utility class."""
import json
import time
from unittest.mock import MagicMock, patch
@ -13,16 +12,26 @@ from litellm.litellm_core_utils.get_blog_posts import (
get_blog_posts,
)
SAMPLE_RESPONSE = {
"posts": [
{
"title": "Test Post",
"description": "A test post.",
"date": "2026-01-01",
"url": "https://www.litellm.ai/blog/test",
}
]
}
SAMPLE_RSS = """\
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>LiteLLM Blog</title>
<item>
<title>Test Post</title>
<link>https://docs.litellm.ai/blog/test</link>
<description>A test post.</description>
<pubDate>Wed, 01 Jan 2026 10:00:00 GMT</pubDate>
</item>
<item>
<title>Second Post</title>
<link>https://docs.litellm.ai/blog/second</link>
<description>Another post.</description>
<pubDate>Tue, 31 Dec 2025 10:00:00 GMT</pubDate>
</item>
</channel>
</rss>
"""
@pytest.fixture(autouse=True)
@ -45,26 +54,48 @@ def test_load_local_blog_posts_returns_list():
assert "url" in first
def test_parse_rss_to_posts():
posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=1)
assert len(posts) == 1
assert posts[0]["title"] == "Test Post"
assert posts[0]["url"] == "https://docs.litellm.ai/blog/test"
assert posts[0]["description"] == "A test post."
assert posts[0]["date"] == "2026-01-01"
def test_parse_rss_to_posts_multiple():
posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=5)
assert len(posts) == 2
assert posts[1]["title"] == "Second Post"
def test_parse_rss_to_posts_invalid_xml():
with pytest.raises(Exception):
GetBlogPosts.parse_rss_to_posts("not xml")
def test_parse_rss_to_posts_missing_channel():
with pytest.raises(ValueError, match="missing <channel>"):
GetBlogPosts.parse_rss_to_posts("<rss></rss>")
def test_validate_blog_posts_valid():
assert GetBlogPosts.validate_blog_posts(SAMPLE_RESPONSE) is True
def test_validate_blog_posts_missing_posts_key():
assert GetBlogPosts.validate_blog_posts({"other": []}) is False
posts = [{"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
assert GetBlogPosts.validate_blog_posts(posts) is True
def test_validate_blog_posts_empty_list():
assert GetBlogPosts.validate_blog_posts({"posts": []}) is False
assert GetBlogPosts.validate_blog_posts([]) is False
def test_validate_blog_posts_not_dict():
assert GetBlogPosts.validate_blog_posts("not a dict") is False
def test_validate_blog_posts_not_list():
assert GetBlogPosts.validate_blog_posts("not a list") is False
def test_get_blog_posts_success():
"""Fetches from remote on first call."""
"""Fetches from RSS on first call."""
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_RESPONSE
mock_response.text = SAMPLE_RSS
mock_response.raise_for_status = MagicMock()
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response):
@ -86,10 +117,10 @@ def test_get_blog_posts_network_error_falls_back_to_local():
assert len(posts) > 0
def test_get_blog_posts_invalid_json_falls_back_to_local():
"""Falls back when remote returns non-dict."""
def test_get_blog_posts_invalid_xml_falls_back_to_local():
"""Falls back when remote returns invalid XML."""
mock_response = MagicMock()
mock_response.json.return_value = "not a dict"
mock_response.text = "not valid xml"
mock_response.raise_for_status = MagicMock()
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response):
@ -101,7 +132,8 @@ def test_get_blog_posts_invalid_json_falls_back_to_local():
def test_get_blog_posts_ttl_cache_not_refetched():
"""Within TTL window, does not re-fetch."""
GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"]
cached = [{"title": "Cached", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
GetBlogPosts._cached_posts = cached
GetBlogPosts._last_fetch_time = time.time() # just now
call_count = 0
@ -110,7 +142,7 @@ def test_get_blog_posts_ttl_cache_not_refetched():
nonlocal call_count
call_count += 1
m = MagicMock()
m.json.return_value = SAMPLE_RESPONSE
m.text = SAMPLE_RSS
m.raise_for_status = MagicMock()
return m
@ -123,11 +155,12 @@ def test_get_blog_posts_ttl_cache_not_refetched():
def test_get_blog_posts_ttl_expired_refetches():
"""After TTL window, re-fetches from remote."""
GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"]
cached = [{"title": "Cached", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
GetBlogPosts._cached_posts = cached
GetBlogPosts._last_fetch_time = time.time() - 7200 # 2 hours ago
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_RESPONSE
mock_response.text = SAMPLE_RSS
mock_response.raise_for_status = MagicMock()
with patch(

View file

@ -23,7 +23,7 @@
"jwt-decode": "^4.0.0",
"lucide-react": "^0.513.0",
"moment": "^2.30.1",
"next": "^16.1.6",
"next": "^16.1.7",
"openai": "^4.93.0",
"papaparse": "^5.5.2",
"react": "^18.3.1",
@ -92,6 +92,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -1773,6 +1774,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@ -1783,6 +1785,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -1792,12 +1795,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@ -1828,9 +1833,9 @@
}
},
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@ -1844,9 +1849,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
"cpu": [
"arm64"
],
@ -1860,9 +1865,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
"cpu": [
"x64"
],
@ -1876,9 +1881,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
"cpu": [
"arm64"
],
@ -1892,9 +1897,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
"cpu": [
"arm64"
],
@ -1908,9 +1913,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
"cpu": [
"x64"
],
@ -1924,9 +1929,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
"cpu": [
"x64"
],
@ -1940,9 +1945,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
"cpu": [
"arm64"
],
@ -1956,9 +1961,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
"cpu": [
"x64"
],
@ -1975,6 +1980,7 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@ -1988,6 +1994,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@ -1997,6 +2004,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@ -2320,7 +2328,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
"integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.1"
@ -3425,12 +3433,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.2.48",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz",
"integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@ -3472,6 +3482,7 @@
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
@ -4332,12 +4343,14 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@ -4351,6 +4364,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -4363,6 +4377,7 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@ -4734,6 +4749,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4759,6 +4775,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@ -4874,6 +4891,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -4997,6 +5015,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@ -5021,6 +5040,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@ -5096,6 +5116,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -5156,6 +5177,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@ -5569,12 +5591,14 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": {
@ -6488,6 +6512,7 @@
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@ -6520,6 +6545,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@ -6557,6 +6583,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@ -6717,6 +6744,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -6867,6 +6895,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@ -7364,6 +7393,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@ -7416,6 +7446,7 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@ -7476,6 +7507,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -7521,6 +7553,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@ -7569,6 +7602,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@ -7845,6 +7879,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@ -8130,6 +8165,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@ -8142,6 +8178,7 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@ -8595,6 +8632,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@ -9167,6 +9205,7 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@ -9180,6 +9219,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -9294,6 +9334,7 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@ -9343,14 +9384,14 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.6",
"@next/env": "16.1.7",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@ -9362,14 +9403,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.6",
"@next/swc-darwin-x64": "16.1.6",
"@next/swc-linux-arm64-gnu": "16.1.6",
"@next/swc-linux-arm64-musl": "16.1.6",
"@next/swc-linux-x64-gnu": "16.1.6",
"@next/swc-linux-x64-musl": "16.1.6",
"@next/swc-win32-arm64-msvc": "16.1.6",
"@next/swc-win32-x64-msvc": "16.1.6",
"@next/swc-darwin-arm64": "16.1.7",
"@next/swc-darwin-x64": "16.1.7",
"@next/swc-linux-arm64-gnu": "16.1.7",
"@next/swc-linux-arm64-musl": "16.1.7",
"@next/swc-linux-x64-gnu": "16.1.7",
"@next/swc-linux-x64-musl": "16.1.7",
"@next/swc-win32-arm64-msvc": "16.1.7",
"@next/swc-win32-x64-msvc": "16.1.7",
"sharp": "^0.34.4"
},
"peerDependencies": {
@ -9505,6 +9546,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -9523,6 +9565,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -9867,6 +9910,7 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
@ -9913,6 +9957,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -9925,6 +9970,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -9934,6 +9980,7 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -9943,7 +9990,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.1"
@ -9962,7 +10009,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@ -9985,6 +10032,7 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -10013,6 +10061,7 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@ -10030,6 +10079,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -10055,6 +10105,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -10097,6 +10148,7 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -10122,6 +10174,7 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@ -10135,6 +10188,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prelude-ls": {
@ -10248,6 +10302,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@ -11036,6 +11091,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@ -11045,6 +11101,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@ -11057,6 +11114,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -11354,6 +11412,7 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@ -11394,6 +11453,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@ -11449,6 +11509,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@ -12089,6 +12150,7 @@
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@ -12124,6 +12186,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -12159,6 +12222,7 @@
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@ -12196,6 +12260,7 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@ -12212,6 +12277,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@ -12239,6 +12305,7 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@ -12248,6 +12315,7 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@ -12289,6 +12357,7 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@ -12355,6 +12424,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@ -12442,6 +12512,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tsconfig-paths": {
@ -12558,7 +12629,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -12760,6 +12831,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
@ -13213,7 +13285,7 @@
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View file

@ -35,7 +35,7 @@
"jwt-decode": "^4.0.0",
"lucide-react": "^0.513.0",
"moment": "^2.30.1",
"next": "^16.1.6",
"next": "^16.1.7",
"openai": "^4.93.0",
"papaparse": "^5.5.2",
"react": "^18.3.1",

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { Card, Title, Text, Divider, Button, TextInput } from "@tremor/react";
import { Typography, Spin, Switch, Select, InputNumber } from "antd";
import { Card, Title, Text, Divider, TextInput } from "@tremor/react";
import { Button, Typography, Spin, Switch, Select, InputNumber } from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { getInternalUserSettings, updateInternalUserSettings, modelAvailableCall } from "./networking";
import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
@ -160,11 +160,10 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
<div className="flex items-center justify-between mb-3">
<Text className="font-medium">Team {index + 1}</Text>
<Button
size="sm"
variant="secondary"
icon={DeleteOutlined}
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => removeTeam(index)}
className="text-red-500 hover:text-red-700"
>
Remove
</Button>
@ -208,7 +207,7 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
</div>
))}
<Button variant="secondary" icon={PlusOutlined} onClick={addTeam} className="w-full">
<Button icon={<PlusOutlined />} onClick={addTeam} className="w-full">
Add Team
</Button>
</div>
@ -462,7 +461,6 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
(isEditing ? (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
setIsEditing(false);
setEditedValues(settings.values || {});
@ -471,12 +469,12 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
>
Cancel
</Button>
<Button onClick={handleSaveSettings} loading={saving}>
<Button type="primary" onClick={handleSaveSettings} loading={saving}>
Save Changes
</Button>
</div>
) : (
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
<Button type="primary" onClick={() => setIsEditing(true)}>Edit Settings</Button>
))}
</div>

View file

@ -11,6 +11,7 @@ import {
getEntityBreakdown,
handleExportCSV,
handleExportJSON,
resolveEntities,
} from "./utils";
vi.mock("@/utils/dataUtils", () => ({
@ -1561,4 +1562,137 @@ describe("EntityUsageExport utils", () => {
window.Blob = originalBlob;
});
});
describe("resolveEntities and aggregated endpoint fallback", () => {
// Simulates the response from /user/daily/activity/aggregated which has
// empty entities but populated api_keys at the breakdown level.
// Derived from mockSpendData: flatten all entities' api_key_breakdowns
// into top-level api_keys, clear entities, and add a second key for team-1
// to test multi-key grouping.
const aggregatedSpendData: EntitySpendData = {
...mockSpendData,
results: mockSpendData.results.slice(0, 1).map((day) => ({
...day,
breakdown: {
entities: {},
api_keys: {
...Object.fromEntries(
Object.values(day.breakdown.entities as Record<string, any>).flatMap((e: any) =>
Object.entries(e.api_key_breakdown || {}),
),
),
// Extra key on team-1 to test multi-key-per-team aggregation
key1b: {
metrics: { spend: 5, api_requests: 50, successful_requests: 48, failed_requests: 2, total_tokens: 500 },
metadata: { team_id: "team-1", key_alias: "staging-key" },
},
},
models: { "gpt-4": { metrics: { spend: 35, api_requests: 350, total_tokens: 3500 } } },
},
})),
};
describe("resolveEntities", () => {
it("should return entities when populated", () => {
const breakdown = {
entities: { e1: { metrics: { spend: 1 } } },
api_keys: { k1: { metrics: { spend: 2 }, metadata: { team_id: "t1" } } },
};
const result = resolveEntities(breakdown);
expect(result).toBe(breakdown.entities);
});
it("should aggregate api_keys into entities when entities is empty", () => {
const breakdown = aggregatedSpendData.results[0].breakdown;
const result = resolveEntities(breakdown);
// Two teams: team-1 (key1+key2) and team-2 (key3)
expect(Object.keys(result)).toHaveLength(2);
expect(result["team-1"]).toBeDefined();
expect(result["team-2"]).toBeDefined();
// team-1 spend = 10.5 (key1) + 5 (key1b)
expect(result["team-1"].metrics.spend).toBe(15.5);
expect(result["team-1"].metrics.api_requests).toBe(150);
expect(result["team-1"].metrics.total_tokens).toBe(1500);
// team-2 spend = 20.3 (key2)
expect(result["team-2"].metrics.spend).toBe(20.3);
expect(result["team-2"].metrics.api_requests).toBe(200);
});
it("should use 'Unassigned' for keys without team_id", () => {
const breakdown = {
entities: {},
api_keys: {
k1: {
metrics: { spend: 7, api_requests: 10, successful_requests: 10, failed_requests: 0, total_tokens: 100 },
metadata: {},
},
},
};
const result = resolveEntities(breakdown);
expect(result["Unassigned"]).toBeDefined();
expect(result["Unassigned"].metrics.spend).toBe(7);
});
it("should handle missing or empty api_keys gracefully", () => {
expect(Object.keys(resolveEntities({ entities: {}, api_keys: {} }))).toHaveLength(0);
expect(Object.keys(resolveEntities({ entities: {} }))).toHaveLength(0);
});
it("should preserve api_key_breakdown on aggregated entities", () => {
const breakdown = aggregatedSpendData.results[0].breakdown;
const result = resolveEntities(breakdown);
// team-1 should have key1 and key1b in api_key_breakdown
expect(Object.keys(result["team-1"].api_key_breakdown)).toEqual(["key1", "key1b"]);
// team-2 should have key2
expect(Object.keys(result["team-2"].api_key_breakdown)).toEqual(["key2"]);
});
});
describe("getEntityBreakdown with aggregated data", () => {
it("should produce breakdown from api_keys when entities is empty", () => {
const result = getEntityBreakdown(aggregatedSpendData);
expect(result.length).toBeGreaterThan(0);
// Sorted by spend desc: team-2 (20.3) then team-1 (15.5)
expect(result[0].metrics.spend).toBe(20.3);
expect(result[1].metrics.spend).toBe(15.5);
});
});
describe("generateDailyData with aggregated data", () => {
it("should produce rows from api_keys when entities is empty", () => {
const result = generateDailyData(aggregatedSpendData, "Team");
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty("Date");
expect(result[0]).toHaveProperty("Team");
});
});
describe("generateDailyWithKeysData with aggregated data", () => {
it("should produce rows from api_keys when entities is empty", () => {
const result = generateDailyWithKeysData(aggregatedSpendData, "Team");
expect(result.length).toBeGreaterThan(0);
// Should have 3 key rows (key1, key1b, key2)
expect(result).toHaveLength(3);
const keyIds = result.map((r) => r["Key ID"]);
expect(keyIds).toContain("key1");
expect(keyIds).toContain("key1b");
expect(keyIds).toContain("key2");
});
});
describe("generateDailyWithModelsData with aggregated data", () => {
it("should produce rows from api_keys when entities is empty", () => {
const result = generateDailyWithModelsData(aggregatedSpendData, "Team");
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty("Model");
});
});
});
});

View file

@ -17,6 +17,49 @@ const extractTeamIdFromApiKeyBreakdown = (apiKeyBreakdown: Record<string, any> |
return null;
};
// Mirrors backend SpendMetrics fields (litellm/types/activity_tracking.py).
// If the backend adds a field, add it here too.
const METRIC_KEYS = [
"spend", "api_requests", "successful_requests", "failed_requests",
"total_tokens", "prompt_tokens", "completion_tokens",
"cache_read_input_tokens", "cache_creation_input_tokens",
] as const;
// When breakdown.entities is empty (aggregated endpoint), reconstruct entities
// from breakdown.api_keys by grouping on metadata.team_id.
const aggregateApiKeysIntoEntities = (breakdown: Record<string, any>): Record<string, any> => {
const apiKeys = breakdown.api_keys;
if (!apiKeys || Object.keys(apiKeys).length === 0) return {};
const grouped: Record<string, any> = {};
for (const [keyId, keyData] of Object.entries<any>(apiKeys)) {
const teamId = keyData?.metadata?.team_id || "Unassigned";
if (!grouped[teamId]) {
grouped[teamId] = {
metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])),
api_key_breakdown: {},
};
}
const m = grouped[teamId].metrics;
const km = keyData?.metrics || {};
for (const k of METRIC_KEYS) {
m[k] += km[k] || 0;
}
grouped[teamId].api_key_breakdown[keyId] = keyData;
}
return grouped;
};
// Returns breakdown.entities if populated, otherwise falls back to
// reconstructing entities from breakdown.api_keys.
export const resolveEntities = (breakdown: Record<string, any>): Record<string, any> => {
const entities = breakdown.entities;
if (entities && Object.keys(entities).length > 0) return entities;
return aggregateApiKeysIntoEntities(breakdown);
};
export const getEntityBreakdown = (
spendData: EntitySpendData,
teamAliasMap: Record<string, string> = {},
@ -24,7 +67,7 @@ export const getEntityBreakdown = (
const entitySpend: { [key: string]: EntityBreakdown } = {};
spendData.results.forEach((day) => {
Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => {
Object.entries(resolveEntities(day.breakdown)).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
@ -80,7 +123,7 @@ export const generateDailyData = (
const dailyBreakdown: any[] = [];
spendData.results.forEach((day) => {
Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => {
Object.entries(resolveEntities(day.breakdown)).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;
@ -129,7 +172,7 @@ export const generateDailyWithKeysData = (
} = {};
spendData.results.forEach((day) => {
Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => {
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
const apiKeyBreakdown = data.api_key_breakdown || {};
// Iterate through each API key in the breakdown
@ -202,7 +245,7 @@ export const generateDailyWithModelsData = (
spendData.results.forEach((day) => {
const dailyEntityModels: { [key: string]: { [key: string]: any } } = {};
Object.entries(day.breakdown.entities || {}).forEach(([entity, entityData]: [string, any]) => {
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, entityData]: [string, any]) => {
if (!dailyEntityModels[entity]) {
dailyEntityModels[entity] = {};
}
@ -230,7 +273,7 @@ export const generateDailyWithModelsData = (
});
Object.entries(dailyEntityModels).forEach(([entity, models]) => {
const entityData = day.breakdown.entities?.[entity];
const entityData = resolveEntities(day.breakdown)[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;

View file

@ -0,0 +1,29 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import HashicorpVaultEmptyPlaceholder from "./HashicorpVaultEmptyPlaceholder";
describe("HashicorpVaultEmptyPlaceholder", () => {
it("should render the empty state message and configure button", () => {
render(<HashicorpVaultEmptyPlaceholder onAdd={vi.fn()} />);
expect(screen.getByText("No Vault Configuration Found")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /configure vault/i })).toBeInTheDocument();
});
it("should call onAdd when the configure button is clicked", async () => {
const onAdd = vi.fn();
const user = userEvent.setup();
render(<HashicorpVaultEmptyPlaceholder onAdd={onAdd} />);
await user.click(screen.getByRole("button", { name: /configure vault/i }));
expect(onAdd).toHaveBeenCalledOnce();
});
it("should display the description text about Vault purpose", () => {
render(<HashicorpVaultEmptyPlaceholder onAdd={vi.fn()} />);
expect(
screen.getByText(/Configure Hashicorp Vault to securely manage provider API keys/),
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,77 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import PageVisibilitySettings from "./PageVisibilitySettings";
vi.mock("@/components/page_utils", () => ({
getAvailablePages: () => [
{ page: "usage", label: "Usage", description: "View usage stats", group: "Analytics" },
{ page: "models", label: "Models", description: "Manage models", group: "Analytics" },
{ page: "keys", label: "API Keys", description: "Manage API keys", group: "Access" },
],
}));
describe("PageVisibilitySettings", () => {
it("should render the not-set tag when enabledPagesInternalUsers is null", () => {
render(
<PageVisibilitySettings
enabledPagesInternalUsers={null}
isUpdating={false}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText("Not set (all pages visible)")).toBeInTheDocument();
});
it("should show the selected page count tag when pages are configured", () => {
render(
<PageVisibilitySettings
enabledPagesInternalUsers={["usage", "keys"]}
isUpdating={false}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText("2 pages selected")).toBeInTheDocument();
});
it("should show singular 'page' when exactly one page is selected", () => {
render(
<PageVisibilitySettings
enabledPagesInternalUsers={["usage"]}
isUpdating={false}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText("1 page selected")).toBeInTheDocument();
});
it("should call onUpdate with null when reset button is clicked", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
render(
<PageVisibilitySettings
enabledPagesInternalUsers={["usage"]}
isUpdating={false}
onUpdate={onUpdate}
/>,
);
// Expand the collapse panel first to reveal the reset button
await user.click(screen.getByRole("button", { name: /configure page visibility/i }));
await user.click(await screen.findByRole("button", { name: /reset to default/i }));
expect(onUpdate).toHaveBeenCalledWith({ enabled_ui_pages_internal_users: null });
});
it("should display the property description when provided", () => {
render(
<PageVisibilitySettings
enabledPagesInternalUsers={null}
enabledPagesPropertyDescription="Controls which pages are visible"
isUpdating={false}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText("Controls which pages are visible")).toBeInTheDocument();
});
});

View file

@ -24,6 +24,7 @@ export default function UISettings() {
const disableVectorStoresProperty = schema?.properties?.disable_vector_stores_for_internal_users;
const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins;
const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org;
const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys;
const values = data?.values ?? {};
const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users);
const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user);
@ -182,6 +183,20 @@ export default function UISettings() {
);
};
const handleToggleDisableCustomApiKeys = (checked: boolean) => {
updateSettings(
{ disable_custom_api_keys: checked },
{
onSuccess: () => {
NotificationManager.success("UI settings updated successfully");
},
onError: (error) => {
NotificationManager.fromBackend(error);
},
},
);
};
return (
<Card title="UI Settings">
{isLoading ? (
@ -382,6 +397,26 @@ export default function UISettings() {
<Divider />
{/* Disable custom Virtual key values */}
<Space align="start" size="middle">
<Switch
checked={Boolean(values.disable_custom_api_keys)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleDisableCustomApiKeys}
aria-label={disableCustomApiKeysProperty?.description ?? "Disable custom Virtual key values"}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Disable custom Virtual key values</Typography.Text>
<Typography.Text type="secondary">
{disableCustomApiKeysProperty?.description ??
"If true, users cannot specify custom key values. All keys must be auto-generated."}
</Typography.Text>
</Space>
</Space>
<Divider />
{/* Page Visibility for Internal Users */}
<PageVisibilitySettings
enabledPagesInternalUsers={values.enabled_ui_pages_internal_users}

View file

@ -166,6 +166,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
const { data: projects, isLoading: isProjectsLoading } = useProjects();
const { data: uiSettingsData } = useUISettings();
const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui);
const disableCustomApiKeys = Boolean(uiSettingsData?.values?.disable_custom_api_keys);
const queryClient = useQueryClient();
const [form] = Form.useForm();
const [isModalVisible, setIsModalVisible] = useState(false);
@ -1581,6 +1582,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
"budget_duration",
"tpm_limit",
"rpm_limit",
...(disableCustomApiKeys ? ["key"] : []),
]}
/>
</AccordionBody>

View file

@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { UiLoadingSpinner } from "./ui-loading-spinner";
describe("UiLoadingSpinner", () => {
it("should render an SVG element", () => {
render(<UiLoadingSpinner data-testid="spinner" />);
expect(screen.getByTestId("spinner")).toBeInTheDocument();
});
it("should apply custom className alongside default classes", () => {
render(<UiLoadingSpinner data-testid="spinner" className="text-red-500" />);
const svg = screen.getByTestId("spinner");
expect(svg).toHaveClass("text-red-500");
expect(svg).toHaveClass("animate-spin");
});
it("should spread additional SVG props onto the element", () => {
render(<UiLoadingSpinner data-testid="spinner" aria-label="Loading" />);
expect(screen.getByLabelText("Loading")).toBeInTheDocument();
});
});

View file

@ -451,7 +451,7 @@ describe("useLogFilterLogic", () => {
);
});
it("should fall back to logs when backend filters are active but API returns empty", async () => {
it("should return empty results when backend filters are active but API returns empty", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue({
data: [],
total: 0,
@ -474,8 +474,7 @@ describe("useLogFilterLogic", () => {
{ timeout: 500 },
);
expect(result.current.filteredLogs.data).toHaveLength(1);
expect(result.current.filteredLogs.data[0].request_id).toBe("client-req");
expect(result.current.filteredLogs.data).toHaveLength(0);
});
it("should refetch when sortBy changes and backend filters are active", async () => {

View file

@ -228,7 +228,7 @@ export function useLogFilterLogic({
const filteredLogs: PaginatedResponse = useMemo(() => {
if (hasBackendFilters) {
// Prefer backend result if present; otherwise fall back to latest logs
if (backendFilteredLogs && backendFilteredLogs.data && backendFilteredLogs.data.length > 0) {
if (backendFilteredLogs && backendFilteredLogs.data) {
return backendFilteredLogs;
}
return (

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { extractErrorMessage } from "./errorUtils";
describe("extractErrorMessage", () => {
it("should return the message from an Error instance", () => {
expect(extractErrorMessage(new Error("Something broke"))).toBe("Something broke");
});
it("should return detail when it is a string", () => {
expect(extractErrorMessage({ detail: "Not found" })).toBe("Not found");
});
it("should join msg fields from a FastAPI 422 detail array", () => {
const err = {
detail: [
{ msg: "field required", loc: ["body", "name"], type: "value_error" },
{ msg: "invalid type", loc: ["body", "age"], type: "type_error" },
],
};
expect(extractErrorMessage(err)).toBe("field required; invalid type");
});
it("should extract error from nested detail object", () => {
expect(extractErrorMessage({ detail: { error: "bad request" } })).toBe("bad request");
});
it("should fall back to message property on plain objects", () => {
expect(extractErrorMessage({ message: "fallback msg" })).toBe("fallback msg");
});
it("should JSON.stringify unknown object shapes", () => {
expect(extractErrorMessage({ foo: "bar" })).toBe('{"foo":"bar"}');
});
it("should stringify primitive non-object values", () => {
expect(extractErrorMessage(42)).toBe("42");
expect(extractErrorMessage(null)).toBe("null");
expect(extractErrorMessage(undefined)).toBe("undefined");
});
});

View file

@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { classifyToolOp, groupToolsByCrud } from "./mcpToolCrudClassification";
describe("classifyToolOp", () => {
it("should classify read operations by name", () => {
expect(classifyToolOp("get-users")).toBe("read");
expect(classifyToolOp("list-items")).toBe("read");
expect(classifyToolOp("search documents")).toBe("read");
});
it("should classify delete operations by name", () => {
expect(classifyToolOp("delete-user")).toBe("delete");
expect(classifyToolOp("remove-item")).toBe("delete");
expect(classifyToolOp("purge-cache")).toBe("delete");
});
it("should classify create operations by name", () => {
expect(classifyToolOp("create-user")).toBe("create");
expect(classifyToolOp("add-item")).toBe("create");
expect(classifyToolOp("upload-file")).toBe("create");
});
it("should classify update operations by name", () => {
expect(classifyToolOp("update-settings")).toBe("update");
expect(classifyToolOp("edit-profile")).toBe("update");
expect(classifyToolOp("rename-file")).toBe("update");
});
it("should prioritize read over delete for names like get-removed-entries", () => {
expect(classifyToolOp("get-removed-entries")).toBe("read");
expect(classifyToolOp("list-deleted-items")).toBe("read");
});
it("should fall back to description when name is unrecognised", () => {
expect(classifyToolOp("mytool", "This will delete the record")).toBe("delete");
expect(classifyToolOp("mytool", "fetch data from the API")).toBe("read");
});
it("should return unknown when neither name nor description match", () => {
expect(classifyToolOp("my_tool")).toBe("unknown");
expect(classifyToolOp("my_tool", "does something")).toBe("unknown");
});
});
describe("groupToolsByCrud", () => {
it("should group tools into their CRUD categories", () => {
const tools = [
{ name: "get-user", description: "" },
{ name: "create-item", description: "" },
{ name: "delete-record", description: "" },
{ name: "update-settings", description: "" },
{ name: "mysteryop", description: "" },
];
const groups = groupToolsByCrud(tools);
expect(groups.read).toHaveLength(1);
expect(groups.create).toHaveLength(1);
expect(groups.delete).toHaveLength(1);
expect(groups.update).toHaveLength(1);
expect(groups.unknown).toHaveLength(1);
});
});