mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
Merge remote-tracking branch 'origin' into litellm_yj_march_17_2026
This commit is contained in:
commit
441f768abd
46 changed files with 3428 additions and 166 deletions
3
.github/workflows/ghcr_deploy.yml
vendored
3
.github/workflows/ghcr_deploy.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
294
docs/my-website/docs/mcp_zero_trust.md
Normal file
294
docs/my-website/docs/mcp_zero_trust.md
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
# MCP Zero Trust Auth (JWT Signer)
|
||||
|
||||

|
||||
|
||||
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
|
||||
374
docs/my-website/release_notes/v1.82.3.md
Normal file
374
docs/my-website/release_notes/v1.82.3.md
Normal 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 ~60–70 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)
|
||||
|
|
@ -631,6 +631,7 @@ const sidebars = {
|
|||
"mcp_openapi",
|
||||
"mcp_oauth",
|
||||
"mcp_aws_sigv4",
|
||||
"mcp_zero_trust",
|
||||
"mcp_public_internet",
|
||||
"mcp_semantic_filter",
|
||||
"mcp_control",
|
||||
|
|
|
|||
BIN
docs/my-website/static/img/mcp_zero_trust_gateway.png
Normal file
BIN
docs/my-website/static/img/mcp_zero_trust_gateway.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.57-py3-none-any.whl
vendored
Normal file
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.57-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.57.tar.gz
vendored
Normal file
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.57.tar.gz
vendored
Normal file
Binary file not shown.
|
|
@ -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==",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2956,7 +2956,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
callback_func=callback,
|
||||
)
|
||||
if (
|
||||
isinstance(callback, CustomLogger) and is_sync_request
|
||||
isinstance(callback, CustomLogger)
|
||||
and is_sync_request
|
||||
and self.call_type
|
||||
!= CallTypes.pass_through.value
|
||||
): # custom logger class
|
||||
callback.log_failure_event(
|
||||
start_time=start_time,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -961,6 +961,8 @@ async def pass_through_request( # noqa: PLR0915
|
|||
if kwargs:
|
||||
for key, value in kwargs.items():
|
||||
request_payload[key] = value
|
||||
if logging_obj is not None:
|
||||
request_payload["litellm_logging_obj"] = logging_obj
|
||||
|
||||
if (
|
||||
"model" not in request_payload
|
||||
|
|
@ -1703,6 +1705,8 @@ async def websocket_passthrough_request( # noqa: PLR0915
|
|||
if kwargs:
|
||||
for key, value in kwargs.items():
|
||||
request_payload[key] = value
|
||||
if logging_obj is not None:
|
||||
request_payload["litellm_logging_obj"] = logging_obj
|
||||
|
||||
# Log the connection failure using the same pattern as HTTP
|
||||
await proxy_logging_obj.post_call_failure_hook(
|
||||
|
|
@ -1729,6 +1733,8 @@ async def websocket_passthrough_request( # noqa: PLR0915
|
|||
if kwargs:
|
||||
for key, value in kwargs.items():
|
||||
request_payload[key] = value
|
||||
if logging_obj is not None:
|
||||
request_payload["litellm_logging_obj"] = logging_obj
|
||||
|
||||
# Log the unexpected error using the same pattern as HTTP
|
||||
await proxy_logging_obj.post_call_failure_hook(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1729,6 +1729,9 @@ class ProxyLogging:
|
|||
original_exception=original_exception,
|
||||
)
|
||||
|
||||
# Remove before callbacks iterate — not serialisable
|
||||
request_data.pop("litellm_logging_obj", None)
|
||||
|
||||
# Track the first HTTPException returned or raised by any callback
|
||||
transformed_exception: Optional[HTTPException] = None
|
||||
|
||||
|
|
@ -1849,7 +1852,7 @@ class ProxyLogging:
|
|||
for k, v in request_data.items():
|
||||
if k in litellm_param_keys:
|
||||
_litellm_params[k] = v
|
||||
elif k != "model" and k != "user":
|
||||
elif k not in ("model", "user", "litellm_logging_obj"):
|
||||
_optional_params[k] = v
|
||||
|
||||
litellm_logging_obj.update_environment_variables(
|
||||
|
|
@ -1865,15 +1868,23 @@ class ProxyLogging:
|
|||
):
|
||||
input = request_data["messages"]
|
||||
litellm_logging_obj.model_call_details["messages"] = input
|
||||
litellm_logging_obj.call_type = CallTypes.acompletion.value
|
||||
if litellm_logging_obj.call_type != CallTypes.pass_through.value:
|
||||
litellm_logging_obj.call_type = CallTypes.acompletion.value
|
||||
elif "prompt" in request_data and isinstance(request_data["prompt"], str):
|
||||
input = request_data["prompt"]
|
||||
litellm_logging_obj.model_call_details["prompt"] = input
|
||||
litellm_logging_obj.call_type = CallTypes.atext_completion.value
|
||||
if litellm_logging_obj.call_type != CallTypes.pass_through.value:
|
||||
litellm_logging_obj.call_type = CallTypes.atext_completion.value
|
||||
elif "input" in request_data and isinstance(request_data["input"], list):
|
||||
input = request_data["input"]
|
||||
litellm_logging_obj.model_call_details["input"] = input
|
||||
litellm_logging_obj.call_type = CallTypes.aembedding.value
|
||||
if litellm_logging_obj.call_type != CallTypes.pass_through.value:
|
||||
litellm_logging_obj.call_type = CallTypes.aembedding.value
|
||||
# Pass-through endpoints are logged via the callback loop's
|
||||
# async_post_call_failure_hook — skip pre_call and failure handlers.
|
||||
if litellm_logging_obj.call_type == CallTypes.pass_through.value:
|
||||
return
|
||||
|
||||
litellm_logging_obj.pre_call(
|
||||
input=input,
|
||||
api_key="",
|
||||
|
|
@ -1909,6 +1920,7 @@ class ProxyLogging:
|
|||
4. /files
|
||||
"""
|
||||
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
|
||||
guardrail_callbacks: List[CustomGuardrail] = []
|
||||
|
|
@ -1931,12 +1943,18 @@ class ProxyLogging:
|
|||
############## Handle Guardrails ########################################
|
||||
#############################################################################
|
||||
|
||||
# Merge model-level guardrails before checking which guardrails to run
|
||||
guardrail_data = _check_and_merge_model_level_guardrails(
|
||||
data=data, llm_router=llm_router
|
||||
)
|
||||
|
||||
for callback in guardrail_callbacks:
|
||||
# Main - V2 Guardrails implementation
|
||||
|
||||
if (
|
||||
callback.should_run_guardrail(
|
||||
data=data, event_type=GuardrailEventHooks.post_call
|
||||
data=guardrail_data,
|
||||
event_type=GuardrailEventHooks.post_call,
|
||||
)
|
||||
is not True
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "litellm"
|
||||
version = "1.82.3"
|
||||
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.3"
|
||||
version = "1.82.4"
|
||||
version_files = [
|
||||
"pyproject.toml:^version"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2385,3 +2385,134 @@ async def test_during_call_hook_parallel_execution_with_error():
|
|||
assert "Guardrail violation detected!" in str(exc_info.value)
|
||||
finally:
|
||||
litellm.callbacks = original_callbacks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_logging_proxy_only_error_preserves_pass_through_call_type():
|
||||
"""Ensure _handle_logging_proxy_only_error does not overwrite call_type
|
||||
when the logging object is already marked as pass_through_endpoint.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.types.utils import CallTypes
|
||||
|
||||
logging_obj = Logging(
|
||||
model="unknown",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
stream=False,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="test-call-id",
|
||||
function_id="test-function-id",
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"litellm_logging_obj": logging_obj,
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"model": "claude-3-5-sonnet",
|
||||
}
|
||||
|
||||
cache = DualCache()
|
||||
proxy_logging = ProxyLogging(user_api_key_cache=cache)
|
||||
|
||||
with patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock):
|
||||
with patch.object(logging_obj, "failure_handler"):
|
||||
await proxy_logging._handle_logging_proxy_only_error(
|
||||
request_data=request_data,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="test_key", token="test_token"
|
||||
),
|
||||
original_exception=Exception("test error"),
|
||||
)
|
||||
|
||||
assert logging_obj.call_type == CallTypes.pass_through.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_logging_obj_excluded_from_optional_params():
|
||||
"""Ensure litellm_logging_obj is excluded from _optional_params to prevent
|
||||
circular references in model_call_details.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
logging_obj = Logging(
|
||||
model="unknown",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
stream=False,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="test-call-id",
|
||||
function_id="test-function-id",
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"litellm_logging_obj": logging_obj,
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"model": "claude-3-5-sonnet",
|
||||
}
|
||||
|
||||
cache = DualCache()
|
||||
proxy_logging = ProxyLogging(user_api_key_cache=cache)
|
||||
|
||||
with patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock):
|
||||
with patch.object(logging_obj, "failure_handler"):
|
||||
await proxy_logging._handle_logging_proxy_only_error(
|
||||
request_data=request_data,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="test_key", token="test_token"
|
||||
),
|
||||
original_exception=Exception("test error"),
|
||||
)
|
||||
|
||||
assert "litellm_logging_obj" not in logging_obj.model_call_details
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_logging_proxy_only_error_skips_handlers_for_pass_through():
|
||||
"""Ensure _handle_logging_proxy_only_error skips async_failure_handler and
|
||||
failure_handler for pass-through endpoint errors, so only
|
||||
async_post_call_failure_hook fires (avoiding duplicate logs).
|
||||
|
||||
Regression test for duplicate Datadog/Arize logs on pass-through failures.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.types.utils import CallTypes
|
||||
|
||||
logging_obj = Logging(
|
||||
model="unknown",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
stream=False,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="test-call-id",
|
||||
function_id="test-function-id",
|
||||
)
|
||||
|
||||
cache = DualCache()
|
||||
proxy_logging = ProxyLogging(user_api_key_cache=cache)
|
||||
|
||||
request_data = {
|
||||
"litellm_logging_obj": logging_obj,
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"model": "claude-3-5-sonnet",
|
||||
}
|
||||
|
||||
with patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock) as mock_async:
|
||||
with patch.object(logging_obj, "failure_handler") as mock_sync:
|
||||
await proxy_logging._handle_logging_proxy_only_error(
|
||||
request_data=request_data,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="test_key", token="test_token"
|
||||
),
|
||||
original_exception=Exception("test error"),
|
||||
)
|
||||
|
||||
# Neither handler should fire for pass-through requests
|
||||
mock_async.assert_not_called()
|
||||
mock_sync.assert_not_called()
|
||||
assert logging_obj.call_type == CallTypes.pass_through.value
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1969,3 +1969,68 @@ def test_function_setup_empty_metadata_falls_back_to_litellm_metadata():
|
|||
assert metadata is not None
|
||||
assert metadata.get("user_api_key_hash") == "sk-hashed-empty-test"
|
||||
assert metadata.get("user_api_key_team_id") == "team-empty-test"
|
||||
|
||||
|
||||
def test_failure_handler_skips_sync_callbacks_for_pass_through_requests(logging_obj):
|
||||
"""Ensure sync failure callbacks are skipped for pass-through endpoint requests.
|
||||
|
||||
Regression test for duplicate Datadog/Arize logs on pass-through endpoint failures.
|
||||
The async_failure_handler fires async_log_failure_event; the sync failure_handler
|
||||
must NOT also fire log_failure_event for pass-through requests.
|
||||
"""
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.types.utils import CallTypes
|
||||
|
||||
class DummyLogger(CustomLogger):
|
||||
pass
|
||||
|
||||
logging_obj.call_type = CallTypes.pass_through.value
|
||||
logging_obj.stream = False
|
||||
logging_obj.model_call_details["litellm_params"] = {}
|
||||
logging_obj.litellm_params = {}
|
||||
|
||||
dummy_logger = DummyLogger()
|
||||
dummy_logger.log_failure_event = MagicMock()
|
||||
|
||||
with patch.object(
|
||||
logging_obj,
|
||||
"get_combined_callback_list",
|
||||
return_value=[dummy_logger],
|
||||
):
|
||||
logging_obj.failure_handler(
|
||||
exception=Exception("test error"),
|
||||
traceback_exception="",
|
||||
)
|
||||
|
||||
dummy_logger.log_failure_event.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("call_type", ["completion", "acompletion"])
|
||||
def test_failure_handler_runs_sync_callbacks_for_non_pass_through_requests(
|
||||
logging_obj, call_type
|
||||
):
|
||||
"""Ensure sync failure callbacks still fire for normal (non-pass-through) requests."""
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
class DummyLogger(CustomLogger):
|
||||
pass
|
||||
|
||||
logging_obj.call_type = call_type
|
||||
logging_obj.stream = False
|
||||
logging_obj.model_call_details["litellm_params"] = {}
|
||||
logging_obj.litellm_params = {}
|
||||
|
||||
dummy_logger = DummyLogger()
|
||||
dummy_logger.log_failure_event = MagicMock()
|
||||
|
||||
with patch.object(
|
||||
logging_obj,
|
||||
"get_combined_callback_list",
|
||||
return_value=[dummy_logger],
|
||||
):
|
||||
logging_obj.failure_handler(
|
||||
exception=Exception("test error"),
|
||||
traceback_exception="",
|
||||
)
|
||||
|
||||
dummy_logger.log_failure_event.assert_called_once()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
182
tests/test_litellm/proxy/test_aiohttp_session_recovery.py
Normal file
182
tests/test_litellm/proxy/test_aiohttp_session_recovery.py
Normal 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
|
||||
300
tests/test_litellm/proxy/test_model_level_guardrails.py
Normal file
300
tests/test_litellm/proxy/test_model_level_guardrails.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""
|
||||
Unit tests for model-level guardrails in post_call paths.
|
||||
|
||||
Tests verify that guardrails configured via litellm_params.guardrails on a
|
||||
deployment are merged into request metadata and trigger execution for both
|
||||
streaming and non-streaming post_call hooks.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
|
||||
|
||||
from litellm.proxy.utils import (
|
||||
_check_and_merge_model_level_guardrails,
|
||||
_merge_guardrails_with_existing,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for _check_and_merge_model_level_guardrails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckAndMergeModelLevelGuardrails:
|
||||
"""Tests for the _check_and_merge_model_level_guardrails function."""
|
||||
|
||||
def test_merge_adds_model_guardrails_to_metadata(self):
|
||||
"""Model-level guardrails are added to metadata.guardrails."""
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"metadata": {"model_info": {"id": "model-uuid-123"}},
|
||||
}
|
||||
mock_router = MagicMock()
|
||||
mock_deployment = MagicMock()
|
||||
mock_deployment.litellm_params.get.return_value = ["openai-moderation"]
|
||||
mock_router.get_deployment.return_value = mock_deployment
|
||||
|
||||
result = _check_and_merge_model_level_guardrails(
|
||||
data=data, llm_router=mock_router
|
||||
)
|
||||
|
||||
assert "openai-moderation" in result["metadata"]["guardrails"]
|
||||
mock_router.get_deployment.assert_called_once_with(model_id="model-uuid-123")
|
||||
|
||||
def test_merge_combines_with_existing_guardrails(self):
|
||||
"""Model-level guardrails merge with existing request guardrails."""
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"metadata": {
|
||||
"model_info": {"id": "model-uuid-123"},
|
||||
"guardrails": ["existing-guardrail"],
|
||||
},
|
||||
}
|
||||
mock_router = MagicMock()
|
||||
mock_deployment = MagicMock()
|
||||
mock_deployment.litellm_params.get.return_value = ["model-guardrail"]
|
||||
mock_router.get_deployment.return_value = mock_deployment
|
||||
|
||||
result = _check_and_merge_model_level_guardrails(
|
||||
data=data, llm_router=mock_router
|
||||
)
|
||||
|
||||
assert "existing-guardrail" in result["metadata"]["guardrails"]
|
||||
assert "model-guardrail" in result["metadata"]["guardrails"]
|
||||
|
||||
def test_no_duplicates_when_guardrail_already_in_metadata(self):
|
||||
"""No duplicates when the same guardrail is in both model and request."""
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"metadata": {
|
||||
"model_info": {"id": "model-uuid-123"},
|
||||
"guardrails": ["openai-moderation"],
|
||||
},
|
||||
}
|
||||
mock_router = MagicMock()
|
||||
mock_deployment = MagicMock()
|
||||
mock_deployment.litellm_params.get.return_value = ["openai-moderation"]
|
||||
mock_router.get_deployment.return_value = mock_deployment
|
||||
|
||||
result = _check_and_merge_model_level_guardrails(
|
||||
data=data, llm_router=mock_router
|
||||
)
|
||||
|
||||
assert result["metadata"]["guardrails"].count("openai-moderation") == 1
|
||||
|
||||
def test_returns_data_unchanged_when_no_router(self):
|
||||
"""Returns data unchanged when llm_router is None."""
|
||||
data = {"model": "gpt-4", "metadata": {}}
|
||||
result = _check_and_merge_model_level_guardrails(
|
||||
data=data, llm_router=None
|
||||
)
|
||||
assert result is data
|
||||
|
||||
def test_returns_data_unchanged_when_no_model_info(self):
|
||||
"""Returns data unchanged when metadata has no model_info."""
|
||||
data = {"model": "gpt-4", "metadata": {}}
|
||||
mock_router = MagicMock()
|
||||
result = _check_and_merge_model_level_guardrails(
|
||||
data=data, llm_router=mock_router
|
||||
)
|
||||
assert result is data
|
||||
|
||||
def test_returns_data_unchanged_when_deployment_has_no_guardrails(self):
|
||||
"""Returns data unchanged when deployment has no guardrails configured."""
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"metadata": {"model_info": {"id": "model-uuid-123"}},
|
||||
}
|
||||
mock_router = MagicMock()
|
||||
mock_deployment = MagicMock()
|
||||
mock_deployment.litellm_params.get.return_value = None
|
||||
mock_router.get_deployment.return_value = mock_deployment
|
||||
|
||||
result = _check_and_merge_model_level_guardrails(
|
||||
data=data, llm_router=mock_router
|
||||
)
|
||||
|
||||
assert result is data
|
||||
|
||||
def test_returns_data_unchanged_when_deployment_not_found(self):
|
||||
"""Returns data unchanged when router can't find the deployment."""
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"metadata": {"model_info": {"id": "nonexistent-id"}},
|
||||
}
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_deployment.return_value = None
|
||||
|
||||
result = _check_and_merge_model_level_guardrails(
|
||||
data=data, llm_router=mock_router
|
||||
)
|
||||
|
||||
assert result is data
|
||||
|
||||
def test_returns_new_data_dict(self):
|
||||
"""Returns a new top-level dict (shallow copy), not the same object."""
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"metadata": {
|
||||
"model_info": {"id": "model-uuid-123"},
|
||||
"guardrails": ["existing"],
|
||||
},
|
||||
}
|
||||
mock_router = MagicMock()
|
||||
mock_deployment = MagicMock()
|
||||
mock_deployment.litellm_params.get.return_value = ["new-guardrail"]
|
||||
mock_router.get_deployment.return_value = mock_deployment
|
||||
|
||||
result = _check_and_merge_model_level_guardrails(
|
||||
data=data, llm_router=mock_router
|
||||
)
|
||||
|
||||
# Result is a different top-level dict
|
||||
assert result is not data
|
||||
# Result should have the merged guardrail
|
||||
assert "new-guardrail" in result["metadata"]["guardrails"]
|
||||
assert "existing" in result["metadata"]["guardrails"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test: post_call_success_hook with model-level guardrails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_call_success_hook_runs_model_level_guardrail():
|
||||
"""
|
||||
Model-level guardrails configured on a deployment should execute in
|
||||
post_call_success_hook (non-streaming path).
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import Choices, Message, ModelResponse, Usage
|
||||
|
||||
class TestGuardrail(CustomGuardrail):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
guardrail_name="test-model-guardrail",
|
||||
event_hook=GuardrailEventHooks.post_call,
|
||||
)
|
||||
self.was_called = False
|
||||
|
||||
async def async_post_call_success_hook(
|
||||
self, data, user_api_key_dict, response
|
||||
):
|
||||
self.was_called = True
|
||||
return response
|
||||
|
||||
guardrail = TestGuardrail()
|
||||
|
||||
# Mock router that returns a deployment with guardrails configured
|
||||
mock_router = MagicMock()
|
||||
mock_deployment = MagicMock()
|
||||
mock_deployment.litellm_params.get.return_value = ["test-model-guardrail"]
|
||||
mock_router.get_deployment.return_value = mock_deployment
|
||||
|
||||
with patch("litellm.callbacks", [guardrail]), patch(
|
||||
"litellm.proxy.proxy_server.llm_router", mock_router
|
||||
):
|
||||
proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
|
||||
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"metadata": {"model_info": {"id": "model-uuid-123"}},
|
||||
}
|
||||
response = ModelResponse(
|
||||
id="resp-1",
|
||||
choices=[
|
||||
Choices(
|
||||
message=Message(content="Hello", role="assistant"),
|
||||
index=0,
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
model="gpt-4",
|
||||
usage=Usage(prompt_tokens=5, completion_tokens=5, total_tokens=10),
|
||||
)
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
|
||||
|
||||
await proxy_logging.post_call_success_hook(
|
||||
data=data,
|
||||
response=response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
assert guardrail.was_called is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_call_success_hook_skips_guardrail_not_on_model():
|
||||
"""
|
||||
Guardrails NOT configured on the model should not execute when
|
||||
no other source (request body, key, team) enables them.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import Choices, Message, ModelResponse, Usage
|
||||
|
||||
class TestGuardrail(CustomGuardrail):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
guardrail_name="unrelated-guardrail",
|
||||
event_hook=GuardrailEventHooks.post_call,
|
||||
)
|
||||
self.was_called = False
|
||||
|
||||
async def async_post_call_success_hook(
|
||||
self, data, user_api_key_dict, response
|
||||
):
|
||||
self.was_called = True
|
||||
return response
|
||||
|
||||
guardrail = TestGuardrail()
|
||||
|
||||
# Deployment has a DIFFERENT guardrail configured
|
||||
mock_router = MagicMock()
|
||||
mock_deployment = MagicMock()
|
||||
mock_deployment.litellm_params.get.return_value = ["some-other-guardrail"]
|
||||
mock_router.get_deployment.return_value = mock_deployment
|
||||
|
||||
with patch("litellm.callbacks", [guardrail]), patch(
|
||||
"litellm.proxy.proxy_server.llm_router", mock_router
|
||||
):
|
||||
proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
|
||||
|
||||
data = {
|
||||
"model": "gpt-4",
|
||||
"metadata": {"model_info": {"id": "model-uuid-123"}},
|
||||
}
|
||||
response = ModelResponse(
|
||||
id="resp-1",
|
||||
choices=[
|
||||
Choices(
|
||||
message=Message(content="Hello", role="assistant"),
|
||||
index=0,
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
model="gpt-4",
|
||||
usage=Usage(prompt_tokens=5, completion_tokens=5, total_tokens=10),
|
||||
)
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
|
||||
|
||||
await proxy_logging.post_call_success_hook(
|
||||
data=data,
|
||||
response=response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
assert guardrail.was_called is False
|
||||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
164
ui/litellm-dashboard/package-lock.json
generated
164
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
40
ui/litellm-dashboard/src/utils/errorUtils.test.ts
Normal file
40
ui/litellm-dashboard/src/utils/errorUtils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue