From 9e6714fe1b9344b06fe819c486d9869e588480ce Mon Sep 17 00:00:00 2001 From: Wiley Kestner Date: Tue, 6 Jan 2026 16:04:02 +0100 Subject: [PATCH 1/5] Tally total_tokens in response if missing (#18468) (#18445) Calculate `total_tokens` in usage data in Response manually if: - `total_tokens` is missing - `total_tokens` can be calculated from input and output tokens Run the test for this feature with: `poetry run pytest tests/test_litellm/responses/test_responses_utils.py -k "test_transform_response_api_usage_calculates_total_from_input_and_output_tokens_if_available" -v` --- litellm/responses/utils.py | 17 ++++++++++++----- .../responses/test_responses_utils.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index a92b5d25a3..7667d1bad8 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -443,11 +443,18 @@ class ResponseAPILoggingUtils: completion_tokens=0, total_tokens=0, ) - response_api_usage: ResponseAPIUsage = ( - ResponseAPIUsage(**usage_input) - if isinstance(usage_input, dict) - else usage_input - ) + response_api_usage: ResponseAPIUsage + if isinstance(usage_input, dict): + total_tokens = usage_input.get("total_tokens") + if total_tokens is None: + input_tokens = usage_input.get("input_tokens") + output_tokens = usage_input.get("output_tokens") + if input_tokens is not None and output_tokens is not None: + total_tokens = input_tokens + output_tokens + usage_input["total_tokens"] = total_tokens + response_api_usage = ResponseAPIUsage(**usage_input) + else: + response_api_usage = usage_input prompt_tokens: int = response_api_usage.input_tokens or 0 completion_tokens: int = response_api_usage.output_tokens or 0 prompt_tokens_details: Optional[PromptTokensDetailsWrapper] = None diff --git a/tests/test_litellm/responses/test_responses_utils.py b/tests/test_litellm/responses/test_responses_utils.py index 96ac2e2c34..09628cd4a7 100644 --- a/tests/test_litellm/responses/test_responses_utils.py +++ b/tests/test_litellm/responses/test_responses_utils.py @@ -203,3 +203,22 @@ class TestResponseAPILoggingUtils: assert result.prompt_tokens == 0 assert result.completion_tokens == 20 assert result.total_tokens == 20 + + def test_transform_response_api_usage_calculates_total_from_input_and_output_tokens_if_available(self): + """Test transformation calculates total_tokens when it's None and input / output tokens are present""" + # Setup + usage = { + "input_tokens": 15, + "output_tokens": 25, + "total_tokens": None, + } + + # Execute + result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( + usage + ) + + # Assert + assert result.prompt_tokens == 15 + assert result.completion_tokens == 25 + assert result.total_tokens == 40 # 15 + 25 From 9f68081f6d044faa018284fea99532c3b9374c6d Mon Sep 17 00:00:00 2001 From: minijeong-log Date: Wed, 7 Jan 2026 03:16:24 +0900 Subject: [PATCH 2/5] feat: Add built-in migration lock to prevent concurrent Prisma migrate deploy (#14440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: prisma migrate deploy with lock Author: Mini Jeong * fix: use redis cache from proxy server Author: Mini Jeong * fix: add type checks and fix unit tests for migration lock - Add DATABASE_URL validation in _create_baseline_migration() and _resolve_all_migrations() - Fix MyPy type errors by adding None checks before using database_url in subprocess calls - Add _resolve_all_migrations mock to failing unit tests to prevent filesystem errors - Apply Black formatting to modified files Fixes: - MyPy type errors: database_url could be None when passed to subprocess - Unit test failures: _resolve_all_migrations tried to create directories in read-only /test path * fix: resolve MyPy type error in vertex_ai vertex_llm_base Fix MyPy type checking error where vertex_api_version parameter type was incompatible with function signature expectation. * fix: Return 403 exception when calling GET responses api * fix: added new step into rotate master key function for processing credentials table * Add redisvl in requirements.txt * fix: fixed the issue of handling root paths when processing Discovery protected resource metadata and authorization server metadata URLs. * fix: added additional grant type into oauth_authorization_server response for fixing mcp auth register bad request issue * fix: added RFC RECOMMENDED property(scopes_supported) to protected resource and authorization server metadata * fix: removed initialize the tool name to MCP server name mapping(oauth2) on startup for avoiding 401 error * fix: upgraded mcp sdk depency version for fixing ClosedResourceError * Use already configured opentelemetry providers Users that instrument using opentelemetry-instrument can now setup exporters as per their environment. * Handle all protocols for all telemetry * Add more tests * feat(mcp): parallelize tool fetching from multiple MCP servers (#18627) * feat(mcp): parallelize tool fetching from multiple MCP servers Replace sequential tool fetching with asyncio.gather() to reduce client timeouts when using multiple MCP servers. Changes: - mcp_server_manager.py: list_tools() now fetches tools in parallel - server.py: _get_tools_from_mcp_servers() now fetches tools in parallel Real-world impact (7 MCP servers example): - Sequential: ~4.5+ seconds (exceeds typical 5-second client timeouts) - Parallel: ~1.2 seconds (max of all servers) Fixes #18626 * fix: copy oauth2_headers to avoid shared dict mutation in parallel tasks * feat: add display_name, model_vendor, and model_version metadata * added the option of adding langsmith tenant id in the env (#18623) * fix(router): Validate routing_strategy at startup to fail fast with helpful error. (#18624) Invalid routing_strategy values (e.g., "simple" instead of "simple-shuffle") previously failed silently, causing confusing "No deployments available" errors downstream. This change adds upfront validation in routing_strategy_init() to: - Check if the provided strategy matches valid string values or RoutingStrategy enum - Raise a clear ValueError listing valid options if invalid - Fail fast at startup instead of at request time Fixes behavior reported in #11330 where users had to debug cryptic errors. Valid strategies: simple-shuffle, least-busy, usage-based-routing, latency-based-routing, cost-based-routing, usage-based-routing-v2 Co-authored-by: Flibbert E. Gibbitz * Add libsndfile to database Docker image for audio processing (#18612) The litellm-database Docker image was missing the libsndfile system library, which is required by the soundfile Python package for audio file processing. This caused failures when using audio transcription endpoints that attempt to calculate audio duration. This adds libsndfile to the runtime dependencies in Dockerfile.database, consistent with Dockerfile.alpine which already includes this library. * Fix: Map Gemini cached_tokens to Langfuse cache_read_input_tokens (#18614) * Fix: Map Gemini cached_tokens to Langfuse cache_read_input_tokens Fixes #18520 ## Problem Langfuse integration was not capturing cached tokens from Gemini models. Gemini returns cached tokens in `usage.prompt_tokens_details.cached_tokens`, but Langfuse only read from top-level `usage.cache_read_input_tokens` (which only Anthropic populates). ## Solution Updated langfuse.py to check both locations: 1. First check top-level cache_read_input_tokens (for Anthropic) 2. Then check prompt_tokens_details.cached_tokens (for Gemini, OpenAI, others) This ensures all providers' cached tokens are properly reported to Langfuse. ## Changes - Modified litellm/integrations/langfuse/langfuse.py (lines 742-761) - Added 3 unit tests in tests/test_litellm/integrations/langfuse/test_gemini_cached_tokens.py - All existing Langfuse tests still pass (11/11) ## Testing - test_cached_tokens_extraction: Verifies Gemini cached_tokens extraction - test_cached_tokens_not_present: Backward compatibility (no cached_tokens) - test_cached_tokens_is_zero: Edge case when cached_tokens = 0 * Refactor: Extract cache token logic into helper function Address review feedback from @officer47p - Created _extract_cache_read_input_tokens() helper function - Reduces code bloat in _log_langfuse_v2 method - Improves testability and reusability - All tests still passing (11/11) * Adding Role Mappings * Fixing Edit SSO Settings Modal * feat: add user_mcp_management_mode for view_all visibility * Fixing tests * fix: missing mcp_allow_all_ui.png * docs: add user_mcp_management_mode * Align responses API streaming hooks with chat pipeline * Clarify responses API streaming context * Address review comments * feat: Add GigaChat provider support (#18564) * feat: Add GigaChat provider support Add native support for GigaChat API (Sber AI, Russia's leading LLM). Supported features: - 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 Closes #18515 * fix: resolve mypy type errors in GigaChat handler - Fix _prepare_file_data return type (use 3-tuple for cleaner type flow) - Add type annotations for lists in _process_content_parts methods - Add type annotations in _collapse_user_messages - Use ChatCompletionToolCallChunk for proper tool_use typing - Add type: ignore[override] for astreaming async generator * refactor(gigachat): migrate to BaseConfig pattern * fix: remove unused imports * fix: resolve mypy type errors * fix: mypy type errors * refactor: address review feedback for GigaChat provider - Remove singleton pattern, reuse litellm HTTPHandler - Move constants/errors to transformation files, delete common_utils.py - Add models to model_prices_and_context_window.json - Fix ssl_verify not passed to HTTP client for embeddings * docs: update GigaChat documentation with ssl_verify requirement * Revert "Add redisvl in requirements.txt" * Put reasoning summary behind feat flag * fix: model eol * fix: anthropic claude-3-opus-20240229 EOL * Revert "fix: model eol" This reverts commit 5aa1665d79d75e0842ec44a8dc23d2e15fdfadd8. * Fix: ImportError: qualifire package is required for QualifireGuardrail. Install it with: pip install qualifire * fix: test_secret_manager_failure_does_not_block_email * fix: test_update_ui_settings_allowlisted_value * fix: test_aaamodel_prices_and_context_window_json_is_valid * fix: test_all_models_have_display_name * fix: async def test_bedrock_apply_guardrail_blocked() * fix: test_databricks_embeddings[True] * fix:test_anthropic_beta_header * fix:test_api_error_handling * fix:mypy mcp management * Revert "feat(model_cost): add display_name, model_vendor, and model_version metadata to model entries" * [Feat] New API Endpoint - Responses API (v1/responses/compact) (#18697) * init transform_compact_response_api_request * init acompact_responses * init async_compact_response_api_handler in llm http handler * init transform_compact_response_api_request for openai * init acompact_responses * fix acompact_responses * add OAI Compact API * docs responses API Compact * code qa checks * test_openai_compact_responses_api * fix mypy linting * fix: remove display name * Add the LITELLM_REASONING_AUTO_SUMMARY in doc * fix model map * [UI] - Feat add request provider form on UI (#18704) * add request provider form * fix link to github * add button * fix link * fix(streaming): normalize status code extraction to prevent 4xx errors from triggering mid-stream fallback (#18698) 在流式处理错误时,添加状态码标准化逻辑,确保 4xx 客户端错误直接抛出而不是被包装成 MidStreamFallbackError。 - 新增 _normalize_status_code 函数用于从异常对象提取状态码 - 优先从异常的 status_code 属性获取,其次从 response.status_code 获取 - 当映射异常或原始异常的状态码在 400-499 范围内时,直接抛出映射异常 - 添加单元测试验证 Vertex AI 400 错误正确抛出为 BadRequestError - 确保流式处理中的客户端错误能够正确传播,而不会触发回退机制 --------- Co-authored-by: Eric84626 Co-authored-by: Eric84626 <97266539+Eric84626@users.noreply.github.com> Co-authored-by: Sameer Kankute Co-authored-by: mangabits <1457532+mangabits@users.noreply.github.com> Co-authored-by: Costa Tsaousis Co-authored-by: Nik Co-authored-by: Shivam Rawat <161387515+shivamrawat1@users.noreply.github.com> Co-authored-by: FlibbertyGibbitz Co-authored-by: Flibbert E. Gibbitz Co-authored-by: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Co-authored-by: yuneng-jiang Co-authored-by: Yuta Saito Co-authored-by: LingXuanYin <3546599908@qq.com> Co-authored-by: YutaSaito <36355491+uc4w6c@users.noreply.github.com> Co-authored-by: 0717376 <103773680+0717376@users.noreply.github.com> Co-authored-by: Ishaan Jaff Co-authored-by: Kris Xia Co-authored-by: Krish Dholakia --- 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 0 -> 137849 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 | 94 +++- .../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, 5826 insertions(+), 566 deletions(-) create mode 100644 docs/my-website/docs/providers/gigachat.md create mode 100644 docs/my-website/docs/response_api_compact.md create mode 100644 docs/my-website/img/mcp_allow_all_ui.png create mode 100644 litellm/llms/gigachat/__init__.py create mode 100644 litellm/llms/gigachat/authenticator.py create mode 100644 litellm/llms/gigachat/chat/__init__.py create mode 100644 litellm/llms/gigachat/chat/streaming.py create mode 100644 litellm/llms/gigachat/chat/transformation.py create mode 100644 litellm/llms/gigachat/embedding/__init__.py create mode 100644 litellm/llms/gigachat/embedding/transformation.py create mode 100644 litellm/llms/gigachat/file_handler.py create mode 100644 tests/llm_responses_api_testing/test_responses_hooks.py create mode 100644 tests/llm_translation/test_gigachat.py create mode 100644 tests/test_litellm/integrations/langfuse/test_gemini_cached_tokens.py create mode 100644 tests/test_litellm/llms/vertex_ai/gemini/test_function_call_args_serialization.py create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index 0e804cbfd1..9a4e9a315e 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 +RUN apk add --no-cache bash openssl tzdata nodejs npm python3 py3-pip libsndfile 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 f6ab4a7308..a7d66a6b7f 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,3 +634,18 @@ 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 new file mode 100644 index 0000000000..13eec298c2 --- /dev/null +++ b/docs/my-website/docs/providers/gigachat.md @@ -0,0 +1,283 @@ +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 87064d442a..f4359a86ba 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -111,6 +111,7 @@ 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 @@ -230,6 +231,7 @@ 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. | @@ -669,6 +671,7 @@ 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 @@ -707,6 +710,7 @@ 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 @@ -774,6 +778,7 @@ 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 @@ -888,4 +893,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 \ No newline at end of file +| ZSCALER_AI_GUARD_URL | Base URL for Zscaler AI Guard API. Default is https://api.us1.zseclipse.net/v1/detection/execute-policy diff --git a/docs/my-website/docs/reasoning_content.md b/docs/my-website/docs/reasoning_content.md index fca3df638c..04c6d7ee6c 100644 --- a/docs/my-website/docs/reasoning_content.md +++ b/docs/my-website/docs/reasoning_content.md @@ -591,3 +591,68 @@ 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 new file mode 100644 index 0000000000..f5caa32ea3 --- /dev/null +++ b/docs/my-website/docs/response_api_compact.md @@ -0,0 +1,104 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..f074deb801eb4ce4c9ad2a48d80f66007a8cb6d2 GIT binary patch literal 137849 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 literal 0 HcmV?d00001 diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 3793aec037..11effc82fe 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -541,7 +541,14 @@ const sidebars = { }, "realtime", "rerank", - "response_api", + { + type: "category", + label: "/responses", + items: [ + "response_api", + "response_api_compact", + ] + }, { 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 7ffbe95be1..a1fa4ef844 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/utils.py +++ b/litellm-proxy-extras/litellm_proxy_extras/utils.py @@ -10,6 +10,7 @@ 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: @@ -18,6 +19,103 @@ 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.""" @@ -346,19 +444,50 @@ class ProxyExtrasDBManager: ) @staticmethod - def setup_database(use_migrate: bool = False) -> bool: + def setup_database( + use_migrate: bool = False, redis_cache: Optional[RedisCache] = None + ) -> bool: """ Set up the database using either prisma migrate or prisma db push - Uses migrations from litellm-proxy-extras package + Uses migrations from litellm-proxy-extras package. + In multi-instance environment, use redis lock to prevent concurrent execution. 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 dfe959bf74..7f7ee21f69 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -197,6 +197,7 @@ 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 @@ -275,6 +276,7 @@ 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 @@ -1440,6 +1442,8 @@ 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 8c8266d5ca..26133ebc22 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -255,6 +255,8 @@ LLM_CONFIG_NAMES = ( "GithubCopilotEmbeddingConfig", "NebiusConfig", "WandbConfig", + "GigaChatConfig", + "GigaChatEmbeddingConfig", "DashScopeChatConfig", "MoonshotChatConfig", "DockerModelRunnerChatConfig", @@ -644,6 +646,8 @@ _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 5b206317b2..a89efc4e82 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -3,6 +3,7 @@ Handler for transforming /chat/completions api requests to litellm.responses req """ import json +import os from typing import ( TYPE_CHECKING, Any, @@ -22,6 +23,7 @@ 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 @@ -691,19 +693,26 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): if isinstance(reasoning_effort, dict): return Reasoning(**reasoning_effort) # type: ignore[typeddict-item] - # If string is passed, map with summary="detailed" + # 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 reasoning_effort == "none": - return Reasoning(effort="none", summary="detailed") # type: ignore + return Reasoning(effort="none", summary="detailed") if auto_summary_enabled else Reasoning(effort="none") # type: ignore elif reasoning_effort == "high": - return Reasoning(effort="high", summary="detailed") + return Reasoning(effort="high", summary="detailed") if auto_summary_enabled else Reasoning(effort="high") elif reasoning_effort == "xhigh": - return Reasoning(effort="xhigh", summary="detailed") # type: ignore[typeddict-item] + return Reasoning(effort="xhigh", summary="detailed") if auto_summary_enabled else Reasoning(effort="xhigh") # type: ignore[typeddict-item] elif reasoning_effort == "medium": - return Reasoning(effort="medium", summary="detailed") + return Reasoning(effort="medium", summary="detailed") if auto_summary_enabled else Reasoning(effort="medium") elif reasoning_effort == "low": - return Reasoning(effort="low", summary="detailed") + return Reasoning(effort="low", summary="detailed") if auto_summary_enabled else Reasoning(effort="low") elif reasoning_effort == "minimal": - return Reasoning(effort="minimal", summary="detailed") + return Reasoning(effort="minimal", summary="detailed") if auto_summary_enabled else Reasoning(effort="minimal") return None def _transform_response_format_to_text_format( diff --git a/litellm/constants.py b/litellm/constants.py index e8524a87c4..1cd2da549c 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -375,6 +375,7 @@ 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 88f7908e9a..6b30b6b736 100644 --- a/litellm/integrations/callback_configs.json +++ b/litellm/integrations/callback_configs.json @@ -187,6 +187,12 @@ "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 f0f355b489..7e62613a7e 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -50,6 +50,42 @@ 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__( @@ -757,8 +793,8 @@ class LangFuseLogger: cache_creation_input_tokens = ( _usage_obj.get("cache_creation_input_tokens") or 0 ) - cache_read_input_tokens = ( - _usage_obj.get("cache_read_input_tokens") or 0 + cache_read_input_tokens = _extract_cache_read_input_tokens( + _usage_obj ) usage = { diff --git a/litellm/integrations/langsmith.py b/litellm/integrations/langsmith.py index cc9b361b69..570b78f292 100644 --- a/litellm/integrations/langsmith.py +++ b/litellm/integrations/langsmith.py @@ -40,6 +40,7 @@ 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() @@ -48,6 +49,7 @@ 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 @@ -76,6 +78,7 @@ 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 = ( @@ -86,11 +89,13 @@ 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( @@ -365,8 +370,11 @@ 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: @@ -418,6 +426,7 @@ 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: @@ -466,6 +475,9 @@ 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 @@ -491,13 +503,16 @@ 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={"x-api-key": langsmith_api_key}, + headers=headers, ) return response.json() diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 12e60bc25b..a7d2326d93 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -196,50 +196,88 @@ 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 - # 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 + def create_tracer_provider(): + provider = TracerProvider(resource=_get_litellm_resource()) + provider.add_span_processor(self._get_span_processor()) + return 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__, - ) + 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, + ) # Grab our tracer from the TracerProvider (not from global context) # This ensures we use the provided TracerProvider (e.g., for testing) @@ -257,39 +295,24 @@ class OpenTelemetry(CustomLogger): return from opentelemetry import metrics - from opentelemetry.sdk.metrics import Histogram, MeterProvider + from opentelemetry.sdk.metrics import MeterProvider - # 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, + def create_meter_provider(): + metric_reader = self._get_metric_reader() + return MeterProvider( + metric_readers=[metric_reader], resource=_get_litellm_resource() ) - 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_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, + ) - 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) + meter = meter_provider.get_meter(__name__) self._operation_duration_histogram = meter.create_histogram( name="gen_ai.client.operation.duration", # Replace with semconv constant in otel 1.38 @@ -327,22 +350,26 @@ class OpenTelemetry(CustomLogger): if not self.config.enable_events: return - from opentelemetry._logs import set_logger_provider + from opentelemetry._logs import get_logger_provider, set_logger_provider from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider from opentelemetry.sdk._logs.export import BatchLogRecordProcessor - # 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 + def create_logger_provider(): + provider = OTLoggerProvider(resource=_get_litellm_resource()) log_exporter = self._get_log_exporter() - if log_exporter: - logger_provider.add_log_record_processor( - BatchLogRecordProcessor(log_exporter) # type: ignore[arg-type] - ) + provider.add_log_record_processor( + BatchLogRecordProcessor(log_exporter) # type: ignore[arg-type] + ) + return provider - set_logger_provider(logger_provider) + 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, + ) def log_success_event(self, kwargs, response_obj, start_time, end_time): self._handle_success(kwargs, response_obj, start_time, end_time) @@ -944,6 +971,15 @@ 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 @@ -1807,7 +1843,8 @@ class OpenTelemetry(CustomLogger): ) return self.OTEL_EXPORTER - if self.OTEL_EXPORTER == "console": + otel_logs_exporter = os.getenv("OTEL_LOGS_EXPORTER") + if self.OTEL_EXPORTER == "console" or otel_logs_exporter == "console": from opentelemetry.sdk._logs.export import ConsoleLogExporter verbose_logger.debug( @@ -1854,6 +1891,67 @@ 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 d92af41717..6baaae7ae3 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -2000,24 +2000,56 @@ class CustomStreamWrapper: ) ## Map to OpenAI Exception try: - raise exception_type( + mapped_exception = exception_type( model=self.model, custom_llm_provider=self.custom_llm_provider, original_exception=e, completion_kwargs={}, extra_kwargs={}, ) - except Exception as e: - from litellm.exceptions import MidStreamFallbackError + except Exception as mapping_error: + mapped_exception = mapping_error - 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, - ) + 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, + ) @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 facabbda72..7a4da98552 100644 --- a/litellm/llms/base_llm/responses/transformation.py +++ b/litellm/llms/base_llm/responses/transformation.py @@ -242,3 +242,30 @@ 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 34ea598a65..ea74040066 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -91,6 +91,7 @@ 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, @@ -850,7 +851,9 @@ class BaseLLMHTTPHandler: ) if client is None or not isinstance(client, HTTPHandler): - sync_httpx_client = _get_httpx_client() + sync_httpx_client = _get_httpx_client( + params={"ssl_verify": litellm_params.get("ssl_verify", None)} + ) else: sync_httpx_client = client @@ -896,7 +899,8 @@ 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) + llm_provider=litellm.LlmProviders(custom_llm_provider), + params={"ssl_verify": litellm_params.get("ssl_verify", None)}, ) else: async_httpx_client = client @@ -2004,6 +2008,10 @@ 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: @@ -2060,6 +2068,18 @@ 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, @@ -2097,6 +2117,8 @@ 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( @@ -2106,6 +2128,8 @@ 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 @@ -2189,6 +2213,18 @@ 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, @@ -2227,6 +2263,8 @@ 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 @@ -2237,6 +2275,8 @@ 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 @@ -3526,6 +3566,174 @@ 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 @@ -8288,4 +8496,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 new file mode 100644 index 0000000000..3ddbd7864d --- /dev/null +++ b/litellm/llms/gigachat/__init__.py @@ -0,0 +1,23 @@ +""" +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 new file mode 100644 index 0000000000..e61015a4a2 --- /dev/null +++ b/litellm/llms/gigachat/authenticator.py @@ -0,0 +1,241 @@ +""" +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 new file mode 100644 index 0000000000..3e030497a1 --- /dev/null +++ b/litellm/llms/gigachat/chat/__init__.py @@ -0,0 +1,12 @@ +""" +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 new file mode 100644 index 0000000000..3565559e43 --- /dev/null +++ b/litellm/llms/gigachat/chat/streaming.py @@ -0,0 +1,134 @@ +""" +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 new file mode 100644 index 0000000000..4ce333a130 --- /dev/null +++ b/litellm/llms/gigachat/chat/transformation.py @@ -0,0 +1,473 @@ +""" +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 new file mode 100644 index 0000000000..af237e49aa --- /dev/null +++ b/litellm/llms/gigachat/embedding/__init__.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 0000000000..0da6565050 --- /dev/null +++ b/litellm/llms/gigachat/embedding/transformation.py @@ -0,0 +1,212 @@ +""" +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 new file mode 100644 index 0000000000..200428a747 --- /dev/null +++ b/litellm/llms/gigachat/file_handler.py @@ -0,0 +1,211 @@ +""" +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 96598c1dfe..cc2439b431 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -500,3 +500,69 @@ 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 826f151df3..d23c698cd7 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 +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple, cast import litellm from litellm._logging import verbose_logger @@ -168,7 +168,6 @@ class VertexBase: ) def _credentials_from_default_auth(self, scopes): - import google.auth as google_auth return google_auth.default(scopes=scopes) @@ -392,7 +391,7 @@ class VertexBase: Returns token, url """ - version: Optional[Literal["v1beta1", "v1"]] = None + version: Optional[Literal["v1", "v1beta1"]] = None if custom_llm_provider == "gemini": url, endpoint = _get_gemini_url( mode=mode, @@ -415,7 +414,7 @@ class VertexBase: stream=stream, vertex_project=vertex_project, vertex_location=vertex_location, - vertex_api_version=version, + vertex_api_version=cast(Literal["v1", "v1beta1"], version), ) return self._check_custom_proxy( diff --git a/litellm/main.py b/litellm/main.py index f4f27eb584..e8a8b504d9 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2141,6 +2141,49 @@ 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 @@ -5224,6 +5267,28 @@ 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 e823dd5dc6..c7a2f60856 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -15831,6 +15831,68 @@ "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", @@ -32092,3 +32154,4 @@ "mode": "chat" } } + diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index ffa17a5b7c..ded591a8f5 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -15,6 +15,7 @@ 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"], @@ -381,13 +382,30 @@ async def callback(code: str, state: str): # ------------------------------ # Optional .well-known endpoints for MCP + OAuth discovery # ------------------------------ -@router.get("/.well-known/oauth-protected-resource/{mcp_server_name}/mcp") +""" + 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") 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": [ ( @@ -401,14 +419,25 @@ 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 [], } - -@router.get("/.well-known/oauth-authorization-server/{mcp_server_name}") +""" + 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") 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) @@ -423,16 +452,21 @@ 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"], - "grant_types_supported": ["authorization_code"], + "scopes_supported": mcp_server.scopes if mcp_server else [], + "grant_types_supported": ["authorization_code", "refresh_token"], "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", + "registration_endpoint": f"{request_base_url}/{mcp_server_name}/register" if mcp_server_name else f"{request_base_url}/register", } diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index a5ac966062..3a548e203c 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") - for server_id in allowed_mcp_servers: + async def _fetch_server_tools(server_id: str) -> List[MCPTool]: + """Fetch tools from a single server with error handling.""" server = self.get_mcp_server_by_id(server_id) if server is None: verbose_logger.warning(f"MCP Server {server_id} not found") - continue + return [] # Get server-specific auth header if available server_auth_header = None @@ -685,15 +685,21 @@ class MCPServerManager: server=server, mcp_auth_header=server_auth_header, ) - list_tools_result.extend(tools) - verbose_logger.info( - f"Successfully fetched {len(tools)} tools from server {server.name}" - ) + return tools except Exception as e: verbose_logger.warning( f"Failed to list tools from server {server.name}: {str(e)}. Continuing with other servers." ) - # Continue with other servers instead of failing completely + 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 + ] verbose_logger.info( f"Successfully fetched {len(list_tools_result)} tools total from all servers" @@ -2003,6 +2009,9 @@ 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 @@ -2284,14 +2293,7 @@ class MCPServerManager: # Check all accessible servers target_server_ids = allowed_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 + return await self._run_health_checks(target_server_ids) async def get_all_allowed_mcp_servers( self, @@ -2306,8 +2308,6 @@ 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,40 +2319,56 @@ class MCPServerManager: verbose_logger.warning(f"MCP Server {server_id} not found in registry") continue - # 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, - ) + mcp_server_table = self._build_mcp_server_table(server) 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. @@ -2360,5 +2376,34 @@ 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 e00fdbfb93..9c7001266f 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -709,7 +709,8 @@ if MCP_AVAILABLE: extra_headers: Optional[Dict[str, str]] = None if server.auth_type == MCPAuth.oauth2: - extra_headers = oauth2_headers + # Copy to avoid mutating the original dict (important for parallel fetching) + extra_headers = oauth2_headers.copy() if oauth2_headers else None if server.extra_headers and raw_headers: if extra_headers is None: @@ -755,11 +756,10 @@ if MCP_AVAILABLE: # Decide whether to add prefix based on number of allowed servers add_prefix = not (len(allowed_mcp_servers) == 1) - # Get tools from each allowed server - all_tools = [] - for server in allowed_mcp_servers: + async def _fetch_and_filter_server_tools(server: MCPServer) -> List[MCPTool]: + """Fetch and filter tools from a single server with error handling.""" if server is None: - continue + return [] server_auth_header, extra_headers = _prepare_mcp_server_headers( server=server, @@ -786,16 +786,24 @@ 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)}" ) - # Continue with other servers instead of failing completely + 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] 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 b77cc40d6d..954c26e2cb 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1908,6 +1908,9 @@ class UserHeaderMapping(LiteLLMPydanticObjectBase): } +UserMCPManagementMode = Literal["restricted", "view_all"] + + class ConfigGeneralSettings(LiteLLMPydanticObjectBase): """ Documents all the fields supported by `general_settings` in config.yaml @@ -2025,6 +2028,10 @@ 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 34049a44c8..537b48f06e 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -319,6 +319,7 @@ class ProxyBaseLLMRequestProcessing: "aget_responses", "adelete_responses", "acancel_responses", + "acompact_responses", "acreate_batch", "aretrieve_batch", "alist_batches", @@ -457,6 +458,7 @@ 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 406ddceabf..fe27af78d5 100644 --- a/litellm/proxy/db/prisma_client.py +++ b/litellm/proxy/db/prisma_client.py @@ -154,7 +154,11 @@ class PrismaManager: prisma_dir = PrismaManager._get_prisma_dir() - return ProxyExtrasDBManager.setup_database(use_migrate=use_migrate) + from litellm.proxy.proxy_server import redis_usage_cache + + return ProxyExtrasDBManager.setup_database( + use_migrate=use_migrate, redis_cache=redis_usage_cache + ) 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 ea8f1b0a97..5850103132 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.new()) # type: ignore + return str(ulid.ULID()) # 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 a871a6637a..47793c8fc8 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._uuid import uuid from litellm._logging import verbose_logger, verbose_proxy_logger +from litellm._uuid import uuid from litellm.constants import LITELLM_PROXY_ADMIN_NAME from litellm.proxy._experimental.mcp_server.utils import ( validate_and_normalize_mcp_server_payload, @@ -67,7 +67,6 @@ 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, @@ -76,8 +75,10 @@ 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 @@ -302,6 +303,20 @@ 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", @@ -319,18 +334,26 @@ if MCP_AVAILABLE: ``` """ - auth_contexts = await build_effective_auth_contexts(user_api_key_dict) + user_mcp_management_mode = _get_user_mcp_management_mode() - 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 + 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() ) - 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: @@ -372,6 +395,17 @@ 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 623e840886..ec1bc5497b 100644 --- a/litellm/proxy/response_api_endpoints/endpoints.py +++ b/litellm/proxy/response_api_endpoints/endpoints.py @@ -698,6 +698,88 @@ 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 fd00cfc1c0..a321e25a9a 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -25,6 +25,7 @@ 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", @@ -116,6 +117,7 @@ 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 e837346df2..8177b177fe 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -1361,3 +1361,205 @@ 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 0407776029..0b838f916e 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -1,5 +1,6 @@ import asyncio import json +import traceback from datetime import datetime from typing import Any, Dict, Optional @@ -11,6 +12,9 @@ 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 @@ -22,7 +26,8 @@ from litellm.types.llms.openai import ( ResponsesAPIStreamEvents, ResponsesAPIStreamingResponse, ) -from litellm.utils import CustomStreamWrapper +from litellm.types.utils import CallTypes +from litellm.utils import CustomStreamWrapper, async_post_call_success_deployment_hook class BaseResponsesAPIStreamingIterator: @@ -40,6 +45,8 @@ 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 @@ -47,21 +54,25 @@ class BaseResponsesAPIStreamingIterator: self.finished = False self.responses_api_provider_config = responses_api_provider_config self.completed_response: Optional[ResponsesAPIStreamingResponse] = None - self.start_time = datetime.now() + self.start_time = getattr(logging_obj, "start_time", datetime.now()) - # set request kwargs + # track request context for hooks 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 ths stream wrapper in litellm/litellm_core_utils/streaming_handler.py + # This matches the 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, @@ -102,13 +113,21 @@ 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 @@ -149,11 +168,159 @@ 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): """ @@ -168,6 +335,8 @@ 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, @@ -176,6 +345,8 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): logging_obj, litellm_metadata, custom_llm_provider, + request_data, + call_type, ) self.stream_iterator = response.aiter_lines() @@ -203,16 +374,21 @@ 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, @@ -229,6 +405,7 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): start_time=self.start_time, end_time=datetime.now(), ) + self._run_post_success_hooks(end_time=datetime.now()) class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): @@ -244,6 +421,8 @@ 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, @@ -252,6 +431,8 @@ class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): logging_obj, litellm_metadata, custom_llm_provider, + request_data, + call_type, ) self.stream_iterator = response.iter_lines() @@ -279,16 +460,21 @@ 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, @@ -304,6 +490,7 @@ class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): start_time=self.start_time, end_time=datetime.now(), ) + self._run_post_success_hooks(end_time=datetime.now()) class MockResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): @@ -324,6 +511,8 @@ 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, @@ -332,6 +521,8 @@ 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 6821ab9e6c..d980b5f74d 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -713,6 +713,23 @@ 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 @@ -812,6 +829,9 @@ 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" ) @@ -3924,6 +3944,7 @@ class Router: "anthropic_messages", "aresponses", "acancel_responses", + "acompact_responses", "responses", "aget_responses", "adelete_responses", @@ -4152,6 +4173,7 @@ 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 23f760ecf3..9c026a117f 100644 --- a/litellm/types/integrations/langsmith.py +++ b/litellm/types/integrations/langsmith.py @@ -31,6 +31,7 @@ class LangsmithCredentialsObject(TypedDict): LANGSMITH_API_KEY: Optional[str] LANGSMITH_PROJECT: Optional[str] LANGSMITH_BASE_URL: str + LANGSMITH_TENANT_ID: Optional[str] class LangsmithQueueObject(TypedDict): @@ -52,6 +53,7 @@ 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 3eec67d9d2..784c8403c3 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2677,6 +2677,7 @@ 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] @@ -2946,6 +2947,7 @@ 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 6b9aeca093..fbbaa94f7a 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7521,6 +7521,8 @@ 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 ( @@ -7716,6 +7718,8 @@ 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 e823dd5dc6..c7a2f60856 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -15831,6 +15831,68 @@ "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", @@ -32092,3 +32154,4 @@ "mode": "chat" } } + diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 45ee47c01b..bc5dea7b97 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -28,7 +28,8 @@ "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" + "delete_container_file": "Supports DELETE /containers/{id}/files/{file_id} endpoint", + "compact": "Supports /responses/compact endpoint" } } }, @@ -1519,6 +1520,7 @@ "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 06a7c17336..249b899b86 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.23.0 ; python_version >= "3.10" # for MCP server +mcp==1.25.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 9a96919da8..60d4f47973 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,13 +61,19 @@ async def test_bedrock_apply_guardrail_blocked(): guardrailVersion="DRAFT", ) - # Mock the make_bedrock_api_request method + # Mock the make_bedrock_api_request method to raise an exception for blocked content with patch.object( - guardrail, "make_bedrock_api_request", new_callable=AsyncMock + guardrail, "make_bedrock_api_request", new_callable=AsyncMock ) as mock_api_request: - # Mock a blocked response from Bedrock - mock_response = {"action": "BLOCKED", "reason": "Content violates policy"} - mock_api_request.return_value = mock_response + # 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": "", + }, + ) # Test the apply_guardrail method should raise an exception with pytest.raises(Exception) as exc_info: @@ -77,8 +83,9 @@ async def test_bedrock_apply_guardrail_blocked(): input_type="request", ) - assert "Content blocked by Bedrock guardrail" in str(exc_info.value) - assert "Content violates policy" in str(exc_info.value) + # 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) @pytest.mark.asyncio @@ -253,7 +260,15 @@ 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_api.return_value = {"action": "BLOCKED", "reason": "policy"} + # 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", + }, + ) with pytest.raises(Exception, match="policy") as exc_info: await guardrail.apply_guardrail( @@ -265,7 +280,8 @@ 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]] - assert "Content blocked by Bedrock guardrail" in str(exc_info.value) + # The apply_guardrail method wraps the original exception in a generic Exception + assert "Bedrock guardrail failed:" 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 5714cd5c48..c981ccd8da 100644 --- a/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py +++ b/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py @@ -1,11 +1,14 @@ 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 +from litellm_proxy_extras.utils import ProxyExtrasDBManager, MigrationLockManager + def test_custom_prisma_dir(monkeypatch): @@ -27,101 +30,279 @@ def test_custom_prisma_dir(monkeypatch): assert os.path.exists(migrations_dir) -class TestPermissionErrorDetection: - """Test cases for permission error detection in Prisma migrations""" +class TestMigrationLockManager: + """Test cases for MigrationLockManager""" - 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_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_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 + 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_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 + result = lock_manager.acquire_lock() - 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 + assert result is True + assert lock_manager.lock_acquired is True + mock_redis.set_cache.assert_called_once() - 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 + 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_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 + result = lock_manager.acquire_lock() - 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 + 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() -class TestIdempotentErrorDetection: - """Test cases for idempotent error detection in Prisma migrations""" +class TestProxyExtrasDBManagerMigrationLock: + """Test cases for ProxyExtrasDBManager with migration locking""" - 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 + @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_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 + # 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_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 + # 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_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 + 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_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 + @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_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 + # 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_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 + # 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 + ) + 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() -class TestErrorClassificationPriority: - """Test cases to ensure errors are correctly classified""" + @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 - 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 + # 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_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 + assert result is True + # Redis가 없을 때는 락 보호 없이 마이그레이션을 실행해야 함 + mock_subprocess.assert_called_once() - 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 + 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 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 7553c67077..5f35d6837c 100644 --- a/tests/llm_responses_api_testing/test_openai_responses_api.py +++ b/tests/llm_responses_api_testing/test_openai_responses_api.py @@ -1814,3 +1814,49 @@ 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 new file mode 100644 index 0000000000..8c0f7dab2a --- /dev/null +++ b/tests/llm_responses_api_testing/test_responses_hooks.py @@ -0,0 +1,165 @@ +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 7c849650bf..ab5709cd72 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, True), + (False, True, False), (True, True, True), (False, False, False), ], diff --git a/tests/llm_translation/test_databricks.py b/tests/llm_translation/test_databricks.py index 40fc712f2b..3013d00288 100644 --- a/tests/llm_translation/test_databricks.py +++ b/tests/llm_translation/test_databricks.py @@ -15,6 +15,7 @@ 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: @@ -725,6 +726,7 @@ 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( { @@ -767,6 +769,7 @@ 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( { @@ -823,6 +826,7 @@ 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( { @@ -895,6 +899,7 @@ 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( { @@ -923,6 +928,7 @@ 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 new file mode 100644 index 0000000000..80bf51b464 --- /dev/null +++ b/tests/llm_translation/test_gigachat.py @@ -0,0 +1,349 @@ +""" +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 2b01b4c2a1..5e92c10fbd 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-opus-20240229", messages=messages) + response = litellm.completion(model="claude-3-7-sonnet-20250219", 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-opus-20240229", + model="anthropic/claude-3-7-sonnet-20250219", 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-opus-20240229", "anthropic.claude-3-sonnet-20240229-v1:0"], + ["anthropic/claude-3-7-sonnet-20250219", "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-opus-20240229", None, None), + ("claude-3-7-sonnet-20250219", 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-opus-20240229", + model="anthropic/claude-3-7-sonnet-20250219", 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-opus-20240229", + model="anthropic/claude-3-7-sonnet-20250219", 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-opus-20240229", + model="anthropic/claude-3-7-sonnet-20250219", 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-opus-20240229", + "anthropic/claude-3-7-sonnet-20250219", ], ) # def test_completion_base64(model): diff --git a/tests/local_testing/test_streaming.py b/tests/local_testing/test_streaming.py index 0d9f84a301..b9b5d0fdb0 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-opus-20240229", + "claude-3-7-sonnet-20250219", "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-opus-20240229", + model="claude-3-7-sonnet-20250219", 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 e63ce9f8b3..bde2b94457 100644 --- a/tests/logging_callback_tests/test_langsmith_unit_test.py +++ b/tests/logging_callback_tests/test_langsmith_unit_test.py @@ -47,6 +47,19 @@ 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(): @@ -60,6 +73,7 @@ async def test_group_batches_by_credentials(): "LANGSMITH_API_KEY": "key1", "LANGSMITH_PROJECT": "proj1", "LANGSMITH_BASE_URL": "url1", + "LANGSMITH_TENANT_ID": None, }, ) @@ -69,6 +83,7 @@ async def test_group_batches_by_credentials(): "LANGSMITH_API_KEY": "key1", "LANGSMITH_PROJECT": "proj1", "LANGSMITH_BASE_URL": "url1", + "LANGSMITH_TENANT_ID": None, }, ) @@ -95,6 +110,7 @@ async def test_group_batches_by_credentials_multiple_credentials(): "LANGSMITH_API_KEY": "key1", "LANGSMITH_PROJECT": "proj1", "LANGSMITH_BASE_URL": "url1", + "LANGSMITH_TENANT_ID": None, }, ) @@ -104,6 +120,7 @@ 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, }, ) @@ -113,6 +130,7 @@ 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, }, ) @@ -127,6 +145,57 @@ 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(): @@ -201,10 +270,43 @@ 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_langsmith_key_based_logging(mocker): +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(): """ In key based logging langsmith_api_key and langsmith_project are passed directly to litellm.acompletion """ @@ -219,10 +321,11 @@ async def test_langsmith_key_based_logging(mocker): mock_response.text = "" mock_async_httpx_handler.post = AsyncMock(return_value=mock_response) - mock_get_client = mocker.patch( + mock_get_client = 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 @@ -253,6 +356,8 @@ async def test_langsmith_key_based_logging(mocker): # 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"] @@ -344,6 +449,8 @@ async def test_langsmith_key_based_logging(mocker): 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 3d0682d903..04f8abe64d 100644 --- a/tests/logging_callback_tests/test_opentelemetry_unit_tests.py +++ b/tests/logging_callback_tests/test_opentelemetry_unit_tests.py @@ -65,31 +65,6 @@ 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 40e223ffe0..45aae3b9ae 100644 --- a/tests/router_unit_tests/test_router_helper_utils.py +++ b/tests/router_unit_tests/test_router_helper_utils.py @@ -79,6 +79,73 @@ 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 6490352c39..596398e639 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,39 +1011,87 @@ def test_multiple_tool_calls_in_single_choice(): def test_map_reasoning_effort_adds_summary_detailed(): """ - Test that _map_reasoning_effort adds summary="detailed" when user provides reasoning_effort as a string. + Test that _map_reasoning_effort behavior with reasoning_auto_summary flag. - 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. + 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. """ + import os + + import litellm from litellm.completion_extras.litellm_responses_transformation.transformation import ( LiteLLMResponsesTransformationHandler, ) handler = LiteLLMResponsesTransformationHandler() - # Test all string effort levels + # Test all string effort levels - DEFAULT BEHAVIOR (no summary) effort_levels = ["none", "low", "medium", "high", "xhigh", "minimal"] - for effort in effort_levels: - result = handler._map_reasoning_effort(effort) + # Save original flag value + original_flag = litellm.reasoning_auto_summary + original_env = os.environ.get("LITELLM_REASONING_AUTO_SUMMARY") + + 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"] - 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}" + 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)") - print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}', summary='detailed'") + # 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") - - # 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'") + 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"] diff --git a/tests/test_litellm/integrations/langfuse/test_gemini_cached_tokens.py b/tests/test_litellm/integrations/langfuse/test_gemini_cached_tokens.py new file mode 100644 index 0000000000..e717840ec9 --- /dev/null +++ b/tests/test_litellm/integrations/langfuse/test_gemini_cached_tokens.py @@ -0,0 +1,90 @@ +""" +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 5d648f601f..6c17570e13 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -172,6 +172,86 @@ 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 @@ -620,7 +700,6 @@ 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) @@ -687,11 +766,8 @@ 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() @@ -1320,6 +1396,23 @@ 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 6a528fef8f..ec2f528a35 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py @@ -691,6 +691,29 @@ 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 new file mode 100644 index 0000000000..0f369fbb8b --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_function_call_args_serialization.py @@ -0,0 +1,355 @@ +""" +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 91a28ee6ec..d09de3a0f2 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,11 +10,13 @@ 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(): @@ -1605,6 +1607,39 @@ 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 6df9abd3fe..4c5723b828 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"], + "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "client_secret_post", } @@ -556,9 +556,33 @@ 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) @@ -568,13 +592,14 @@ 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_server", + mcp_server_name="test_oauth", ) # 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 @@ -584,9 +609,33 @@ 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) @@ -596,13 +645,15 @@ 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_server", + mcp_server_name="test_oauth", ) # 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 6491e11024..d59b3f04ef 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,7 +594,26 @@ 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 6d6129b17b..35ed49a84e 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_qualifire.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_qualifire.py @@ -2,6 +2,7 @@ Unit tests for Qualifire guardrail integration. """ +import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -139,76 +140,98 @@ class TestQualifireGuardrailEvaluateKwargs: @pytest.mark.asyncio async def test_evaluate_called_with_prompt_injections(self): """Test that evaluate is called with prompt_injections enabled.""" - from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import ( - QualifireGuardrail, - ) + # 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, + ) - 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.""" - from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import ( - QualifireGuardrail, - ) + # 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, + ) - 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 011031c1e4..97c1733a93 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,6 +40,7 @@ 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"} @@ -59,6 +60,10 @@ 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( @@ -96,6 +101,7 @@ 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"} @@ -115,6 +121,10 @@ 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 cc268ab992..f2bae2cb14 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,6 +231,40 @@ 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): """ @@ -1096,6 +1130,51 @@ 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 8fdfd6897a..ad4f53dac4 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,18 +742,16 @@ 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 - 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, + # Override the FastAPI dependency with a proper mock + mock_user_auth = UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, ) + 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() @@ -761,7 +759,11 @@ class TestProxySettingEndpoints: payload = {"disable_model_add_for_internal_users": True} - response = client.patch("/update/ui_settings", json=payload) + try: + response = client.patch("/update/ui_settings", json=payload) + finally: + # Clean up the dependency override + app.dependency_overrides.clear() assert response.status_code == 200 data = response.json() @@ -780,18 +782,16 @@ 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 - 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, + # Override the FastAPI dependency with a proper mock + mock_user_auth = UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, ) + 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,7 +802,11 @@ class TestProxySettingEndpoints: "unsupported_flag": True, } - response = client.patch("/update/ui_settings", json=payload) + try: + response = client.patch("/update/ui_settings", json=payload) + finally: + # Clean up the dependency override + app.dependency_overrides.clear() 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 3e09c3c2ca..f03f397711 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,13 +27,15 @@ export interface SSOSettingsValues { proxy_base_url: string | null; user_email: string | null; ui_access_mode: string | null; - role_mappings: { - provider: string; - group_claim: string; - default_role: "internal_user" | "internal_user_viewer" | "proxy_admin" | "proxy_admin_viewer"; - roles: { - [key: string]: string[]; - }; + 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[]; }; } 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 6541817959..a8b1d2cddc 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,6 +18,7 @@ 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"; @@ -274,6 +275,30 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te )} + + {/* Missing Provider Banner */} + {selectedModelId && !isLoading ? ( = ({ form, onFormS prevValues.use_role_mappings !== currentValues.use_role_mappings} + shouldUpdate={(prevValues, currentValues) => + prevValues.use_role_mappings !== currentValues.use_role_mappings || + prevValues.sso_provider !== currentValues.sso_provider + } > {({ getFieldValue }) => { const useRoleMappings = getFieldValue("use_role_mappings"); - return useRoleMappings ? ( + const provider = getFieldValue("sso_provider"); + const supportsRoleMappings = provider === "okta" || provider === "generic"; + return useRoleMappings && supportsRoleMappings ? ( = ({ form, onFormS prevValues.use_role_mappings !== currentValues.use_role_mappings} + shouldUpdate={(prevValues, currentValues) => + prevValues.use_role_mappings !== currentValues.use_role_mappings || + prevValues.sso_provider !== currentValues.sso_provider + } > {({ getFieldValue }) => { const useRoleMappings = getFieldValue("use_role_mappings"); - return useRoleMappings ? ( + const provider = getFieldValue("sso_provider"); + const supportsRoleMappings = provider === "okta" || provider === "generic"; + return useRoleMappings && supportsRoleMappings ? ( <> diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.test.tsx index 7d8a35b7f4..ef6ec6c705 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.test.tsx @@ -1,60 +1,20 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import DeleteSSOSettingsModal from "./DeleteSSOSettingsModal"; -vi.mock("@/app/(dashboard)/hooks/sso/useSSOSettings", () => ({ - useSSOSettings: vi.fn(() => ({ - data: { - values: { - google_client_id: "test-client-id", - }, - }, - })), -})); - -vi.mock("@/app/(dashboard)/hooks/sso/useEditSSOSettings", () => ({ - useEditSSOSettings: vi.fn(() => ({ - mutateAsync: vi.fn(), - isPending: false, - })), -})); - -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: vi.fn(() => ({ - accessToken: "test-token", - userId: "test-user-id", - userRole: "proxy_admin", - })), -})); - -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - }, - }); - describe("DeleteSSOSettingsModal", () => { it("should render", () => { const onCancel = vi.fn(); const onSuccess = vi.fn(); - const queryClient = createQueryClient(); render( - - - , + , ); expect(screen.getByText("Confirm Clear SSO Settings")).toBeInTheDocument(); expect( - screen.getByText( - "Are you sure you want to clear all SSO settings? Users will no longer be able to login using SSO after this change.", - ), + screen.getByText("Are you sure you want to clear all SSO settings? This action cannot be undone."), ).toBeInTheDocument(); + expect(screen.getByText("Users will no longer be able to login using SSO after this change.")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx index 44cbf0020e..6a28b6490e 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx @@ -1,66 +1,79 @@ -import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings"; -import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings"; +import { Modal } from "antd"; import React from "react"; -import DeleteResourceModal from "../../../../common_components/DeleteResourceModal"; import NotificationsManager from "../../../../molecules/notifications_manager"; +import { updateSSOSettings } from "../../../../networking"; import { parseErrorMessage } from "../../../../shared/errorUtils"; -import { detectSSOProvider } from "../utils"; interface DeleteSSOSettingsModalProps { isVisible: boolean; onCancel: () => void; onSuccess: () => void; + accessToken: string | null; } -const DeleteSSOSettingsModal: React.FC = ({ isVisible, onCancel, onSuccess }) => { - const { data: ssoSettings } = useSSOSettings(); - const { mutateAsync: editSSOSettings, isPending: isEditingSSOSettings } = useEditSSOSettings(); - +const DeleteSSOSettingsModal: React.FC = ({ + isVisible, + onCancel, + onSuccess, + accessToken, +}) => { // Handle clearing SSO settings const handleClearSSO = async () => { - const clearSettings = { - google_client_id: null, - google_client_secret: null, - microsoft_client_id: null, - microsoft_client_secret: null, - microsoft_tenant: null, - generic_client_id: null, - generic_client_secret: null, - generic_authorization_endpoint: null, - generic_token_endpoint: null, - generic_userinfo_endpoint: null, - proxy_base_url: null, - user_email: null, - sso_provider: null, - role_mappings: null, - }; + if (!accessToken) { + NotificationsManager.fromBackend("No access token available"); + return; + } - await editSSOSettings(clearSettings, { - onSuccess: () => { - NotificationsManager.success("SSO settings cleared successfully"); - onCancel(); - onSuccess(); - }, - onError: (error) => { - NotificationsManager.fromBackend("Failed to clear SSO settings: " + parseErrorMessage(error)); - }, - }); + try { + // Clear all SSO settings + const clearSettings = { + google_client_id: null, + google_client_secret: null, + microsoft_client_id: null, + microsoft_client_secret: null, + microsoft_tenant: null, + generic_client_id: null, + generic_client_secret: null, + generic_authorization_endpoint: null, + generic_token_endpoint: null, + generic_userinfo_endpoint: null, + proxy_base_url: null, + user_email: null, + sso_provider: null, + }; + + await updateSSOSettings(accessToken, clearSettings); + + NotificationsManager.success("SSO settings cleared successfully"); + + // Close modal and trigger success callback + onCancel(); + onSuccess(); + } catch (error) { + console.error("Failed to clear SSO settings:", error); + NotificationsManager.fromBackend("Failed to clear SSO settings: " + parseErrorMessage(error)); + } }; return ( - + onCancel={onCancel} + okText="Yes, Clear" + cancelText="Cancel" + okButtonProps={{ + danger: true, + style: { + backgroundColor: "#dc2626", + borderColor: "#dc2626", + }, + }} + > +

Are you sure you want to clear all SSO settings? This action cannot be undone.

+

Users will no longer be able to login using SSO after this change.

+ ); }; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.test.tsx deleted file mode 100644 index f4b7b9aadd..0000000000 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { RoleMappings as RoleMappingsType } from "@/app/(dashboard)/hooks/sso/useSSOSettings"; -import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { renderWithProviders } from "../../../../../tests/test-utils"; -import RoleMappings from "./RoleMappings"; - -describe("RoleMappings", () => { - it("should render successfully", () => { - const roleMappings: RoleMappingsType = { - provider: "generic", - group_claim: "groups", - default_role: "internal_user", - roles: { - proxy_admin: ["admin-group"], - proxy_admin_viewer: [], - internal_user: ["user-group"], - internal_user_viewer: [], - }, - }; - - renderWithProviders(); - - expect(screen.getByText("Role Mappings")).toBeInTheDocument(); - }); - - it("should return null when roleMappings is undefined", () => { - const { container } = renderWithProviders(); - - expect(container.firstChild).toBeNull(); - }); - - it("should display Group Claim and Default Role with correct values and display names", () => { - const testCases: Array<{ role: RoleMappingsType["default_role"]; displayName: string; groupClaim: string }> = [ - { role: "internal_user_viewer", displayName: "Internal Viewer", groupClaim: "custom-groups-1" }, - { role: "internal_user", displayName: "Internal User", groupClaim: "custom-groups-2" }, - { role: "proxy_admin_viewer", displayName: "Proxy Admin Viewer", groupClaim: "custom-groups-3" }, - { role: "proxy_admin", displayName: "Proxy Admin", groupClaim: "custom-groups-4" }, - ]; - - testCases.forEach(({ role, displayName, groupClaim }) => { - const roleMappings: RoleMappingsType = { - provider: "generic", - group_claim: groupClaim, - default_role: role, - roles: { - proxy_admin: [], - proxy_admin_viewer: [], - internal_user: [], - internal_user_viewer: [], - }, - }; - - const { unmount } = renderWithProviders(); - - expect(screen.getByText("Group Claim")).toBeInTheDocument(); - expect(screen.getByText(groupClaim)).toBeInTheDocument(); - expect(screen.getByText("Default Role")).toBeInTheDocument(); - const displayNameElements = screen.getAllByText(displayName); - expect(displayNameElements.length).toBeGreaterThan(0); - unmount(); - }); - }); - - it("should display table with roles, groups as Tags when mapped, and 'No groups mapped' when empty", () => { - const roleMappings: RoleMappingsType = { - provider: "generic", - group_claim: "groups", - default_role: "internal_user", - roles: { - proxy_admin: ["admin-group-1", "admin-group-2", "admin-group-3"], - proxy_admin_viewer: ["viewer-group"], - internal_user: ["user-group"], - internal_user_viewer: [], - }, - }; - - renderWithProviders(); - - expect(screen.getByText("Role")).toBeInTheDocument(); - expect(screen.getByText("Mapped Groups")).toBeInTheDocument(); - expect(screen.getAllByText("Proxy Admin").length).toBeGreaterThan(0); - expect(screen.getAllByText("Proxy Admin Viewer").length).toBeGreaterThan(0); - expect(screen.getAllByText("Internal User").length).toBeGreaterThan(0); - expect(screen.getAllByText("Internal Viewer").length).toBeGreaterThan(0); - expect(screen.getByText("admin-group-1")).toBeInTheDocument(); - expect(screen.getByText("admin-group-2")).toBeInTheDocument(); - expect(screen.getByText("admin-group-3")).toBeInTheDocument(); - expect(screen.getByText("viewer-group")).toBeInTheDocument(); - expect(screen.getByText("user-group")).toBeInTheDocument(); - expect(screen.getByText("No groups mapped")).toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx deleted file mode 100644 index 3750ee8818..0000000000 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { RoleMappings as RoleMappingsType } from "@/app/(dashboard)/hooks/sso/useSSOSettings"; -import { Card, Divider, Table, Tag, Typography } from "antd"; -import { Users } from "lucide-react"; -import { defaultRoleDisplayNames } from "./constants"; -const { Title, Text } = Typography; - -export default function RoleMappings({ roleMappings }: { roleMappings: RoleMappingsType | undefined }) { - if (!roleMappings) { - return null; - } - - const roleMappingsColumns = [ - { - title: "Role", - dataIndex: "role", - key: "role", - render: (text: string) => {defaultRoleDisplayNames[text]}, - }, - { - title: "Mapped Groups", - dataIndex: "groups", - key: "groups", - render: (groups: string[]) => ( - <> - {groups.length > 0 ? ( - groups.map((group, index) => ( - - {group} - - )) - ) : ( - No groups mapped - )} - - ), - }, - ]; - return ( - -
- - Role Mappings -
-
-
-
- Group Claim -
- {roleMappings.group_claim} -
-
-
- Default Role -
- {defaultRoleDisplayNames[roleMappings.default_role]} -
-
-
- - ({ - role, - groups, - }))} - pagination={false} - bordered - size="small" - className="w-full" - /> - - - ); -} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx index adc1251cde..27ff96af05 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx @@ -1,23 +1,23 @@ "use client"; import { useSSOSettings, type SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { Button, Card, Descriptions, Space, Typography } from "antd"; import { Edit, Shield, Trash2 } from "lucide-react"; import { useState } from "react"; -import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants"; import AddSSOSettingsModal from "./Modals/AddSSOSettingsModal"; import DeleteSSOSettingsModal from "./Modals/DeleteSSOSettingsModal"; import EditSSOSettingsModal from "./Modals/EditSSOSettingsModal"; import RedactableField from "./RedactableField"; -import RoleMappings from "./RoleMappings"; import SSOSettingsEmptyPlaceholder from "./SSOSettingsEmptyPlaceholder"; import SSOSettingsLoadingSkeleton from "./SSOSettingsLoadingSkeleton"; -import { detectSSOProvider } from "./utils"; +import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants"; const { Title, Text } = Typography; export default function SSOSettings() { const { data: ssoSettings, refetch, isLoading } = useSSOSettings(); + const { accessToken } = useAuthorized(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [isEditModalVisible, setIsEditModalVisible] = useState(false); @@ -26,8 +26,24 @@ export default function SSOSettings() { Boolean(ssoSettings?.values.microsoft_client_id) || Boolean(ssoSettings?.values.generic_client_id); + // Determine the SSO provider based on the configuration + const detectSSOProvider = (values: SSOSettingsValues): string | null => { + if (values.google_client_id) return "google"; + if (values.microsoft_client_id) return "microsoft"; + if (values.generic_client_id) { + // Check if it looks like Okta/Auth0 based on endpoints + if ( + values.generic_authorization_endpoint?.includes("okta") || + values.generic_authorization_endpoint?.includes("auth0") + ) { + return "okta"; + } + return "generic"; + } + return null; + }; + const selectedProvider = ssoSettings?.values ? detectSSOProvider(ssoSettings.values) : null; - const isRoleMappingsEnabled = Boolean(ssoSettings?.values.role_mappings); const renderEndpointValue = (value?: string | null) => ( @@ -169,52 +185,46 @@ export default function SSOSettings() { {isLoading ? ( ) : ( - - - - {/* Header Section */} -
-
- -
- SSO Configuration - Manage Single Sign-On authentication settings -
-
- -
- {isSSOConfigured && ( - <> - - - - )} + + + {/* Header Section */} +
+
+ +
+ SSO Configuration + Manage Single Sign-On authentication settings
- {isSSOConfigured ? ( - renderSSOSettings() - ) : ( - setIsAddModalVisible(true)} /> - )} - - - {isRoleMappingsEnabled && } - +
+ {isSSOConfigured && ( + <> + + + + )} +
+
+ + {isSSOConfigured ? ( + renderSSOSettings() + ) : ( + setIsAddModalVisible(true)} /> + )} +
+
)} setIsDeleteModalVisible(false)} onSuccess={() => refetch()} + accessToken={accessToken} /> = { okta: "Okta / Auth0 SSO", generic: "Generic SSO", }; - -export const defaultRoleDisplayNames: Record = { - internal_user_viewer: "Internal Viewer", - internal_user: "Internal User", - proxy_admin_viewer: "Proxy Admin Viewer", - proxy_admin: "Proxy Admin", -}; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts index 718302d35f..1c878d7b7b 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts @@ -55,7 +55,6 @@ describe("processSSOSettingsPayload", () => { default_role: "proxy_admin", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", other_field: "value", }; @@ -84,7 +83,6 @@ describe("processSSOSettingsPayload", () => { default_role: "internal_user", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); @@ -102,7 +100,6 @@ describe("processSSOSettingsPayload", () => { default_role: "internal_user_viewer", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); @@ -124,7 +121,6 @@ describe("processSSOSettingsPayload", () => { default_role: "proxy_admin_viewer", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); @@ -146,7 +142,6 @@ describe("processSSOSettingsPayload", () => { default_role: "internal_user", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); @@ -165,7 +160,6 @@ describe("processSSOSettingsPayload", () => { default_role: "internal_user", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); @@ -180,7 +174,6 @@ describe("processSSOSettingsPayload", () => { default_role: "internal_user_viewer", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); @@ -193,7 +186,6 @@ describe("processSSOSettingsPayload", () => { default_role: "internal_user", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); @@ -206,7 +198,6 @@ describe("processSSOSettingsPayload", () => { default_role: "proxy_admin_viewer", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); @@ -219,7 +210,6 @@ describe("processSSOSettingsPayload", () => { default_role: "proxy_admin", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); @@ -232,7 +222,6 @@ describe("processSSOSettingsPayload", () => { default_role: "unknown_role", group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); @@ -244,7 +233,6 @@ describe("processSSOSettingsPayload", () => { const formValues = { group_claim: "groups", use_role_mappings: true, - sso_provider: "generic", }; const result = processSSOSettingsPayload(formValues); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts index c199048df3..3533e1226c 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts @@ -1,5 +1,3 @@ -import { SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings"; - /** * Processes SSO settings form values and transforms them into the payload format expected by the API * Handles role mappings transformation and field extraction @@ -20,7 +18,7 @@ export const processSSOSettingsPayload = (formValues: Record): Reco ...rest, }; - // Add role mappings only if use_role_mappings is checked AND provider supports role mappings + // Add role mappings if use_role_mappings is checked if (use_role_mappings) { // Helper function to split comma-separated string into array const splitTeams = (teams: string | undefined): string[] => { @@ -54,20 +52,3 @@ export const processSSOSettingsPayload = (formValues: Record): Reco return payload; }; - -// Determine the SSO provider based on the configuration -export const detectSSOProvider = (values: SSOSettingsValues): string | null => { - if (values.google_client_id) return "google"; - if (values.microsoft_client_id) return "microsoft"; - if (values.generic_client_id) { - // Check if it looks like Okta/Auth0 based on endpoints - if ( - values.generic_authorization_endpoint?.includes("okta") || - values.generic_authorization_endpoint?.includes("auth0") - ) { - return "okta"; - } - return "generic"; - } - return null; -}; diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx index a62d8baa75..9600c96256 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx @@ -136,7 +136,7 @@ export const useMcpOAuthFlow = ({ if (!hasPreconfiguredCredentials) { const registration = await registerMcpOAuthClient(accessToken, serverId, { client_name: temporaryPayload.alias || temporaryPayload.server_name || serverId, - grant_types: ["authorization_code", "refresh_token"], + grant_types: ["authorization_code"], response_types: ["code"], token_endpoint_auth_method: temporaryPayload.credentials && temporaryPayload.credentials.client_secret ? "client_secret_post" : "none", From 0756b48963c0065199cbb61f81596531ba3e698e Mon Sep 17 00:00:00 2001 From: Felipe Peter Date: Tue, 6 Jan 2026 10:33:25 -0800 Subject: [PATCH 5/5] Add Anthropic cache control option to image tool call results (#18674) --- .../prompt_templates/factory.py | 7 +- ...llm_core_utils_prompt_templates_factory.py | 246 +++++++++++++++--- 2 files changed, 211 insertions(+), 42 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 12570a02de..cbb31be146 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1645,9 +1645,12 @@ def convert_to_anthropic_tool_result( ) elif content["type"] == "image_url": format = content["image_url"].get("format") if isinstance(content["image_url"], dict) else None - anthropic_content_list.append( - create_anthropic_image_param(content["image_url"], format=format) + _anthropic_image_param = create_anthropic_image_param(content["image_url"], format=format) + _anthropic_image_param = add_cache_control_to_content( + anthropic_content_element=_anthropic_image_param, + original_content_element=content, ) + anthropic_content_list.append(_anthropic_image_param) anthropic_content = anthropic_content_list anthropic_tool_result: Optional[AnthropicMessagesToolResultParam] = None diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index c8fe6efeaa..a4188f03cd 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -620,26 +620,26 @@ def test_bedrock_tools_unpack_defs(): def test_bedrock_image_processor_content_type_fallback_url_extension(): """ - Test that _post_call_image_processing falls back to URL extension + Test that _post_call_image_processing falls back to URL extension when content-type is binary/octet-stream or application/octet-stream """ import base64 - + # Create mock response with binary/octet-stream content-type mock_response = MagicMock() mock_response.headers.get.return_value = "binary/octet-stream" - + # Create a simple PNG header (magic bytes) png_header = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a" png_content = png_header + b"\x00" * 100 # Add some padding mock_response.content = png_content - + # Test with .png URL image_url = "https://example.com/test-image.png" base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing( mock_response, image_url ) - + assert content_type == "image/png" assert base64_bytes == base64.b64encode(png_content).decode("utf-8") @@ -650,22 +650,22 @@ def test_bedrock_image_processor_content_type_fallback_binary_detection(): when content-type is missing and URL extension is not recognized """ import base64 - + # Create mock response with no content-type mock_response = MagicMock() mock_response.headers.get.return_value = None - + # Create a JPEG header (magic bytes) jpeg_header = b"\xff\xd8\xff" jpeg_content = jpeg_header + b"\x00" * 100 # Add some padding mock_response.content = jpeg_content - + # Test with URL without extension image_url = "https://example.com/test-image-without-extension" base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing( mock_response, image_url ) - + assert content_type == "image/jpeg" assert base64_bytes == base64.b64encode(jpeg_content).decode("utf-8") @@ -675,22 +675,22 @@ def test_bedrock_image_processor_content_type_fallback_application_octet_stream( Test that _post_call_image_processing handles application/octet-stream correctly """ import base64 - + # Create mock response with application/octet-stream content-type mock_response = MagicMock() mock_response.headers.get.return_value = "application/octet-stream" - + # Create a GIF header (magic bytes) gif_header = b"GIF8" + b"\x00" + b"a" gif_content = gif_header + b"\x00" * 100 # Add some padding mock_response.content = gif_content - + # Test with .gif URL image_url = "https://s3.amazonaws.com/bucket/image.gif" base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing( mock_response, image_url ) - + assert content_type == "image/gif" assert base64_bytes == base64.b64encode(gif_content).decode("utf-8") @@ -700,22 +700,22 @@ def test_bedrock_image_processor_content_type_with_query_params(): Test that _post_call_image_processing correctly extracts extension from URL with query parameters """ import base64 - + # Create mock response with binary/octet-stream content-type mock_response = MagicMock() mock_response.headers.get.return_value = "binary/octet-stream" - + # Create a WebP header (magic bytes) webp_header = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP" webp_content = webp_header + b"\x00" * 100 # Add some padding mock_response.content = webp_content - + # Test with URL containing query parameters (common in S3 signed URLs) image_url = "https://s3.amazonaws.com/bucket/image.webp?AWSAccessKeyId=123&Expires=456&Signature=789" base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing( mock_response, image_url ) - + assert content_type == "image/webp" assert base64_bytes == base64.b64encode(webp_content).decode("utf-8") @@ -725,21 +725,21 @@ def test_bedrock_image_processor_content_type_normal_header(): Test that _post_call_image_processing works normally when content-type is correctly set """ import base64 - + # Create mock response with correct content-type mock_response = MagicMock() mock_response.headers.get.return_value = "image/png" - + # Create a PNG header png_header = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a" png_content = png_header + b"\x00" * 100 mock_response.content = png_content - + image_url = "https://example.com/test-image.png" base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing( mock_response, image_url ) - + assert content_type == "image/png" assert base64_bytes == base64.b64encode(png_content).decode("utf-8") @@ -751,16 +751,16 @@ def test_bedrock_image_processor_content_type_fallback_failure(): # Create mock response with binary/octet-stream content-type mock_response = MagicMock() mock_response.headers.get.return_value = "binary/octet-stream" - + # Create content with unrecognizable image format mock_response.content = b"\x00" * 100 - + # Test with URL without recognizable extension image_url = "https://example.com/unknown-file" - + with pytest.raises(ValueError) as excinfo: BedrockImageProcessor._post_call_image_processing(mock_response, image_url) - + assert "Unable to determine content type" in str(excinfo.value) @@ -771,18 +771,18 @@ def test_bedrock_image_processor_content_type_jpeg_variants(): # Create mock response with binary/octet-stream mock_response = MagicMock() mock_response.headers.get.return_value = "binary/octet-stream" - + jpeg_header = b"\xff\xd8\xff" jpeg_content = jpeg_header + b"\x00" * 100 mock_response.content = jpeg_content - + # Test with .jpg extension image_url_jpg = "https://example.com/photo.jpg" _, content_type_jpg = BedrockImageProcessor._post_call_image_processing( mock_response, image_url_jpg ) assert content_type_jpg == "image/jpeg" - + # Test with .jpeg extension image_url_jpeg = "https://example.com/photo.jpeg" _, content_type_jpeg = BedrockImageProcessor._post_call_image_processing( @@ -797,22 +797,22 @@ def test_bedrock_image_processor_content_type_pdf_document(): when content-type is binary/octet-stream """ import base64 - + # Create mock response with binary/octet-stream content-type mock_response = MagicMock() mock_response.headers.get.return_value = "binary/octet-stream" - + # Create a PDF header (magic bytes: %PDF) pdf_header = b"%PDF-1.4" pdf_content = pdf_header + b"\x00" * 100 mock_response.content = pdf_content - + # Test with .pdf URL pdf_url = "https://s3.amazonaws.com/bucket/document.pdf" base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing( mock_response, pdf_url ) - + assert content_type == "application/pdf" assert base64_bytes == base64.b64encode(pdf_content).decode("utf-8") @@ -822,12 +822,12 @@ def test_bedrock_image_processor_content_type_document_formats(): Test that _post_call_image_processing handles various document formats """ import base64 - + # Create mock response mock_response = MagicMock() mock_response.headers.get.return_value = "application/octet-stream" mock_response.content = b"\x00" * 100 - + # Test various document formats test_cases = [ ("https://example.com/doc.pdf", "application/pdf"), @@ -837,7 +837,7 @@ def test_bedrock_image_processor_content_type_document_formats(): ("https://example.com/page.html", "text/html"), ("https://example.com/readme.txt", "text/plain"), ] - + for url, expected_mime in test_cases: _, content_type = BedrockImageProcessor._post_call_image_processing( mock_response, url @@ -850,21 +850,21 @@ def test_bedrock_image_processor_content_type_s3_pdf_with_query(): Test that _post_call_image_processing handles S3 PDF with query parameters """ import base64 - + # Create mock response mock_response = MagicMock() mock_response.headers.get.return_value = "binary/octet-stream" - + pdf_content = b"%PDF-1.4" + b"\x00" * 100 mock_response.content = pdf_content - + # S3 signed URL with query parameters s3_url = "https://my-bucket.s3.us-east-1.amazonaws.com/documents/report.pdf?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&Expires=1234567890&Signature=abcdef123456" - + base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing( mock_response, s3_url ) - + assert content_type == "application/pdf" assert base64_bytes == base64.b64encode(pdf_content).decode("utf-8") @@ -1137,3 +1137,169 @@ def test_bedrock_create_bedrock_block_different_document_formats(): assert f"DocumentPDFmessages_" in block["document"]["name"] assert block["document"]["name"].endswith(f"_{format_type}") assert block["document"]["format"] == format_type + + +def test_convert_to_anthropic_tool_result_image_with_cache_control(): + """ + Test that cache_control is properly applied to image content in tool results. + This tests the functionality added in the uncommitted changes where + add_cache_control_to_content is called for image_url content types. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_anthropic_tool_result, + ) + + # Test with base64 image data URI + message = { + "role": "tool", + "tool_call_id": "call_test_123", + "content": [ + { + "type": "text", + "text": "Here is the image you requested:", + }, + { + "type": "image_url", + "image_url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQ", + "cache_control": {"type": "ephemeral"}, + }, + ], + } + + result = convert_to_anthropic_tool_result(message) + + # Verify the result structure + assert result["type"] == "tool_result" + assert result["tool_use_id"] == "call_test_123" + assert isinstance(result["content"], list) + assert len(result["content"]) == 2 + + # Verify text content + assert result["content"][0]["type"] == "text" + assert result["content"][0]["text"] == "Here is the image you requested:" + + # Verify image content with cache_control + assert result["content"][1]["type"] == "image" + assert result["content"][1]["source"]["type"] == "base64" + assert result["content"][1]["source"]["media_type"] == "image/jpeg" + assert "cache_control" in result["content"][1] + assert result["content"][1]["cache_control"]["type"] == "ephemeral" + + +def test_convert_to_anthropic_tool_result_image_without_cache_control(): + """ + Test that images without cache_control in tool results work correctly. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_anthropic_tool_result, + ) + + message = { + "role": "tool", + "tool_call_id": "call_test_456", + "content": [ + { + "type": "image_url", + "image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA", + }, + ], + } + + result = convert_to_anthropic_tool_result(message) + + # Verify the result structure + assert result["type"] == "tool_result" + assert result["tool_use_id"] == "call_test_456" + assert isinstance(result["content"], list) + assert len(result["content"]) == 1 + + # Verify image content without cache_control (cache_control will be None if not set) + assert result["content"][0]["type"] == "image" + assert result["content"][0]["source"]["type"] == "base64" + assert result["content"][0]["source"]["media_type"] == "image/png" + assert result["content"][0].get("cache_control") is None + + +def test_convert_to_anthropic_tool_result_mixed_content_with_cache_control(): + """ + Test tool results with mixed content types (text and image) where only some have cache_control. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_anthropic_tool_result, + ) + + message = { + "role": "tool", + "tool_call_id": "call_test_789", + "content": [ + { + "type": "text", + "text": "First image:", + "cache_control": {"type": "ephemeral"}, + }, + { + "type": "image_url", + "image_url": "data:image/jpeg;base64,/9j/4AAQSkZJRg", + "cache_control": {"type": "ephemeral"}, + }, + { + "type": "text", + "text": "Second image (no cache):", + }, + { + "type": "image_url", + "image_url": "data:image/png;base64,iVBORw0KGgo", + }, + ], + } + + result = convert_to_anthropic_tool_result(message) + + assert result["type"] == "tool_result" + assert isinstance(result["content"], list) + assert len(result["content"]) == 4 + + # First text with cache_control + assert result["content"][0]["type"] == "text" + assert result["content"][0]["cache_control"]["type"] == "ephemeral" + + # First image with cache_control + assert result["content"][1]["type"] == "image" + assert result["content"][1]["cache_control"]["type"] == "ephemeral" + + # Second text without cache_control (cache_control will be None if not set) + assert result["content"][2]["type"] == "text" + assert result["content"][2].get("cache_control") is None + + # Second image without cache_control (cache_control will be None if not set) + assert result["content"][3]["type"] == "image" + assert result["content"][3].get("cache_control") is None + + +def test_convert_to_anthropic_tool_result_image_url_as_http(): + """ + Test that HTTP/HTTPS URLs with cache_control are handled correctly. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_anthropic_tool_result, + ) + + message = { + "role": "tool", + "tool_call_id": "call_http_001", + "content": [ + { + "type": "image_url", + "image_url": "https://example.com/image.jpg", + "cache_control": {"type": "ephemeral"}, + }, + ], + } + + result = convert_to_anthropic_tool_result(message) + + # Verify image is passed as URL reference with cache_control + assert result["content"][0]["type"] == "image" + assert result["content"][0]["source"]["type"] == "url" + assert result["content"][0]["source"]["url"] == "https://example.com/image.jpg" + assert result["content"][0]["cache_control"]["type"] == "ephemeral"