From 70047345282dedc94be1aab778301f157b31064f Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Tue, 6 Jan 2026 23:56:02 +0530 Subject: [PATCH] =?UTF-8?q?Revert=20"feat:=20Add=20built-in=20migration=20?= =?UTF-8?q?lock=20to=20prevent=20concurrent=20Prisma=20migrat=E2=80=A6"=20?= =?UTF-8?q?(#18719)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9f68081f6d044faa018284fea99532c3b9374c6d. --- docker/Dockerfile.database | 2 +- docs/my-website/docs/mcp_control.md | 17 +- docs/my-website/docs/providers/gigachat.md | 283 ----------- docs/my-website/docs/proxy/config_settings.md | 7 +- docs/my-website/docs/reasoning_content.md | 65 --- docs/my-website/docs/response_api_compact.md | 104 ---- docs/my-website/img/mcp_allow_all_ui.png | Bin 137849 -> 0 bytes docs/my-website/sidebars.js | 9 +- .../litellm_proxy_extras/utils.py | 133 +---- litellm/__init__.py | 4 - litellm/_lazy_imports_registry.py | 4 - .../transformation.py | 23 +- litellm/constants.py | 1 - litellm/integrations/callback_configs.json | 6 - litellm/integrations/langfuse/langfuse.py | 40 +- litellm/integrations/langsmith.py | 19 +- litellm/integrations/opentelemetry.py | 256 +++------- .../litellm_core_utils/streaming_handler.py | 54 +- .../llms/base_llm/responses/transformation.py | 27 - litellm/llms/custom_httpx/llm_http_handler.py | 214 +------- litellm/llms/gigachat/__init__.py | 23 - litellm/llms/gigachat/authenticator.py | 241 --------- litellm/llms/gigachat/chat/__init__.py | 12 - litellm/llms/gigachat/chat/streaming.py | 134 ----- litellm/llms/gigachat/chat/transformation.py | 473 ------------------ litellm/llms/gigachat/embedding/__init__.py | 7 - .../llms/gigachat/embedding/transformation.py | 212 -------- litellm/llms/gigachat/file_handler.py | 211 -------- .../llms/openai/responses/transformation.py | 66 --- litellm/llms/vertex_ai/vertex_llm_base.py | 7 +- litellm/main.py | 65 --- ...odel_prices_and_context_window_backup.json | 63 --- .../mcp_server/discoverable_endpoints.py | 44 +- .../mcp_server/mcp_server_manager.py | 141 ++---- .../proxy/_experimental/mcp_server/server.py | 24 +- litellm/proxy/_types.py | 7 - litellm/proxy/common_request_processing.py | 2 - litellm/proxy/db/prisma_client.py | 6 +- .../guardrails/guardrail_hooks/lasso/lasso.py | 2 +- .../mcp_management_endpoints.py | 58 +-- .../proxy/response_api_endpoints/endpoints.py | 82 --- litellm/proxy/route_llm_request.py | 2 - litellm/responses/main.py | 202 -------- litellm/responses/streaming_iterator.py | 217 +------- litellm/router.py | 22 - litellm/types/integrations/langsmith.py | 2 - litellm/types/utils.py | 2 - litellm/utils.py | 4 - model_prices_and_context_window.json | 63 --- provider_endpoints_support.json | 4 +- requirements.txt | 2 +- .../test_bedrock_apply_guardrail.py | 34 +- .../test_litellm_proxy_extras_utils.py | 337 +++---------- .../test_openai_responses_api.py | 46 -- .../test_responses_hooks.py | 165 ------ .../test_anthropic_completion.py | 2 +- tests/llm_translation/test_databricks.py | 6 - tests/llm_translation/test_gigachat.py | 349 ------------- tests/local_testing/test_completion.py | 16 +- tests/local_testing/test_streaming.py | 6 +- .../test_langsmith_unit_test.py | 111 +--- .../test_opentelemetry_unit_tests.py | 25 + .../test_router_helper_utils.py | 67 --- ...responses_transformation_transformation.py | 96 +--- .../langfuse/test_gemini_cached_tokens.py | 90 ---- .../integrations/test_opentelemetry.py | 103 +--- .../test_streaming_handler.py | 23 - .../test_function_call_args_serialization.py | 355 ------------- ...test_vertex_and_google_ai_studio_gemini.py | 35 -- .../mcp_server/test_discoverable_endpoints.py | 57 +-- .../mcp_server/test_mcp_server_manager.py | 19 - .../guardrail_hooks/test_qualifire.py | 135 +++-- .../hooks/test_key_management_event_hooks.py | 10 - .../test_mcp_management_endpoints.py | 79 --- .../test_proxy_setting_endpoints.py | 48 +- .../(dashboard)/hooks/sso/useSSOSettings.ts | 20 +- .../ModelsAndEndpointsView.tsx | 25 - .../Modals/BaseSSOSettingsForm.tsx | 18 +- .../Modals/DeleteSSOSettingsModal.test.tsx | 46 +- .../Modals/DeleteSSOSettingsModal.tsx | 103 ++-- .../SSOSettings/RoleMappings.test.tsx | 92 ---- .../SSOSettings/RoleMappings.tsx | 74 --- .../AdminSettings/SSOSettings/SSOSettings.tsx | 92 ++-- .../AdminSettings/SSOSettings/constants.ts | 7 - .../AdminSettings/SSOSettings/utils.test.ts | 12 - .../AdminSettings/SSOSettings/utils.ts | 21 +- .../src/hooks/useMcpOAuthFlow.tsx | 2 +- 87 files changed, 567 insertions(+), 5827 deletions(-) delete mode 100644 docs/my-website/docs/providers/gigachat.md delete mode 100644 docs/my-website/docs/response_api_compact.md delete mode 100644 docs/my-website/img/mcp_allow_all_ui.png delete mode 100644 litellm/llms/gigachat/__init__.py delete mode 100644 litellm/llms/gigachat/authenticator.py delete mode 100644 litellm/llms/gigachat/chat/__init__.py delete mode 100644 litellm/llms/gigachat/chat/streaming.py delete mode 100644 litellm/llms/gigachat/chat/transformation.py delete mode 100644 litellm/llms/gigachat/embedding/__init__.py delete mode 100644 litellm/llms/gigachat/embedding/transformation.py delete mode 100644 litellm/llms/gigachat/file_handler.py delete mode 100644 tests/llm_responses_api_testing/test_responses_hooks.py delete mode 100644 tests/llm_translation/test_gigachat.py delete mode 100644 tests/test_litellm/integrations/langfuse/test_gemini_cached_tokens.py delete mode 100644 tests/test_litellm/llms/vertex_ai/gemini/test_function_call_args_serialization.py delete mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index 9a4e9a315ea..0e804cbfd12 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -48,7 +48,7 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime USER root # Install runtime dependencies -RUN apk add --no-cache bash openssl tzdata nodejs npm python3 py3-pip libsndfile +RUN apk add --no-cache bash openssl tzdata nodejs npm python3 py3-pip WORKDIR /app # Copy the current directory contents into the container at /app diff --git a/docs/my-website/docs/mcp_control.md b/docs/my-website/docs/mcp_control.md index a7d66a6b7fc..f6ab4a73087 100644 --- a/docs/my-website/docs/mcp_control.md +++ b/docs/my-website/docs/mcp_control.md @@ -108,7 +108,7 @@ Some MCP servers are meant to be shared broadly—think internal knowledge bases 3. Toggle **Allow All LiteLLM Keys** on. MCP server configuration in Admin UI @@ -634,18 +634,3 @@ Control which tools different teams can access from the same MCP server. For exa This video shows how to set allowed tools for a Key, Team, or Organization. - - -## Dashboard View Modes - -Proxy admins can also control what non-admins see inside the MCP dashboard via `general_settings.user_mcp_management_mode`: - -- `restricted` *(default)* – users only see servers that their team explicitly has access to. -- `view_all` – every dashboard user can see the full MCP server list. - -```yaml title="Config example" -general_settings: - user_mcp_management_mode: view_all -``` - -This is useful when you want discoverability for MCP offerings without granting additional execution privileges. diff --git a/docs/my-website/docs/providers/gigachat.md b/docs/my-website/docs/providers/gigachat.md deleted file mode 100644 index 13eec298c25..00000000000 --- a/docs/my-website/docs/providers/gigachat.md +++ /dev/null @@ -1,283 +0,0 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# GigaChat -https://developers.sber.ru/docs/ru/gigachat/api/overview - -GigaChat is Sber AI's large language model, Russia's leading LLM provider. - -:::tip - -**We support ALL GigaChat models, just set `model=gigachat/` as a prefix when sending litellm requests** - -::: - -:::warning - -GigaChat API uses self-signed SSL certificates. You must pass `ssl_verify=False` in your requests. - -::: - -## Supported Features - -| Feature | Supported | -|---------|-----------| -| Chat Completion | Yes | -| Streaming | Yes | -| Async | Yes | -| Function Calling / Tools | Yes | -| Structured Output (JSON Schema) | Yes (via function call emulation) | -| Image Input | Yes (base64 and URL) - GigaChat-2-Max, GigaChat-2-Pro only | -| Embeddings | Yes | - -## API Key - -GigaChat uses OAuth authentication. Set your credentials as environment variables: - -```python -import os - -# Required: Set credentials (base64-encoded client_id:client_secret) -os.environ['GIGACHAT_CREDENTIALS'] = "your-credentials-here" - -# Optional: Set scope (default is GIGACHAT_API_PERS for personal use) -os.environ['GIGACHAT_SCOPE'] = "GIGACHAT_API_PERS" # or GIGACHAT_API_B2B for business -``` - -Get your credentials at: https://developers.sber.ru/studio/ - -## Sample Usage - -```python -from litellm import completion -import os - -os.environ['GIGACHAT_CREDENTIALS'] = "your-credentials-here" - -response = completion( - model="gigachat/GigaChat-2-Max", - messages=[ - {"role": "user", "content": "Hello from LiteLLM!"} - ], - ssl_verify=False, # Required for GigaChat -) -print(response) -``` - -## Sample Usage - Streaming - -```python -from litellm import completion -import os - -os.environ['GIGACHAT_CREDENTIALS'] = "your-credentials-here" - -response = completion( - model="gigachat/GigaChat-2-Max", - messages=[ - {"role": "user", "content": "Hello from LiteLLM!"} - ], - stream=True, - ssl_verify=False, # Required for GigaChat -) - -for chunk in response: - print(chunk) -``` - -## Sample Usage - Function Calling - -```python -from litellm import completion -import os - -os.environ['GIGACHAT_CREDENTIALS'] = "your-credentials-here" - -tools = [{ - "type": "function", - "function": { - "name": "get_weather", - "description": "Get weather for a city", - "parameters": { - "type": "object", - "properties": { - "city": {"type": "string", "description": "City name"} - }, - "required": ["city"] - } - } -}] - -response = completion( - model="gigachat/GigaChat-2-Max", - messages=[{"role": "user", "content": "What's the weather in Moscow?"}], - tools=tools, - ssl_verify=False, # Required for GigaChat -) -print(response) -``` - -## Sample Usage - Structured Output - -GigaChat supports structured output via JSON schema (emulated through function calling): - -```python -from litellm import completion -import os - -os.environ['GIGACHAT_CREDENTIALS'] = "your-credentials-here" - -response = completion( - model="gigachat/GigaChat-2-Max", - messages=[{"role": "user", "content": "Extract info: John is 30 years old"}], - response_format={ - "type": "json_schema", - "json_schema": { - "name": "person", - "schema": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - } - } - } - }, - ssl_verify=False, # Required for GigaChat -) -print(response) # Returns JSON: {"name": "John", "age": 30} -``` - -## Sample Usage - Image Input - -GigaChat supports image input via base64 or URL (GigaChat-2-Max and GigaChat-2-Pro only): - -```python -from litellm import completion -import os - -os.environ['GIGACHAT_CREDENTIALS'] = "your-credentials-here" - -response = completion( - model="gigachat/GigaChat-2-Max", # Vision requires GigaChat-2-Max or GigaChat-2-Pro - messages=[{ - "role": "user", - "content": [ - {"type": "text", "text": "What's in this image?"}, - {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}} - ] - }], - ssl_verify=False, # Required for GigaChat -) -print(response) -``` - -## Sample Usage - Embeddings - -```python -from litellm import embedding -import os - -os.environ['GIGACHAT_CREDENTIALS'] = "your-credentials-here" - -response = embedding( - model="gigachat/Embeddings", - input=["Hello world", "How are you?"], - ssl_verify=False, # Required for GigaChat -) -print(response) -``` - -## Usage with LiteLLM Proxy - -### 1. Set GigaChat Models on config.yaml - -```yaml -model_list: - - model_name: gigachat - litellm_params: - model: gigachat/GigaChat-2-Max - api_key: "os.environ/GIGACHAT_CREDENTIALS" - ssl_verify: false - - model_name: gigachat-lite - litellm_params: - model: gigachat/GigaChat-2-Lite - api_key: "os.environ/GIGACHAT_CREDENTIALS" - ssl_verify: false - - model_name: gigachat-embeddings - litellm_params: - model: gigachat/Embeddings - api_key: "os.environ/GIGACHAT_CREDENTIALS" - ssl_verify: false -``` - -### 2. Start Proxy - -```bash -litellm --config config.yaml -``` - -### 3. Test it - - - - -```shell -curl --location 'http://0.0.0.0:4000/chat/completions' \ ---header 'Content-Type: application/json' \ ---data '{ - "model": "gigachat", - "messages": [ - { - "role": "user", - "content": "Hello!" - } - ] -}' -``` - - - -```python -import openai -client = openai.OpenAI( - api_key="anything", - base_url="http://0.0.0.0:4000" -) - -response = client.chat.completions.create( - model="gigachat", - messages=[{"role": "user", "content": "Hello!"}] -) -print(response) -``` - - - -## Supported Models - -### Chat Models - -| Model Name | Context Window | Vision | Description | -|------------|----------------|--------|-------------| -| gigachat/GigaChat-2-Lite | 128K | No | Fast, lightweight model | -| gigachat/GigaChat-2-Pro | 128K | Yes | Professional model with vision | -| gigachat/GigaChat-2-Max | 128K | Yes | Maximum capability model | - -### Embedding Models - -| Model Name | Max Input | Dimensions | Description | -|------------|-----------|------------|-------------| -| gigachat/Embeddings | 512 | 1024 | Standard embeddings | -| gigachat/Embeddings-2 | 512 | 1024 | Updated embeddings | -| gigachat/EmbeddingsGigaR | 4096 | 2560 | High-dimensional embeddings | - -:::note -Available models may vary depending on your API access level (personal or business). -::: - -## Limitations - -- Only one function call per request (GigaChat API limitation) -- Maximum 1 image per message, 10 images total per conversation -- GigaChat API uses self-signed SSL certificates - `ssl_verify=False` is required diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index f4359a86ba9..87064d442ae 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -111,7 +111,6 @@ general_settings: master_key: string maximum_spend_logs_retention_period: 30d # The maximum time to retain spend logs before deletion. maximum_spend_logs_retention_interval: 1d # interval in which the spend log cleanup task should run in. - user_mcp_management_mode: restricted # or "view_all" # Database Settings database_url: string @@ -231,7 +230,6 @@ router_settings: | image_generation_model | str | The default model to use for image generation - ignores model set in request | | store_model_in_db | boolean | If true, enables storing model + credential information in the DB. | | supported_db_objects | List[str] | Fine-grained control over which object types to load from the database when `store_model_in_db` is True. Available types: `"models"`, `"mcp"`, `"guardrails"`, `"vector_stores"`, `"pass_through_endpoints"`, `"prompts"`, `"model_cost_map"`. If not set, all object types are loaded (default behavior). Example: `supported_db_objects: ["mcp"]` to only load MCP servers from DB. | -| user_mcp_management_mode | string | Controls what non-admins can see on the MCP dashboard. `restricted` (default) only lists MCP servers that the user’s teams are explicitly allowed to access. `view_all` lets every user see the full MCP server list. Tool list/call always respects per-key permissions, so users still cannot run MCP calls without access. | | store_prompts_in_spend_logs | boolean | If true, allows prompts and responses to be stored in the spend logs table. | | max_request_size_mb | int | The maximum size for requests in MB. Requests above this size will be rejected. | | max_response_size_mb | int | The maximum size for responses in MB. LLM Responses above this size will not be sent. | @@ -671,7 +669,6 @@ router_settings: | LANGSMITH_DEFAULT_RUN_NAME | Default name for Langsmith run | LANGSMITH_PROJECT | Project name for Langsmith integration | LANGSMITH_SAMPLING_RATE | Sampling rate for Langsmith logging -| LANGSMITH_TENANT_ID | Tenant ID for Langsmith multi-tenant deployments | LANGTRACE_API_KEY | API key for Langtrace service | LASSO_API_BASE | Base URL for Lasso API | LASSO_API_KEY | API key for Lasso service @@ -710,7 +707,6 @@ router_settings: | LITELLM_MODE | Operating mode for LiteLLM (e.g., production, development) | LITELLM_NON_ROOT | Flag to run LiteLLM in non-root mode for enhanced security in Docker containers | LITELLM_RATE_LIMIT_WINDOW_SIZE | Rate limit window size for LiteLLM. Default is 60 -| LITELLM_REASONING_AUTO_SUMMARY | If set to "true", automatically enables detailed reasoning summaries for reasoning models (e.g., o1, o3-mini, deepseek-reasoner). When enabled, adds `summary: "detailed"` to reasoning effort configurations. Default is "false" | LITELLM_SALT_KEY | Salt key for encryption in LiteLLM | LITELLM_SSL_CIPHERS | SSL/TLS cipher configuration for faster handshakes. Controls cipher suite preferences for OpenSSL connections. | LITELLM_SECRET_AWS_KMS_LITELLM_LICENSE | AWS KMS encrypted license for LiteLLM @@ -778,7 +774,6 @@ router_settings: | OTEL_EXPORTER_OTLP_HEADERS | Headers for OpenTelemetry requests | OTEL_SERVICE_NAME | Service name identifier for OpenTelemetry | OTEL_TRACER_NAME | Tracer name for OpenTelemetry tracing -| OTEL_LOGS_EXPORTER | Exporter type for OpenTelemetry logs (e.g., console) | PAGERDUTY_API_KEY | API key for PagerDuty Alerting | PANW_PRISMA_AIRS_API_KEY | API key for PANW Prisma AIRS service | PANW_PRISMA_AIRS_API_BASE | Base URL for PANW Prisma AIRS service @@ -893,4 +888,4 @@ router_settings: | DEFAULT_SHARED_HEALTH_CHECK_LOCK_TTL | Time-to-live in seconds for health check lock in shared health check mode. Default is 60 (1 minute) | ZSCALER_AI_GUARD_API_KEY | API key for Zscaler AI Guard service | ZSCALER_AI_GUARD_POLICY_ID | Policy ID for Zscaler AI Guard guardrails -| ZSCALER_AI_GUARD_URL | Base URL for Zscaler AI Guard API. Default is https://api.us1.zseclipse.net/v1/detection/execute-policy +| ZSCALER_AI_GUARD_URL | Base URL for Zscaler AI Guard API. Default is https://api.us1.zseclipse.net/v1/detection/execute-policy \ No newline at end of file diff --git a/docs/my-website/docs/reasoning_content.md b/docs/my-website/docs/reasoning_content.md index 04c6d7ee6cc..fca3df638c7 100644 --- a/docs/my-website/docs/reasoning_content.md +++ b/docs/my-website/docs/reasoning_content.md @@ -591,68 +591,3 @@ Expected Response - -## OpenAI Responses API - Auto-Summary Control - -When using OpenAI Responses API models (like `gpt-5`) via `/chat/completions` with `reasoning_effort`, you can control whether `summary="detailed"` is automatically added to the reasoning parameter. - -### Enabling Auto-Summary - -You can enable automatic `summary="detailed"` in two ways: - - - - -```python -import litellm - -# Enable auto-summary globally -litellm.reasoning_auto_summary = True - -response = litellm.completion( - model="openai/responses/gpt-5-mini", - messages=[{"role": "user", "content": "What is the capital of France?"}], - reasoning_effort="low", # Will automatically add summary="detailed" -) -``` - - - - - -```bash -# Set environment variable -export LITELLM_REASONING_AUTO_SUMMARY=true - -# Or in your .env file -LITELLM_REASONING_AUTO_SUMMARY=true -``` - - - - - -```yaml -litellm_settings: - reasoning_auto_summary: true # Enable auto-summary for all requests - -model_list: - - model_name: gpt-5-mini - litellm_params: - model: openai/responses/gpt-5-mini -``` - - - - -### Manual Control (Recommended) - -For fine-grained control, pass `reasoning_effort` as a dictionary: - -```python -response = litellm.completion( - model="openai/responses/gpt-5-mini", - messages=[{"role": "user", "content": "What is the capital of France?"}], - reasoning_effort={"effort": "low", "summary": "detailed"}, # Explicit control -) -``` diff --git a/docs/my-website/docs/response_api_compact.md b/docs/my-website/docs/response_api_compact.md deleted file mode 100644 index f5caa32ea33..00000000000 --- a/docs/my-website/docs/response_api_compact.md +++ /dev/null @@ -1,104 +0,0 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# /responses/compact - -Compress conversation history using OpenAI's `/responses/compact` endpoint. - -| Feature | Supported | -|---------|-----------| -| Supported LiteLLM Versions | 1.72.0+ | -| Supported Providers | `openai` | - -## Usage - -### LiteLLM Python SDK - -```python showLineNumbers title="Compact Response" -import litellm - -response = litellm.compact_responses( - model="openai/gpt-4o", - input=[{"role": "user", "content": "Hello, how are you?"}], - instructions="Be helpful", - previous_response_id="resp_abc123" # optional -) - -print(response.id) -print(response.object) # "response.compaction" -print(response.output) -``` - -### LiteLLM Proxy - - - - -```bash showLineNumbers title="Compact Request" -curl http://localhost:4000/v1/responses/compact \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer sk-1234" \ - -d '{ - "model": "openai/gpt-4o", - "input": [{"role": "user", "content": "Hello"}], - "instructions": "Be helpful" - }' -``` - - - - -```python showLineNumbers title="Compact with OpenAI SDK" -import httpx - -response = httpx.post( - "http://localhost:4000/v1/responses/compact", - headers={"Authorization": "Bearer sk-1234"}, - json={ - "model": "openai/gpt-4o", - "input": [{"role": "user", "content": "Hello"}], - "instructions": "Be helpful" - } -) - -print(response.json()) -``` - - - - -## Request Parameters - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `model` | string | Yes | Model to use for compaction | -| `input` | string or array | Yes | Input messages to compact | -| `instructions` | string | No | System instructions | -| `previous_response_id` | string | No | ID of previous response to continue from | - -## Response Format - -```json -{ - "id": "resp_abc123", - "object": "response.compaction", - "created_at": 1734366691, - "output": [ - { - "type": "message", - "role": "assistant", - "content": [...] - }, - { - "type": "compaction", - "encrypted_content": "..." - } - ], - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "total_tokens": 150 - } -} -``` - diff --git a/docs/my-website/img/mcp_allow_all_ui.png b/docs/my-website/img/mcp_allow_all_ui.png deleted file mode 100644 index f074deb801eb4ce4c9ad2a48d80f66007a8cb6d2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 137849 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 diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 11effc82fe7..3793aec037f 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -541,14 +541,7 @@ const sidebars = { }, "realtime", "rerank", - { - type: "category", - label: "/responses", - items: [ - "response_api", - "response_api_compact", - ] - }, + "response_api", { type: "category", label: "/search", diff --git a/litellm-proxy-extras/litellm_proxy_extras/utils.py b/litellm-proxy-extras/litellm_proxy_extras/utils.py index a1fa4ef8440..7ffbe95be13 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/utils.py +++ b/litellm-proxy-extras/litellm_proxy_extras/utils.py @@ -10,7 +10,6 @@ from pathlib import Path from typing import Optional from litellm_proxy_extras._logging import logger -from litellm.caching.redis_cache import RedisCache def str_to_bool(value: Optional[str]) -> bool: @@ -19,103 +18,6 @@ def str_to_bool(value: Optional[str]) -> bool: return value.lower() in ("true", "1", "t", "y", "yes") -class MigrationLockManager: - """Redis-based lock manager for database migrations""" - - MIGRATION_LOCK_KEY = "migration_lock" - LOCK_TTL_SECONDS = 300 # 5 minutes TTL - - def __init__(self, redis_cache: Optional[RedisCache] = None): - self.redis_cache = redis_cache - self.lock_acquired = False - self.pod_id = f"pod_{os.getpid()}_{int(time.time())}" - - def _get_redis_lock_key(self) -> str: - """Get Redis lock key for migration""" - return f"migration_lock:{self.MIGRATION_LOCK_KEY}" - - def acquire_lock(self) -> bool: - """Acquire migration lock""" - if self.redis_cache is None: - logger.warning( - "Redis cache is not available, running migration without lock protection" - ) - self.lock_acquired = True - return True - - try: - lock_key = self._get_redis_lock_key() - - # Redis SET with NX (only if not exists) and EX (expiration) - acquired = self.redis_cache.set_cache( - key=lock_key, value=self.pod_id, nx=True, ttl=self.LOCK_TTL_SECONDS - ) - - if acquired: - self.lock_acquired = True - logger.info(f"Migration lock acquired by pod {self.pod_id}") - return True - else: - logger.info("Migration lock is already held by another pod") - return False - - except Exception as e: - logger.warning(f"Failed to acquire migration lock: {e}") - return False - - def wait_for_lock_release( - self, check_interval: int = 5, max_wait: int = 300 - ) -> bool: - """Wait for another process to release the lock""" - if self.redis_cache is None: - logger.warning("Redis cache is not available, cannot wait for lock") - return False - - logger.info(f"Waiting for migration lock to be released (max {max_wait}s)...") - start_time = time.time() - - while time.time() - start_time < max_wait: - # Try to acquire lock using the public acquire_lock method - if self.acquire_lock(): - logger.info( - f"Migration lock acquired after waiting by pod {self.pod_id}" - ) - return True - - time.sleep(check_interval) - - logger.warning(f"Failed to acquire migration lock within {max_wait} seconds") - return False - - def release_lock(self): - """Release migration lock""" - if not self.lock_acquired or self.redis_cache is None: - return - - try: - lock_key = self._get_redis_lock_key() - - # Verify current pod owns the lock - current_value = self.redis_cache.get_cache(lock_key) - if current_value and str(current_value) == self.pod_id: - self.redis_cache.delete_cache(lock_key) - logger.info(f"Migration lock released by pod {self.pod_id}") - else: - logger.warning(f"Pod {self.pod_id} cannot release lock (not owner)") - - except Exception as e: - logger.warning(f"Failed to release migration lock: {e}") - finally: - self.lock_acquired = False - - def __enter__(self): - """Context manager entry - acquire lock when entering with statement""" - self.acquire_lock() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit - release lock when exiting with statement""" - self.release_lock() def _get_prisma_env() -> dict: """Get environment variables for Prisma, handling offline mode if configured.""" @@ -444,50 +346,19 @@ class ProxyExtrasDBManager: ) @staticmethod - def setup_database( - use_migrate: bool = False, redis_cache: Optional[RedisCache] = None - ) -> bool: + def setup_database(use_migrate: bool = False) -> bool: """ Set up the database using either prisma migrate or prisma db push - Uses migrations from litellm-proxy-extras package. - In multi-instance environment, use redis lock to prevent concurrent execution. + Uses migrations from litellm-proxy-extras package Args: schema_path (str): Path to the Prisma schema file use_migrate (bool): Whether to use prisma migrate instead of db push - redis_cache: Redis cache instance for distributed locking Returns: bool: True if setup was successful, False otherwise """ schema_path = ProxyExtrasDBManager._get_prisma_dir() + "/schema.prisma" - - database_url = os.getenv("DATABASE_URL") - if not database_url: - logger.error("DATABASE_URL environment variable is not set") - return False - - # Use MigrationLockManager to prevent concurrent migration execution - with MigrationLockManager(redis_cache) as lock_manager: - # Lock is already acquired in __enter__, check if it was successful - if not lock_manager.lock_acquired: - # Cannot acquire lock, another process is running migration - logger.info( - "Another pod is running migration, waiting for completion..." - ) - - # Wait for other process to complete migration - if not lock_manager.wait_for_lock_release(): - logger.error("Failed to acquire migration lock after waiting") - return False - - # Successfully acquired lock, proceed with migration - logger.info("Acquired migration lock, proceeding with migration") - return ProxyExtrasDBManager._execute_migration(use_migrate, schema_path) - - @staticmethod - def _execute_migration(use_migrate: bool, schema_path: str) -> bool: - """Execute the actual migration""" for attempt in range(4): original_dir = os.getcwd() migrations_dir = ProxyExtrasDBManager._get_prisma_dir() diff --git a/litellm/__init__.py b/litellm/__init__.py index 7f7ee21f692..dfe959bf747 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -197,7 +197,6 @@ retry = True api_key: Optional[str] = None openai_key: Optional[str] = None groq_key: Optional[str] = None -gigachat_key: Optional[str] = None databricks_key: Optional[str] = None openai_like_key: Optional[str] = None azure_key: Optional[str] = None @@ -276,7 +275,6 @@ banned_keywords_list: Optional[Union[str, List]] = None llm_guard_mode: Literal["all", "key-specific", "request-specific"] = "all" guardrail_name_config_map: Dict[str, GuardrailItem] = {} include_cost_in_streaming_usage: bool = False -reasoning_auto_summary: bool = False ### PROMPTS #### from litellm.types.prompts.init_prompts import PromptSpec @@ -1442,8 +1440,6 @@ if TYPE_CHECKING: from .llms.github_copilot.chat.transformation import GithubCopilotConfig as GithubCopilotConfig from .llms.github_copilot.responses.transformation import GithubCopilotResponsesAPIConfig as GithubCopilotResponsesAPIConfig from .llms.github_copilot.embedding.transformation import GithubCopilotEmbeddingConfig as GithubCopilotEmbeddingConfig - from .llms.gigachat.chat.transformation import GigaChatConfig as GigaChatConfig - from .llms.gigachat.embedding.transformation import GigaChatEmbeddingConfig as GigaChatEmbeddingConfig from .llms.nebius.chat.transformation import NebiusConfig as NebiusConfig from .llms.wandb.chat.transformation import WandbConfig as WandbConfig from .llms.dashscope.chat.transformation import DashScopeChatConfig as DashScopeChatConfig diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 26133ebc222..8c8266d5ca4 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -255,8 +255,6 @@ LLM_CONFIG_NAMES = ( "GithubCopilotEmbeddingConfig", "NebiusConfig", "WandbConfig", - "GigaChatConfig", - "GigaChatEmbeddingConfig", "DashScopeChatConfig", "MoonshotChatConfig", "DockerModelRunnerChatConfig", @@ -646,8 +644,6 @@ _LLM_CONFIGS_IMPORT_MAP = { "GithubCopilotEmbeddingConfig": (".llms.github_copilot.embedding.transformation", "GithubCopilotEmbeddingConfig"), "NebiusConfig": (".llms.nebius.chat.transformation", "NebiusConfig"), "WandbConfig": (".llms.wandb.chat.transformation", "WandbConfig"), - "GigaChatConfig": (".llms.gigachat.chat.transformation", "GigaChatConfig"), - "GigaChatEmbeddingConfig": (".llms.gigachat.embedding.transformation", "GigaChatEmbeddingConfig"), "DashScopeChatConfig": (".llms.dashscope.chat.transformation", "DashScopeChatConfig"), "MoonshotChatConfig": (".llms.moonshot.chat.transformation", "MoonshotChatConfig"), "DockerModelRunnerChatConfig": (".llms.docker_model_runner.chat.transformation", "DockerModelRunnerChatConfig"), diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index a89efc4e82b..5b206317b29 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -3,7 +3,6 @@ Handler for transforming /chat/completions api requests to litellm.responses req """ import json -import os from typing import ( TYPE_CHECKING, Any, @@ -23,7 +22,6 @@ from typing import ( from openai.types.responses.tool_param import FunctionToolParam from pydantic import BaseModel -import litellm from litellm import ModelResponse from litellm._logging import verbose_logger from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator @@ -693,26 +691,19 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): if isinstance(reasoning_effort, dict): return Reasoning(**reasoning_effort) # type: ignore[typeddict-item] - # Check if auto-summary is enabled via flag or environment variable - # Priority: litellm.reasoning_auto_summary flag > LITELLM_REASONING_AUTO_SUMMARY env var - auto_summary_enabled = ( - litellm.reasoning_auto_summary - or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true" - ) - - # If string is passed, map with optional summary based on flag/env var + # If string is passed, map with summary="detailed" if reasoning_effort == "none": - return Reasoning(effort="none", summary="detailed") if auto_summary_enabled else Reasoning(effort="none") # type: ignore + return Reasoning(effort="none", summary="detailed") # type: ignore elif reasoning_effort == "high": - return Reasoning(effort="high", summary="detailed") if auto_summary_enabled else Reasoning(effort="high") + return Reasoning(effort="high", summary="detailed") elif reasoning_effort == "xhigh": - return Reasoning(effort="xhigh", summary="detailed") if auto_summary_enabled else Reasoning(effort="xhigh") # type: ignore[typeddict-item] + return Reasoning(effort="xhigh", summary="detailed") # type: ignore[typeddict-item] elif reasoning_effort == "medium": - return Reasoning(effort="medium", summary="detailed") if auto_summary_enabled else Reasoning(effort="medium") + return Reasoning(effort="medium", summary="detailed") elif reasoning_effort == "low": - return Reasoning(effort="low", summary="detailed") if auto_summary_enabled else Reasoning(effort="low") + return Reasoning(effort="low", summary="detailed") elif reasoning_effort == "minimal": - return Reasoning(effort="minimal", summary="detailed") if auto_summary_enabled else Reasoning(effort="minimal") + return Reasoning(effort="minimal", summary="detailed") return None def _transform_response_format_to_text_format( diff --git a/litellm/constants.py b/litellm/constants.py index 1cd2da549ca..e8524a87c41 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -375,7 +375,6 @@ LITELLM_CHAT_PROVIDERS = [ "perplexity", "mistral", "groq", - "gigachat", "nvidia_nim", "cerebras", "baseten", diff --git a/litellm/integrations/callback_configs.json b/litellm/integrations/callback_configs.json index 6b30b6b736e..88f7908e9a2 100644 --- a/litellm/integrations/callback_configs.json +++ b/litellm/integrations/callback_configs.json @@ -187,12 +187,6 @@ "ui_name": "Sampling Rate", "description": "Sampling rate for logging (0.0 to 1.0, default: 1.0)", "required": false - }, - "langsmith_tenant_id": { - "type": "text", - "ui_name": "Tenant ID", - "description": "LangSmith tenant ID for organization-scoped API keys (required when using org-scoped keys)", - "required": false } }, "description": "Langsmith Logging Integration" diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index 7e62613a7e4..f0f355b4895 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -50,42 +50,6 @@ else: Langfuse = Any -def _extract_cache_read_input_tokens(usage_obj) -> int: - """ - Extract cache_read_input_tokens from usage object. - - Checks both: - 1. Top-level cache_read_input_tokens (Anthropic format) - 2. prompt_tokens_details.cached_tokens (Gemini, OpenAI format) - - See: https://github.com/BerriAI/litellm/issues/18520 - - Args: - usage_obj: Usage object from LLM response - - Returns: - int: Number of cached tokens read, defaults to 0 - """ - cache_read_input_tokens = usage_obj.get("cache_read_input_tokens") or 0 - - # Check prompt_tokens_details.cached_tokens (used by Gemini and other providers) - if hasattr(usage_obj, "prompt_tokens_details"): - prompt_tokens_details = getattr(usage_obj, "prompt_tokens_details", None) - if ( - prompt_tokens_details is not None - and hasattr(prompt_tokens_details, "cached_tokens") - ): - cached_tokens = getattr(prompt_tokens_details, "cached_tokens", None) - if ( - cached_tokens is not None - and isinstance(cached_tokens, (int, float)) - and cached_tokens > 0 - ): - cache_read_input_tokens = cached_tokens - - return cache_read_input_tokens - - class LangFuseLogger: # Class variables or attributes def __init__( @@ -793,8 +757,8 @@ class LangFuseLogger: cache_creation_input_tokens = ( _usage_obj.get("cache_creation_input_tokens") or 0 ) - cache_read_input_tokens = _extract_cache_read_input_tokens( - _usage_obj + cache_read_input_tokens = ( + _usage_obj.get("cache_read_input_tokens") or 0 ) usage = { diff --git a/litellm/integrations/langsmith.py b/litellm/integrations/langsmith.py index 570b78f2927..cc9b361b69d 100644 --- a/litellm/integrations/langsmith.py +++ b/litellm/integrations/langsmith.py @@ -40,7 +40,6 @@ class LangsmithLogger(CustomBatchLogger): langsmith_project: Optional[str] = None, langsmith_base_url: Optional[str] = None, langsmith_sampling_rate: Optional[float] = None, - langsmith_tenant_id: Optional[str] = None, **kwargs, ): self.flush_lock = asyncio.Lock() @@ -49,7 +48,6 @@ class LangsmithLogger(CustomBatchLogger): langsmith_api_key=langsmith_api_key, langsmith_project=langsmith_project, langsmith_base_url=langsmith_base_url, - langsmith_tenant_id=langsmith_tenant_id, ) self.sampling_rate: float = ( langsmith_sampling_rate @@ -78,7 +76,6 @@ class LangsmithLogger(CustomBatchLogger): langsmith_api_key: Optional[str] = None, langsmith_project: Optional[str] = None, langsmith_base_url: Optional[str] = None, - langsmith_tenant_id: Optional[str] = None, ) -> LangsmithCredentialsObject: _credentials_api_key = langsmith_api_key or os.getenv("LANGSMITH_API_KEY") _credentials_project = ( @@ -89,13 +86,11 @@ class LangsmithLogger(CustomBatchLogger): or os.getenv("LANGSMITH_BASE_URL") or "https://api.smith.langchain.com" ) - _credentials_tenant_id = langsmith_tenant_id or os.getenv("LANGSMITH_TENANT_ID") return LangsmithCredentialsObject( LANGSMITH_API_KEY=_credentials_api_key, LANGSMITH_BASE_URL=_credentials_base_url, LANGSMITH_PROJECT=_credentials_project, - LANGSMITH_TENANT_ID=_credentials_tenant_id, ) def _prepare_log_data( @@ -370,11 +365,8 @@ class LangsmithLogger(CustomBatchLogger): """ langsmith_api_base = credentials["LANGSMITH_BASE_URL"] langsmith_api_key = credentials["LANGSMITH_API_KEY"] - langsmith_tenant_id = credentials.get("LANGSMITH_TENANT_ID") url = self._add_endpoint_to_url(langsmith_api_base, "runs/batch") headers = {"x-api-key": langsmith_api_key} - if langsmith_tenant_id: - headers["x-tenant-id"] = langsmith_tenant_id elements_to_log = [queue_object["data"] for queue_object in queue_objects] try: @@ -426,7 +418,6 @@ class LangsmithLogger(CustomBatchLogger): api_key=credentials["LANGSMITH_API_KEY"], project=credentials["LANGSMITH_PROJECT"], base_url=credentials["LANGSMITH_BASE_URL"], - tenant_id=credentials.get("LANGSMITH_TENANT_ID"), ) if key not in log_queue_by_credentials: @@ -475,9 +466,6 @@ class LangsmithLogger(CustomBatchLogger): langsmith_base_url=standard_callback_dynamic_params.get( "langsmith_base_url", None ), - langsmith_tenant_id=standard_callback_dynamic_params.get( - "langsmith_tenant_id", None - ), ) else: credentials = self.default_credentials @@ -503,16 +491,13 @@ class LangsmithLogger(CustomBatchLogger): def get_run_by_id(self, run_id): langsmith_api_key = self.default_credentials["LANGSMITH_API_KEY"] + langsmith_api_base = self.default_credentials["LANGSMITH_BASE_URL"] - langsmith_tenant_id = self.default_credentials.get("LANGSMITH_TENANT_ID") url = f"{langsmith_api_base}/runs/{run_id}" - headers = {"x-api-key": langsmith_api_key} - if langsmith_tenant_id: - headers["x-tenant-id"] = langsmith_tenant_id response = litellm.module_level_client.get( url=url, - headers=headers, + headers={"x-api-key": langsmith_api_key}, ) return response.json() diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index a7d2326d938..12e60bc25bb 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -196,88 +196,50 @@ class OpenTelemetry(CustomLogger): litellm.service_callback.append(self) setattr(proxy_server, "open_telemetry_logger", self) - def _get_or_create_provider( - self, - provider, - provider_name: str, - get_existing_provider_fn, - sdk_provider_class, - create_new_provider_fn, - set_provider_fn, - ): - """ - Generic helper to get or create an OpenTelemetry provider (Tracer, Meter, or Logger). - - Args: - provider: The provider instance passed to the init function (can be None) - provider_name: Name for logging (e.g., "TracerProvider") - get_existing_provider_fn: Function to get the existing global provider - sdk_provider_class: The SDK provider class to check for (e.g., TracerProvider from SDK) - create_new_provider_fn: Function to create a new provider instance - set_provider_fn: Function to set the provider globally - - Returns: - The provider to use (either existing, new, or explicitly provided) - """ - if provider is not None: - # Provider explicitly provided (e.g., for testing) - # Do NOT call set_provider_fn - the caller is responsible for managing global state - # If they want it to be global, they've already set it before passing it to us - verbose_logger.debug( - "OpenTelemetry: Using provided TracerProvider: %s", - type(provider).__name__, - ) - return provider - - # Check if a provider is already set globally - try: - existing_provider = get_existing_provider_fn() - - # If a real SDK provider exists (set by another SDK like Langfuse), use it - # This uses a positive check for SDK providers instead of a negative check for proxy providers - if isinstance(existing_provider, sdk_provider_class): - verbose_logger.debug( - "OpenTelemetry: Using existing %s: %s", - provider_name, - type(existing_provider).__name__, - ) - provider = existing_provider - # Don't call set_provider to preserve existing context - else: - # Default proxy provider or unknown type, create our own - verbose_logger.debug("OpenTelemetry: Creating new %s", provider_name) - provider = create_new_provider_fn() - set_provider_fn(provider) - except Exception as e: - # Fallback: create a new provider if something goes wrong - verbose_logger.debug( - "OpenTelemetry: Exception checking existing %s, creating new one: %s", - provider_name, - str(e), - ) - provider = create_new_provider_fn() - set_provider_fn(provider) - - return provider - def _init_tracing(self, tracer_provider): from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.trace import SpanKind - def create_tracer_provider(): - provider = TracerProvider(resource=_get_litellm_resource()) - provider.add_span_processor(self._get_span_processor()) - return provider + # use provided tracer or create a new one + if tracer_provider is None: + # Check if a TracerProvider is already set globally (e.g., by Langfuse SDK) + try: + from opentelemetry.trace import ProxyTracerProvider - tracer_provider = self._get_or_create_provider( - provider=tracer_provider, - provider_name="TracerProvider", - get_existing_provider_fn=trace.get_tracer_provider, - sdk_provider_class=TracerProvider, - create_new_provider_fn=create_tracer_provider, - set_provider_fn=trace.set_tracer_provider, - ) + existing_provider = trace.get_tracer_provider() + + # If an actual provider exists (not the default proxy), use it + if not isinstance(existing_provider, ProxyTracerProvider): + verbose_logger.debug( + "OpenTelemetry: Using existing TracerProvider: %s", + type(existing_provider).__name__, + ) + tracer_provider = existing_provider + # Don't call set_tracer_provider to preserve existing context + else: + # No real provider exists yet, create our own + verbose_logger.debug("OpenTelemetry: Creating new TracerProvider") + tracer_provider = TracerProvider(resource=_get_litellm_resource()) + tracer_provider.add_span_processor(self._get_span_processor()) + trace.set_tracer_provider(tracer_provider) + except Exception as e: + # Fallback: create a new provider if something goes wrong + verbose_logger.debug( + "OpenTelemetry: Exception checking existing provider, creating new one: %s", + str(e), + ) + tracer_provider = TracerProvider(resource=_get_litellm_resource()) + tracer_provider.add_span_processor(self._get_span_processor()) + trace.set_tracer_provider(tracer_provider) + else: + # Tracer provider explicitly provided (e.g., for testing) + # Do NOT call set_tracer_provider - the caller is responsible for managing global state + # If they want it to be global, they've already set it before passing it to us + verbose_logger.debug( + "OpenTelemetry: Using provided TracerProvider: %s", + type(tracer_provider).__name__, + ) # Grab our tracer from the TracerProvider (not from global context) # This ensures we use the provided TracerProvider (e.g., for testing) @@ -295,24 +257,39 @@ class OpenTelemetry(CustomLogger): return from opentelemetry import metrics - from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics import Histogram, MeterProvider - def create_meter_provider(): - metric_reader = self._get_metric_reader() - return MeterProvider( - metric_readers=[metric_reader], resource=_get_litellm_resource() + # Only create OTLP infrastructure if no custom meter provider is provided + if meter_provider is None: + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( + OTLPMetricExporter, + ) + from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + PeriodicExportingMetricReader, ) - meter_provider = self._get_or_create_provider( - provider=meter_provider, - provider_name="MeterProvider", - get_existing_provider_fn=metrics.get_meter_provider, - sdk_provider_class=MeterProvider, - create_new_provider_fn=create_meter_provider, - set_provider_fn=metrics.set_meter_provider, - ) + normalized_endpoint = self._normalize_otel_endpoint( + self.config.endpoint, "metrics" + ) + _metric_exporter = OTLPMetricExporter( + endpoint=normalized_endpoint, + headers=OpenTelemetry._get_headers_dictionary(self.config.headers), + preferred_temporality={Histogram: AggregationTemporality.DELTA}, + ) + _metric_reader = PeriodicExportingMetricReader( + _metric_exporter, export_interval_millis=10000 + ) - meter = meter_provider.get_meter(__name__) + meter_provider = MeterProvider( + metric_readers=[_metric_reader], resource=_get_litellm_resource() + ) + meter = meter_provider.get_meter(__name__) + else: + # Use the provided meter provider as-is, without creating additional OTLP infrastructure + meter = meter_provider.get_meter(__name__) + + metrics.set_meter_provider(meter_provider) self._operation_duration_histogram = meter.create_histogram( name="gen_ai.client.operation.duration", # Replace with semconv constant in otel 1.38 @@ -350,26 +327,22 @@ class OpenTelemetry(CustomLogger): if not self.config.enable_events: return - from opentelemetry._logs import get_logger_provider, set_logger_provider + from opentelemetry._logs import set_logger_provider from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider from opentelemetry.sdk._logs.export import BatchLogRecordProcessor - def create_logger_provider(): - provider = OTLoggerProvider(resource=_get_litellm_resource()) + # set up log pipeline + if logger_provider is None: + litellm_resource = _get_litellm_resource() + logger_provider = OTLoggerProvider(resource=litellm_resource) + # Only add OTLP exporter if we created the logger provider ourselves log_exporter = self._get_log_exporter() - provider.add_log_record_processor( - BatchLogRecordProcessor(log_exporter) # type: ignore[arg-type] - ) - return provider + if log_exporter: + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(log_exporter) # type: ignore[arg-type] + ) - self._get_or_create_provider( - provider=logger_provider, - provider_name="LoggerProvider", - get_existing_provider_fn=get_logger_provider, - sdk_provider_class=OTLoggerProvider, - create_new_provider_fn=create_logger_provider, - set_provider_fn=set_logger_provider, - ) + set_logger_provider(logger_provider) def log_success_event(self, kwargs, response_obj, start_time, end_time): self._handle_success(kwargs, response_obj, start_time, end_time) @@ -971,15 +944,6 @@ class OpenTelemetry(CustomLogger): if not self.config.enable_events: return - # NOTE: Semantic logs (gen_ai.content.prompt/completion events) have compatibility issues - # with OTEL SDK >= 1.39.0 due to breaking changes in PR #4676: - # - LogRecord moved from opentelemetry.sdk._logs to opentelemetry.sdk._logs._internal - # - LogRecord constructor no longer accepts 'resource' parameter (now inherited from LoggerProvider) - # - LogData class was removed entirely - # These logs work correctly in OTEL SDK < 1.39.0 but may fail in >= 1.39.0. - # See: https://github.com/open-telemetry/opentelemetry-python/pull/4676 - # TODO: Refactor to use the proper OTEL Logs API instead of directly creating SDK LogRecords - from opentelemetry._logs import SeverityNumber, get_logger, get_logger_provider from opentelemetry.sdk._logs import LogRecord as SdkLogRecord @@ -1843,8 +1807,7 @@ class OpenTelemetry(CustomLogger): ) return self.OTEL_EXPORTER - otel_logs_exporter = os.getenv("OTEL_LOGS_EXPORTER") - if self.OTEL_EXPORTER == "console" or otel_logs_exporter == "console": + if self.OTEL_EXPORTER == "console": from opentelemetry.sdk._logs.export import ConsoleLogExporter verbose_logger.debug( @@ -1891,67 +1854,6 @@ class OpenTelemetry(CustomLogger): return ConsoleLogExporter() - def _get_metric_reader(self): - """ - Get the appropriate metric reader based on the configuration. - """ - from opentelemetry.sdk.metrics import Histogram - from opentelemetry.sdk.metrics.export import ( - AggregationTemporality, - ConsoleMetricExporter, - PeriodicExportingMetricReader, - ) - - verbose_logger.debug( - "OpenTelemetry Logger, initializing metric reader\nself.OTEL_EXPORTER: %s\nself.OTEL_ENDPOINT: %s\nself.OTEL_HEADERS: %s", - self.OTEL_EXPORTER, - self.OTEL_ENDPOINT, - self.OTEL_HEADERS, - ) - - _split_otel_headers = OpenTelemetry._get_headers_dictionary(self.OTEL_HEADERS) - normalized_endpoint = self._normalize_otel_endpoint(self.OTEL_ENDPOINT, "metrics") - - if self.OTEL_EXPORTER == "console": - exporter = ConsoleMetricExporter() - return PeriodicExportingMetricReader(exporter, export_interval_millis=5000) - - elif ( - self.OTEL_EXPORTER == "otlp_http" - or self.OTEL_EXPORTER == "http/protobuf" - or self.OTEL_EXPORTER == "http/json" - ): - from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( - OTLPMetricExporter, - ) - - exporter = OTLPMetricExporter( - endpoint=normalized_endpoint, - headers=_split_otel_headers, - preferred_temporality={Histogram: AggregationTemporality.DELTA}, - ) - return PeriodicExportingMetricReader(exporter, export_interval_millis=5000) - - elif self.OTEL_EXPORTER == "otlp_grpc" or self.OTEL_EXPORTER == "grpc": - from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( - OTLPMetricExporter, - ) - - exporter = OTLPMetricExporter( - endpoint=normalized_endpoint, - headers=_split_otel_headers, - preferred_temporality={Histogram: AggregationTemporality.DELTA}, - ) - return PeriodicExportingMetricReader(exporter, export_interval_millis=5000) - - else: - verbose_logger.warning( - "OpenTelemetry: Unknown metric exporter '%s', defaulting to console. Supported: console, otlp_http, otlp_grpc", - self.OTEL_EXPORTER, - ) - exporter = ConsoleMetricExporter() - return PeriodicExportingMetricReader(exporter, export_interval_millis=5000) - def _normalize_otel_endpoint( self, endpoint: Optional[str], signal_type: str ) -> Optional[str]: diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 6baaae7ae3f..d92af417175 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -2000,56 +2000,24 @@ class CustomStreamWrapper: ) ## Map to OpenAI Exception try: - mapped_exception = exception_type( + raise exception_type( model=self.model, custom_llm_provider=self.custom_llm_provider, original_exception=e, completion_kwargs={}, extra_kwargs={}, ) - except Exception as mapping_error: - mapped_exception = mapping_error + except Exception as e: + from litellm.exceptions import MidStreamFallbackError - def _normalize_status_code(exc: Exception) -> Optional[int]: - """ - Best-effort status_code extraction. - Uses status_code on the exception, then falls back to the response. - """ - try: - code = getattr(exc, "status_code", None) - if code is not None: - return int(code) - except Exception: - pass - - response = getattr(exc, "response", None) - if response is not None: - try: - status_code = getattr(response, "status_code", None) - if status_code is not None: - return int(status_code) - except Exception: - pass - return None - - mapped_status_code = _normalize_status_code(mapped_exception) - original_status_code = _normalize_status_code(e) - - if mapped_status_code is not None and 400 <= mapped_status_code < 500: - raise mapped_exception - if original_status_code is not None and 400 <= original_status_code < 500: - raise mapped_exception - - from litellm.exceptions import MidStreamFallbackError - - raise MidStreamFallbackError( - message=str(mapped_exception), - model=self.model, - llm_provider=self.custom_llm_provider or "anthropic", - original_exception=mapped_exception, - generated_content=self.response_uptil_now, - is_pre_first_chunk=not self.sent_first_chunk, - ) + raise MidStreamFallbackError( + message=str(e), + model=self.model, + llm_provider=self.custom_llm_provider or "anthropic", + original_exception=e, + generated_content=self.response_uptil_now, + is_pre_first_chunk=not self.sent_first_chunk, + ) @staticmethod def _strip_sse_data_from_chunk(chunk: Optional[str]) -> Optional[str]: diff --git a/litellm/llms/base_llm/responses/transformation.py b/litellm/llms/base_llm/responses/transformation.py index 7a4da985528..facabbda72a 100644 --- a/litellm/llms/base_llm/responses/transformation.py +++ b/litellm/llms/base_llm/responses/transformation.py @@ -242,30 +242,3 @@ class BaseResponsesAPIConfig(ABC): ######################################################### ########## END CANCEL RESPONSE API TRANSFORMATION ####### ######################################################### - - ######################################################### - ########## COMPACT RESPONSE API TRANSFORMATION ########## - ######################################################### - @abstractmethod - def transform_compact_response_api_request( - self, - model: str, - input: Union[str, ResponseInputParam], - response_api_optional_request_params: Dict, - api_base: str, - litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> Tuple[str, Dict]: - pass - - @abstractmethod - def transform_compact_response_api_response( - self, - raw_response: httpx.Response, - logging_obj: LiteLLMLoggingObj, - ) -> ResponsesAPIResponse: - pass - - ######################################################### - ########## END COMPACT RESPONSE API TRANSFORMATION ###### - ######################################################### diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index ea740400664..34ea598a655 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -91,7 +91,6 @@ from litellm.types.rerank import RerankResponse from litellm.types.responses.main import DeleteResponseResult from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import ( - CallTypes, EmbeddingResponse, FileTypes, LiteLLMBatch, @@ -851,9 +850,7 @@ class BaseLLMHTTPHandler: ) if client is None or not isinstance(client, HTTPHandler): - sync_httpx_client = _get_httpx_client( - params={"ssl_verify": litellm_params.get("ssl_verify", None)} - ) + sync_httpx_client = _get_httpx_client() else: sync_httpx_client = client @@ -899,8 +896,7 @@ class BaseLLMHTTPHandler: ) -> EmbeddingResponse: if client is None or not isinstance(client, AsyncHTTPHandler): async_httpx_client = get_async_httpx_client( - llm_provider=litellm.LlmProviders(custom_llm_provider), - params={"ssl_verify": litellm_params.get("ssl_verify", None)}, + llm_provider=litellm.LlmProviders(custom_llm_provider) ) else: async_httpx_client = client @@ -2008,10 +2004,6 @@ class BaseLLMHTTPHandler: """ Handles responses API requests. When _is_async=True, returns a coroutine instead of making the call directly. - - Keeps the pre-transform request context for streaming so post-call hooks/metadata - (added for Responses API parity with chat) receive the original params instead of - the provider-shaped body that caused them to be skipped before. """ if _is_async: @@ -2068,18 +2060,6 @@ class BaseLLMHTTPHandler: if extra_body: data.update(extra_body) - # Preserve the OpenAI-style request context (not sent to the provider) for streaming - # hooks/metadata; the streaming iterator now consumes this to run deployment hooks - # with the same info as chat, including litellm_params. - request_context: Dict[str, Any] = {"input": input} - try: - request_context.update(response_api_optional_request_params) - except Exception: - pass - # Needed by streaming callbacks/metadata helpers to reconstruct api_base/model_id - # but never included in the outbound provider payload. - request_context["litellm_params"] = dict(litellm_params) - ## LOGGING logging_obj.pre_call( input=input, @@ -2117,8 +2097,6 @@ class BaseLLMHTTPHandler: responses_api_provider_config=responses_api_provider_config, litellm_metadata=litellm_metadata, custom_llm_provider=custom_llm_provider, - request_data=request_context, - call_type=CallTypes.responses.value, ) return SyncResponsesAPIStreamingIterator( @@ -2128,8 +2106,6 @@ class BaseLLMHTTPHandler: responses_api_provider_config=responses_api_provider_config, litellm_metadata=litellm_metadata, custom_llm_provider=custom_llm_provider, - request_data=request_context, - call_type=CallTypes.responses.value, ) else: # For non-streaming requests @@ -2213,18 +2189,6 @@ class BaseLLMHTTPHandler: if extra_body: data.update(extra_body) - # Preserve the OpenAI-style request context (not sent to the provider) for streaming - # hooks/metadata; the streaming iterator now consumes this to run deployment hooks - # with the same info as chat, including litellm_params. - request_context: Dict[str, Any] = {"input": input} - try: - request_context.update(response_api_optional_request_params) - except Exception: - pass - # Needed by streaming callbacks/metadata helpers to reconstruct api_base/model_id - # but never included in the outbound provider payload. - request_context["litellm_params"] = dict(litellm_params) - ## LOGGING logging_obj.pre_call( input=input, @@ -2263,8 +2227,6 @@ class BaseLLMHTTPHandler: responses_api_provider_config=responses_api_provider_config, litellm_metadata=litellm_metadata, custom_llm_provider=custom_llm_provider, - request_data=request_context, - call_type=CallTypes.responses.value, ) # Return the streaming iterator @@ -2275,8 +2237,6 @@ class BaseLLMHTTPHandler: responses_api_provider_config=responses_api_provider_config, litellm_metadata=litellm_metadata, custom_llm_provider=custom_llm_provider, - request_data=request_context, - call_type=CallTypes.responses.value, ) else: # For non-streaming, proceed as before @@ -3566,174 +3526,6 @@ class BaseLLMHTTPHandler: logging_obj=logging_obj, ) - def compact_response_api_handler( - self, - model: str, - input: Union[str, "ResponseInputParam"], - responses_api_provider_config: BaseResponsesAPIConfig, - response_api_optional_request_params: Dict, - litellm_params: GenericLiteLLMParams, - logging_obj: LiteLLMLoggingObj, - custom_llm_provider: Optional[str], - extra_headers: Optional[Dict[str, Any]] = None, - extra_body: Optional[Dict[str, Any]] = None, - timeout: Optional[Union[float, httpx.Timeout]] = None, - client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None, - _is_async: bool = False, - shared_session: Optional["ClientSession"] = None, - ) -> Union[ResponsesAPIResponse, Coroutine[Any, Any, ResponsesAPIResponse]]: - """ - Handler for the compact responses API. - """ - if _is_async: - return self.async_compact_response_api_handler( - model=model, - input=input, - responses_api_provider_config=responses_api_provider_config, - response_api_optional_request_params=response_api_optional_request_params, - litellm_params=litellm_params, - logging_obj=logging_obj, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - extra_body=extra_body, - timeout=timeout, - client=client, - shared_session=shared_session, - ) - if client is None or not isinstance(client, HTTPHandler): - sync_httpx_client = _get_httpx_client( - params={"ssl_verify": litellm_params.get("ssl_verify", None)} - ) - else: - sync_httpx_client = client - - headers = responses_api_provider_config.validate_environment( - headers=extra_headers or {}, model=model, litellm_params=litellm_params - ) - - if extra_headers: - headers.update(extra_headers) - - api_base = responses_api_provider_config.get_complete_url( - api_base=litellm_params.api_base, - litellm_params=dict(litellm_params), - ) - - url, data = responses_api_provider_config.transform_compact_response_api_request( - model=model, - input=input, - response_api_optional_request_params=response_api_optional_request_params, - api_base=api_base, - litellm_params=litellm_params, - headers=headers, - ) - - ## LOGGING - logging_obj.pre_call( - input=input, - api_key="", - additional_args={ - "complete_input_dict": data, - "api_base": url, - "headers": headers, - }, - ) - - try: - response = sync_httpx_client.post( - url=url, headers=headers, json=data, timeout=timeout - ) - - except Exception as e: - raise self._handle_error( - e=e, - provider_config=responses_api_provider_config, - ) - - return responses_api_provider_config.transform_compact_response_api_response( - raw_response=response, - logging_obj=logging_obj, - ) - - async def async_compact_response_api_handler( - self, - model: str, - input: Union[str, "ResponseInputParam"], - responses_api_provider_config: BaseResponsesAPIConfig, - response_api_optional_request_params: Dict, - litellm_params: GenericLiteLLMParams, - logging_obj: LiteLLMLoggingObj, - custom_llm_provider: Optional[str], - extra_headers: Optional[Dict[str, Any]] = None, - extra_body: Optional[Dict[str, Any]] = None, - timeout: Optional[Union[float, httpx.Timeout]] = None, - client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None, - _is_async: bool = False, - shared_session: Optional["ClientSession"] = None, - ) -> ResponsesAPIResponse: - """ - Async version of the compact response API handler. - """ - if client is None or not isinstance(client, AsyncHTTPHandler): - verbose_logger.debug( - f"Creating HTTP client for compact_response with shared_session: {id(shared_session) if shared_session else None}" - ) - async_httpx_client = get_async_httpx_client( - llm_provider=litellm.LlmProviders(custom_llm_provider), - params={"ssl_verify": litellm_params.get("ssl_verify", None)}, - shared_session=shared_session, - ) - else: - async_httpx_client = client - - headers = responses_api_provider_config.validate_environment( - headers=extra_headers or {}, model=model, litellm_params=litellm_params - ) - - if extra_headers: - headers.update(extra_headers) - - api_base = responses_api_provider_config.get_complete_url( - api_base=litellm_params.api_base, - litellm_params=dict(litellm_params), - ) - - url, data = responses_api_provider_config.transform_compact_response_api_request( - model=model, - input=input, - response_api_optional_request_params=response_api_optional_request_params, - api_base=api_base, - litellm_params=litellm_params, - headers=headers, - ) - - ## LOGGING - logging_obj.pre_call( - input=input, - api_key="", - additional_args={ - "complete_input_dict": data, - "api_base": url, - "headers": headers, - }, - ) - - try: - response = await async_httpx_client.post( - url=url, headers=headers, json=data, timeout=timeout - ) - - except Exception as e: - raise self._handle_error( - e=e, - provider_config=responses_api_provider_config, - ) - - return responses_api_provider_config.transform_compact_response_api_response( - raw_response=response, - logging_obj=logging_obj, - ) - def list_files(self): """ Lists all files @@ -8496,4 +8288,4 @@ class BaseLLMHTTPHandler: return skills_api_provider_config.transform_delete_skill_response( raw_response=response, logging_obj=logging_obj, - ) + ) \ No newline at end of file diff --git a/litellm/llms/gigachat/__init__.py b/litellm/llms/gigachat/__init__.py deleted file mode 100644 index 3ddbd7864d9..00000000000 --- a/litellm/llms/gigachat/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -GigaChat Provider for LiteLLM - -GigaChat is Sber AI's large language model (Russia's leading LLM). -Supports: -- Chat completions (sync/async) -- Streaming (sync/async) -- Function calling / Tools -- Structured output via JSON schema (emulated through function calls) -- Image input (base64 and URL) -- Embeddings - -API Documentation: https://developers.sber.ru/docs/ru/gigachat/api/overview -""" - -from .chat.transformation import GigaChatConfig, GigaChatError -from .embedding.transformation import GigaChatEmbeddingConfig - -__all__ = [ - "GigaChatConfig", - "GigaChatEmbeddingConfig", - "GigaChatError", -] diff --git a/litellm/llms/gigachat/authenticator.py b/litellm/llms/gigachat/authenticator.py deleted file mode 100644 index e61015a4a21..00000000000 --- a/litellm/llms/gigachat/authenticator.py +++ /dev/null @@ -1,241 +0,0 @@ -""" -GigaChat OAuth Authenticator - -Handles OAuth 2.0 token management for GigaChat API. -Based on official GigaChat SDK authentication flow. -""" - -import time -import uuid -from typing import Optional, Tuple - -import httpx - -from litellm._logging import verbose_logger -from litellm.caching.caching import InMemoryCache -from litellm.llms.base_llm.chat.transformation import BaseLLMException -from litellm.llms.custom_httpx.http_handler import ( - HTTPHandler, - _get_httpx_client, - get_async_httpx_client, -) -from litellm.secret_managers.main import get_secret_str -from litellm.types.utils import LlmProviders - -# GigaChat OAuth endpoint -GIGACHAT_AUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth" - -# Default scope for personal API access -GIGACHAT_SCOPE = "GIGACHAT_API_PERS" - -# Token expiry buffer in milliseconds (refresh token 60s before expiry) -TOKEN_EXPIRY_BUFFER_MS = 60000 - -# Cache for access tokens -_token_cache = InMemoryCache() - - -class GigaChatAuthError(BaseLLMException): - """GigaChat authentication error.""" - - pass - - -def _get_credentials() -> Optional[str]: - """Get GigaChat credentials from environment.""" - return get_secret_str("GIGACHAT_CREDENTIALS") or get_secret_str("GIGACHAT_API_KEY") - - -def _get_auth_url() -> str: - """Get GigaChat auth URL from environment or use default.""" - return get_secret_str("GIGACHAT_AUTH_URL") or GIGACHAT_AUTH_URL - - -def _get_scope() -> str: - """Get GigaChat scope from environment or use default.""" - return get_secret_str("GIGACHAT_SCOPE") or GIGACHAT_SCOPE - - -def _get_http_client() -> HTTPHandler: - """Get cached httpx client with SSL verification disabled.""" - return _get_httpx_client(params={"ssl_verify": False}) - - -def get_access_token( - credentials: Optional[str] = None, - scope: Optional[str] = None, - auth_url: Optional[str] = None, -) -> str: - """ - Get valid access token, using cache if available. - - Args: - credentials: Base64-encoded credentials (client_id:client_secret) - scope: API scope (GIGACHAT_API_PERS, GIGACHAT_API_CORP, etc.) - auth_url: OAuth endpoint URL - - Returns: - Access token string - - Raises: - GigaChatAuthError: If authentication fails - """ - credentials = credentials or _get_credentials() - if not credentials: - raise GigaChatAuthError( - status_code=401, - message="GigaChat credentials not provided. Set GIGACHAT_CREDENTIALS or GIGACHAT_API_KEY environment variable.", - ) - - scope = scope or _get_scope() - auth_url = auth_url or _get_auth_url() - - # Check cache - cache_key = f"gigachat_token:{credentials[:16]}" - cached = _token_cache.get_cache(cache_key) - if cached: - token, expires_at = cached - # Check if token is still valid (with buffer) - if time.time() * 1000 < expires_at - TOKEN_EXPIRY_BUFFER_MS: - verbose_logger.debug("Using cached GigaChat access token") - return token - - # Request new token - token, expires_at = _request_token_sync(credentials, scope, auth_url) - - # Cache token - ttl_seconds = max(0, (expires_at - TOKEN_EXPIRY_BUFFER_MS - time.time() * 1000) / 1000) - if ttl_seconds > 0: - _token_cache.set_cache(cache_key, (token, expires_at), ttl=ttl_seconds) - - return token - - -async def get_access_token_async( - credentials: Optional[str] = None, - scope: Optional[str] = None, - auth_url: Optional[str] = None, -) -> str: - """Async version of get_access_token.""" - credentials = credentials or _get_credentials() - if not credentials: - raise GigaChatAuthError( - status_code=401, - message="GigaChat credentials not provided. Set GIGACHAT_CREDENTIALS or GIGACHAT_API_KEY environment variable.", - ) - - scope = scope or _get_scope() - auth_url = auth_url or _get_auth_url() - - # Check cache - cache_key = f"gigachat_token:{credentials[:16]}" - cached = _token_cache.get_cache(cache_key) - if cached: - token, expires_at = cached - if time.time() * 1000 < expires_at - TOKEN_EXPIRY_BUFFER_MS: - verbose_logger.debug("Using cached GigaChat access token") - return token - - # Request new token - token, expires_at = await _request_token_async(credentials, scope, auth_url) - - # Cache token - ttl_seconds = max(0, (expires_at - TOKEN_EXPIRY_BUFFER_MS - time.time() * 1000) / 1000) - if ttl_seconds > 0: - _token_cache.set_cache(cache_key, (token, expires_at), ttl=ttl_seconds) - - return token - - -def _request_token_sync( - credentials: str, - scope: str, - auth_url: str, -) -> Tuple[str, int]: - """ - Request new access token from GigaChat OAuth endpoint (sync). - - Returns: - Tuple of (access_token, expires_at_ms) - """ - headers = { - "Authorization": f"Basic {credentials}", - "RqUID": str(uuid.uuid4()), - "Content-Type": "application/x-www-form-urlencoded", - } - data = {"scope": scope} - - verbose_logger.debug(f"Requesting GigaChat access token from {auth_url}") - - try: - client = _get_http_client() - response = client.post(auth_url, headers=headers, data=data, timeout=30) - response.raise_for_status() - return _parse_token_response(response) - except httpx.HTTPStatusError as e: - raise GigaChatAuthError( - status_code=e.response.status_code, - message=f"GigaChat authentication failed: {e.response.text}", - ) - except httpx.RequestError as e: - raise GigaChatAuthError( - status_code=500, - message=f"GigaChat authentication request failed: {str(e)}", - ) - - -async def _request_token_async( - credentials: str, - scope: str, - auth_url: str, -) -> Tuple[str, int]: - """Async version of _request_token_sync.""" - headers = { - "Authorization": f"Basic {credentials}", - "RqUID": str(uuid.uuid4()), - "Content-Type": "application/x-www-form-urlencoded", - } - data = {"scope": scope} - - verbose_logger.debug(f"Requesting GigaChat access token from {auth_url}") - - try: - client = get_async_httpx_client( - llm_provider=LlmProviders.GIGACHAT, - params={"ssl_verify": False}, - ) - response = await client.post(auth_url, headers=headers, data=data, timeout=30) - response.raise_for_status() - return _parse_token_response(response) - except httpx.HTTPStatusError as e: - raise GigaChatAuthError( - status_code=e.response.status_code, - message=f"GigaChat authentication failed: {e.response.text}", - ) - except httpx.RequestError as e: - raise GigaChatAuthError( - status_code=500, - message=f"GigaChat authentication request failed: {str(e)}", - ) - - -def _parse_token_response(response: httpx.Response) -> Tuple[str, int]: - """Parse OAuth token response.""" - data = response.json() - - # GigaChat returns either 'tok'/'exp' or 'access_token'/'expires_at' - access_token = data.get("tok") or data.get("access_token") - expires_at = data.get("exp") or data.get("expires_at") - - if not access_token: - raise GigaChatAuthError( - status_code=500, - message=f"Invalid token response: {data}", - ) - - # expires_at is in milliseconds - if isinstance(expires_at, str): - expires_at = int(expires_at) - - verbose_logger.debug("GigaChat access token obtained successfully") - return access_token, expires_at diff --git a/litellm/llms/gigachat/chat/__init__.py b/litellm/llms/gigachat/chat/__init__.py deleted file mode 100644 index 3e030497a1a..00000000000 --- a/litellm/llms/gigachat/chat/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -GigaChat Chat Module -""" - -from .transformation import GigaChatConfig, GigaChatError -from .streaming import GigaChatModelResponseIterator - -__all__ = [ - "GigaChatConfig", - "GigaChatError", - "GigaChatModelResponseIterator", -] diff --git a/litellm/llms/gigachat/chat/streaming.py b/litellm/llms/gigachat/chat/streaming.py deleted file mode 100644 index 3565559e43c..00000000000 --- a/litellm/llms/gigachat/chat/streaming.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -GigaChat Streaming Response Handler -""" - -import json -import uuid -from typing import Any, Optional - -from litellm.types.llms.openai import ChatCompletionToolCallChunk, ChatCompletionToolCallFunctionChunk -from litellm.types.utils import GenericStreamingChunk - - -class GigaChatModelResponseIterator: - """Iterator for GigaChat streaming responses.""" - - def __init__( - self, - streaming_response: Any, - sync_stream: bool, - json_mode: Optional[bool] = False, - ): - self.streaming_response = streaming_response - self.response_iterator = self.streaming_response - self.json_mode = json_mode - - def chunk_parser(self, chunk: dict) -> GenericStreamingChunk: - """Parse a single streaming chunk from GigaChat.""" - text = "" - tool_use: Optional[ChatCompletionToolCallChunk] = None - is_finished = False - finish_reason: Optional[str] = None - - choices = chunk.get("choices", []) - if not choices: - return GenericStreamingChunk( - text="", - tool_use=None, - is_finished=False, - finish_reason="", - usage=None, - index=0, - ) - - choice = choices[0] - delta = choice.get("delta", {}) - finish_reason = choice.get("finish_reason") - - # Extract text content - text = delta.get("content", "") or "" - - # Handle function_call in stream - if finish_reason == "function_call" and delta.get("function_call"): - func_call = delta["function_call"] - args = func_call.get("arguments", {}) - - if isinstance(args, dict): - args = json.dumps(args, ensure_ascii=False) - - tool_use = ChatCompletionToolCallChunk( - id=f"call_{uuid.uuid4().hex[:24]}", - type="function", - function=ChatCompletionToolCallFunctionChunk( - name=func_call.get("name", ""), - arguments=args, - ), - index=0, - ) - finish_reason = "tool_calls" - - if finish_reason is not None: - is_finished = True - - return GenericStreamingChunk( - text=text, - tool_use=tool_use, - is_finished=is_finished, - finish_reason=finish_reason or "", - usage=None, - index=choice.get("index", 0), - ) - - def __iter__(self): - return self - - def __next__(self) -> GenericStreamingChunk: - try: - chunk = self.response_iterator.__next__() - if isinstance(chunk, str): - # Parse SSE format: data: {...} - if chunk.startswith("data: "): - chunk = chunk[6:] - if chunk.strip() == "[DONE]": - raise StopIteration - try: - chunk = json.loads(chunk) - except json.JSONDecodeError: - return GenericStreamingChunk( - text="", - tool_use=None, - is_finished=False, - finish_reason="", - usage=None, - index=0, - ) - return self.chunk_parser(chunk) - except StopIteration: - raise - - def __aiter__(self): - return self - - async def __anext__(self) -> GenericStreamingChunk: - try: - chunk = await self.response_iterator.__anext__() - if isinstance(chunk, str): - # Parse SSE format - if chunk.startswith("data: "): - chunk = chunk[6:] - if chunk.strip() == "[DONE]": - raise StopAsyncIteration - try: - chunk = json.loads(chunk) - except json.JSONDecodeError: - return GenericStreamingChunk( - text="", - tool_use=None, - is_finished=False, - finish_reason="", - usage=None, - index=0, - ) - return self.chunk_parser(chunk) - except StopAsyncIteration: - raise diff --git a/litellm/llms/gigachat/chat/transformation.py b/litellm/llms/gigachat/chat/transformation.py deleted file mode 100644 index 4ce333a1309..00000000000 --- a/litellm/llms/gigachat/chat/transformation.py +++ /dev/null @@ -1,473 +0,0 @@ -""" -GigaChat Chat Transformation - -Transforms OpenAI-format requests to GigaChat format and back. -""" - -import json -import time -import uuid -from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional, Union - -import httpx - -from litellm._logging import verbose_logger -from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException -from litellm.secret_managers.main import get_secret_str -from litellm.types.llms.openai import AllMessageValues -from litellm.types.utils import Choices, Message, ModelResponse, Usage - -from ..authenticator import get_access_token -from ..file_handler import upload_file_sync - -if TYPE_CHECKING: - from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj - - LiteLLMLoggingObj = _LiteLLMLoggingObj -else: - LiteLLMLoggingObj = Any - -# GigaChat API endpoint -GIGACHAT_BASE_URL = "https://gigachat.devices.sberbank.ru/api/v1" - - -class GigaChatError(BaseLLMException): - """GigaChat API error.""" - - pass - - -class GigaChatConfig(BaseConfig): - """ - Configuration class for GigaChat API. - - GigaChat is Sber's (Russia's largest bank) LLM API. - - Supported parameters: - temperature: Sampling temperature (0-2, default 0.87) - top_p: Nucleus sampling parameter - max_tokens: Maximum tokens to generate - repetition_penalty: Repetition penalty factor - profanity_check: Enable content filtering - stream: Enable streaming - """ - - temperature: Optional[float] = None - top_p: Optional[float] = None - max_tokens: Optional[int] = None - repetition_penalty: Optional[float] = None - profanity_check: Optional[bool] = None - - def __init__( - self, - temperature: Optional[float] = None, - top_p: Optional[float] = None, - max_tokens: Optional[int] = None, - repetition_penalty: Optional[float] = None, - profanity_check: Optional[bool] = None, - ) -> None: - locals_ = locals().copy() - for key, value in locals_.items(): - if key != "self" and value is not None: - setattr(self.__class__, key, value) - # Instance variables for current request context - self._current_credentials: Optional[str] = None - self._current_api_base: Optional[str] = None - - def get_complete_url( - self, - api_base: Optional[str], - api_key: Optional[str], - model: str, - optional_params: dict, - litellm_params: dict, - stream: Optional[bool] = None, - ) -> str: - """Get complete API URL for chat completions.""" - base = api_base or get_secret_str("GIGACHAT_API_BASE") or GIGACHAT_BASE_URL - return f"{base}/chat/completions" - - def validate_environment( - self, - headers: dict, - model: str, - messages: List[AllMessageValues], - optional_params: dict, - litellm_params: dict, - api_key: Optional[str] = None, - api_base: Optional[str] = None, - ) -> dict: - """ - Set up headers with OAuth token. - """ - # Get access token - credentials = api_key or get_secret_str("GIGACHAT_CREDENTIALS") or get_secret_str("GIGACHAT_API_KEY") - access_token = get_access_token(credentials=credentials) - - # Store credentials for image uploads - self._current_credentials = credentials - self._current_api_base = api_base - - headers["Authorization"] = f"Bearer {access_token}" - headers["Content-Type"] = "application/json" - headers["Accept"] = "application/json" - - return headers - - def get_supported_openai_params(self, model: str) -> List[str]: - """Return list of supported OpenAI parameters.""" - return [ - "stream", - "temperature", - "top_p", - "max_tokens", - "max_completion_tokens", - "stop", - "tools", - "tool_choice", - "functions", - "function_call", - "response_format", - ] - - def map_openai_params( - self, - non_default_params: dict, - optional_params: dict, - model: str, - drop_params: bool, - ) -> dict: - """Map OpenAI parameters to GigaChat parameters.""" - for param, value in non_default_params.items(): - if param == "stream": - optional_params["stream"] = value - elif param == "temperature": - # GigaChat: temperature 0 means use top_p=0 instead - if value == 0: - optional_params["top_p"] = 0 - else: - optional_params["temperature"] = value - elif param == "top_p": - optional_params["top_p"] = value - elif param in ("max_tokens", "max_completion_tokens"): - optional_params["max_tokens"] = value - elif param == "stop": - # GigaChat doesn't support stop sequences - pass - elif param == "tools": - # Convert tools to functions format - optional_params["functions"] = self._convert_tools_to_functions(value) - elif param == "tool_choice": - if isinstance(value, dict) and value.get("function"): - optional_params["function_call"] = {"name": value["function"]["name"]} - elif value == "auto": - pass # Default behavior - elif value == "required": - # GigaChat doesn't have 'required', handled differently - pass - elif param == "functions": - optional_params["functions"] = value - elif param == "function_call": - optional_params["function_call"] = value - elif param == "response_format": - # Handle structured output via function calling - if value.get("type") == "json_schema": - json_schema = value.get("json_schema", {}) - schema_name = json_schema.get("name", "structured_output") - schema = json_schema.get("schema", {}) - - function_def = { - "name": schema_name, - "description": f"Output structured response: {schema_name}", - "parameters": schema, - } - - if "functions" not in optional_params: - optional_params["functions"] = [] - optional_params["functions"].append(function_def) - optional_params["function_call"] = {"name": schema_name} - optional_params["_structured_output"] = True - - return optional_params - - def _convert_tools_to_functions(self, tools: List[dict]) -> List[dict]: - """Convert OpenAI tools format to GigaChat functions format.""" - functions = [] - for tool in tools: - if tool.get("type") == "function": - func = tool.get("function", {}) - functions.append({ - "name": func.get("name", ""), - "description": func.get("description", ""), - "parameters": func.get("parameters", {}), - }) - return functions - - def _upload_image(self, image_url: str) -> Optional[str]: - """ - Upload image to GigaChat and return file_id. - - Args: - image_url: URL or base64 data URL of the image - - Returns: - file_id string or None if upload failed - """ - try: - return upload_file_sync( - image_url=image_url, - credentials=self._current_credentials, - api_base=self._current_api_base, - ) - except Exception as e: - verbose_logger.error(f"Failed to upload image: {e}") - return None - - def transform_request( - self, - model: str, - messages: List[AllMessageValues], - optional_params: dict, - litellm_params: dict, - headers: dict, - ) -> dict: - """Transform OpenAI request to GigaChat format.""" - # Transform messages - giga_messages = self._transform_messages(messages) - - # Build request - request_data = { - "model": model.replace("gigachat/", ""), - "messages": giga_messages, - } - - # Add optional params - for key in ["temperature", "top_p", "max_tokens", "stream", - "repetition_penalty", "profanity_check"]: - if key in optional_params: - request_data[key] = optional_params[key] - - # Add functions if present - if "functions" in optional_params: - request_data["functions"] = optional_params["functions"] - if "function_call" in optional_params: - request_data["function_call"] = optional_params["function_call"] - - return request_data - - def _transform_messages(self, messages: List[AllMessageValues]) -> List[dict]: - """Transform OpenAI messages to GigaChat format.""" - transformed = [] - - for i, msg in enumerate(messages): - message = dict(msg) - - # Remove unsupported fields - message.pop("name", None) - - # Transform roles - role = message.get("role", "user") - if role == "developer": - message["role"] = "system" - elif role == "system" and i > 0: - # GigaChat only allows system message as first message - message["role"] = "user" - elif role == "tool": - message["role"] = "function" - content = message.get("content", "") - if not isinstance(content, str): - message["content"] = json.dumps(content, ensure_ascii=False) - - # Handle None content - if message.get("content") is None: - message["content"] = "" - - # Handle list content (multimodal) - extract text and images - content = message.get("content") - if isinstance(content, list): - texts = [] - attachments = [] - for part in content: - if isinstance(part, dict): - if part.get("type") == "text": - texts.append(part.get("text", "")) - elif part.get("type") == "image_url": - # Extract image URL and upload to GigaChat - image_url = part.get("image_url", {}) - if isinstance(image_url, str): - url = image_url - else: - url = image_url.get("url", "") - if url: - file_id = self._upload_image(url) - if file_id: - attachments.append(file_id) - message["content"] = "\n".join(texts) if texts else "" - if attachments: - message["attachments"] = attachments - - # Transform tool_calls to function_call - tool_calls = message.get("tool_calls") - if tool_calls and isinstance(tool_calls, list) and len(tool_calls) > 0: - tool_call = tool_calls[0] - func = tool_call.get("function", {}) - args = func.get("arguments", "{}") - if isinstance(args, str): - try: - args = json.loads(args) - except json.JSONDecodeError: - args = {} - message["function_call"] = { - "name": func.get("name", ""), - "arguments": args, - } - message.pop("tool_calls", None) - - transformed.append(message) - - # Collapse consecutive user messages - return self._collapse_user_messages(transformed) - - def _collapse_user_messages(self, messages: List[dict]) -> List[dict]: - """Collapse consecutive user messages into one.""" - collapsed: List[dict] = [] - prev_user_msg: Optional[dict] = None - content_parts: List[str] = [] - - for msg in messages: - if msg.get("role") == "user" and prev_user_msg is not None: - content_parts.append(msg.get("content", "")) - else: - if content_parts and prev_user_msg: - prev_user_msg["content"] = "\n".join( - [prev_user_msg.get("content", "")] + content_parts - ) - content_parts = [] - collapsed.append(msg) - prev_user_msg = msg if msg.get("role") == "user" else None - - if content_parts and prev_user_msg: - prev_user_msg["content"] = "\n".join( - [prev_user_msg.get("content", "")] + content_parts - ) - - return collapsed - - def transform_response( - self, - model: str, - raw_response: httpx.Response, - model_response: ModelResponse, - logging_obj: LiteLLMLoggingObj, - request_data: dict, - messages: List[AllMessageValues], - optional_params: dict, - litellm_params: dict, - encoding: Any, - api_key: Optional[str] = None, - json_mode: Optional[bool] = None, - ) -> ModelResponse: - """Transform GigaChat response to OpenAI format.""" - try: - response_json = raw_response.json() - except Exception: - raise GigaChatError( - status_code=raw_response.status_code, - message=f"Invalid JSON response: {raw_response.text}", - ) - - is_structured_output = optional_params.get("_structured_output", False) - - choices = [] - for choice in response_json.get("choices", []): - message_data = choice.get("message", {}) - finish_reason = choice.get("finish_reason", "stop") - - # Transform function_call to tool_calls or content - if finish_reason == "function_call" and message_data.get("function_call"): - func_call = message_data["function_call"] - args = func_call.get("arguments", {}) - - if is_structured_output: - # Convert to content for structured output - if isinstance(args, dict): - content = json.dumps(args, ensure_ascii=False) - else: - content = str(args) - message_data["content"] = content - message_data.pop("function_call", None) - message_data.pop("functions_state_id", None) - finish_reason = "stop" - else: - # Convert to tool_calls format - if isinstance(args, dict): - args = json.dumps(args, ensure_ascii=False) - message_data["tool_calls"] = [{ - "id": f"call_{uuid.uuid4().hex[:24]}", - "type": "function", - "function": { - "name": func_call.get("name", ""), - "arguments": args, - } - }] - message_data.pop("function_call", None) - finish_reason = "tool_calls" - - # Clean up GigaChat-specific fields - message_data.pop("functions_state_id", None) - - choices.append( - Choices( - index=choice.get("index", 0), - message=Message( - role=message_data.get("role", "assistant"), - content=message_data.get("content"), - tool_calls=message_data.get("tool_calls"), - ), - finish_reason=finish_reason, - ) - ) - - # Build usage - usage_data = response_json.get("usage", {}) - usage = Usage( - prompt_tokens=usage_data.get("prompt_tokens", 0), - completion_tokens=usage_data.get("completion_tokens", 0), - total_tokens=usage_data.get("total_tokens", 0), - ) - - model_response.id = response_json.get("id", f"chatcmpl-{uuid.uuid4().hex[:12]}") - model_response.created = response_json.get("created", int(time.time())) - model_response.model = model - model_response.choices = choices # type: ignore - setattr(model_response, "usage", usage) - - return model_response - - def get_error_class( - self, - error_message: str, - status_code: int, - headers: Union[dict, httpx.Headers], - ) -> BaseLLMException: - """Return GigaChat error class.""" - return GigaChatError( - status_code=status_code, - message=error_message, - headers=headers, - ) - - def get_model_response_iterator( - self, - streaming_response: Union[Iterator[str], AsyncIterator[str], ModelResponse], - sync_stream: bool, - json_mode: Optional[bool] = False, - ): - """Return streaming response iterator.""" - from .streaming import GigaChatModelResponseIterator - - return GigaChatModelResponseIterator( - streaming_response=streaming_response, - sync_stream=sync_stream, - json_mode=json_mode, - ) diff --git a/litellm/llms/gigachat/embedding/__init__.py b/litellm/llms/gigachat/embedding/__init__.py deleted file mode 100644 index af237e49aab..00000000000 --- a/litellm/llms/gigachat/embedding/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -GigaChat Embedding Module -""" - -from .transformation import GigaChatEmbeddingConfig - -__all__ = ["GigaChatEmbeddingConfig"] diff --git a/litellm/llms/gigachat/embedding/transformation.py b/litellm/llms/gigachat/embedding/transformation.py deleted file mode 100644 index 0da6565050e..00000000000 --- a/litellm/llms/gigachat/embedding/transformation.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -GigaChat Embedding Transformation - -Transforms OpenAI /v1/embeddings format to GigaChat format. -API Documentation: https://developers.sber.ru/docs/ru/gigachat/api/reference/rest/post-embeddings -""" - -import types -from typing import List, Optional, Tuple, Union - -import httpx - -from litellm import LlmProviders -from litellm.llms.base_llm.chat.transformation import BaseLLMException -from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig -from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj -from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues -from litellm.types.utils import EmbeddingResponse - -from ..authenticator import get_access_token - -# GigaChat API endpoint -GIGACHAT_BASE_URL = "https://gigachat.devices.sberbank.ru/api/v1" - - -class GigaChatEmbeddingError(BaseLLMException): - """GigaChat Embedding API error.""" - - pass - - -class GigaChatEmbeddingConfig(BaseEmbeddingConfig): - """ - Configuration class for GigaChat Embeddings API. - - GigaChat embeddings endpoint: POST /api/v1/embeddings - """ - - def __init__(self) -> None: - pass - - @classmethod - def get_config(cls): - return { - k: v - for k, v in cls.__dict__.items() - if not k.startswith("__") - and not isinstance( - v, - ( - types.FunctionType, - types.BuiltinFunctionType, - classmethod, - staticmethod, - ), - ) - and v is not None - } - - def get_supported_openai_params(self, model: str) -> List[str]: - """GigaChat embeddings don't support additional parameters.""" - return [] - - def map_openai_params( - self, - non_default_params: dict, - optional_params: dict, - model: str, - drop_params: bool, - ) -> dict: - """Map OpenAI params to GigaChat format (no special mapping needed).""" - return optional_params - - def _get_openai_compatible_provider_info( - self, - api_base: Optional[str], - api_key: Optional[str], - ) -> Tuple[str, Optional[str], Optional[str]]: - """ - Returns provider info for GigaChat. - - Returns: - Tuple of (custom_llm_provider, api_base, dynamic_api_key) - """ - api_base = api_base or GIGACHAT_BASE_URL - return LlmProviders.GIGACHAT.value, api_base, api_key - - def get_complete_url( - self, - api_base: Optional[str], - api_key: Optional[str], - model: str, - optional_params: dict, - litellm_params: dict, - stream: Optional[bool] = None, - ) -> str: - """Get the complete URL for embeddings endpoint.""" - base = api_base or GIGACHAT_BASE_URL - return f"{base}/embeddings" - - def transform_embedding_request( - self, - model: str, - input: AllEmbeddingInputValues, - optional_params: dict, - headers: dict, - ) -> dict: - """ - Transform OpenAI embedding request to GigaChat format. - - GigaChat format: - { - "model": "Embeddings", - "input": ["text1", "text2", ...] - } - """ - # Normalize input to list - if isinstance(input, str): - input_list: list = [input] - elif isinstance(input, list): - input_list = input - else: - input_list = [input] - - # Remove gigachat/ prefix from model if present - if model.startswith("gigachat/"): - model = model[9:] - - return { - "model": model, - "input": input_list, - } - - def transform_embedding_response( - self, - model: str, - raw_response: httpx.Response, - model_response: EmbeddingResponse, - logging_obj: LiteLLMLoggingObj, - api_key: Optional[str], - request_data: dict, - optional_params: dict, - litellm_params: dict, - ) -> EmbeddingResponse: - """ - Transform GigaChat embedding response to OpenAI format. - - GigaChat returns: - { - "object": "list", - "data": [{"object": "embedding", "embedding": [...], "index": 0, "usage": {...}}], - "model": "Embeddings" - } - """ - response_json = raw_response.json() - - # Log response - logging_obj.post_call( - input=request_data.get("input"), - api_key=api_key, - additional_args={"complete_input_dict": request_data}, - original_response=response_json, - ) - - # Calculate total tokens from individual embeddings - total_tokens = 0 - if "data" in response_json: - for emb in response_json["data"]: - if "usage" in emb and "prompt_tokens" in emb["usage"]: - total_tokens += emb["usage"]["prompt_tokens"] - # Remove usage from individual embeddings (not part of OpenAI format) - if "usage" in emb: - del emb["usage"] - - # Set overall usage - response_json["usage"] = { - "prompt_tokens": total_tokens, - "total_tokens": total_tokens, - } - - return EmbeddingResponse(**response_json) - - def validate_environment( - self, - headers: dict, - model: str, - messages: List[AllMessageValues], - optional_params: dict, - litellm_params: dict, - api_key: Optional[str] = None, - api_base: Optional[str] = None, - ) -> dict: - """ - Set up headers with OAuth token for GigaChat. - """ - # Get access token via OAuth - access_token = get_access_token(api_key) - - default_headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {access_token}", - } - return {**default_headers, **headers} - - def get_error_class( - self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] - ) -> BaseLLMException: - """Return GigaChat-specific error class.""" - return GigaChatEmbeddingError( - status_code=status_code, - message=error_message, - ) diff --git a/litellm/llms/gigachat/file_handler.py b/litellm/llms/gigachat/file_handler.py deleted file mode 100644 index 200428a747a..00000000000 --- a/litellm/llms/gigachat/file_handler.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -GigaChat File Handler - -Handles file uploads to GigaChat API for image processing. -GigaChat requires files to be uploaded first, then referenced by file_id. -""" - -import base64 -import hashlib -import re -import uuid -from typing import Dict, Optional, Tuple - -from litellm._logging import verbose_logger -from litellm.llms.custom_httpx.http_handler import ( - _get_httpx_client, - get_async_httpx_client, -) -from litellm.types.utils import LlmProviders - -from .authenticator import get_access_token, get_access_token_async - -# GigaChat API endpoint -GIGACHAT_BASE_URL = "https://gigachat.devices.sberbank.ru/api/v1" - -# Simple in-memory cache for file IDs -_file_cache: Dict[str, str] = {} - - -def _get_url_hash(url: str) -> str: - """Generate hash for URL to use as cache key.""" - return hashlib.sha256(url.encode()).hexdigest() - - -def _parse_data_url(data_url: str) -> Optional[Tuple[bytes, str, str]]: - """ - Parse data URL (base64 image). - - Returns: - Tuple of (content_bytes, content_type, extension) or None - """ - match = re.match(r"data:([^;]+);base64,(.+)", data_url) - if not match: - return None - - content_type = match.group(1) - base64_data = match.group(2) - content_bytes = base64.b64decode(base64_data) - ext = content_type.split("/")[-1].split(";")[0] or "jpg" - - return content_bytes, content_type, ext - - -def _download_image_sync(url: str) -> Tuple[bytes, str, str]: - """Download image from URL synchronously.""" - client = _get_httpx_client(params={"ssl_verify": False}) - response = client.get(url) - response.raise_for_status() - - content_type = response.headers.get("content-type", "image/jpeg") - ext = content_type.split("/")[-1].split(";")[0] or "jpg" - - return response.content, content_type, ext - - -async def _download_image_async(url: str) -> Tuple[bytes, str, str]: - """Download image from URL asynchronously.""" - client = get_async_httpx_client( - llm_provider=LlmProviders.GIGACHAT, - params={"ssl_verify": False}, - ) - response = await client.get(url) - response.raise_for_status() - - content_type = response.headers.get("content-type", "image/jpeg") - ext = content_type.split("/")[-1].split(";")[0] or "jpg" - - return response.content, content_type, ext - - -def upload_file_sync( - image_url: str, - credentials: Optional[str] = None, - api_base: Optional[str] = None, -) -> Optional[str]: - """ - Upload file to GigaChat and return file_id (sync). - - Args: - image_url: URL or base64 data URL of the image - credentials: GigaChat credentials for auth - api_base: Optional custom API base URL - - Returns: - file_id string or None if upload failed - """ - url_hash = _get_url_hash(image_url) - - # Check cache - if url_hash in _file_cache: - verbose_logger.debug(f"Image found in cache: {url_hash[:16]}...") - return _file_cache[url_hash] - - try: - # Get image data - parsed = _parse_data_url(image_url) - if parsed: - content_bytes, content_type, ext = parsed - verbose_logger.debug("Decoded base64 image") - else: - verbose_logger.debug(f"Downloading image from URL: {image_url[:80]}...") - content_bytes, content_type, ext = _download_image_sync(image_url) - - filename = f"{uuid.uuid4()}.{ext}" - - # Get access token - access_token = get_access_token(credentials) - - # Upload to GigaChat - base_url = api_base or GIGACHAT_BASE_URL - upload_url = f"{base_url}/files" - - client = _get_httpx_client(params={"ssl_verify": False}) - response = client.post( - upload_url, - headers={"Authorization": f"Bearer {access_token}"}, - files={"file": (filename, content_bytes, content_type)}, - data={"purpose": "general"}, - timeout=60, - ) - response.raise_for_status() - result = response.json() - - file_id = result.get("id") - if file_id: - _file_cache[url_hash] = file_id - verbose_logger.debug(f"File uploaded successfully, file_id: {file_id}") - - return file_id - - except Exception as e: - verbose_logger.error(f"Error uploading file to GigaChat: {e}") - return None - - -async def upload_file_async( - image_url: str, - credentials: Optional[str] = None, - api_base: Optional[str] = None, -) -> Optional[str]: - """ - Upload file to GigaChat and return file_id (async). - - Args: - image_url: URL or base64 data URL of the image - credentials: GigaChat credentials for auth - api_base: Optional custom API base URL - - Returns: - file_id string or None if upload failed - """ - url_hash = _get_url_hash(image_url) - - # Check cache - if url_hash in _file_cache: - verbose_logger.debug(f"Image found in cache: {url_hash[:16]}...") - return _file_cache[url_hash] - - try: - # Get image data - parsed = _parse_data_url(image_url) - if parsed: - content_bytes, content_type, ext = parsed - verbose_logger.debug("Decoded base64 image") - else: - verbose_logger.debug(f"Downloading image from URL: {image_url[:80]}...") - content_bytes, content_type, ext = await _download_image_async(image_url) - - filename = f"{uuid.uuid4()}.{ext}" - - # Get access token - access_token = await get_access_token_async(credentials) - - # Upload to GigaChat - base_url = api_base or GIGACHAT_BASE_URL - upload_url = f"{base_url}/files" - - client = get_async_httpx_client( - llm_provider=LlmProviders.GIGACHAT, - params={"ssl_verify": False}, - ) - response = await client.post( - upload_url, - headers={"Authorization": f"Bearer {access_token}"}, - files={"file": (filename, content_bytes, content_type)}, - data={"purpose": "general"}, - timeout=60, - ) - response.raise_for_status() - result = response.json() - - file_id = result.get("id") - if file_id: - _file_cache[url_hash] = file_id - verbose_logger.debug(f"File uploaded successfully, file_id: {file_id}") - - return file_id - - except Exception as e: - verbose_logger.error(f"Error uploading file to GigaChat: {e}") - return None diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index cc2439b431a..96598c1dfe6 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -500,69 +500,3 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): response._hidden_params["headers"] = raw_response_headers return response - - ######################################################### - ########## COMPACT RESPONSE API TRANSFORMATION ########## - ######################################################### - def transform_compact_response_api_request( - self, - model: str, - input: Union[str, ResponseInputParam], - response_api_optional_request_params: Dict, - api_base: str, - litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> Tuple[str, Dict]: - """ - Transform the compact response API request into a URL and data - - OpenAI API expects the following request - - POST /v1/responses/compact - """ - url = f"{api_base}/compact" - - input = self._validate_input_param(input) - data = dict( - ResponsesAPIRequestParams( - model=model, input=input, **response_api_optional_request_params - ) - ) - - return url, data - - def transform_compact_response_api_response( - self, - raw_response: httpx.Response, - logging_obj: LiteLLMLoggingObj, - ) -> ResponsesAPIResponse: - """ - Transform the compact response API response into a ResponsesAPIResponse - """ - try: - logging_obj.post_call( - original_response=raw_response.text, - additional_args={"complete_input_dict": {}}, - ) - raw_response_json = raw_response.json() - raw_response_json["created_at"] = _safe_convert_created_field( - raw_response_json["created_at"] - ) - except Exception: - raise OpenAIError( - message=raw_response.text, status_code=raw_response.status_code - ) - raw_response_headers = dict(raw_response.headers) - processed_headers = process_response_headers(raw_response_headers) - - try: - response = ResponsesAPIResponse(**raw_response_json) - except Exception: - verbose_logger.debug( - f"Error constructing ResponsesAPIResponse: {raw_response_json}, using model_construct" - ) - response = ResponsesAPIResponse.model_construct(**raw_response_json) - - response._hidden_params["additional_headers"] = processed_headers - response._hidden_params["headers"] = raw_response_headers - - return response diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index d23c698cd7a..826f151df35 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -6,7 +6,7 @@ Handles Authentication and generating request urls for Vertex AI and Google AI S import json import os -from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple import litellm from litellm._logging import verbose_logger @@ -168,6 +168,7 @@ class VertexBase: ) def _credentials_from_default_auth(self, scopes): + import google.auth as google_auth return google_auth.default(scopes=scopes) @@ -391,7 +392,7 @@ class VertexBase: Returns token, url """ - version: Optional[Literal["v1", "v1beta1"]] = None + version: Optional[Literal["v1beta1", "v1"]] = None if custom_llm_provider == "gemini": url, endpoint = _get_gemini_url( mode=mode, @@ -414,7 +415,7 @@ class VertexBase: stream=stream, vertex_project=vertex_project, vertex_location=vertex_location, - vertex_api_version=cast(Literal["v1", "v1beta1"], version), + vertex_api_version=version, ) return self._check_custom_proxy( diff --git a/litellm/main.py b/litellm/main.py index e8a8b504d96..f4f27eb5841 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2141,49 +2141,6 @@ def completion( # type: ignore # noqa: PLR0915 logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements client=client, ) - elif custom_llm_provider == "gigachat": - # GigaChat - Sber AI's LLM (Russia) - api_key = ( - api_key - or litellm.api_key - or litellm.gigachat_key - or get_secret("GIGACHAT_API_KEY") - or get_secret("GIGACHAT_CREDENTIALS") - ) - - headers = headers or litellm.headers or {} - - ## COMPLETION CALL - try: - response = base_llm_http_handler.completion( - model=model, - messages=messages, - headers=headers, - model_response=model_response, - api_key=api_key, - api_base=api_base, - acompletion=acompletion, - logging_obj=logging, - optional_params=optional_params, - litellm_params=litellm_params, - shared_session=shared_session, - timeout=timeout, - client=client, - custom_llm_provider=custom_llm_provider, - encoding=_get_encoding(), - stream=stream, - provider_config=provider_config, - ) - except Exception as e: - ## LOGGING - log the original exception returned - logging.post_call( - input=messages, - api_key=api_key, - original_response=str(e), - additional_args={"headers": headers}, - ) - raise e - elif custom_llm_provider == "sap": headers = headers or litellm.headers ## LOAD CONFIG - if set @@ -5267,28 +5224,6 @@ def embedding( # noqa: PLR0915 aembedding=aembedding, litellm_params={}, ) - elif custom_llm_provider == "gigachat": - api_key = ( - api_key - or litellm.api_key - or litellm.gigachat_key - or get_secret_str("GIGACHAT_CREDENTIALS") - or get_secret_str("GIGACHAT_API_KEY") - ) - response = base_llm_http_handler.embedding( - model=model, - input=input, - custom_llm_provider=custom_llm_provider, - api_base=api_base, - api_key=api_key, - logging_obj=logging, - timeout=timeout, - model_response=EmbeddingResponse(), - optional_params=optional_params, - client=client, - aembedding=aembedding, - litellm_params={"ssl_verify": kwargs.get("ssl_verify", None)}, - ) else: raise LiteLLMUnknownProvider( model=model, custom_llm_provider=custom_llm_provider diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index c7a2f60856d..e823dd5dc6b 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -15831,68 +15831,6 @@ "max_tokens": 8191, "mode": "embedding" }, - "gigachat/GigaChat-2-Lite": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.0, - "supports_function_calling": true, - "supports_system_messages": true - }, - "gigachat/GigaChat-2-Max": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.0, - "supports_function_calling": true, - "supports_system_messages": true, - "supports_vision": true - }, - "gigachat/GigaChat-2-Pro": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.0, - "supports_function_calling": true, - "supports_system_messages": true, - "supports_vision": true - }, - "gigachat/Embeddings": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 512, - "max_tokens": 512, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 1024 - }, - "gigachat/Embeddings-2": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 512, - "max_tokens": 512, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 1024 - }, - "gigachat/EmbeddingsGigaR": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 4096, - "max_tokens": 4096, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 2560 - }, "google.gemma-3-12b-it": { "input_cost_per_token": 9e-08, "litellm_provider": "bedrock_converse", @@ -32154,4 +32092,3 @@ "mode": "chat" } } - diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index ded591a8f53..ffa17a5b7c4 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -15,7 +15,6 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( ) from litellm.proxy.common_utils.http_parsing_utils import _read_request_body from litellm.types.mcp_server.mcp_server_manager import MCPServer -from litellm.proxy.utils import get_server_root_path router = APIRouter( tags=["mcp"], @@ -382,30 +381,13 @@ async def callback(code: str, state: str): # ------------------------------ # Optional .well-known endpoints for MCP + OAuth discovery # ------------------------------ -""" - Per SEP-985, the client MUST: - 1. Try resource_metadata from WWW-Authenticate header (if present) - 2. Fall back to path-based well-known URI: /.well-known/oauth-protected-resource/{path} - ( - If the resource identifier value contains a path or query component, any terminating slash (/) - following the host component MUST be removed before inserting /.well-known/ and the well-known - URI path suffix between the host component and the path(include root path) and/or query components. - https://datatracker.ietf.org/doc/html/rfc9728#section-3.1) - 3. Fall back to root-based well-known URI: /.well-known/oauth-protected-resource -""" -@router.get(f"/.well-known/oauth-protected-resource{'' if get_server_root_path() == '/' else get_server_root_path()}/{{mcp_server_name}}/mcp") +@router.get("/.well-known/oauth-protected-resource/{mcp_server_name}/mcp") @router.get("/.well-known/oauth-protected-resource") async def oauth_protected_resource_mcp( request: Request, mcp_server_name: Optional[str] = None ): - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, - ) # Get the correct base URL considering X-Forwarded-* headers request_base_url = get_request_base_url(request) - mcp_server: Optional[MCPServer] = None - if mcp_server_name: - mcp_server = global_mcp_server_manager.get_mcp_server_by_name(mcp_server_name) return { "authorization_servers": [ ( @@ -419,25 +401,14 @@ async def oauth_protected_resource_mcp( if mcp_server_name else f"{request_base_url}/mcp" ), # this is what Claude will call - "scopes_supported": mcp_server.scopes if mcp_server else [], } -""" - https://datatracker.ietf.org/doc/html/rfc8414#section-3.1 - RFC 8414: Path-aware OAuth discovery - If the issuer identifier value contains a path component, any - terminating "/" MUST be removed before inserting "/.well-known/" and - the well-known URI suffix between the host component and the path(include root path) - component. -""" -@router.get(f"/.well-known/oauth-authorization-server{'' if get_server_root_path() == '/' else get_server_root_path()}/{{mcp_server_name}}") + +@router.get("/.well-known/oauth-authorization-server/{mcp_server_name}") @router.get("/.well-known/oauth-authorization-server") async def oauth_authorization_server_mcp( request: Request, mcp_server_name: Optional[str] = None ): - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, - ) # Get the correct base URL considering X-Forwarded-* headers request_base_url = get_request_base_url(request) @@ -452,21 +423,16 @@ async def oauth_authorization_server_mcp( else f"{request_base_url}/token" ) - mcp_server: Optional[MCPServer] = None - if mcp_server_name: - mcp_server = global_mcp_server_manager.get_mcp_server_by_name(mcp_server_name) - return { "issuer": request_base_url, # point to your proxy "authorization_endpoint": authorization_endpoint, "token_endpoint": token_endpoint, "response_types_supported": ["code"], - "scopes_supported": mcp_server.scopes if mcp_server else [], - "grant_types_supported": ["authorization_code", "refresh_token"], + "grant_types_supported": ["authorization_code"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["client_secret_post"], # Claude expects a registration endpoint, even if we just fake it - "registration_endpoint": f"{request_base_url}/{mcp_server_name}/register" if mcp_server_name else f"{request_base_url}/register", + "registration_endpoint": f"{request_base_url}/{mcp_server_name}/register", } diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 3a548e203c5..a5ac966062e 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -660,14 +660,14 @@ class MCPServerManager: """ allowed_mcp_servers = await self.get_allowed_mcp_servers(user_api_key_auth) + list_tools_result: List[MCPTool] = [] verbose_logger.debug("SERVER MANAGER LISTING TOOLS") - async def _fetch_server_tools(server_id: str) -> List[MCPTool]: - """Fetch tools from a single server with error handling.""" + for server_id in allowed_mcp_servers: server = self.get_mcp_server_by_id(server_id) if server is None: verbose_logger.warning(f"MCP Server {server_id} not found") - return [] + continue # Get server-specific auth header if available server_auth_header = None @@ -685,21 +685,15 @@ class MCPServerManager: server=server, mcp_auth_header=server_auth_header, ) - return tools + list_tools_result.extend(tools) + verbose_logger.info( + f"Successfully fetched {len(tools)} tools from server {server.name}" + ) except Exception as e: verbose_logger.warning( f"Failed to list tools from server {server.name}: {str(e)}. Continuing with other servers." ) - return [] - - # Fetch tools from all servers in parallel - tasks = [_fetch_server_tools(server_id) for server_id in allowed_mcp_servers] - results = await asyncio.gather(*tasks) - - # Flatten results into single list - list_tools_result: List[MCPTool] = [ - tool for tools in results for tool in tools - ] + # Continue with other servers instead of failing completely verbose_logger.info( f"Successfully fetched {len(list_tools_result)} tools total from all servers" @@ -2009,9 +2003,6 @@ class MCPServerManager: Note: This now handles prefixed tool names """ for server in self.get_registry().values(): - if server.auth_type == MCPAuth.oauth2: - # Skip OAuth2 servers for now as they may require user-specific tokens - continue tools = await self._get_tools_from_server(server) for tool in tools: # The tool.name here is already prefixed from _get_tools_from_server @@ -2293,7 +2284,14 @@ class MCPServerManager: # Check all accessible servers target_server_ids = allowed_server_ids - return await self._run_health_checks(target_server_ids) + # Run health checks concurrently + tasks = [self.health_check_server(server_id) for server_id in target_server_ids] + results = await asyncio.gather(*tasks) + + # Filter out None results (servers that were not found) + list_mcp_servers = [server for server in results if server is not None] + + return list_mcp_servers async def get_all_allowed_mcp_servers( self, @@ -2308,6 +2306,8 @@ class MCPServerManager: Returns: List of MCP server objects without health status """ + from datetime import datetime + # Get allowed server IDs allowed_server_ids = await self.get_allowed_mcp_servers(user_api_key_auth) @@ -2319,56 +2319,40 @@ class MCPServerManager: verbose_logger.warning(f"MCP Server {server_id} not found in registry") continue - mcp_server_table = self._build_mcp_server_table(server) + # Build LiteLLM_MCPServerTable without health check + mcp_server_table = LiteLLM_MCPServerTable( + server_id=server.server_id, + server_name=server.server_name, + alias=server.alias, + description=( + server.mcp_info.get("description") if server.mcp_info else None + ), + url=server.url, + transport=server.transport, + auth_type=server.auth_type, + created_at=datetime.now(), + updated_at=datetime.now(), + teams=[], + mcp_access_groups=server.access_groups or [], + allowed_tools=server.allowed_tools or [], + extra_headers=server.extra_headers or [], + mcp_info=server.mcp_info, + static_headers=server.static_headers, + status=None, # No health check performed + last_health_check=None, # No health check performed + health_check_error=None, + command=getattr(server, "command", None), + args=getattr(server, "args", None) or [], + env=getattr(server, "env", None) or {}, + authorization_url=server.authorization_url, + token_url=server.token_url, + registration_url=server.registration_url, + allow_all_keys=server.allow_all_keys, + ) list_mcp_servers.append(mcp_server_table) return list_mcp_servers - def _build_mcp_server_table(self, server: MCPServer) -> LiteLLM_MCPServerTable: - from datetime import datetime - - return LiteLLM_MCPServerTable( - server_id=server.server_id, - server_name=server.server_name, - alias=server.alias, - description=( - server.mcp_info.get("description") if server.mcp_info else None - ), - url=server.url, - transport=server.transport, - auth_type=server.auth_type, - created_at=datetime.now(), - updated_at=datetime.now(), - teams=[], - mcp_access_groups=server.access_groups or [], - allowed_tools=server.allowed_tools or [], - extra_headers=server.extra_headers or [], - mcp_info=server.mcp_info, - static_headers=server.static_headers, - status=None, # No health check performed - last_health_check=None, # No health check performed - health_check_error=None, - command=getattr(server, "command", None), - args=getattr(server, "args", None) or [], - env=getattr(server, "env", None) or {}, - authorization_url=server.authorization_url, - token_url=server.token_url, - registration_url=server.registration_url, - allow_all_keys=server.allow_all_keys, - ) - - async def get_all_mcp_servers_unfiltered(self) -> List[LiteLLM_MCPServerTable]: - """Return all MCP servers from registry without applying access controls.""" - - registry = self.get_registry() - if not registry: - return [] - - servers: List[LiteLLM_MCPServerTable] = [] - for server in registry.values(): - servers.append(self._build_mcp_server_table(server)) - return servers - async def reload_servers_from_database(self): """ Public method to reload all MCP servers from database into registry. @@ -2376,34 +2360,5 @@ class MCPServerManager: """ await self._add_mcp_servers_from_db_to_in_memory_registry() - async def get_all_mcp_servers_with_health_unfiltered( - self, server_ids: Optional[List[str]] = None - ) -> List[LiteLLM_MCPServerTable]: - """Return health info for all servers in registry regardless of user access.""" - - registry = self.get_registry() - if not registry: - return [] - - if server_ids: - target_server_ids = [sid for sid in server_ids if sid in registry] - else: - target_server_ids = list(registry.keys()) - - if not target_server_ids: - return [] - - return await self._run_health_checks(target_server_ids) - - async def _run_health_checks( - self, target_server_ids: List[str] - ) -> List[LiteLLM_MCPServerTable]: - if not target_server_ids: - return [] - - tasks = [self.health_check_server(server_id) for server_id in target_server_ids] - results = await asyncio.gather(*tasks) - return [server for server in results if server is not None] - global_mcp_server_manager: MCPServerManager = MCPServerManager() diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 9c7001266f0..e00fdbfb930 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -709,8 +709,7 @@ if MCP_AVAILABLE: extra_headers: Optional[Dict[str, str]] = None if server.auth_type == MCPAuth.oauth2: - # Copy to avoid mutating the original dict (important for parallel fetching) - extra_headers = oauth2_headers.copy() if oauth2_headers else None + extra_headers = oauth2_headers if server.extra_headers and raw_headers: if extra_headers is None: @@ -756,10 +755,11 @@ if MCP_AVAILABLE: # Decide whether to add prefix based on number of allowed servers add_prefix = not (len(allowed_mcp_servers) == 1) - async def _fetch_and_filter_server_tools(server: MCPServer) -> List[MCPTool]: - """Fetch and filter tools from a single server with error handling.""" + # Get tools from each allowed server + all_tools = [] + for server in allowed_mcp_servers: if server is None: - return [] + continue server_auth_header, extra_headers = _prepare_mcp_server_headers( server=server, @@ -786,24 +786,16 @@ if MCP_AVAILABLE: user_api_key_auth=user_api_key_auth, ) + all_tools.extend(filtered_tools) + verbose_logger.debug( f"Successfully fetched {len(tools)} tools from server {server.name}, {len(filtered_tools)} after filtering" ) - return filtered_tools except Exception as e: verbose_logger.exception( f"Error getting tools from server {server.name}: {str(e)}" ) - return [] - - # Fetch tools from all servers in parallel - tasks = [ - _fetch_and_filter_server_tools(server) for server in allowed_mcp_servers - ] - results = await asyncio.gather(*tasks) - - # Flatten results into single list - all_tools: List[MCPTool] = [tool for tools in results for tool in tools] + # Continue with other servers instead of failing completely verbose_logger.info( f"Successfully fetched {len(all_tools)} tools total from all MCP servers" diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 954c26e2cb2..b77cc40d6dc 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1908,9 +1908,6 @@ class UserHeaderMapping(LiteLLMPydanticObjectBase): } -UserMCPManagementMode = Literal["restricted", "view_all"] - - class ConfigGeneralSettings(LiteLLMPydanticObjectBase): """ Documents all the fields supported by `general_settings` in config.yaml @@ -2028,10 +2025,6 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): None, description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map'. If not set, all objects are loaded (default behavior).", ) - user_mcp_management_mode: Optional[UserMCPManagementMode] = Field( - None, - description="Controls how non-admin users interact with MCP servers in the dashboard. 'restricted' shows only accessible servers, 'view_all' lists every server in read-only mode.", - ) class ConfigYAML(LiteLLMPydanticObjectBase): diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index ae7cb612c9c..7947eb9b045 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -323,7 +323,6 @@ class ProxyBaseLLMRequestProcessing: "aget_responses", "adelete_responses", "acancel_responses", - "acompact_responses", "acreate_batch", "aretrieve_batch", "alist_batches", @@ -485,7 +484,6 @@ class ProxyBaseLLMRequestProcessing: "aget_responses", "adelete_responses", "acancel_responses", - "acompact_responses", "atext_completion", "aimage_edit", "alist_input_items", diff --git a/litellm/proxy/db/prisma_client.py b/litellm/proxy/db/prisma_client.py index fe27af78d58..406ddceabf5 100644 --- a/litellm/proxy/db/prisma_client.py +++ b/litellm/proxy/db/prisma_client.py @@ -154,11 +154,7 @@ class PrismaManager: prisma_dir = PrismaManager._get_prisma_dir() - from litellm.proxy.proxy_server import redis_usage_cache - - return ProxyExtrasDBManager.setup_database( - use_migrate=use_migrate, redis_cache=redis_usage_cache - ) + return ProxyExtrasDBManager.setup_database(use_migrate=use_migrate) else: # Use prisma db push with increased timeout subprocess.run( diff --git a/litellm/proxy/guardrails/guardrail_hooks/lasso/lasso.py b/litellm/proxy/guardrails/guardrail_hooks/lasso/lasso.py index 5850103132c..ea8f1b0a97f 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/lasso/lasso.py +++ b/litellm/proxy/guardrails/guardrail_hooks/lasso/lasso.py @@ -118,7 +118,7 @@ class LassoGuardrail(CustomGuardrail): Falls back to UUID if ULID library is not available. """ if ULID_AVAILABLE and ulid is not None: - return str(ulid.ULID()) # type: ignore + return str(ulid.new()) # type: ignore else: verbose_proxy_logger.debug("ULID library not available, using UUID") return str(uuid.uuid4()) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 47793c8fc8e..a871a6637a2 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -32,8 +32,8 @@ from fastapi import ( from fastapi.responses import JSONResponse import litellm -from litellm._logging import verbose_logger, verbose_proxy_logger from litellm._uuid import uuid +from litellm._logging import verbose_logger, verbose_proxy_logger from litellm.constants import LITELLM_PROXY_ADMIN_NAME from litellm.proxy._experimental.mcp_server.utils import ( validate_and_normalize_mcp_server_payload, @@ -67,6 +67,7 @@ if MCP_AVAILABLE: from litellm.proxy._experimental.mcp_server.ui_session_utils import ( build_effective_auth_contexts, ) + from litellm.proxy.common_utils.http_parsing_utils import _read_request_body from litellm.proxy._types import ( LiteLLM_MCPServerTable, LitellmUserRoles, @@ -75,10 +76,8 @@ if MCP_AVAILABLE: SpecialMCPServerName, UpdateMCPServerRequest, UserAPIKeyAuth, - UserMCPManagementMode, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth - from litellm.proxy.common_utils.http_parsing_utils import _read_request_body from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.management_helpers.utils import management_endpoint_wrapper from litellm.types.mcp import MCPCredentials @@ -303,20 +302,6 @@ if MCP_AVAILABLE: return {"access_groups": access_groups_list} ## FastAPI Routes - def _get_user_mcp_management_mode() -> UserMCPManagementMode: - proxy_general_settings: dict = {} - try: - from litellm.proxy.proxy_server import ( - general_settings as proxy_general_settings, - ) - except Exception: - pass - - mode = proxy_general_settings.get("user_mcp_management_mode") - if mode == "view_all": - return "view_all" - return "restricted" - @router.get( "/server", description="Returns the mcp server list with associated teams", @@ -334,26 +319,18 @@ if MCP_AVAILABLE: ``` """ - user_mcp_management_mode = _get_user_mcp_management_mode() + auth_contexts = await build_effective_auth_contexts(user_api_key_dict) - if user_mcp_management_mode == "view_all": - servers = await global_mcp_server_manager.get_all_mcp_servers_unfiltered() - redacted_mcp_servers = _redact_mcp_credentials_list(servers) - else: - auth_contexts = await build_effective_auth_contexts(user_api_key_dict) - - aggregated_servers: Dict[str, LiteLLM_MCPServerTable] = {} - for auth_context in auth_contexts: - servers = await global_mcp_server_manager.get_all_allowed_mcp_servers( - user_api_key_auth=auth_context - ) - for server in servers: - if server.server_id not in aggregated_servers: - aggregated_servers[server.server_id] = server - - redacted_mcp_servers = _redact_mcp_credentials_list( - aggregated_servers.values() + aggregated_servers: Dict[str, LiteLLM_MCPServerTable] = {} + for auth_context in auth_contexts: + servers = await global_mcp_server_manager.get_all_allowed_mcp_servers( + user_api_key_auth=auth_context ) + for server in servers: + if server.server_id not in aggregated_servers: + aggregated_servers[server.server_id] = server + + redacted_mcp_servers = _redact_mcp_credentials_list(aggregated_servers.values()) # augment the mcp servers with public status if litellm.public_mcp_servers is not None: @@ -395,17 +372,6 @@ if MCP_AVAILABLE: --header 'Authorization: Bearer your_api_key_here' ``` """ - user_mcp_management_mode = _get_user_mcp_management_mode() - - if user_mcp_management_mode == "view_all": - servers = await global_mcp_server_manager.get_all_mcp_servers_with_health_unfiltered( - server_ids=server_ids - ) - return [ - {"server_id": server.server_id, "status": server.status} - for server in servers - ] - auth_contexts = await build_effective_auth_contexts(user_api_key_dict) server_status_map: Dict[ diff --git a/litellm/proxy/response_api_endpoints/endpoints.py b/litellm/proxy/response_api_endpoints/endpoints.py index ec1bc5497bd..623e8408862 100644 --- a/litellm/proxy/response_api_endpoints/endpoints.py +++ b/litellm/proxy/response_api_endpoints/endpoints.py @@ -698,88 +698,6 @@ async def get_response_input_items( ) -@router.post( - "/v1/responses/compact", - dependencies=[Depends(user_api_key_auth)], - tags=["responses"], -) -@router.post( - "/responses/compact", - dependencies=[Depends(user_api_key_auth)], - tags=["responses"], -) -@router.post( - "/openai/v1/responses/compact", - dependencies=[Depends(user_api_key_auth)], - tags=["responses"], -) -async def compact_response( - request: Request, - fastapi_response: Response, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): - """ - Compact a response by running a compaction pass over a conversation. - - Returns encrypted, opaque items that can be used to reduce context size. - - Follows the OpenAI Responses API spec: https://platform.openai.com/docs/api-reference/responses/compact - - ```bash - curl -X POST http://localhost:4000/v1/responses/compact \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer sk-1234" \ - -d '{ - "model": "gpt-4o", - "input": [{"role": "user", "content": "Hello"}] - }' - ``` - """ - from litellm.proxy.proxy_server import ( - _read_request_body, - general_settings, - llm_router, - proxy_config, - proxy_logging_obj, - select_data_generator, - user_api_base, - user_max_tokens, - user_model, - user_request_timeout, - user_temperature, - version, - ) - - data = await _read_request_body(request=request) - processor = ProxyBaseLLMRequestProcessing(data=data) - try: - return await processor.base_process_llm_request( - request=request, - fastapi_response=fastapi_response, - user_api_key_dict=user_api_key_dict, - route_type="acompact_responses", - proxy_logging_obj=proxy_logging_obj, - llm_router=llm_router, - general_settings=general_settings, - proxy_config=proxy_config, - select_data_generator=select_data_generator, - model=None, - user_model=user_model, - user_temperature=user_temperature, - user_request_timeout=user_request_timeout, - user_max_tokens=user_max_tokens, - user_api_base=user_api_base, - version=version, - ) - except Exception as e: - raise await processor._handle_llm_api_exception( - e=e, - user_api_key_dict=user_api_key_dict, - proxy_logging_obj=proxy_logging_obj, - version=version, - ) - - @router.post( "/v1/responses/{response_id}/cancel", dependencies=[Depends(user_api_key_auth)], diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index a321e25a9a5..fd00cfc1c0a 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -25,7 +25,6 @@ ROUTE_ENDPOINT_MAPPING = { "alist_input_items": "/responses/{response_id}/input_items", "aimage_edit": "/images/edits", "acancel_responses": "/responses/{response_id}/cancel", - "acompact_responses": "/responses/compact", "aocr": "/ocr", "asearch": "/search", "avideo_generation": "/videos", @@ -117,7 +116,6 @@ async def route_request( "aget_responses", "adelete_responses", "acancel_responses", - "acompact_responses", "acreate_response_reply", "alist_input_items", "_arealtime", # private function for realtime API diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 8177b177fe6..e837346df23 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -1361,205 +1361,3 @@ def cancel_responses( completion_kwargs=local_vars, extra_kwargs=kwargs, ) - - -@client -async def acompact_responses( - input: Union[str, ResponseInputParam], - model: str, - instructions: Optional[str] = None, - previous_response_id: Optional[str] = None, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Optional[Dict[str, Any]] = None, - extra_query: Optional[Dict[str, Any]] = None, - extra_body: Optional[Dict[str, Any]] = None, - timeout: Optional[Union[float, httpx.Timeout]] = None, - # LiteLLM specific params, - custom_llm_provider: Optional[str] = None, - **kwargs, -) -> ResponsesAPIResponse: - """ - Async version of the POST Compact Responses API - - POST /v1/responses/compact endpoint in the responses API - - Runs a compaction pass over a conversation, returning encrypted, opaque items. - """ - local_vars = locals() - try: - loop = asyncio.get_event_loop() - kwargs["acompact_responses"] = True - - # get custom llm provider so we can use this for mapping exceptions - if custom_llm_provider is None: - _, custom_llm_provider, _, _ = litellm.get_llm_provider( - model=model, api_base=local_vars.get("base_url", None) - ) - - func = partial( - compact_responses, - input=input, - model=model, - instructions=instructions, - previous_response_id=previous_response_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - custom_llm_provider=custom_llm_provider, - **kwargs, - ) - - ctx = contextvars.copy_context() - func_with_context = partial(ctx.run, func) - init_response = await loop.run_in_executor(None, func_with_context) - - if asyncio.iscoroutine(init_response): - response = await init_response - else: - response = init_response - - # Update the responses_api_response_id with the model_id - if isinstance(response, ResponsesAPIResponse): - response = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id( - responses_api_response=response, - litellm_metadata=kwargs.get("litellm_metadata", {}), - custom_llm_provider=custom_llm_provider, - ) - - return response - except Exception as e: - raise litellm.exception_type( - model=model, - custom_llm_provider=custom_llm_provider, - original_exception=e, - completion_kwargs=local_vars, - extra_kwargs=kwargs, - ) - - -@client -def compact_responses( - input: Union[str, ResponseInputParam], - model: str, - instructions: Optional[str] = None, - previous_response_id: Optional[str] = None, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Optional[Dict[str, Any]] = None, - extra_query: Optional[Dict[str, Any]] = None, - extra_body: Optional[Dict[str, Any]] = None, - timeout: Optional[Union[float, httpx.Timeout]] = None, - # LiteLLM specific params, - custom_llm_provider: Optional[str] = None, - **kwargs, -) -> Union[ResponsesAPIResponse, Coroutine[Any, Any, ResponsesAPIResponse]]: - """ - Synchronous version of the POST Compact Responses API - - POST /v1/responses/compact endpoint in the responses API - - Runs a compaction pass over a conversation, returning encrypted, opaque items. - """ - local_vars = locals() - try: - litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore - litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None) - _is_async = kwargs.pop("acompact_responses", False) is True - - # get llm provider logic - litellm_params = GenericLiteLLMParams(**kwargs) - - ( - model, - custom_llm_provider, - dynamic_api_key, - dynamic_api_base, - ) = litellm.get_llm_provider( - model=model, - custom_llm_provider=custom_llm_provider, - api_base=litellm_params.api_base, - api_key=litellm_params.api_key, - ) - - if custom_llm_provider is None: - raise ValueError("custom_llm_provider is required but passed as None") - - # get provider config - responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( - ProviderConfigManager.get_provider_responses_api_config( - model=model, - provider=litellm.LlmProviders(custom_llm_provider), - ) - ) - - if responses_api_provider_config is None: - raise ValueError( - f"COMPACT responses is not supported for {custom_llm_provider}" - ) - - local_vars.update(kwargs) - - # Build optional params for compact endpoint - response_api_optional_params: ResponsesAPIOptionalRequestParams = ( - ResponsesAPIRequestUtils.get_requested_response_api_optional_param( - local_vars - ) - ) - - # Get optional parameters for the responses API - responses_api_request_params: Dict = ( - ResponsesAPIRequestUtils.get_optional_params_responses_api( - model=model, - responses_api_provider_config=responses_api_provider_config, - response_api_optional_params=response_api_optional_params, - allowed_openai_params=None, - ) - ) - - # Pre Call logging - litellm_logging_obj.update_environment_variables( - model=model, - optional_params=dict(responses_api_request_params), - litellm_params={ - **responses_api_request_params, - "litellm_call_id": litellm_call_id, - }, - custom_llm_provider=custom_llm_provider, - ) - - # Call the handler with _is_async flag instead of directly calling the async handler - response = base_llm_http_handler.compact_response_api_handler( - model=model, - input=input, - responses_api_provider_config=responses_api_provider_config, - response_api_optional_request_params=responses_api_request_params, - litellm_params=litellm_params, - logging_obj=litellm_logging_obj, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - extra_body=extra_body, - timeout=timeout or request_timeout, - _is_async=_is_async, - client=kwargs.get("client"), - shared_session=kwargs.get("shared_session"), - ) - - # Update the responses_api_response_id with the model_id - if isinstance(response, ResponsesAPIResponse): - response = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id( - responses_api_response=response, - litellm_metadata=kwargs.get("litellm_metadata", {}), - custom_llm_provider=custom_llm_provider, - ) - - return response - except Exception as e: - raise litellm.exception_type( - model=model, - custom_llm_provider=custom_llm_provider, - original_exception=e, - completion_kwargs=local_vars, - extra_kwargs=kwargs, - ) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 0b838f916e2..0407776029d 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -1,6 +1,5 @@ import asyncio import json -import traceback from datetime import datetime from typing import Any, Dict, Optional @@ -12,9 +11,6 @@ from litellm.litellm_core_utils.asyncify import run_async_function from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.llm_response_utils.get_api_base import get_api_base -from litellm.litellm_core_utils.llm_response_utils.response_metadata import ( - update_response_metadata, -) from litellm.litellm_core_utils.thread_pool_executor import executor from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig from litellm.responses.utils import ResponsesAPIRequestUtils @@ -26,8 +22,7 @@ from litellm.types.llms.openai import ( ResponsesAPIStreamEvents, ResponsesAPIStreamingResponse, ) -from litellm.types.utils import CallTypes -from litellm.utils import CustomStreamWrapper, async_post_call_success_deployment_hook +from litellm.utils import CustomStreamWrapper class BaseResponsesAPIStreamingIterator: @@ -45,8 +40,6 @@ class BaseResponsesAPIStreamingIterator: logging_obj: LiteLLMLoggingObj, litellm_metadata: Optional[Dict[str, Any]] = None, custom_llm_provider: Optional[str] = None, - request_data: Optional[Dict[str, Any]] = None, - call_type: Optional[str] = None, ): self.response = response self.model = model @@ -54,25 +47,21 @@ class BaseResponsesAPIStreamingIterator: self.finished = False self.responses_api_provider_config = responses_api_provider_config self.completed_response: Optional[ResponsesAPIStreamingResponse] = None - self.start_time = getattr(logging_obj, "start_time", datetime.now()) + self.start_time = datetime.now() - # track request context for hooks + # set request kwargs self.litellm_metadata = litellm_metadata self.custom_llm_provider = custom_llm_provider - self.request_data: Dict[str, Any] = request_data or {} - self.call_type: Optional[str] = call_type # set hidden params for response headers (e.g., x-litellm-model-id) - # This matches the stream wrapper in litellm/litellm_core_utils/streaming_handler.py + # This matches ths stream wrapper in litellm/litellm_core_utils/streaming_handler.py _api_base = get_api_base( model=model or "", optional_params=self.logging_obj.model_call_details.get( "litellm_params", {} ), ) - _model_info: Dict = ( - litellm_metadata.get("model_info", {}) if litellm_metadata else {} - ) + _model_info: Dict = litellm_metadata.get("model_info", {}) if litellm_metadata else {} self._hidden_params = { "model_id": _model_info.get("id", None), "api_base": _api_base, @@ -113,21 +102,13 @@ class BaseResponsesAPIStreamingIterator: # if "response" in parsed_chunk, then encode litellm specific information like custom_llm_provider response_object = getattr(openai_responses_api_chunk, "response", None) if response_object: - response = ( - ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id( - responses_api_response=response_object, - litellm_metadata=self.litellm_metadata, - custom_llm_provider=self.custom_llm_provider, - ) + response = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id( + responses_api_response=response_object, + litellm_metadata=self.litellm_metadata, + custom_llm_provider=self.custom_llm_provider, ) setattr(openai_responses_api_chunk, "response", response) - # Allow callbacks to modify chunk before returning - openai_responses_api_chunk = run_async_function( - async_function=self._call_post_streaming_deployment_hook, - chunk=openai_responses_api_chunk, - ) - # Store the completed response if ( openai_responses_api_chunk @@ -168,159 +149,11 @@ class BaseResponsesAPIStreamingIterator: except json.JSONDecodeError: # If we can't parse the chunk, continue return None - except Exception as e: - # Ensure failures trigger failure hooks - self._handle_failure(e) - raise def _handle_logging_completed_response(self): """Base implementation - should be overridden by subclasses""" pass - async def _call_post_streaming_deployment_hook(self, chunk): - """ - Allow callbacks to modify streaming chunks before returning (parity with chat). - """ - try: - # Align with chat pipeline: use logging_obj model_call_details + call_type - typed_call_type: Optional[CallTypes] = None - if self.call_type is not None: - try: - typed_call_type = CallTypes(self.call_type) - except ValueError: - typed_call_type = None - if typed_call_type is None: - try: - typed_call_type = CallTypes(getattr(self.logging_obj, "call_type", None)) - except Exception: - typed_call_type = None - - request_data = self.request_data or getattr( - self.logging_obj, "model_call_details", {} - ) - callbacks = getattr(litellm, "callbacks", None) or [] - hooks_ran = False - for callback in callbacks: - if hasattr(callback, "async_post_call_streaming_deployment_hook"): - hooks_ran = True - result = await callback.async_post_call_streaming_deployment_hook( - request_data=request_data, - response_chunk=chunk, - call_type=typed_call_type, - ) - if result is not None: - chunk = result - if hooks_ran: - setattr(chunk, "_post_streaming_hooks_ran", True) - return chunk - except Exception: - return chunk - - async def call_post_streaming_hooks_for_testing(self, chunk): - """ - Helper to invoke streaming deployment hooks explicitly (used in tests). - """ - return await self._call_post_streaming_deployment_hook(chunk) - - def _run_post_success_hooks(self, end_time: datetime): - """ - Run post-call deployment hooks and update metadata similar to chat pipeline. - """ - if self.completed_response is None: - return - - request_payload: Dict[str, Any] = {} - if isinstance(self.request_data, dict): - request_payload.update(self.request_data) - try: - if hasattr(self.logging_obj, "model_call_details"): - request_payload.update(self.logging_obj.model_call_details) - except Exception: - pass - if "litellm_params" not in request_payload: - try: - request_payload["litellm_params"] = getattr( - self.logging_obj, "model_call_details", {} - ).get("litellm_params", {}) - except Exception: - request_payload["litellm_params"] = {} - - try: - update_response_metadata( - result=self.completed_response, - logging_obj=self.logging_obj, - model=self.model, - kwargs=request_payload, - start_time=self.start_time, - end_time=end_time, - ) - except Exception: - # Non-blocking - pass - - try: - typed_call_type: Optional[CallTypes] = None - if self.call_type is not None: - try: - typed_call_type = CallTypes(self.call_type) - except ValueError: - typed_call_type = None - except Exception: - typed_call_type = None - if typed_call_type is None: - try: - typed_call_type = CallTypes.responses - except Exception: - typed_call_type = None - - try: - # Call synchronously; async hook will be executed via asyncio.run in a new loop - run_async_function( - async_function=async_post_call_success_deployment_hook, - request_data=request_payload, - response=self.completed_response, - call_type=typed_call_type, - ) - except Exception: - pass - - def _handle_failure(self, exception: Exception): - """ - Trigger failure handlers before bubbling the exception. - """ - traceback_exception = traceback.format_exc() - try: - run_async_function( - async_function=self.logging_obj.async_failure_handler, - exception=exception, - traceback_exception=traceback_exception, - start_time=self.start_time, - end_time=datetime.now(), - ) - except Exception: - pass - - try: - executor.submit( - self.logging_obj.failure_handler, - exception, - traceback_exception, - self.start_time, - datetime.now(), - ) - except Exception: - pass - - -async def call_post_streaming_hooks_for_testing(iterator, chunk): - """ - Module-level helper for tests to ensure hooks can be invoked even if the iterator is wrapped. - """ - hook_fn = getattr(iterator, "_call_post_streaming_deployment_hook", None) - if hook_fn is None: - return chunk - return await hook_fn(chunk) - class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): """ @@ -335,8 +168,6 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): logging_obj: LiteLLMLoggingObj, litellm_metadata: Optional[Dict[str, Any]] = None, custom_llm_provider: Optional[str] = None, - request_data: Optional[Dict[str, Any]] = None, - call_type: Optional[str] = None, ): super().__init__( response, @@ -345,8 +176,6 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): logging_obj, litellm_metadata, custom_llm_provider, - request_data, - call_type, ) self.stream_iterator = response.aiter_lines() @@ -374,21 +203,16 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): except httpx.HTTPError as e: # Handle HTTP errors self.finished = True - self._handle_failure(e) - raise e - except Exception as e: - self.finished = True - self._handle_failure(e) raise e def _handle_logging_completed_response(self): """Handle logging for completed responses in async context""" # Create a deep copy for logging to avoid modifying the response object that will be returned to the user - # The logging handlers may transform usage from Responses API format (input_tokens/output_tokens) + # The logging handlers may transform usage from Responses API format (input_tokens/output_tokens) # to chat completion format (prompt_tokens/completion_tokens) for internal logging import copy logging_response = copy.deepcopy(self.completed_response) - + asyncio.create_task( self.logging_obj.async_success_handler( result=logging_response, @@ -405,7 +229,6 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): start_time=self.start_time, end_time=datetime.now(), ) - self._run_post_success_hooks(end_time=datetime.now()) class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): @@ -421,8 +244,6 @@ class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): logging_obj: LiteLLMLoggingObj, litellm_metadata: Optional[Dict[str, Any]] = None, custom_llm_provider: Optional[str] = None, - request_data: Optional[Dict[str, Any]] = None, - call_type: Optional[str] = None, ): super().__init__( response, @@ -431,8 +252,6 @@ class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): logging_obj, litellm_metadata, custom_llm_provider, - request_data, - call_type, ) self.stream_iterator = response.iter_lines() @@ -460,21 +279,16 @@ class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): except httpx.HTTPError as e: # Handle HTTP errors self.finished = True - self._handle_failure(e) - raise e - except Exception as e: - self.finished = True - self._handle_failure(e) raise e def _handle_logging_completed_response(self): """Handle logging for completed responses in sync context""" # Create a deep copy for logging to avoid modifying the response object that will be returned to the user - # The logging handlers may transform usage from Responses API format (input_tokens/output_tokens) + # The logging handlers may transform usage from Responses API format (input_tokens/output_tokens) # to chat completion format (prompt_tokens/completion_tokens) for internal logging import copy logging_response = copy.deepcopy(self.completed_response) - + run_async_function( async_function=self.logging_obj.async_success_handler, result=logging_response, @@ -490,7 +304,6 @@ class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): start_time=self.start_time, end_time=datetime.now(), ) - self._run_post_success_hooks(end_time=datetime.now()) class MockResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): @@ -511,8 +324,6 @@ class MockResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): logging_obj: LiteLLMLoggingObj, litellm_metadata: Optional[Dict[str, Any]] = None, custom_llm_provider: Optional[str] = None, - request_data: Optional[Dict[str, Any]] = None, - call_type: Optional[str] = None, ): super().__init__( response=response, @@ -521,8 +332,6 @@ class MockResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): logging_obj=logging_obj, litellm_metadata=litellm_metadata, custom_llm_provider=custom_llm_provider, - request_data=request_data, - call_type=call_type, ) # one-time transform diff --git a/litellm/router.py b/litellm/router.py index d980b5f74d8..6821ab9e6c6 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -713,23 +713,6 @@ class Router: self, routing_strategy: Union[RoutingStrategy, str], routing_strategy_args: dict ): verbose_router_logger.info(f"Routing strategy: {routing_strategy}") - - # Validate routing_strategy value to fail fast with helpful error - # See: https://github.com/BerriAI/litellm/issues/11330 - # Derive valid strategies from RoutingStrategy enum + "simple-shuffle" (default, not in enum) - valid_strategy_strings = ["simple-shuffle"] + [s.value for s in RoutingStrategy] - - if routing_strategy is not None: - is_valid_string = isinstance(routing_strategy, str) and routing_strategy in valid_strategy_strings - is_valid_enum = isinstance(routing_strategy, RoutingStrategy) - if not is_valid_string and not is_valid_enum: - raise ValueError( - f"Invalid routing_strategy: '{routing_strategy}'. " - f"Valid options: {valid_strategy_strings}. " - f"Check 'router_settings.routing_strategy' in your config.yaml " - f"or the 'routing_strategy' parameter if using the Router SDK directly." - ) - if ( routing_strategy == RoutingStrategy.LEAST_BUSY.value or routing_strategy == RoutingStrategy.LEAST_BUSY @@ -829,9 +812,6 @@ class Router: self.acancel_responses = self.factory_function( litellm.acancel_responses, call_type="acancel_responses" ) - self.acompact_responses = self.factory_function( - litellm.acompact_responses, call_type="acompact_responses" - ) self.adelete_responses = self.factory_function( litellm.adelete_responses, call_type="adelete_responses" ) @@ -3944,7 +3924,6 @@ class Router: "anthropic_messages", "aresponses", "acancel_responses", - "acompact_responses", "responses", "aget_responses", "adelete_responses", @@ -4173,7 +4152,6 @@ class Router: elif call_type in ( "aget_responses", "acancel_responses", - "acompact_responses", "adelete_responses", "alist_input_items", ): diff --git a/litellm/types/integrations/langsmith.py b/litellm/types/integrations/langsmith.py index 9c026a117fd..23f760ecf32 100644 --- a/litellm/types/integrations/langsmith.py +++ b/litellm/types/integrations/langsmith.py @@ -31,7 +31,6 @@ class LangsmithCredentialsObject(TypedDict): LANGSMITH_API_KEY: Optional[str] LANGSMITH_PROJECT: Optional[str] LANGSMITH_BASE_URL: str - LANGSMITH_TENANT_ID: Optional[str] class LangsmithQueueObject(TypedDict): @@ -53,7 +52,6 @@ class CredentialsKey(NamedTuple): api_key: str project: str base_url: str - tenant_id: Optional[str] @dataclass diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 784c8403c3f..3eec67d9d26 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2677,7 +2677,6 @@ class StandardCallbackDynamicParams(TypedDict, total=False): langsmith_project: Optional[str] langsmith_base_url: Optional[str] langsmith_sampling_rate: Optional[float] - langsmith_tenant_id: Optional[str] # Humanloop dynamic params humanloop_api_key: Optional[str] @@ -2947,7 +2946,6 @@ class LlmProviders(str, Enum): MISTRAL = "mistral" MILVUS = "milvus" GROQ = "groq" - GIGACHAT = "gigachat" NVIDIA_NIM = "nvidia_nim" CEREBRAS = "cerebras" AI21_CHAT = "ai21_chat" diff --git a/litellm/utils.py b/litellm/utils.py index fbbaa94f7a1..6b9aeca0934 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7521,8 +7521,6 @@ class ProviderConfigManager: return litellm.CompactifAIChatConfig() elif litellm.LlmProviders.GITHUB_COPILOT == provider: return litellm.GithubCopilotConfig() - elif litellm.LlmProviders.GIGACHAT == provider: - return litellm.GigaChatConfig() elif litellm.LlmProviders.RAGFLOW == provider: return litellm.RAGFlowConfig() elif ( @@ -7718,8 +7716,6 @@ class ProviderConfigManager: return litellm.CometAPIEmbeddingConfig() elif litellm.LlmProviders.GITHUB_COPILOT == provider: return litellm.GithubCopilotEmbeddingConfig() - elif litellm.LlmProviders.GIGACHAT == provider: - return litellm.GigaChatEmbeddingConfig() elif litellm.LlmProviders.SAGEMAKER == provider: from litellm.llms.sagemaker.embedding.transformation import ( SagemakerEmbeddingConfig, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index c7a2f60856d..e823dd5dc6b 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -15831,68 +15831,6 @@ "max_tokens": 8191, "mode": "embedding" }, - "gigachat/GigaChat-2-Lite": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.0, - "supports_function_calling": true, - "supports_system_messages": true - }, - "gigachat/GigaChat-2-Max": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.0, - "supports_function_calling": true, - "supports_system_messages": true, - "supports_vision": true - }, - "gigachat/GigaChat-2-Pro": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.0, - "supports_function_calling": true, - "supports_system_messages": true, - "supports_vision": true - }, - "gigachat/Embeddings": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 512, - "max_tokens": 512, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 1024 - }, - "gigachat/Embeddings-2": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 512, - "max_tokens": 512, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 1024 - }, - "gigachat/EmbeddingsGigaR": { - "input_cost_per_token": 0.0, - "litellm_provider": "gigachat", - "max_input_tokens": 4096, - "max_tokens": 4096, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 2560 - }, "google.gemma-3-12b-it": { "input_cost_per_token": 9e-08, "litellm_provider": "bedrock_converse", @@ -32154,4 +32092,3 @@ "mode": "chat" } } - diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index bc5dea7b97c..45ee47c01bc 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -28,8 +28,7 @@ "list_container_files": "Supports GET /containers/{id}/files endpoint", "retrieve_container_file": "Supports GET /containers/{id}/files/{file_id} endpoint", "retrieve_container_file_content": "Supports GET /containers/{id}/files/{file_id}/content endpoint", - "delete_container_file": "Supports DELETE /containers/{id}/files/{file_id} endpoint", - "compact": "Supports /responses/compact endpoint" + "delete_container_file": "Supports DELETE /containers/{id}/files/{file_id} endpoint" } } }, @@ -1520,7 +1519,6 @@ "retrieve_container_file": true, "retrieve_container_file_content": true, "delete_container_file": true, - "compact": true, "a2a": true, "interactions": true } diff --git a/requirements.txt b/requirements.txt index 249b899b86b..06a7c17336c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ google-cloud-aiplatform==1.47.0 # for vertex ai calls google-cloud-iam==2.19.1 # for GCP IAM Redis authentication google-genai==1.22.0 anthropic[vertex]==0.54.0 -mcp==1.25.0 ; python_version >= "3.10" # for MCP server +mcp==1.23.0 ; python_version >= "3.10" # for MCP server google-generativeai==0.5.0 # for vertex ai calls async_generator==1.10.0 # for async ollama calls langfuse==2.59.7 # for langfuse self-hosted logging diff --git a/tests/enterprise/litellm_enterprise/proxy/guardrails/test_bedrock_apply_guardrail.py b/tests/enterprise/litellm_enterprise/proxy/guardrails/test_bedrock_apply_guardrail.py index 60d4f479733..9a96919da87 100644 --- a/tests/enterprise/litellm_enterprise/proxy/guardrails/test_bedrock_apply_guardrail.py +++ b/tests/enterprise/litellm_enterprise/proxy/guardrails/test_bedrock_apply_guardrail.py @@ -61,19 +61,13 @@ async def test_bedrock_apply_guardrail_blocked(): guardrailVersion="DRAFT", ) - # Mock the make_bedrock_api_request method to raise an exception for blocked content + # Mock the make_bedrock_api_request method with patch.object( - guardrail, "make_bedrock_api_request", new_callable=AsyncMock + guardrail, "make_bedrock_api_request", new_callable=AsyncMock ) as mock_api_request: - # Mock the method to raise an HTTPException as it would for blocked content - from fastapi import HTTPException - mock_api_request.side_effect = HTTPException( - status_code=400, - detail={ - "error": "Violated guardrail policy", - "bedrock_guardrail_response": "", - }, - ) + # Mock a blocked response from Bedrock + mock_response = {"action": "BLOCKED", "reason": "Content violates policy"} + mock_api_request.return_value = mock_response # Test the apply_guardrail method should raise an exception with pytest.raises(Exception) as exc_info: @@ -83,9 +77,8 @@ async def test_bedrock_apply_guardrail_blocked(): input_type="request", ) - # The apply_guardrail method wraps the original exception in a generic Exception - assert "Bedrock guardrail failed:" in str(exc_info.value) - assert "Violated guardrail policy" in str(exc_info.value) + assert "Content blocked by Bedrock guardrail" in str(exc_info.value) + assert "Content violates policy" in str(exc_info.value) @pytest.mark.asyncio @@ -260,15 +253,7 @@ async def test_bedrock_apply_guardrail_filters_request_messages_when_flag_enable with patch.object( guardrail, "make_bedrock_api_request", new_callable=AsyncMock ) as mock_api: - # Mock the method to raise an HTTPException as it would for blocked content - from fastapi import HTTPException - mock_api.side_effect = HTTPException( - status_code=400, - detail={ - "error": "Violated guardrail policy", - "bedrock_guardrail_response": "policy", - }, - ) + mock_api.return_value = {"action": "BLOCKED", "reason": "policy"} with pytest.raises(Exception, match="policy") as exc_info: await guardrail.apply_guardrail( @@ -280,8 +265,7 @@ async def test_bedrock_apply_guardrail_filters_request_messages_when_flag_enable assert mock_api.called _, kwargs = mock_api.call_args assert kwargs["messages"] == [request_messages[-1]] - # The apply_guardrail method wraps the original exception in a generic Exception - assert "Bedrock guardrail failed:" in str(exc_info.value) + assert "Content blocked by Bedrock guardrail" in str(exc_info.value) def test_bedrock_guardrail_filters_latest_user_message_when_enabled(): diff --git a/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py b/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py index c981ccd8dac..5714cd5c487 100644 --- a/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py +++ b/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py @@ -1,14 +1,11 @@ import os import sys -import time -from unittest.mock import Mock, patch sys.path.insert( 0, os.path.abspath("../..") ) # Adds the parent directory to the system path -from litellm_proxy_extras.utils import ProxyExtrasDBManager, MigrationLockManager - +from litellm_proxy_extras.utils import ProxyExtrasDBManager def test_custom_prisma_dir(monkeypatch): @@ -30,279 +27,101 @@ def test_custom_prisma_dir(monkeypatch): assert os.path.exists(migrations_dir) -class TestMigrationLockManager: - """Test cases for MigrationLockManager""" +class TestPermissionErrorDetection: + """Test cases for permission error detection in Prisma migrations""" - def test_acquire_lock_without_redis(self): - """Test lock acquisition when Redis is not available""" - lock_manager = MigrationLockManager() - result = lock_manager.acquire_lock() - assert result is True # Should return True when Redis is not available - assert lock_manager.lock_acquired is True # Redis 없을 때도 lock_acquired는 True + def test_is_permission_error_postgres_42501(self): + """Test detection of PostgreSQL 42501 error code (insufficient privilege)""" + error_message = "Database error code: 42501 - permission denied for table users" + assert ProxyExtrasDBManager._is_permission_error(error_message) is True - def test_acquire_lock_with_redis_success(self): - """Test successful lock acquisition with Redis""" - mock_redis = Mock() - mock_redis.set_cache.return_value = True - lock_manager = MigrationLockManager(mock_redis) + def test_is_permission_error_must_be_owner(self): + """Test detection of 'must be owner of table' error""" + error_message = "ERROR: must be owner of table my_table" + assert ProxyExtrasDBManager._is_permission_error(error_message) is True - result = lock_manager.acquire_lock() + def test_is_permission_error_permission_denied_schema(self): + """Test detection of 'permission denied for schema' error""" + error_message = "permission denied for schema public" + assert ProxyExtrasDBManager._is_permission_error(error_message) is True - assert result is True - assert lock_manager.lock_acquired is True - mock_redis.set_cache.assert_called_once() + def test_is_permission_error_permission_denied_table(self): + """Test detection of 'permission denied for table' error""" + error_message = "permission denied for table my_table" + assert ProxyExtrasDBManager._is_permission_error(error_message) is True - def test_acquire_lock_with_redis_failure(self): - """Test failed lock acquisition with Redis""" - mock_redis = Mock() - mock_redis.set_cache.return_value = False - lock_manager = MigrationLockManager(mock_redis) + def test_is_permission_error_must_be_owner_schema(self): + """Test detection of 'must be owner of schema' error""" + error_message = "must be owner of schema public" + assert ProxyExtrasDBManager._is_permission_error(error_message) is True - result = lock_manager.acquire_lock() + def test_is_permission_error_case_insensitive(self): + """Test that permission error detection is case insensitive""" + error_message = "PERMISSION DENIED FOR TABLE my_table" + assert ProxyExtrasDBManager._is_permission_error(error_message) is True - assert result is False - assert lock_manager.lock_acquired is False - mock_redis.set_cache.assert_called_once() - - def test_acquire_lock_with_redis_exception(self): - """Test lock acquisition with Redis exception""" - mock_redis = Mock() - mock_redis.set_cache.side_effect = Exception("Redis error") - lock_manager = MigrationLockManager(mock_redis) - - result = lock_manager.acquire_lock() - - assert result is False - assert lock_manager.lock_acquired is False - - def test_wait_for_lock_release_success(self): - """Test successful waiting for lock release""" - mock_redis = Mock() - # First call returns False (lock held), second call returns True (lock acquired) - mock_redis.set_cache.side_effect = [False, True] - lock_manager = MigrationLockManager(mock_redis) - - result = lock_manager.wait_for_lock_release(check_interval=0.1, max_wait=1) - - assert result is True - assert lock_manager.lock_acquired is True - assert mock_redis.set_cache.call_count == 2 - - def test_wait_for_lock_release_timeout(self): - """Test timeout while waiting for lock release""" - mock_redis = Mock() - mock_redis.set_cache.return_value = False # Lock always held - lock_manager = MigrationLockManager(mock_redis) - - result = lock_manager.wait_for_lock_release(check_interval=0.1, max_wait=0.2) - - assert result is False - assert lock_manager.lock_acquired is False - - def test_release_lock_not_acquired(self): - """Test releasing lock when not acquired""" - mock_redis = Mock() - lock_manager = MigrationLockManager(mock_redis) - - lock_manager.release_lock() - - mock_redis.get_cache.assert_not_called() - mock_redis.delete_cache.assert_not_called() - - def test_release_lock_success(self): - """Test successful lock release""" - mock_redis = Mock() - mock_redis.get_cache.return_value = "pod_123_456" - lock_manager = MigrationLockManager(mock_redis) - lock_manager.pod_id = "pod_123_456" - lock_manager.lock_acquired = True - - lock_manager.release_lock() - - mock_redis.get_cache.assert_called_once() - mock_redis.delete_cache.assert_called_once() - assert lock_manager.lock_acquired is False - - def test_release_lock_wrong_owner(self): - """Test releasing lock when not the owner""" - mock_redis = Mock() - mock_redis.get_cache.return_value = "pod_999_999" # Different pod - lock_manager = MigrationLockManager(mock_redis) - lock_manager.pod_id = "pod_123_456" - lock_manager.lock_acquired = True - - lock_manager.release_lock() - - mock_redis.get_cache.assert_called_once() - mock_redis.delete_cache.assert_not_called() - assert lock_manager.lock_acquired is False - - def test_context_manager(self): - """Test MigrationLockManager as context manager""" - mock_redis = Mock() - mock_redis.set_cache.return_value = True - # Mock get_cache to return the same pod_id for successful release - mock_redis.get_cache.return_value = "pod_123_456" - - lock_manager = MigrationLockManager(mock_redis) - lock_manager.pod_id = "pod_123_456" # Set consistent pod_id - - with lock_manager: - assert lock_manager.lock_acquired is True - - # Should call release_lock when exiting context - mock_redis.get_cache.assert_called_once() - mock_redis.delete_cache.assert_called_once() + def test_is_permission_error_negative(self): + """Test that non-permission errors are not detected as permission errors""" + error_message = "column 'id' already exists" + assert ProxyExtrasDBManager._is_permission_error(error_message) is False -class TestProxyExtrasDBManagerMigrationLock: - """Test cases for ProxyExtrasDBManager with migration locking""" +class TestIdempotentErrorDetection: + """Test cases for idempotent error detection in Prisma migrations""" - @patch("litellm_proxy_extras.utils.ProxyExtrasDBManager._resolve_all_migrations") - @patch("litellm_proxy_extras.utils.subprocess.run") - @patch("litellm_proxy_extras.utils.ProxyExtrasDBManager._get_prisma_dir") - @patch("os.chdir") - def test_setup_database_with_redis_lock_success( - self, mock_chdir, mock_get_prisma_dir, mock_subprocess, mock_resolve_migrations - ): - """Test successful database setup with Redis lock""" - # Setup mocks - mock_get_prisma_dir.return_value = "/test/prisma" - mock_subprocess.return_value = Mock(stdout="Migration completed", stderr="") - mock_resolve_migrations.return_value = None + def test_is_idempotent_error_already_exists(self): + """Test detection of generic 'already exists' error""" + error_message = "object already exists" + assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True - # Mock Redis cache - mock_redis = Mock() - mock_redis.set_cache.return_value = True # Lock acquired successfully - mock_redis.get_cache.return_value = "pod_123_456" + def test_is_idempotent_error_column_already_exists(self): + """Test detection of 'column already exists' error""" + error_message = "column 'email' already exists" + assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True - # Set DATABASE_URL - with patch.dict( - os.environ, {"DATABASE_URL": "postgresql://test:test@localhost/test"} - ): - result = ProxyExtrasDBManager.setup_database( - use_migrate=True, redis_cache=mock_redis - ) + def test_is_idempotent_error_duplicate_key(self): + """Test detection of duplicate key violation error""" + error_message = "duplicate key value violates unique constraint" + assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True - assert result is True - # set_cache is called once in acquire_lock (__enter__ calls acquire_lock) - assert mock_redis.set_cache.call_count == 1 - mock_subprocess.assert_called_once() + def test_is_idempotent_error_relation_already_exists(self): + """Test detection of 'relation already exists' error""" + error_message = "relation 'users_pkey' already exists" + assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True - @patch("litellm_proxy_extras.utils.ProxyExtrasDBManager._resolve_all_migrations") - @patch("litellm_proxy_extras.utils.subprocess.run") - @patch("litellm_proxy_extras.utils.ProxyExtrasDBManager._get_prisma_dir") - @patch("os.chdir") - def test_setup_database_with_redis_lock_wait_and_skip( - self, mock_chdir, mock_get_prisma_dir, mock_subprocess, mock_resolve_migrations - ): - """Test database setup when lock is held by another pod, then acquired after waiting""" - # Setup mocks - mock_get_prisma_dir.return_value = "/test/prisma" - mock_resolve_migrations.return_value = None + def test_is_idempotent_error_constraint_already_exists(self): + """Test detection of 'constraint already exists' error""" + error_message = "constraint 'fk_user_id' already exists" + assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True - # Mock Redis cache - first call fails, second call succeeds - mock_redis = Mock() - mock_redis.set_cache.side_effect = [False, True] # First fails, then succeeds - mock_redis.get_cache.return_value = "pod_123_456" + def test_is_idempotent_error_case_insensitive(self): + """Test that idempotent error detection is case insensitive""" + error_message = "COLUMN 'ID' ALREADY EXISTS" + assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True - # Set DATABASE_URL - with patch.dict( - os.environ, {"DATABASE_URL": "postgresql://test:test@localhost/test"} - ): - result = ProxyExtrasDBManager.setup_database( - use_migrate=True, redis_cache=mock_redis - ) + def test_is_idempotent_error_negative(self): + """Test that non-idempotent errors are not detected as idempotent errors""" + error_message = "Database error code: 42501 - permission denied" + assert ProxyExtrasDBManager._is_idempotent_error(error_message) is False - assert result is True # Should return True after waiting and acquiring lock - # set_cache is called 2 times: once in __enter__, once in wait_for_lock_release - assert mock_redis.set_cache.call_count == 2 - # Proceed for case handling in case of migration failure - mock_subprocess.assert_called_once() - @patch("litellm_proxy_extras.utils.ProxyExtrasDBManager._resolve_all_migrations") - @patch("litellm_proxy_extras.utils.subprocess.run") - @patch("litellm_proxy_extras.utils.ProxyExtrasDBManager._get_prisma_dir") - @patch("os.chdir") - def test_setup_database_without_redis( - self, mock_chdir, mock_get_prisma_dir, mock_subprocess, mock_resolve_migrations - ): - """Test database setup without Redis cache""" - # Setup mocks - mock_get_prisma_dir.return_value = "/test/prisma" - mock_subprocess.return_value = Mock(stdout="Migration completed", stderr="") - mock_resolve_migrations.return_value = None +class TestErrorClassificationPriority: + """Test cases to ensure errors are correctly classified""" - # Set DATABASE_URL - with patch.dict( - os.environ, {"DATABASE_URL": "postgresql://test:test@localhost/test"} - ): - result = ProxyExtrasDBManager.setup_database( - use_migrate=True, redis_cache=None - ) + def test_permission_error_not_classified_as_idempotent(self): + """Ensure permission errors are not mistakenly classified as idempotent""" + error_message = "Database error code: 42501 - must be owner of table users" + assert ProxyExtrasDBManager._is_permission_error(error_message) is True + assert ProxyExtrasDBManager._is_idempotent_error(error_message) is False - assert result is True - # Redis가 없을 때는 락 보호 없이 마이그레이션을 실행해야 함 - mock_subprocess.assert_called_once() + def test_idempotent_error_not_classified_as_permission(self): + """Ensure idempotent errors are not mistakenly classified as permission errors""" + error_message = "column 'created_at' already exists" + assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True + assert ProxyExtrasDBManager._is_permission_error(error_message) is False - def test_setup_database_no_database_url(self): - """Test database setup without DATABASE_URL""" - with patch.dict(os.environ, {}, clear=True): - result = ProxyExtrasDBManager.setup_database( - use_migrate=True, redis_cache=None - ) - - assert result is False - - @patch("litellm_proxy_extras.utils.subprocess.run") - @patch("litellm_proxy_extras.utils.ProxyExtrasDBManager._get_prisma_dir") - @patch("os.chdir") - @patch.object( - MigrationLockManager, "LOCK_TTL_SECONDS", 1 - ) # Set short TTL for testing - def test_setup_database_lock_timeout( - self, mock_chdir, mock_get_prisma_dir, mock_subprocess - ): - """Test database setup when lock acquisition times out""" - # Setup mocks - mock_get_prisma_dir.return_value = "/test/prisma" - - # Mock Redis cache - always fails to acquire lock - mock_redis = Mock() - mock_redis.set_cache.return_value = False # Always fails - - # Set DATABASE_URL - with patch.dict( - os.environ, {"DATABASE_URL": "postgresql://test:test@localhost/test"} - ): - # Patch the wait_for_lock_release method to use shorter timeout - with patch.object( - MigrationLockManager, "wait_for_lock_release" - ) as mock_wait: - mock_wait.return_value = False # Simulate timeout - - result = ProxyExtrasDBManager.setup_database( - use_migrate=True, redis_cache=mock_redis - ) - - assert result is False # Should return False after timeout - mock_subprocess.assert_not_called() # Should not run migration - # Verify that wait_for_lock_release was called with default parameters - mock_wait.assert_called_once_with() - - def test_wait_for_lock_release_actual_timeout(self): - """Test actual timeout behavior of wait_for_lock_release with real timing""" - mock_redis = Mock() - mock_redis.set_cache.return_value = False # Always fails to acquire lock - lock_manager = MigrationLockManager(mock_redis) - - # Test with very short timeout to verify actual timeout behavior - start_time = time.time() - result = lock_manager.wait_for_lock_release(check_interval=0.1, max_wait=0.5) - end_time = time.time() - - assert result is False # Should timeout - assert end_time - start_time >= 0.5 # Should wait at least the max_wait time - assert end_time - start_time < 1.0 # But not too much longer - # Should have called set_cache multiple times during the wait - assert mock_redis.set_cache.call_count > 1 + def test_unknown_error_classified_as_neither(self): + """Ensure unknown errors are classified as neither permission nor idempotent""" + error_message = "connection timeout" + assert ProxyExtrasDBManager._is_permission_error(error_message) is False + assert ProxyExtrasDBManager._is_idempotent_error(error_message) is False diff --git a/tests/llm_responses_api_testing/test_openai_responses_api.py b/tests/llm_responses_api_testing/test_openai_responses_api.py index 5f35d6837c0..7553c670774 100644 --- a/tests/llm_responses_api_testing/test_openai_responses_api.py +++ b/tests/llm_responses_api_testing/test_openai_responses_api.py @@ -1814,49 +1814,3 @@ async def test_extra_body_merges_with_request_data(extra_body_mock_response_data assert "temperature" in request_body assert "custom_field" in request_body assert request_body["custom_field"] == "custom_value" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sync_mode", [True, False]) -async def test_openai_compact_responses_api(sync_mode): - """ - Test the compact_responses API for OpenAI. - - This test verifies that the compact_responses endpoint works correctly - for compressing conversation history. - """ - litellm._turn_on_debug() - litellm.set_verbose = True - - input_messages = [ - {"role": "user", "content": "Hello, how are you?"}, - {"role": "assistant", "content": "I'm doing well, thank you for asking!"}, - {"role": "user", "content": "What is the weather like today?"}, - ] - - try: - if sync_mode: - response = litellm.compact_responses( - model="openai/gpt-4o", - input=input_messages, - instructions="Be helpful and concise", - ) - else: - response = await litellm.acompact_responses( - model="openai/gpt-4o", - input=input_messages, - instructions="Be helpful and concise", - ) - except litellm.InternalServerError: - pytest.skip("Skipping test due to InternalServerError") - except litellm.BadRequestError as e: - # compact_responses may not be available for all models/accounts - pytest.skip(f"Skipping test due to BadRequestError: {e}") - - print("compact_responses response=", json.dumps(response, indent=4, default=str)) - - # Validate response structure - assert response is not None - assert "id" in response, "Response should have an 'id' field" - assert "output" in response, "Response should have an 'output' field" - assert isinstance(response["output"], list), "Output should be a list" diff --git a/tests/llm_responses_api_testing/test_responses_hooks.py b/tests/llm_responses_api_testing/test_responses_hooks.py deleted file mode 100644 index 8c0f7dab2af..00000000000 --- a/tests/llm_responses_api_testing/test_responses_hooks.py +++ /dev/null @@ -1,165 +0,0 @@ -import asyncio -from datetime import datetime -from types import SimpleNamespace - -import httpx -import pytest - -import litellm -from litellm.integrations.custom_logger import CustomLogger -from litellm.responses import streaming_iterator as streaming_module -from litellm.responses.streaming_iterator import ResponsesAPIStreamingIterator -from litellm.types.llms.openai import ResponsesAPIStreamEvents -from litellm.types.utils import CallTypes - - -class _FakeLoggingObj: - def __init__(self): - self.success_calls = 0 - self.async_success_calls = 0 - self.failure_calls = 0 - self.async_failure_calls = 0 - self.start_time = datetime.now() - self.model_call_details = {"litellm_params": {}} - - # Signature alignment with Logging handlers - def success_handler(self, *args, **kwargs): - self.success_calls += 1 - - async def async_success_handler(self, *args, **kwargs): - self.async_success_calls += 1 - - def failure_handler(self, *args, **kwargs): - self.failure_calls += 1 - - async def async_failure_handler(self, *args, **kwargs): - self.async_failure_calls += 1 - - -@pytest.mark.asyncio -async def test_responses_streaming_triggers_hooks(monkeypatch): - """ - Ensure streaming iterator fires success + post-call hooks for responses API. - """ - hook_calls = {"post_call": 0, "metadata": 0} - seen = {} - - async def fake_post_call(request_data, response, call_type): - hook_calls["post_call"] += 1 - seen["request_data"] = request_data - seen["call_type"] = call_type - - def fake_update_metadata(**kwargs): - hook_calls["metadata"] += 1 - - monkeypatch.setattr( - streaming_module, - "async_post_call_success_deployment_hook", - fake_post_call, - ) - monkeypatch.setattr( - streaming_module, - "update_response_metadata", - fake_update_metadata, - ) - - logging_obj = _FakeLoggingObj() - - iterator = ResponsesAPIStreamingIterator( - response=httpx.Response(200), - model="test-model", - responses_api_provider_config=SimpleNamespace(), # not used in this test - logging_obj=logging_obj, - request_data={"foo": "bar", "litellm_params": {}}, - call_type=CallTypes.responses.value, - ) - - # Simulate completed streaming event - iterator.completed_response = SimpleNamespace( - type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, response=SimpleNamespace() - ) - - iterator._handle_logging_completed_response() - await asyncio.sleep(0.2) # allow async tasks to run - - assert logging_obj.success_calls == 1 - assert logging_obj.async_success_calls == 1 - assert hook_calls["post_call"] == 1 - assert hook_calls["metadata"] == 1 - assert seen["request_data"]["foo"] == "bar" - assert seen["request_data"].get("litellm_params") is not None - assert seen["call_type"] == CallTypes.responses - - -@pytest.mark.asyncio -async def test_responses_streaming_calls_post_streaming_deployment_hook(monkeypatch): - """ - Ensure per-chunk streaming deployment hook can modify chunks. - """ - - class _HookLogger(CustomLogger): - async def async_post_call_streaming_deployment_hook( - self, request_data, response_chunk, call_type - ): - response_chunk.tagged = True - return response_chunk - - # Set callbacks to our fake hook - original_callbacks = litellm.callbacks - litellm.callbacks = [_HookLogger()] - - logging_obj = _FakeLoggingObj() - - class _StubConfig: - def transform_streaming_response(self, **kwargs): - return SimpleNamespace( - type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, response=None - ) - - iterator = ResponsesAPIStreamingIterator( - response=httpx.Response(200), - model="test-model", - responses_api_provider_config=_StubConfig(), - logging_obj=logging_obj, - request_data={"foo": "bar"}, - call_type=CallTypes.responses.value, - ) - - # Call hook helper directly to verify chunk is modified/flagged - chunk = SimpleNamespace(type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, response=None) - chunk = await streaming_module.call_post_streaming_hooks_for_testing(iterator, chunk) - assert getattr(chunk, "_post_streaming_hooks_ran", False) is True - assert getattr(chunk, "tagged", False) is True - - # reset callbacks - litellm.callbacks = original_callbacks - - -@pytest.mark.asyncio -async def test_responses_streaming_failure_triggers_failure_handlers(): - """ - If transform raises, failure handlers should be called. - """ - - class _FailConfig: - def transform_streaming_response(self, **kwargs): - raise ValueError("boom") - - logging_obj = _FakeLoggingObj() - - iterator = ResponsesAPIStreamingIterator( - response=httpx.Response(200), - model="test-model", - responses_api_provider_config=_FailConfig(), - logging_obj=logging_obj, - request_data={"foo": "bar"}, - call_type=CallTypes.responses.value, - ) - - with pytest.raises(ValueError): - iterator._process_chunk('{"delta": "chunk"}') - - # allow failure callbacks to run - await asyncio.sleep(0.2) - assert logging_obj.failure_calls >= 1 - assert logging_obj.async_failure_calls >= 1 diff --git a/tests/llm_translation/test_anthropic_completion.py b/tests/llm_translation/test_anthropic_completion.py index ab5709cd72d..7c849650bf6 100644 --- a/tests/llm_translation/test_anthropic_completion.py +++ b/tests/llm_translation/test_anthropic_completion.py @@ -385,7 +385,7 @@ def test_anthropic_tool_use(tool_type, tool_config, message_content): "computer_tool_used, prompt_caching_set, expected_beta_header", [ (True, False, True), - (False, True, False), + (False, True, True), (True, True, True), (False, False, False), ], diff --git a/tests/llm_translation/test_databricks.py b/tests/llm_translation/test_databricks.py index 3013d00288f..40fc712f2b7 100644 --- a/tests/llm_translation/test_databricks.py +++ b/tests/llm_translation/test_databricks.py @@ -15,7 +15,6 @@ import litellm from litellm.exceptions import BadRequestError from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.utils import CustomStreamWrapper -from litellm._version import version from base_llm_unit_tests import BaseLLMChatTest, BaseAnthropicChatTest try: @@ -726,7 +725,6 @@ def test_embeddings_with_sync_http_handler(monkeypatch): headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", - "User-Agent": f"litellm/{version}", }, data=json.dumps( { @@ -769,7 +767,6 @@ def test_embeddings_with_async_http_handler(monkeypatch): headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", - "User-Agent": f"litellm/{version}", }, data=json.dumps( { @@ -826,7 +823,6 @@ def test_embeddings_uses_databricks_sdk_if_api_key_and_base_not_specified(monkey headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", - "User-Agent": f"litellm/{version}", }, data=json.dumps( { @@ -899,7 +895,6 @@ async def test_databricks_embeddings(sync_mode, monkeypatch): headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", - "User-Agent": f"litellm/{version}", }, data=json.dumps( { @@ -928,7 +923,6 @@ async def test_databricks_embeddings(sync_mode, monkeypatch): headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", - "User-Agent": f"litellm/{version}", }, data=json.dumps( { diff --git a/tests/llm_translation/test_gigachat.py b/tests/llm_translation/test_gigachat.py deleted file mode 100644 index 80bf51b4646..00000000000 --- a/tests/llm_translation/test_gigachat.py +++ /dev/null @@ -1,349 +0,0 @@ -""" -Tests for GigaChat LiteLLM Provider - -Tests message transformation, parameter handling, and response transformation. -Run with: pytest tests/llm_translation/test_gigachat.py -v -""" - -import json -import pytest -from unittest.mock import Mock, MagicMock - - -class TestGigaChatMessageTransformation: - """Tests for message transformation (OpenAI -> GigaChat format)""" - - @pytest.fixture - def config(self): - from litellm.llms.gigachat.chat.transformation import GigaChatConfig - return GigaChatConfig() - - def test_simple_user_message(self, config): - """Basic user message should pass through""" - messages = [{"role": "user", "content": "Hello"}] - result = config._transform_messages(messages) - - assert len(result) == 1 - assert result[0]["role"] == "user" - assert result[0]["content"] == "Hello" - - def test_developer_role_to_system(self, config): - """Developer role should be converted to system""" - messages = [{"role": "developer", "content": "You are helpful"}] - result = config._transform_messages(messages) - - assert result[0]["role"] == "system" - - def test_system_after_first_becomes_user(self, config): - """System message after first position should become user""" - messages = [ - {"role": "assistant", "content": "Response"}, - {"role": "system", "content": "Additional instruction"}, - ] - result = config._transform_messages(messages) - - assert result[0]["role"] == "assistant" - assert result[1]["role"] == "user" # system after first becomes user - - def test_tool_role_to_function(self, config): - """Tool role should be converted to function""" - messages = [{"role": "tool", "content": "result data"}] - result = config._transform_messages(messages) - - assert result[0]["role"] == "function" - - def test_tool_calls_to_function_call(self, config): - """tool_calls should be converted to function_call""" - messages = [{ - "role": "assistant", - "content": "", - "tool_calls": [{ - "id": "call_123", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{"city": "Moscow"}' - } - }] - }] - result = config._transform_messages(messages) - - assert "function_call" in result[0] - assert result[0]["function_call"]["name"] == "get_weather" - assert result[0]["function_call"]["arguments"] == {"city": "Moscow"} - assert "tool_calls" not in result[0] - - def test_none_content_becomes_empty_string(self, config): - """None content should become empty string""" - messages = [{"role": "assistant", "content": None}] - result = config._transform_messages(messages) - - assert result[0]["content"] == "" - - def test_name_field_removed(self, config): - """name field should be removed (not supported by GigaChat)""" - messages = [{"role": "user", "content": "Hi", "name": "John"}] - result = config._transform_messages(messages) - - assert "name" not in result[0] - - -class TestGigaChatCollapseUserMessages: - """Tests for collapsing consecutive user messages""" - - @pytest.fixture - def config(self): - from litellm.llms.gigachat.chat.transformation import GigaChatConfig - return GigaChatConfig() - - def test_no_collapse_single_message(self, config): - """Single message should not be changed""" - messages = [{"role": "user", "content": "Hello"}] - result = config._collapse_user_messages(messages) - - assert len(result) == 1 - assert result[0]["content"] == "Hello" - - def test_collapse_consecutive_user_messages(self, config): - """Consecutive user messages should be collapsed""" - messages = [ - {"role": "user", "content": "First"}, - {"role": "user", "content": "Second"}, - {"role": "user", "content": "Third"}, - ] - result = config._collapse_user_messages(messages) - - assert len(result) == 1 - assert "First" in result[0]["content"] - assert "Second" in result[0]["content"] - assert "Third" in result[0]["content"] - - def test_no_collapse_with_assistant_between(self, config): - """Messages with assistant between should not be collapsed""" - messages = [ - {"role": "user", "content": "First"}, - {"role": "assistant", "content": "Response"}, - {"role": "user", "content": "Second"}, - ] - result = config._collapse_user_messages(messages) - - assert len(result) == 3 - - -class TestGigaChatToolsTransformation: - """Tests for tools -> functions conversion""" - - @pytest.fixture - def config(self): - from litellm.llms.gigachat.chat.transformation import GigaChatConfig - return GigaChatConfig() - - def test_single_tool_conversion(self, config): - """Single tool should be converted correctly""" - tools = [{ - "type": "function", - "function": { - "name": "get_weather", - "description": "Get weather for a city", - "parameters": { - "type": "object", - "properties": { - "city": {"type": "string"} - } - } - } - }] - result = config._convert_tools_to_functions(tools) - - assert len(result) == 1 - assert result[0]["name"] == "get_weather" - assert result[0]["description"] == "Get weather for a city" - - def test_multiple_tools_conversion(self, config): - """Multiple tools should all be converted""" - tools = [ - {"type": "function", "function": {"name": "func1", "description": "First", "parameters": {"type": "object", "properties": {}}}}, - {"type": "function", "function": {"name": "func2", "description": "Second", "parameters": {"type": "object", "properties": {}}}}, - ] - result = config._convert_tools_to_functions(tools) - - assert len(result) == 2 - assert result[0]["name"] == "func1" - assert result[1]["name"] == "func2" - - -class TestGigaChatParamsTransformation: - """Tests for parameter transformation""" - - @pytest.fixture - def config(self): - from litellm.llms.gigachat.chat.transformation import GigaChatConfig - return GigaChatConfig() - - def test_temperature_zero_becomes_top_p_zero(self, config): - """temperature=0 should become top_p=0""" - params = {"temperature": 0} - result = config.map_openai_params( - non_default_params=params, - optional_params={}, - model="GigaChat", - drop_params=False, - ) - - assert "top_p" in result - assert result["top_p"] == 0 - assert "temperature" not in result - - def test_temperature_nonzero_preserved(self, config): - """Non-zero temperature should be preserved""" - params = {"temperature": 0.7} - result = config.map_openai_params( - non_default_params=params, - optional_params={}, - model="GigaChat", - drop_params=False, - ) - - assert result["temperature"] == 0.7 - - def test_max_completion_tokens_to_max_tokens(self, config): - """max_completion_tokens should become max_tokens""" - params = {"max_completion_tokens": 100} - result = config.map_openai_params( - non_default_params=params, - optional_params={}, - model="GigaChat", - drop_params=False, - ) - - assert result["max_tokens"] == 100 - - def test_structured_output_via_json_schema(self, config): - """json_schema response_format should trigger structured output mode""" - params = { - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "person", - "schema": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - } - } - } - } - } - result = config.map_openai_params( - non_default_params=params, - optional_params={}, - model="GigaChat", - drop_params=False, - ) - - assert "_structured_output" in result - assert result["_structured_output"] is True - assert "function_call" in result - assert result["function_call"]["name"] == "person" - - -class TestGigaChatProviderRegistration: - """Tests for provider registration in LiteLLM""" - - def test_gigachat_in_provider_list(self): - """GigaChat should be in provider list""" - from litellm.types.utils import LlmProviders - - assert hasattr(LlmProviders, "GIGACHAT") - assert LlmProviders.GIGACHAT.value == "gigachat" - - def test_gigachat_in_chat_providers(self): - """GigaChat should be in LITELLM_CHAT_PROVIDERS""" - from litellm.constants import LITELLM_CHAT_PROVIDERS - - assert "gigachat" in LITELLM_CHAT_PROVIDERS - - def test_gigachat_key_exists(self): - """gigachat_key should be available""" - import litellm - - assert hasattr(litellm, "gigachat_key") - - def test_gigachat_config_exists(self): - """GigaChatConfig should be available""" - import litellm - - assert hasattr(litellm, "GigaChatConfig") - - -class TestGigaChatTransformRequest: - """Tests for request transformation""" - - @pytest.fixture - def config(self): - from litellm.llms.gigachat.chat.transformation import GigaChatConfig - return GigaChatConfig() - - def test_basic_request(self, config): - """Basic request should be transformed correctly""" - messages = [{"role": "user", "content": "Hello"}] - result = config.transform_request( - model="gigachat/GigaChat", - messages=messages, - optional_params={}, - litellm_params={}, - headers={}, - ) - - assert result["model"] == "GigaChat" - assert len(result["messages"]) == 1 - assert result["messages"][0]["role"] == "user" - - def test_request_with_temperature(self, config): - """Request with temperature should include it""" - messages = [{"role": "user", "content": "Hello"}] - result = config.transform_request( - model="gigachat/GigaChat", - messages=messages, - optional_params={"temperature": 0.7}, - litellm_params={}, - headers={}, - ) - - assert result["temperature"] == 0.7 - - def test_request_with_functions(self, config): - """Request with functions should include them""" - messages = [{"role": "user", "content": "Hello"}] - functions = [{"name": "test", "description": "Test", "parameters": {}}] - result = config.transform_request( - model="gigachat/GigaChat", - messages=messages, - optional_params={"functions": functions}, - litellm_params={}, - headers={}, - ) - - assert "functions" in result - assert len(result["functions"]) == 1 - - -class TestGigaChatSupportedParams: - """Tests for supported parameters""" - - @pytest.fixture - def config(self): - from litellm.llms.gigachat.chat.transformation import GigaChatConfig - return GigaChatConfig() - - def test_supported_params(self, config): - """Check supported parameters list""" - supported = config.get_supported_openai_params("GigaChat") - - assert "temperature" in supported - assert "max_tokens" in supported - assert "max_completion_tokens" in supported - assert "tools" in supported - assert "response_format" in supported - assert "stream" in supported diff --git a/tests/local_testing/test_completion.py b/tests/local_testing/test_completion.py index 5e92c10fbdc..2b01b4c2a12 100644 --- a/tests/local_testing/test_completion.py +++ b/tests/local_testing/test_completion.py @@ -286,7 +286,7 @@ def test_completion_claude_3_empty_response(): }, ] try: - response = litellm.completion(model="claude-3-7-sonnet-20250219", messages=messages) + response = litellm.completion(model="claude-3-opus-20240229", messages=messages) print(response) except litellm.InternalServerError as e: pytest.skip(f"InternalServerError - {str(e)}") @@ -313,7 +313,7 @@ def test_completion_claude_3(): try: # test without max tokens response = completion( - model="anthropic/claude-3-7-sonnet-20250219", + model="anthropic/claude-3-opus-20240229", messages=messages, ) # Add any assertions, here to check response args @@ -326,7 +326,7 @@ def test_completion_claude_3(): @pytest.mark.parametrize( "model", - ["anthropic/claude-3-7-sonnet-20250219", "anthropic.claude-3-sonnet-20240229-v1:0"], + ["anthropic/claude-3-opus-20240229", "anthropic.claude-3-sonnet-20240229-v1:0"], ) def test_completion_claude_3_function_call(model): litellm.set_verbose = True @@ -411,7 +411,7 @@ def test_completion_claude_3_function_call(model): "model, api_key, api_base", [ ("gpt-3.5-turbo", None, None), - ("claude-3-7-sonnet-20250219", None, None), + ("claude-3-opus-20240229", None, None), ("anthropic.claude-3-sonnet-20240229-v1:0", None, None), # ( # "azure_ai/command-r-plus", @@ -512,7 +512,7 @@ async def test_anthropic_no_content_error(): try: litellm.drop_params = True response = await litellm.acompletion( - model="anthropic/claude-3-7-sonnet-20250219", + model="anthropic/claude-3-opus-20240229", api_key=os.getenv("ANTHROPIC_API_KEY"), messages=[ { @@ -630,7 +630,7 @@ def test_completion_claude_3_multi_turn_conversations(): ] try: response = completion( - model="anthropic/claude-3-7-sonnet-20250219", + model="anthropic/claude-3-opus-20240229", messages=messages, ) print(response) @@ -644,7 +644,7 @@ def test_completion_claude_3_stream(): try: # test without max tokens response = completion( - model="anthropic/claude-3-7-sonnet-20250219", + model="anthropic/claude-3-opus-20240229", messages=messages, max_tokens=10, stream=True, @@ -669,7 +669,7 @@ def encode_image(image_path): [ "gpt-4o", "azure/gpt-4.1-mini", - "anthropic/claude-3-7-sonnet-20250219", + "anthropic/claude-3-opus-20240229", ], ) # def test_completion_base64(model): diff --git a/tests/local_testing/test_streaming.py b/tests/local_testing/test_streaming.py index b9b5d0fdb07..0d9f84a301c 100644 --- a/tests/local_testing/test_streaming.py +++ b/tests/local_testing/test_streaming.py @@ -1418,7 +1418,7 @@ def test_bedrock_claude_3_streaming(): @pytest.mark.parametrize( "model", [ - "claude-3-7-sonnet-20250219", + "claude-3-opus-20240229", "cohere.command-r-plus-v1:0", # bedrock "gpt-3.5-turbo", ], @@ -2914,7 +2914,7 @@ def test_completion_claude_3_function_call_with_streaming(): try: # test without max tokens response = completion( - model="claude-3-7-sonnet-20250219", + model="claude-3-opus-20240229", messages=messages, tools=tools, tool_choice="required", @@ -2946,7 +2946,7 @@ def test_completion_claude_3_function_call_with_streaming(): "model", [ "gemini/gemini-2.5-flash-lite", - ], + ], # "claude-3-opus-20240229" ) # @pytest.mark.asyncio async def test_acompletion_function_call_with_streaming(model): diff --git a/tests/logging_callback_tests/test_langsmith_unit_test.py b/tests/logging_callback_tests/test_langsmith_unit_test.py index bde2b944579..e63ce9f8b38 100644 --- a/tests/logging_callback_tests/test_langsmith_unit_test.py +++ b/tests/logging_callback_tests/test_langsmith_unit_test.py @@ -47,19 +47,6 @@ async def test_get_credentials_from_env(): credentials = logger.get_credentials_from_env() assert credentials["LANGSMITH_BASE_URL"] == "https://api.smith.langchain.com" - # Test with tenant_id - credentials = logger.get_credentials_from_env( - langsmith_tenant_id="test-tenant-id" - ) - assert credentials["LANGSMITH_TENANT_ID"] == "test-tenant-id" - - # Test tenant_id from environment variable - import os - os.environ["LANGSMITH_TENANT_ID"] = "env-tenant-id" - credentials = logger.get_credentials_from_env() - assert credentials["LANGSMITH_TENANT_ID"] == "env-tenant-id" - del os.environ["LANGSMITH_TENANT_ID"] - @pytest.mark.asyncio async def test_group_batches_by_credentials(): @@ -73,7 +60,6 @@ async def test_group_batches_by_credentials(): "LANGSMITH_API_KEY": "key1", "LANGSMITH_PROJECT": "proj1", "LANGSMITH_BASE_URL": "url1", - "LANGSMITH_TENANT_ID": None, }, ) @@ -83,7 +69,6 @@ async def test_group_batches_by_credentials(): "LANGSMITH_API_KEY": "key1", "LANGSMITH_PROJECT": "proj1", "LANGSMITH_BASE_URL": "url1", - "LANGSMITH_TENANT_ID": None, }, ) @@ -110,7 +95,6 @@ async def test_group_batches_by_credentials_multiple_credentials(): "LANGSMITH_API_KEY": "key1", "LANGSMITH_PROJECT": "proj1", "LANGSMITH_BASE_URL": "url1", - "LANGSMITH_TENANT_ID": None, }, ) @@ -120,7 +104,6 @@ async def test_group_batches_by_credentials_multiple_credentials(): "LANGSMITH_API_KEY": "key2", # Different API key "LANGSMITH_PROJECT": "proj1", "LANGSMITH_BASE_URL": "url1", - "LANGSMITH_TENANT_ID": None, }, ) @@ -130,7 +113,6 @@ async def test_group_batches_by_credentials_multiple_credentials(): "LANGSMITH_API_KEY": "key1", "LANGSMITH_PROJECT": "proj2", # Different project "LANGSMITH_BASE_URL": "url1", - "LANGSMITH_TENANT_ID": None, }, ) @@ -145,57 +127,6 @@ async def test_group_batches_by_credentials_multiple_credentials(): assert len(batch_group.queue_objects) == 1 # Each group should have one object -@pytest.mark.asyncio -async def test_group_batches_by_credentials_with_tenant_id(): - - # Test that different tenant_ids create separate groups - logger = LangsmithLogger(langsmith_api_key="test-key") - - queue_obj1 = LangsmithQueueObject( - data={"test": "data1"}, - credentials={ - "LANGSMITH_API_KEY": "key1", - "LANGSMITH_PROJECT": "proj1", - "LANGSMITH_BASE_URL": "url1", - "LANGSMITH_TENANT_ID": "tenant1", - }, - ) - - queue_obj2 = LangsmithQueueObject( - data={"test": "data2"}, - credentials={ - "LANGSMITH_API_KEY": "key1", - "LANGSMITH_PROJECT": "proj1", - "LANGSMITH_BASE_URL": "url1", - "LANGSMITH_TENANT_ID": "tenant2", # Different tenant_id - }, - ) - - queue_obj3 = LangsmithQueueObject( - data={"test": "data3"}, - credentials={ - "LANGSMITH_API_KEY": "key1", - "LANGSMITH_PROJECT": "proj1", - "LANGSMITH_BASE_URL": "url1", - "LANGSMITH_TENANT_ID": "tenant1", # Same as queue_obj1 - }, - ) - - logger.log_queue = [queue_obj1, queue_obj2, queue_obj3] - - grouped = logger._group_batches_by_credentials() - - # Should have two groups: one for tenant1 (queue_obj1 and queue_obj3), one for tenant2 (queue_obj2) - assert len(grouped) == 2 - for key, batch_group in grouped.items(): - assert isinstance(key, CredentialsKey) - assert key.tenant_id in ["tenant1", "tenant2"] - if key.tenant_id == "tenant1": - assert len(batch_group.queue_objects) == 2 - else: - assert len(batch_group.queue_objects) == 1 - - # Test make_dot_order @pytest.mark.asyncio async def test_make_dot_order(): @@ -270,43 +201,10 @@ async def test_async_send_batch(): call_args = logger.async_httpx_client.post.call_args assert "runs/batch" in call_args[1]["url"] assert "x-api-key" in call_args[1]["headers"] - # tenant_id should not be in headers if not provided - assert "x-tenant-id" not in call_args[1]["headers"] @pytest.mark.asyncio -async def test_async_send_batch_with_tenant_id(): - logger = LangsmithLogger( - langsmith_api_key="test-key", - langsmith_tenant_id="test-tenant-id" - ) - - # Mock the httpx client - mock_response = AsyncMock() - mock_response.status_code = 200 - logger.async_httpx_client = AsyncMock() - logger.async_httpx_client.post.return_value = mock_response - - # Add test data to queue - logger.log_queue = [ - LangsmithQueueObject( - data={"test": "data"}, credentials=logger.default_credentials - ) - ] - - await logger.async_send_batch() - - # Verify the API call includes tenant_id header - logger.async_httpx_client.post.assert_called_once() - call_args = logger.async_httpx_client.post.call_args - assert "runs/batch" in call_args[1]["url"] - assert "x-api-key" in call_args[1]["headers"] - assert "x-tenant-id" in call_args[1]["headers"] - assert call_args[1]["headers"]["x-tenant-id"] == "test-tenant-id" - - -@pytest.mark.asyncio -async def test_langsmith_key_based_logging(): +async def test_langsmith_key_based_logging(mocker): """ In key based logging langsmith_api_key and langsmith_project are passed directly to litellm.acompletion """ @@ -321,11 +219,10 @@ async def test_langsmith_key_based_logging(): mock_response.text = "" mock_async_httpx_handler.post = AsyncMock(return_value=mock_response) - mock_get_client = patch( + mock_get_client = mocker.patch( "litellm.integrations.langsmith.get_async_httpx_client", return_value=mock_async_httpx_handler ) - mock_get_client.start() litellm.set_verbose = True litellm.DEFAULT_FLUSH_INTERVAL_SECONDS = 1 @@ -356,8 +253,6 @@ async def test_langsmith_key_based_logging(): # Check headers contain the correct API key assert call_args[1]["headers"]["x-api-key"] == "fake_key_project2" - # tenant_id should not be in headers if not provided - assert "x-tenant-id" not in call_args[1]["headers"] # Verify the request body contains the expected data request_body = call_args[1]["json"] @@ -449,8 +344,6 @@ async def test_langsmith_key_based_logging(): actual_body["post"][0]["session_name"] == expected_body["post"][0]["session_name"] ) - - mock_get_client.stop() except Exception as e: pytest.fail(f"Error occurred: {e}") diff --git a/tests/logging_callback_tests/test_opentelemetry_unit_tests.py b/tests/logging_callback_tests/test_opentelemetry_unit_tests.py index 04f8abe64de..3d0682d9033 100644 --- a/tests/logging_callback_tests/test_opentelemetry_unit_tests.py +++ b/tests/logging_callback_tests/test_opentelemetry_unit_tests.py @@ -65,6 +65,31 @@ class TestOpentelemetryUnitTests(BaseLoggingCallbackTest): # External spans should only be closed by their creators parent_otel_span.end.assert_not_called() + def test_init_tracing_respects_existing_tracer_provider(self): + """ + Unit test: _init_tracing() should respect existing TracerProvider. + + When a TracerProvider already exists (e.g., set by Langfuse SDK), + LiteLLM should use it instead of creating a new one. + """ + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from litellm.integrations.opentelemetry import OpenTelemetry + + # Setup: Create and set an existing TracerProvider + tracer_provider = TracerProvider() + trace.set_tracer_provider(tracer_provider) + existing_provider = trace.get_tracer_provider() + + # Act: Initialize OpenTelemetry integration (should detect existing provider) + otel_integration = OpenTelemetry() + + # Assert: The existing provider should still be active + current_provider = trace.get_tracer_provider() + assert current_provider is existing_provider, ( + "Existing TracerProvider should be respected and not overridden" + ) + def test_get_span_context_detects_active_span(self): """ Unit test: _get_span_context() should auto-detect active spans from global context. diff --git a/tests/router_unit_tests/test_router_helper_utils.py b/tests/router_unit_tests/test_router_helper_utils.py index 45aae3b9aee..40e223ffe07 100644 --- a/tests/router_unit_tests/test_router_helper_utils.py +++ b/tests/router_unit_tests/test_router_helper_utils.py @@ -79,73 +79,6 @@ def test_routing_strategy_init(model_list): ) -def test_routing_strategy_init_invalid_strategy(model_list): - """Test that invalid routing_strategy raises ValueError with helpful message. - - See: https://github.com/BerriAI/litellm/issues/11330 - Invalid strategies like 'simple' (without '-shuffle') should fail fast - with a clear error, not silently cause 'No deployments available' errors. - """ - router = Router(model_list=model_list) - - # Test common mistake: "simple" instead of "simple-shuffle" - with pytest.raises(ValueError) as exc_info: - router.routing_strategy_init( - routing_strategy="simple", - routing_strategy_args={} - ) - - # Verify error message is helpful - error_msg = str(exc_info.value) - assert "Invalid routing_strategy" in error_msg - assert "simple" in error_msg - assert "simple-shuffle" in error_msg # Suggests the correct option - # Verify error message tells user WHERE to fix it - assert "config.yaml" in error_msg - assert "router_settings.routing_strategy" in error_msg - assert "Router SDK" in error_msg - - # Test completely invalid strategy - with pytest.raises(ValueError) as exc_info: - router.routing_strategy_init( - routing_strategy="not-a-real-strategy", - routing_strategy_args={} - ) - assert "Invalid routing_strategy" in str(exc_info.value) - - -def test_routing_strategy_init_valid_string_strategies(model_list): - """Test that all valid string routing strategies work without error. - - Valid strategies are derived from RoutingStrategy enum values plus 'simple-shuffle'. - """ - from litellm.types.router import RoutingStrategy - - router = Router(model_list=model_list) - - # All strategies from enum + simple-shuffle (default, not in enum) - valid_strategies = ["simple-shuffle"] + [s.value for s in RoutingStrategy] - - for strategy in valid_strategies: - # Should not raise - router.routing_strategy_init( - routing_strategy=strategy, routing_strategy_args={} - ) - - -def test_routing_strategy_init_valid_enum_strategies(model_list): - """Test that RoutingStrategy enum values work without error.""" - from litellm.types.router import RoutingStrategy - - router = Router(model_list=model_list) - - for strategy in RoutingStrategy: - # Should not raise when passing enum directly - router.routing_strategy_init( - routing_strategy=strategy, routing_strategy_args={} - ) - - def test_print_deployment(model_list): """Test if the api key is masked correctly""" diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 596398e639f..6490352c39b 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -1011,87 +1011,39 @@ def test_multiple_tool_calls_in_single_choice(): def test_map_reasoning_effort_adds_summary_detailed(): """ - Test that _map_reasoning_effort behavior with reasoning_auto_summary flag. + Test that _map_reasoning_effort adds summary="detailed" when user provides reasoning_effort as a string. - By default (flag=False), summary should NOT be added to avoid: - 1. Breaking for users without verified OpenAI orgs (400 errors) - 2. Making requests more expensive by including summary reasoning tokens - - When flag is enabled (flag=True or env var), summary="detailed" is added. + This ensures that when users pass reasoning_effort in the completions API for OpenAI responses/models, + the transformation automatically includes summary="detailed" in the reasoning parameter. """ - import os - - import litellm from litellm.completion_extras.litellm_responses_transformation.transformation import ( LiteLLMResponsesTransformationHandler, ) handler = LiteLLMResponsesTransformationHandler() - # Test all string effort levels - DEFAULT BEHAVIOR (no summary) + # Test all string effort levels effort_levels = ["none", "low", "medium", "high", "xhigh", "minimal"] - # Save original flag value - original_flag = litellm.reasoning_auto_summary - original_env = os.environ.get("LITELLM_REASONING_AUTO_SUMMARY") + for effort in effort_levels: + result = handler._map_reasoning_effort(effort) + + assert result is not None, f"Result should not be None for effort={effort}" + assert result["effort"] == effort, f"Effort should be {effort}" + assert result["summary"] == "detailed", f"Summary should be 'detailed' for effort={effort}" + + print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}', summary='detailed'") - try: - # Test 1: Default behavior (flag=False, no env var) - NO summary - litellm.reasoning_auto_summary = False - if "LITELLM_REASONING_AUTO_SUMMARY" in os.environ: - del os.environ["LITELLM_REASONING_AUTO_SUMMARY"] - - for effort in effort_levels: - result = handler._map_reasoning_effort(effort) - - assert result is not None, f"Result should not be None for effort={effort}" - assert result["effort"] == effort, f"Effort should be {effort}" - assert "summary" not in result, f"Summary should NOT be present by default for effort={effort}" - - print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}' (no summary by default)") - - # Test 2: With flag enabled - summary IS added - litellm.reasoning_auto_summary = True - - for effort in effort_levels: - result = handler._map_reasoning_effort(effort) - - assert result is not None, f"Result should not be None for effort={effort}" - assert result["effort"] == effort, f"Effort should be {effort}" - assert result["summary"] == "detailed", f"Summary should be 'detailed' when flag is enabled for effort={effort}" - - print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}', summary='detailed' (flag enabled)") - - # Test 3: With env var enabled (flag disabled) - summary IS added - litellm.reasoning_auto_summary = False - os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = "true" - - result = handler._map_reasoning_effort("high") - assert result["summary"] == "detailed", "Summary should be 'detailed' when env var is enabled" - print("✓ LITELLM_REASONING_AUTO_SUMMARY env var works correctly") - - # Test 4: Dict input is passed through as-is (no modification) - litellm.reasoning_auto_summary = False - if "LITELLM_REASONING_AUTO_SUMMARY" in os.environ: - del os.environ["LITELLM_REASONING_AUTO_SUMMARY"] - - dict_input = {"effort": "high", "summary": "custom_summary"} - result_dict = handler._map_reasoning_effort(dict_input) - assert result_dict["effort"] == "high" - assert result_dict["summary"] == "custom_summary" - print("✓ Dict input is passed through without modification") - - # Test 5: None/unknown values return None - result_unknown = handler._map_reasoning_effort("unknown_value") - assert result_unknown is None - print("✓ Unknown reasoning_effort values return None") - - print("✓ All reasoning_effort behaviors work correctly with flag/env var control") + # Test that dict input is passed through as-is (no modification) + dict_input = {"effort": "high", "summary": "custom_summary"} + result_dict = handler._map_reasoning_effort(dict_input) + assert result_dict["effort"] == "high" + assert result_dict["summary"] == "custom_summary" + print("✓ Dict input is passed through without modification") - finally: - # Restore original values - litellm.reasoning_auto_summary = original_flag - if original_env is not None: - os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = original_env - elif "LITELLM_REASONING_AUTO_SUMMARY" in os.environ: - del os.environ["LITELLM_REASONING_AUTO_SUMMARY"] + # Test that None/unknown values return None + result_unknown = handler._map_reasoning_effort("unknown_value") + assert result_unknown is None + print("✓ Unknown reasoning_effort values return None") + + print("✓ All reasoning_effort string values correctly map to summary='detailed'") diff --git a/tests/test_litellm/integrations/langfuse/test_gemini_cached_tokens.py b/tests/test_litellm/integrations/langfuse/test_gemini_cached_tokens.py deleted file mode 100644 index e717840ec95..00000000000 --- a/tests/test_litellm/integrations/langfuse/test_gemini_cached_tokens.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Test for Langfuse integration with Gemini cached_tokens bug -https://github.com/BerriAI/litellm/issues/18520 -""" -import pytest -from litellm.types.utils import PromptTokensDetailsWrapper, Usage - - -def test_cached_tokens_extraction(): - """ - Test that we can extract cached_tokens from prompt_tokens_details. - This is the core logic fix for https://github.com/BerriAI/litellm/issues/18520 - """ - # Create usage object like Gemini returns - usage = Usage( - prompt_tokens=20209, - prompt_tokens_details=PromptTokensDetailsWrapper( - cached_tokens=20203, - text_tokens=6, - ), - completion_tokens=541, - ) - - # Simulate the logic from langfuse.py lines 745-757 (after the fix) - cache_read_input_tokens = 0 # Default value - - # Check prompt_tokens_details.cached_tokens (the fix) - if hasattr(usage, "prompt_tokens_details"): - prompt_tokens_details = getattr(usage, "prompt_tokens_details", None) - if ( - prompt_tokens_details is not None - and hasattr(prompt_tokens_details, "cached_tokens") - ): - cached_tokens = getattr(prompt_tokens_details, "cached_tokens", None) - if cached_tokens is not None and cached_tokens > 0: - cache_read_input_tokens = cached_tokens - - # Verify the fix works - assert cache_read_input_tokens == 20203, f"Expected 20203, got {cache_read_input_tokens}" - - -def test_cached_tokens_not_present(): - """Test backward compatibility when cached_tokens is not present""" - # Usage without prompt_tokens_details - usage = Usage( - prompt_tokens=100, - completion_tokens=50, - ) - - cache_read_input_tokens = 0 - - if hasattr(usage, "prompt_tokens_details"): - prompt_tokens_details = getattr(usage, "prompt_tokens_details", None) - if ( - prompt_tokens_details is not None - and hasattr(prompt_tokens_details, "cached_tokens") - ): - cached_tokens = getattr(prompt_tokens_details, "cached_tokens", None) - if cached_tokens is not None and cached_tokens > 0: - cache_read_input_tokens = cached_tokens - - # Should remain 0 - assert cache_read_input_tokens == 0 - - -def test_cached_tokens_is_zero(): - """Test when cached_tokens is explicitly 0""" - usage = Usage( - prompt_tokens=100, - prompt_tokens_details=PromptTokensDetailsWrapper( - cached_tokens=0, - text_tokens=100, - ), - completion_tokens=50, - ) - - cache_read_input_tokens = 0 - - if hasattr(usage, "prompt_tokens_details"): - prompt_tokens_details = getattr(usage, "prompt_tokens_details", None) - if ( - prompt_tokens_details is not None - and hasattr(prompt_tokens_details, "cached_tokens") - ): - cached_tokens = getattr(prompt_tokens_details, "cached_tokens", None) - if cached_tokens is not None and cached_tokens > 0: - cache_read_input_tokens = cached_tokens - - # Should remain 0 when cached_tokens is 0 - assert cache_read_input_tokens == 0 diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 6c17570e135..5d648f601f6 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -172,86 +172,6 @@ class TestOpenTelemetryCostBreakdown(unittest.TestCase): assert ("gen_ai.cost.original_cost", 0.004) not in call_args_list -class TestOpenTelemetryProviderInitialization(unittest.TestCase): - """Test suite for verifying provider initialization respects existing providers""" - - def test_init_tracing_respects_existing_tracer_provider(self): - """ - Unit test: _init_tracing() should respect existing TracerProvider. - - When a TracerProvider already exists (e.g., set by Langfuse SDK), - LiteLLM should use it instead of creating a new one. - """ - from opentelemetry import trace - from opentelemetry.sdk.trace import TracerProvider - - # Setup: Create and set an existing TracerProvider - tracer_provider = TracerProvider() - trace.set_tracer_provider(tracer_provider) - existing_provider = trace.get_tracer_provider() - - # Act: Initialize OpenTelemetry integration (should detect existing provider) - otel_integration = OpenTelemetry() - - # Assert: The existing provider should still be active - current_provider = trace.get_tracer_provider() - assert current_provider is existing_provider, ( - "Existing TracerProvider should be respected and not overridden" - ) - - @patch.dict(os.environ, {"LITELLM_OTEL_INTEGRATION_ENABLE_METRICS": "true"}, clear=True) - def test_init_metrics_respects_existing_meter_provider(self): - """ - Unit test: _init_metrics() should respect existing MeterProvider. - - When a MeterProvider already exists (e.g., set by Langfuse SDK), - LiteLLM should use it instead of creating a new one. - """ - from opentelemetry import metrics - from opentelemetry.sdk.metrics import MeterProvider - - # Create and set an existing MeterProvider - meter_provider = MeterProvider() - metrics.set_meter_provider(meter_provider) - existing_provider = metrics.get_meter_provider() - - # Act: Initialize OpenTelemetry integration (should detect existing provider) - config = OpenTelemetryConfig.from_env() - otel_integration = OpenTelemetry(config=config) - - # Assert: The existing provider should still be active - current_provider = metrics.get_meter_provider() - assert current_provider is existing_provider, ( - "Existing MeterProvider should be respected and not overridden" - ) - - @patch.dict(os.environ, {"LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS": "true"}, clear=True) - def test_init_logs_respects_existing_logger_provider(self): - """ - Unit test: _init_logs() should respect existing LoggerProvider. - - When a LoggerProvider already exists (e.g., set by Langfuse SDK), - LiteLLM should use it instead of creating a new one. - """ - from opentelemetry._logs import get_logger_provider, set_logger_provider - from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider - - # Create and set an existing LoggerProvider - logger_provider = OTLoggerProvider() - set_logger_provider(logger_provider) - existing_provider = get_logger_provider() - - # Act: Initialize OpenTelemetry integration (should detect existing provider) - config = OpenTelemetryConfig.from_env() - otel_integration = OpenTelemetry(config=config) - - # Assert: The existing provider should still be active - current_provider = get_logger_provider() - assert current_provider is existing_provider, ( - "Existing LoggerProvider should be respected and not overridden" - ) - - class TestOpenTelemetry(unittest.TestCase): POLL_INTERVAL = 0.05 POLL_TIMEOUT = 2.0 @@ -700,6 +620,7 @@ class TestOpenTelemetry(unittest.TestCase): self.assertEqual(attributes.get("extra.attr"), "extra-value") + def test_handle_success_spans_only(self): # make sure neither events nor metrics is on os.environ.pop("LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS", None) @@ -766,8 +687,11 @@ class TestOpenTelemetry(unittest.TestCase): logs = log_exporter.get_finished_logs() self.assertFalse(logs, "Did not expect any logs") - @patch.dict(os.environ, {"LITELLM_OTEL_INTEGRATION_ENABLE_METRICS": "true"}, clear=True) def test_handle_success_spans_and_metrics(self): + # only metrics on + os.environ.pop("LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS", None) + os.environ["LITELLM_OTEL_INTEGRATION_ENABLE_METRICS"] = "true" + # ─── build in‐memory OTEL providers/exporters ───────────────────────────── span_exporter = InMemorySpanExporter() tracer_provider = TracerProvider() @@ -1396,23 +1320,6 @@ class TestOpenTelemetryProtocolSelection(unittest.TestCase): ) self.assertEqual(normalized, "http://collector:4317/v1/logs") - def test_get_metric_reader_uses_http_exporter_for_http_protobuf(self): - """Test that http/protobuf protocol uses OTLPMetricExporterHTTP""" - from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( - OTLPMetricExporter, - ) - from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader - - config = OpenTelemetryConfig( - exporter="http/protobuf", endpoint="http://collector:4318" - ) - otel = OpenTelemetry(config=config) - - reader = otel._get_metric_reader() - - self.assertIsInstance(reader, PeriodicExportingMetricReader) - self.assertIsInstance(reader._exporter, OTLPMetricExporter) - class TestOpenTelemetryExternalSpan(unittest.TestCase): """ diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py index ec2f528a35d..6a528fef8f0 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py @@ -691,29 +691,6 @@ async def test_streaming_completion_start_time(logging_obj: Logging): ) -@pytest.mark.asyncio -async def test_vertex_streaming_bad_request_not_midstream(logging_obj: Logging): - """Ensure Vertex bad request errors surface as 400, not mid-stream fallbacks.""" - from litellm.llms.vertex_ai.common_utils import VertexAIError - - async def _raise_bad_request(**kwargs): - raise VertexAIError(status_code=400, message="invalid maxOutputTokens", headers=None) - - response = CustomStreamWrapper( - completion_stream=None, - model="gemini-3-pro-preview", - logging_obj=logging_obj, - custom_llm_provider="vertex_ai_beta", - make_call=_raise_bad_request, - ) - - with pytest.raises(litellm.BadRequestError) as excinfo: - await response.__anext__() - - assert getattr(excinfo.value, "status_code", None) == 400 - assert "invalid maxOutputTokens" in str(excinfo.value) - - def test_streaming_handler_with_created_time_propagation( initialized_custom_stream_wrapper: CustomStreamWrapper, logging_obj: Logging ): diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_function_call_args_serialization.py b/tests/test_litellm/llms/vertex_ai/gemini/test_function_call_args_serialization.py deleted file mode 100644 index 0f369fbb8b9..00000000000 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_function_call_args_serialization.py +++ /dev/null @@ -1,355 +0,0 @@ -""" -Test cases for functionCall args serialization in Vertex AI Gemini. - -This test file specifically tests the edge cases where Vertex AI might return -functionCall args in unexpected formats that could lead to invalid JSON strings -like: {"x":"x"}{"a":"a"} -""" -import json -from typing import List, Optional - -import pytest - -from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexGeminiConfig, -) -from litellm.types.llms.vertex_ai import HttpxPartType - - -class TestFunctionCallArgsSerialization: - """Test cases for functionCall args serialization edge cases.""" - - def test_normal_dict_args(self): - """Test normal case: args is a dict.""" - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - "args": {"location": "Boston", "unit": "celsius"}, - } - } - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 1 - assert tools[0]["function"]["name"] == "get_weather" - - # Verify arguments is a valid JSON string - arguments = tools[0]["function"]["arguments"] - assert isinstance(arguments, str) - # Should be valid JSON - parsed = json.loads(arguments) - assert parsed == {"location": "Boston", "unit": "celsius"} - - def test_none_args(self): - """Test case: args is None.""" - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - "args": None, - } - } - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 1 - arguments = tools[0]["function"]["arguments"] - # Should serialize None to "null" or empty dict - assert isinstance(arguments, str) - parsed = json.loads(arguments) - # json.dumps(None) returns "null" - assert parsed is None or parsed == {} - - def test_args_as_string_valid_json(self): - """Test case: args is already a valid JSON string.""" - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - "args": '{"location": "Boston"}', # String, not dict - } - } - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 1 - arguments = tools[0]["function"]["arguments"] - # If args is a string, json.dumps will double-encode it - # This would result in: "{\"location\": \"Boston\"}" - assert isinstance(arguments, str) - # This is the problematic case - string gets double-encoded - # The result would be a JSON string containing a JSON string - parsed = json.loads(arguments) - # If it's double-encoded, parsed would be a string, not a dict - if isinstance(parsed, str): - # Double-encoded case - inner_parsed = json.loads(parsed) - assert inner_parsed == {"location": "Boston"} - else: - # Normal case (shouldn't happen if args is string) - assert parsed == {"location": "Boston"} - - def test_args_as_string_invalid_json_concatenated(self): - """Test case: args is a string with concatenated JSON objects (the bug case). - - When args is a string like '{"x":"x"}{"a":"a"}', json.dumps() will serialize it - as a JSON string, resulting in: "{\"x\":\"x\"}{\"a\":\"a\"}" - This is a valid JSON string (the outer quotes), but the content inside is invalid JSON. - When you try to parse the inner content, it fails. - """ - # This simulates the case where Vertex might return something like: - # args = '{"x":"x"}{"a":"a"}' # Two JSON objects concatenated - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - "args": '{"x":"x"}{"a":"a"}', # Invalid concatenated JSON - } - } - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 1 - arguments = tools[0]["function"]["arguments"] - assert isinstance(arguments, str) - - # json.dumps() on a string will escape it, so we get: - # arguments = '"{\\"x\\":\\"x\\"}{\\"a\\":\\"a\\"}"' - # This is a valid JSON string (the outer quotes), but the inner content is invalid - parsed_outer = json.loads(arguments) - assert isinstance(parsed_outer, str) - - # The inner string is invalid JSON (two objects concatenated) - # This is the bug: the inner content cannot be parsed as valid JSON - with pytest.raises(json.JSONDecodeError): - json.loads(parsed_outer) - - # The arguments string would be: "{\"x\":\"x\"}{\"a\":\"a\"}" - # Which when parsed gives: '{"x":"x"}{"a":"a"}' (invalid JSON) - - def test_args_as_array(self): - """Test case: args is an array (unexpected but possible).""" - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - "args": [{"x": "x"}, {"a": "a"}], # Array of objects - } - } - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 1 - arguments = tools[0]["function"]["arguments"] - assert isinstance(arguments, str) - # Should serialize array correctly - parsed = json.loads(arguments) - assert parsed == [{"x": "x"}, {"a": "a"}] - - def test_args_missing_key(self): - """Test case: args key is missing from functionCall. - - This will raise a KeyError because the code directly accesses part["functionCall"]["args"] - without checking if the key exists. This is a bug that should be fixed. - """ - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - # args key missing - } - } - ] - - # This should raise KeyError because args key is missing - with pytest.raises(KeyError): - VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - def test_multiple_function_calls(self): - """Test case: multiple function calls in parts.""" - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - "args": {"location": "Boston"}, - } - }, - { - "functionCall": { - "name": "get_time", - "args": {"timezone": "EST"}, - } - }, - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 2 - assert tools[0]["function"]["name"] == "get_weather" - assert tools[1]["function"]["name"] == "get_time" - - # Both should have valid JSON arguments - args1 = json.loads(tools[0]["function"]["arguments"]) - args2 = json.loads(tools[1]["function"]["arguments"]) - assert args1 == {"location": "Boston"} - assert args2 == {"timezone": "EST"} - - def test_args_with_vertex_protobuf_format(self): - """Test case: args in Vertex protobuf format with string_value, etc.""" - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - "args": { - "location": {"string_value": "Boston, MA"}, - "unit": {"string_value": "celsius"}, - }, - } - } - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 1 - arguments = tools[0]["function"]["arguments"] - assert isinstance(arguments, str) - # Should serialize the nested structure correctly - parsed = json.loads(arguments) - assert "location" in parsed - assert "unit" in parsed - - def test_args_as_empty_dict(self): - """Test case: args is an empty dict.""" - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - "args": {}, - } - } - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 1 - arguments = tools[0]["function"]["arguments"] - assert isinstance(arguments, str) - parsed = json.loads(arguments) - assert parsed == {} - - def test_args_with_special_characters(self): - """Test case: args contains special characters that need escaping.""" - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - "args": { - "location": 'Boston, MA "downtown"', - "note": "Line 1\nLine 2", - }, - } - } - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 1 - arguments = tools[0]["function"]["arguments"] - assert isinstance(arguments, str) - # Should handle special characters correctly - parsed = json.loads(arguments) - assert parsed["location"] == 'Boston, MA "downtown"' - assert parsed["note"] == "Line 1\nLine 2" - - def test_args_as_list_of_strings_that_look_like_json(self): - """Test case: args is a list containing strings that look like JSON objects.""" - # This could potentially cause issues if not handled correctly - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "get_weather", - "args": ['{"x":"x"}', '{"a":"a"}'], # List of JSON strings - } - } - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 1 - arguments = tools[0]["function"]["arguments"] - assert isinstance(arguments, str) - # Should serialize list correctly - parsed = json.loads(arguments) - assert isinstance(parsed, list) - assert parsed == ['{"x":"x"}', '{"a":"a"}'] - - def test_args_as_dict_with_nested_structures(self): - """Test case: args contains nested dicts and lists.""" - parts: List[HttpxPartType] = [ - { - "functionCall": { - "name": "complex_function", - "args": { - "nested": {"key": "value"}, - "list": [1, 2, 3], - "mixed": [{"a": 1}, {"b": 2}], - }, - } - } - ] - - function, tools, idx = VertexGeminiConfig._transform_parts( - parts=parts, cumulative_tool_call_idx=0, is_function_call=False - ) - - assert tools is not None - assert len(tools) == 1 - arguments = tools[0]["function"]["arguments"] - assert isinstance(arguments, str) - parsed = json.loads(arguments) - assert parsed["nested"] == {"key": "value"} - assert parsed["list"] == [1, 2, 3] - assert parsed["mixed"] == [{"a": 1}, {"b": 2}] - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) - diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index d09de3a0f26..91a28ee6ec9 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -10,13 +10,11 @@ from pydantic import BaseModel import litellm from litellm import ModelResponse, completion -from litellm.llms.vertex_ai.common_utils import VertexAIError from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( VertexGeminiConfig, ) from litellm.types.llms.vertex_ai import UsageMetadata from litellm.types.utils import ChoiceLogprobs, Usage -from litellm.utils import CustomStreamWrapper def test_top_logprobs(): @@ -1607,39 +1605,6 @@ def test_vertex_ai_annotation_streaming_events(): assert "Weather information" in annotation["url_citation"]["title"] -@pytest.mark.asyncio -async def test_vertex_ai_streaming_bad_request_is_not_wrapped(): - class DummyLogging: - def __init__(self): - self.model_call_details = {"litellm_params": {}} - self.optional_params = {} - self.messages = [] - self.completion_start_time = None - self.stream_options = None - - def failure_handler(self, *args, **kwargs): - return None - - async def async_failure_handler(self, *args, **kwargs): - return None - - async def failing_make_call(client=None, **kwargs): - raise VertexAIError(status_code=400, message="bad input", headers={}) - - stream = CustomStreamWrapper( - completion_stream=None, - make_call=failing_make_call, - model="gemini-3-pro-preview", - logging_obj=DummyLogging(), - custom_llm_provider="vertex_ai_beta", - ) - - with pytest.raises(litellm.BadRequestError) as exc_info: - await stream.__anext__() - - assert getattr(exc_info.value, "status_code", None) == 400 - - def test_vertex_ai_annotation_conversion(): """ Test the conversion of Vertex AI grounding metadata to OpenAI annotations. diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 4c5723b8284..6df9abd3fee 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -354,7 +354,7 @@ async def test_register_client_remote_registration_success(): request_payload = { "client_name": "Litellm Proxy", - "grant_types": ["authorization_code", "refresh_token"], + "grant_types": ["authorization_code"], "response_types": ["code"], "token_endpoint_auth_method": "client_secret_post", } @@ -556,33 +556,9 @@ async def test_oauth_protected_resource_respects_x_forwarded_proto(): from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( oauth_protected_resource_mcp, ) - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, - ) - from litellm.types.mcp import MCPAuth - from litellm.types.mcp_server.mcp_server_manager import MCPServer - from litellm.proxy._types import MCPTransport from fastapi import Request except ImportError: pytest.skip("MCP discoverable endpoints not available") - # Clear registry - global_mcp_server_manager.registry.clear() - - # Create mock OAuth2 server - oauth2_server = MCPServer( - server_id="test_oauth_server", - name="test_oauth", - server_name="test_oauth", - alias="test_oauth", - transport=MCPTransport.http, - auth_type=MCPAuth.oauth2, - client_id="test_client_id", - client_secret="test_client_secret", - authorization_url="https://provider.com/oauth/authorize", - token_url="https://provider.com/oauth/token", - scopes=["read", "write"], - ) - global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server # Mock request with http base_url but X-Forwarded-Proto: https mock_request = MagicMock(spec=Request) @@ -592,14 +568,13 @@ async def test_oauth_protected_resource_respects_x_forwarded_proto(): # Call the endpoint response = await oauth_protected_resource_mcp( request=mock_request, - mcp_server_name="test_oauth", + mcp_server_name="test_server", ) # Verify response uses HTTPS URLs assert response["authorization_servers"][0].startswith( "https://litellm.example.com/" ) - assert response["scopes_supported"] == oauth2_server.scopes @pytest.mark.asyncio @@ -609,33 +584,9 @@ async def test_oauth_authorization_server_respects_x_forwarded_proto(): from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( oauth_authorization_server_mcp, ) - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, - ) - from litellm.types.mcp import MCPAuth - from litellm.types.mcp_server.mcp_server_manager import MCPServer - from litellm.proxy._types import MCPTransport from fastapi import Request except ImportError: pytest.skip("MCP discoverable endpoints not available") - # Clear registry - global_mcp_server_manager.registry.clear() - - # Create mock OAuth2 server - oauth2_server = MCPServer( - server_id="test_oauth_server", - name="test_oauth", - server_name="test_oauth", - alias="test_oauth", - transport=MCPTransport.http, - auth_type=MCPAuth.oauth2, - client_id="test_client_id", - client_secret="test_client_secret", - authorization_url="https://provider.com/oauth/authorize", - token_url="https://provider.com/oauth/token", - scopes=["read", "write"], - ) - global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server # Mock request with http base_url but X-Forwarded-Proto: https mock_request = MagicMock(spec=Request) @@ -645,15 +596,13 @@ async def test_oauth_authorization_server_respects_x_forwarded_proto(): # Call the endpoint response = await oauth_authorization_server_mcp( request=mock_request, - mcp_server_name="test_oauth", + mcp_server_name="test_server", ) # Verify response uses HTTPS URLs assert response["authorization_endpoint"].startswith("https://litellm.example.com/") assert response["token_endpoint"].startswith("https://litellm.example.com/") assert response["registration_endpoint"].startswith("https://litellm.example.com/") - assert response["grant_types_supported"] == ["authorization_code", "refresh_token"] - assert response["scopes_supported"] == oauth2_server.scopes @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index d59b3f04ef5..6491e11024a 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -594,26 +594,7 @@ class TestMCPServerManager: assert ( server.registration_url == "https://discovered.example.com/register" ) - @pytest.mark.asyncio - async def test_config_oauth_initialize_tool_name_to_mcp_server_name_mapping(self): - manager = MCPServerManager() - config = { - "example": { - "url": "https://example.com/mcp", - "transport": MCPTransport.http, - "auth_type": MCPAuth.oauth2, - "scopes": ["config"], - "authorization_url": "https://config.example.com/auth", - } - } - - await manager.load_servers_from_config(config) - - # Initialize the tool mapping - await manager._initialize_tool_name_to_mcp_server_name_mapping() - assert manager.tool_name_to_mcp_server_name_mapping == {} - @pytest.mark.asyncio async def test_list_tools_handles_missing_server_alias(self): """Test that list_tools handles servers without alias gracefully""" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_qualifire.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_qualifire.py index 35ed49a84ed..6d6129b17bb 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_qualifire.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_qualifire.py @@ -2,7 +2,6 @@ Unit tests for Qualifire guardrail integration. """ -import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -140,98 +139,76 @@ class TestQualifireGuardrailEvaluateKwargs: @pytest.mark.asyncio async def test_evaluate_called_with_prompt_injections(self): """Test that evaluate is called with prompt_injections enabled.""" - # Mock the qualifire module and its types - mock_qualifire_types = MagicMock() - mock_llm_message = MagicMock() - mock_llm_tool_call = MagicMock() - mock_message_instance = MagicMock() - mock_llm_message.return_value = mock_message_instance - - mock_qualifire_types.LLMMessage = mock_llm_message - mock_qualifire_types.LLMToolCall = mock_llm_tool_call - - with patch.dict('sys.modules', {'qualifire': MagicMock(), 'qualifire.types': mock_qualifire_types}): - from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import ( - QualifireGuardrail, - ) + from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import ( + QualifireGuardrail, + ) - guardrail = QualifireGuardrail( - api_key="test_key", - prompt_injections=True, - guardrail_name="test_guardrail", - ) + guardrail = QualifireGuardrail( + api_key="test_key", + prompt_injections=True, + guardrail_name="test_guardrail", + ) - # Mock the client - mock_client = MagicMock() - mock_result = MagicMock() - mock_result.score = 100 - mock_result.status = "completed" - mock_result.evaluationResults = [] - mock_client.evaluate.return_value = mock_result - guardrail._client = mock_client + # Mock the client + mock_client = MagicMock() + mock_result = MagicMock() + mock_result.score = 100 + mock_result.status = "completed" + mock_result.evaluationResults = [] + mock_client.evaluate.return_value = mock_result + guardrail._client = mock_client - messages = [{"role": "user", "content": "Hello, world!"}] + messages = [{"role": "user", "content": "Hello, world!"}] - await guardrail._run_qualifire_check( - messages=messages, output=None, dynamic_params={} - ) + await guardrail._run_qualifire_check( + messages=messages, output=None, dynamic_params={} + ) - # Verify evaluate was called with correct kwargs - mock_client.evaluate.assert_called_once() - call_kwargs = mock_client.evaluate.call_args[1] - assert call_kwargs["prompt_injections"] is True - assert "messages" in call_kwargs + # Verify evaluate was called with correct kwargs + mock_client.evaluate.assert_called_once() + call_kwargs = mock_client.evaluate.call_args[1] + assert call_kwargs["prompt_injections"] is True + assert "messages" in call_kwargs @pytest.mark.asyncio async def test_evaluate_called_with_multiple_checks(self): """Test that evaluate is called with multiple checks enabled.""" - # Mock the qualifire module and its types - mock_qualifire_types = MagicMock() - mock_llm_message = MagicMock() - mock_llm_tool_call = MagicMock() - mock_message_instance = MagicMock() - mock_llm_message.return_value = mock_message_instance - - mock_qualifire_types.LLMMessage = mock_llm_message - mock_qualifire_types.LLMToolCall = mock_llm_tool_call - - with patch.dict('sys.modules', {'qualifire': MagicMock(), 'qualifire.types': mock_qualifire_types}): - from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import ( - QualifireGuardrail, - ) + from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import ( + QualifireGuardrail, + ) - guardrail = QualifireGuardrail( - api_key="test_key", - prompt_injections=True, - pii_check=True, - hallucinations_check=True, - assertions=["Output must be valid JSON"], - guardrail_name="test_guardrail", - ) + guardrail = QualifireGuardrail( + api_key="test_key", + prompt_injections=True, + pii_check=True, + hallucinations_check=True, + assertions=["Output must be valid JSON"], + guardrail_name="test_guardrail", + ) - # Mock the client - mock_client = MagicMock() - mock_result = MagicMock() - mock_result.score = 100 - mock_result.status = "completed" - mock_result.evaluationResults = [] - mock_client.evaluate.return_value = mock_result - guardrail._client = mock_client + # Mock the client + mock_client = MagicMock() + mock_result = MagicMock() + mock_result.score = 100 + mock_result.status = "completed" + mock_result.evaluationResults = [] + mock_client.evaluate.return_value = mock_result + guardrail._client = mock_client - messages = [{"role": "user", "content": "Hello, world!"}] + messages = [{"role": "user", "content": "Hello, world!"}] - await guardrail._run_qualifire_check( - messages=messages, output="Test output", dynamic_params={} - ) + await guardrail._run_qualifire_check( + messages=messages, output="Test output", dynamic_params={} + ) - # Verify evaluate was called with correct kwargs - mock_client.evaluate.assert_called_once() - call_kwargs = mock_client.evaluate.call_args[1] - assert call_kwargs["prompt_injections"] is True - assert call_kwargs["pii_check"] is True - assert call_kwargs["hallucinations_check"] is True - assert call_kwargs["assertions"] == ["Output must be valid JSON"] - assert call_kwargs["output"] == "Test output" + # Verify evaluate was called with correct kwargs + mock_client.evaluate.assert_called_once() + call_kwargs = mock_client.evaluate.call_args[1] + assert call_kwargs["prompt_injections"] is True + assert call_kwargs["pii_check"] is True + assert call_kwargs["hallucinations_check"] is True + assert call_kwargs["assertions"] == ["Output must be valid JSON"] + assert call_kwargs["output"] == "Test output" class TestQualifireGuardrailCheckIfFlagged: diff --git a/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py b/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py index 97c1733a935..011031c1e4f 100644 --- a/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py +++ b/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py @@ -40,7 +40,6 @@ class TestKeyManagementEventHooksIndependentOperations: mock_data = MagicMock() mock_data.key_alias = "test-key-alias" mock_data.team_id = None - mock_data.send_invite_email = True mock_response = MagicMock() mock_response.model_dump.return_value = {"key": "sk-test", "token": "test-token"} @@ -60,10 +59,6 @@ class TestKeyManagementEventHooksIndependentOperations: KeyManagementEventHooks, "_store_virtual_key_in_secret_manager", side_effect=mock_store_secret, - ), patch.object( - KeyManagementEventHooks, - "_is_email_sending_enabled", - return_value=True, ), patch( "litellm.store_audit_logs", False ), patch( @@ -101,7 +96,6 @@ class TestKeyManagementEventHooksIndependentOperations: mock_data = MagicMock() mock_data.key_alias = "test-key-alias" mock_data.team_id = None - mock_data.send_invite_email = True mock_response = MagicMock() mock_response.model_dump.return_value = {"key": "sk-test", "token": "test-token"} @@ -121,10 +115,6 @@ class TestKeyManagementEventHooksIndependentOperations: KeyManagementEventHooks, "_store_virtual_key_in_secret_manager", side_effect=mock_store_secret_raises, - ), patch.object( - KeyManagementEventHooks, - "_is_email_sending_enabled", - return_value=True, ), patch( "litellm.store_audit_logs", False ), patch( diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index f2bae2cb14a..cc268ab9925 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -231,40 +231,6 @@ class TestListMCPServers: assert server.url == "https://mcp.deepwiki.com/mcp" assert server.transport == "http" - @pytest.mark.asyncio - async def test_list_mcp_servers_view_all_mode(self): - """Users should see all MCP servers when view_all mode is enabled.""" - - mock_user_auth = generate_mock_user_api_key_auth( - user_role=LitellmUserRoles.INTERNAL_USER - ) - - mock_servers = [ - generate_mock_mcp_server_db_record(server_id="server-1", alias="One"), - generate_mock_mcp_server_db_record(server_id="server-2", alias="Two"), - ] - - mock_manager = MagicMock() - mock_manager.get_all_mcp_servers_unfiltered = AsyncMock( - return_value=mock_servers - ) - - with patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints._get_user_mcp_management_mode", - return_value="view_all", - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", - mock_manager, - ): - from litellm.proxy.management_endpoints.mcp_management_endpoints import ( - fetch_all_mcp_servers, - ) - - result = await fetch_all_mcp_servers(user_api_key_dict=mock_user_auth) - - assert len(result) == 2 - assert {server.server_id for server in result} == {"server-1", "server-2"} - @pytest.mark.asyncio async def test_list_mcp_servers_combined_config_and_db(self): """ @@ -1130,51 +1096,6 @@ class TestHealthCheckServers: assert result[0]["server_id"] == "server-1" assert result[0]["status"] == "healthy" - @pytest.mark.asyncio - async def test_health_check_view_all_mode(self): - """view_all mode should return health info for all MCP servers.""" - - from litellm.proxy.management_endpoints.mcp_management_endpoints import ( - health_check_servers, - ) - - mock_user_auth = generate_mock_user_api_key_auth( - user_role=LitellmUserRoles.INTERNAL_USER - ) - - health_result_one = generate_mock_mcp_server_db_record( - server_id="server-1", alias="One" - ) - health_result_one.status = "healthy" - - health_result_two = generate_mock_mcp_server_db_record( - server_id="server-2", alias="Two" - ) - health_result_two.status = "unhealthy" - - mock_manager = MagicMock() - mock_manager.get_all_mcp_servers_with_health_unfiltered = AsyncMock( - return_value=[health_result_one, health_result_two] - ) - - with patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints._get_user_mcp_management_mode", - return_value="view_all", - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", - mock_manager, - ): - result = await health_check_servers( - server_ids=None, - user_api_key_dict=mock_user_auth, - ) - - assert len(result) == 2 - assert result[0]["server_id"] == "server-1" - assert result[0]["status"] == "healthy" - assert result[1]["server_id"] == "server-2" - assert result[1]["status"] == "unhealthy" - @pytest.mark.asyncio async def test_health_check_unauthorized_servers(self): """ diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index ad4f53dac4b..8fdfd6897a8 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -742,16 +742,18 @@ class TestProxySettingEndpoints: ): """Test updating UI settings with an allowlisted field""" from unittest.mock import AsyncMock, MagicMock - from litellm.proxy.auth.user_api_key_auth import user_api_key_auth - from litellm.proxy._types import UserAPIKeyAuth - # Override the FastAPI dependency with a proper mock - mock_user_auth = UserAPIKeyAuth( - user_id="test-user-123", - user_role=LitellmUserRoles.PROXY_ADMIN, + class MockUser: + def __init__(self, user_role): + self.user_role = user_role + + async def mock_admin_auth(): + return MockUser(LitellmUserRoles.PROXY_ADMIN) + + monkeypatch.setattr( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.user_api_key_auth", + mock_admin_auth, ) - app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth - monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) mock_prisma = MagicMock() mock_prisma.db.litellm_uisettings.upsert = AsyncMock() @@ -759,11 +761,7 @@ class TestProxySettingEndpoints: payload = {"disable_model_add_for_internal_users": True} - try: - response = client.patch("/update/ui_settings", json=payload) - finally: - # Clean up the dependency override - app.dependency_overrides.clear() + response = client.patch("/update/ui_settings", json=payload) assert response.status_code == 200 data = response.json() @@ -782,16 +780,18 @@ class TestProxySettingEndpoints: ): """Test non-allowlisted UI settings are ignored on update""" from unittest.mock import AsyncMock, MagicMock - from litellm.proxy.auth.user_api_key_auth import user_api_key_auth - from litellm.proxy._types import UserAPIKeyAuth - # Override the FastAPI dependency with a proper mock - mock_user_auth = UserAPIKeyAuth( - user_id="test-user-123", - user_role=LitellmUserRoles.PROXY_ADMIN, + class MockUser: + def __init__(self, user_role): + self.user_role = user_role + + async def mock_admin_auth(): + return MockUser(LitellmUserRoles.PROXY_ADMIN) + + monkeypatch.setattr( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.user_api_key_auth", + mock_admin_auth, ) - app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth - monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) mock_prisma = MagicMock() mock_prisma.db.litellm_uisettings.upsert = AsyncMock() @@ -802,11 +802,7 @@ class TestProxySettingEndpoints: "unsupported_flag": True, } - try: - response = client.patch("/update/ui_settings", json=payload) - finally: - # Clean up the dependency override - app.dependency_overrides.clear() + response = client.patch("/update/ui_settings", json=payload) assert response.status_code == 200 data = response.json() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts index f03f3977115..3e09c3c2ca8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts @@ -1,7 +1,7 @@ -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { getSSOSettings } from "@/components/networking"; import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; +import { getSSOSettings } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export interface SSOFieldSchema { description: string; @@ -27,15 +27,13 @@ export interface SSOSettingsValues { proxy_base_url: string | null; user_email: string | null; ui_access_mode: string | null; - role_mappings: RoleMappings; -} - -export interface RoleMappings { - provider: string; - group_claim: string; - default_role: "internal_user" | "internal_user_viewer" | "proxy_admin" | "proxy_admin_viewer"; - roles: { - [key: string]: string[]; + role_mappings: { + provider: string; + group_claim: string; + default_role: "internal_user" | "internal_user_viewer" | "proxy_admin" | "proxy_admin_viewer"; + roles: { + [key: string]: string[]; + }; }; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index a8b1d2cddc9..65418179595 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -18,7 +18,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react"; import type { UploadProps } from "antd"; import { Form, Typography } from "antd"; -import { PlusCircleOutlined } from "@ant-design/icons"; import React, { useEffect, useMemo, useState } from "react"; import AddModelTab from "../../../components/add_model/add_model_tab"; import HealthCheckComponent from "../../../components/model_dashboard/HealthCheckComponent"; @@ -275,30 +274,6 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te )} - - {/* Missing Provider Banner */} - {selectedModelId && !isLoading ? ( = ({ form, onFormS - prevValues.use_role_mappings !== currentValues.use_role_mappings || - prevValues.sso_provider !== currentValues.sso_provider - } + shouldUpdate={(prevValues, currentValues) => prevValues.use_role_mappings !== currentValues.use_role_mappings} > {({ getFieldValue }) => { const useRoleMappings = getFieldValue("use_role_mappings"); - const provider = getFieldValue("sso_provider"); - const supportsRoleMappings = provider === "okta" || provider === "generic"; - return useRoleMappings && supportsRoleMappings ? ( + return useRoleMappings ? ( = ({ form, onFormS - prevValues.use_role_mappings !== currentValues.use_role_mappings || - prevValues.sso_provider !== currentValues.sso_provider - } + shouldUpdate={(prevValues, currentValues) => prevValues.use_role_mappings !== currentValues.use_role_mappings} > {({ getFieldValue }) => { const useRoleMappings = getFieldValue("use_role_mappings"); - const provider = getFieldValue("sso_provider"); - const supportsRoleMappings = provider === "okta" || provider === "generic"; - return useRoleMappings && supportsRoleMappings ? ( + return useRoleMappings ? ( <>