From 9a3d9b263241c97e58115bf7a0260ebb8166fe0f Mon Sep 17 00:00:00 2001 From: Vinh Pham Huu Date: Wed, 15 Apr 2026 17:11:12 +0700 Subject: [PATCH 01/15] feat: Enhance support for video metadata across all Gemini models in transformation logic and tests --- docs/my-website/docs/providers/vertex.md | 11 ++- .../llms/vertex_ai/gemini/transformation.py | 22 ++--- .../test_vertex_ai_gemini_transformation.py | 88 +++++++++++++++++++ ...test_vertex_and_google_ai_studio_gemini.py | 46 +++------- 4 files changed, 117 insertions(+), 50 deletions(-) diff --git a/docs/my-website/docs/providers/vertex.md b/docs/my-website/docs/providers/vertex.md index 0079bd2f57e..835e3bbcc2d 100644 --- a/docs/my-website/docs/providers/vertex.md +++ b/docs/my-website/docs/providers/vertex.md @@ -2061,7 +2061,7 @@ assert isinstance( ## Media Resolution Control (Images & Videos) -For Gemini 3+ models, LiteLLM supports per-part media resolution control using OpenAI's `detail` parameter. This allows you to specify different resolution levels for individual images and videos in your request, whether using `image_url` or `file` content types. +LiteLLM supports per-part media resolution control using OpenAI's `detail` parameter for all Gemini models. This allows you to specify different resolution levels for individual images and videos in your request, whether using `image_url` or `file` content types. **Supported `detail` values:** - `"low"` - Maps to `media_resolution: "low"` (280 tokens for images, 70 tokens per frame for videos) @@ -2146,12 +2146,12 @@ response = completion( :::info -**Per-Part Resolution:** Each image or video in your request can have its own `detail` setting, allowing mixed-resolution requests (e.g., a high-res chart alongside a low-res icon). This feature works with both `image_url` and `file` content types, and is only available for Gemini 3+ models. +**Per-Part Resolution:** Each image or video in your request can have its own `detail` setting, allowing mixed-resolution requests (e.g., a high-res chart alongside a low-res icon). This feature works with both `image_url` and `file` content types across all Gemini models. ::: ## Video Metadata Control -For Gemini 3+ models, LiteLLM supports fine-grained video processing control through the `video_metadata` field. This allows you to specify frame extraction rates and time ranges for video analysis. +LiteLLM supports fine-grained video processing control through the `video_metadata` field for all Gemini models (1.x, 2.x, 3+). This allows you to specify frame extraction rates and time ranges for video analysis. **Supported `video_metadata` parameters:** @@ -2168,8 +2168,11 @@ For Gemini 3+ models, LiteLLM supports fine-grained video processing control thr - `fps` remains unchanged ::: +:::tip +Video clipping (`start_offset`/`end_offset`) and frame rate control (`fps`) are supported by all Gemini models, but analysis quality is significantly higher with the **Gemini 2.5 series** (e.g., `gemini-2.5-flash`, `gemini-2.5-pro`). +::: + :::warning -- **Gemini 3+ Only:** This feature is only available for Gemini 3.0 and newer models - **Video Files Recommended:** While `video_metadata` is designed for video files, error handling for other media types is delegated to the Vertex AI API - **File Formats Supported:** Works with `gs://`, `https://`, and base64-encoded video files ::: diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index 6157a384dc0..d49a16fa908 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -131,23 +131,19 @@ def _extract_max_media_resolution_from_messages( return max_resolution -def _apply_gemini_3_metadata( +def _apply_gemini_metadata( part: PartType, model: Optional[str], media_resolution_enum: Optional[Dict[str, str]], video_metadata: Optional[Dict[str, Any]], ) -> PartType: """ - Apply the unique media_resolution and video_metadata parameters of Gemini 3+ + Apply media_resolution and video_metadata parameters to a Gemini part. + Both are supported across all Gemini models (1.x, 2.x, 3+). """ if model is None: return part - from .vertex_and_google_ai_studio_gemini import VertexGeminiConfig - - if not VertexGeminiConfig._is_gemini_3_or_newer(model): - return part - part_dict = dict(part) if media_resolution_enum is not None: @@ -205,7 +201,7 @@ def _process_gemini_media( mime_type = format file_data = FileDataType(mime_type=mime_type, file_uri=image_url) part: PartType = {"file_data": file_data} - return _apply_gemini_3_metadata( + return _apply_gemini_metadata( part, model, media_resolution_enum, video_metadata ) elif ( @@ -215,14 +211,14 @@ def _process_gemini_media( ): file_data = FileDataType(mime_type=image_type, file_uri=image_url) part = {"file_data": file_data} - return _apply_gemini_3_metadata( + return _apply_gemini_metadata( part, model, media_resolution_enum, video_metadata ) elif "http://" in image_url or "https://" in image_url or "base64" in image_url: image = convert_to_anthropic_image_obj(image_url, format=format) _blob: BlobType = {"data": image["data"], "mime_type": image["media_type"]} part = {"inline_data": cast(BlobType, _blob)} - return _apply_gemini_3_metadata( + return _apply_gemini_metadata( part, model, media_resolution_enum, video_metadata ) raise Exception("Invalid image received - {}".format(image_url)) @@ -732,9 +728,9 @@ def _transform_request_body( # noqa: PLR0915 **filtered_params ) - # For Gemini 2.x models, add media_resolution to generation_config (global) - # Gemini 3+ supports per-part media_resolution, but 2.x only supports global - # Gemini 1.x does not support mediaResolution at all + # For Gemini 2.x models, also add media_resolution to generation_config (global) + # as a fallback, since some 2.x versions may not support per-part media_resolution. + # Gemini 1.x does not support mediaResolution at all. if "gemini-2" in model: max_media_resolution = _extract_max_media_resolution_from_messages(messages) if max_media_resolution: diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index 98cdf830304..ab9ae4c167e 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -930,6 +930,94 @@ class TestMediaResolution: assert "mediaResolution" not in result["generationConfig"] +# Tests for VideoMetadata support across all Gemini models (Issue #25474) +class TestVideoMetadataAllGeminiModels: + """Tests that video_metadata (fps, start_offset, end_offset) works for all Gemini models""" + + def _make_video_messages(self, video_metadata: dict) -> list: + return [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Analyze this video"}, + { + "type": "file", + "file": { + "file_id": "gs://bucket/video.mp4", + "format": "video/mp4", + "video_metadata": video_metadata, + }, + }, + ], + } + ] + + def _get_file_part(self, contents: list) -> dict: + for part in contents[0]["parts"]: + if "file_data" in part: + return part + raise AssertionError("No file part found in contents") + + def test_video_metadata_fps_gemini_2_5_flash(self): + """Gemini 2.5 Flash: fps in video_metadata should be forwarded (Issue #25474)""" + messages = self._make_video_messages({"fps": 5}) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-flash" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + assert file_part["video_metadata"]["fps"] == 5 + + def test_video_metadata_fps_gemini_2_5_pro(self): + """Gemini 2.5 Pro: fps in video_metadata should be forwarded (Issue #25474)""" + messages = self._make_video_messages({"fps": 10}) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-pro" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + assert file_part["video_metadata"]["fps"] == 10 + + def test_video_metadata_offsets_gemini_2_5_flash(self): + """Gemini 2.5 Flash: start_offset/end_offset converted to camelCase (Issue #25474)""" + messages = self._make_video_messages( + {"start_offset": "5s", "end_offset": "30s"} + ) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-flash" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + vm = file_part["video_metadata"] + assert vm["startOffset"] == "5s" + assert vm["endOffset"] == "30s" + + def test_video_metadata_all_fields_gemini_2_5_flash(self): + """Gemini 2.5 Flash: all video_metadata fields forwarded correctly (Issue #25474)""" + messages = self._make_video_messages( + {"fps": 5, "start_offset": "10s", "end_offset": "60s"} + ) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-flash" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + vm = file_part["video_metadata"] + assert vm["fps"] == 5 + assert vm["startOffset"] == "10s" + assert vm["endOffset"] == "60s" + + def test_video_metadata_gemini_1_5_pro(self): + """Gemini 1.5 Pro: video_metadata should also be forwarded (Issue #25474)""" + messages = self._make_video_messages({"fps": 2}) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-1.5-pro" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + assert file_part["video_metadata"]["fps"] == 2 + + def test_convert_tool_response_with_base64_image(): """Test tool response with base64 data URI image.""" # Create a small test image (1x1 red pixel PNG) 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 a0979664943..f77bba56cb4 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 @@ -3476,8 +3476,8 @@ def test_new_detail_levels(): assert file_part["media_resolution"] == {"level": "MEDIA_RESOLUTION_MEDIUM"} -def test_video_metadata_only_for_gemini_3(): - """Test that video_metadata is only applied for Gemini 3+ models (Issue #19026)""" +def test_video_metadata_supported_for_all_gemini_models(): + """Test that video_metadata is applied for all Gemini models (Issue #25474)""" from litellm.llms.vertex_ai.gemini.transformation import ( _gemini_convert_messages_with_history, ) @@ -3499,39 +3499,19 @@ def test_video_metadata_only_for_gemini_3(): } ] - # Test with Gemini 1.5 (should not have video_metadata or media_resolution) - contents_1_5 = _gemini_convert_messages_with_history( - messages=messages, model="gemini-1.5-pro" - ) + for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"]: + contents = _gemini_convert_messages_with_history(messages=messages, model=model) - file_part_1_5 = None - for part in contents_1_5[0]["parts"]: - if "file_data" in part: - file_part_1_5 = part - break + file_part = None + for part in contents[0]["parts"]: + if "file_data" in part: + file_part = part + break - assert file_part_1_5 is not None - assert ( - "media_resolution" not in file_part_1_5 - ), "Gemini 1.5 should not have media_resolution" - assert ( - "video_metadata" not in file_part_1_5 - ), "Gemini 1.5 should not have video_metadata" - - # Test with Gemini 3 (should have both) - contents_3 = _gemini_convert_messages_with_history( - messages=messages, model="gemini-3-pro-preview" - ) - - file_part_3 = None - for part in contents_3[0]["parts"]: - if "file_data" in part: - file_part_3 = part - break - - assert file_part_3 is not None - assert "media_resolution" in file_part_3, "Gemini 3 should have media_resolution" - assert "video_metadata" in file_part_3, "Gemini 3 should have video_metadata" + assert file_part is not None, f"{model}: file part should exist" + assert "video_metadata" in file_part, f"{model}: video_metadata should be present" + assert file_part["video_metadata"]["fps"] == 5, f"{model}: fps should be 5" + assert "media_resolution" in file_part, f"{model}: media_resolution should be present" def test_chunk_parser_handles_prompt_feedback_block(): From 61aee29b41fa55ca039ca6a9eb64a525939afe9b Mon Sep 17 00:00:00 2001 From: Vinh Pham Huu Date: Wed, 15 Apr 2026 17:56:15 +0700 Subject: [PATCH 02/15] feat: Update video metadata handling and media resolution checks for Gemini models --- litellm/llms/vertex_ai/gemini/transformation.py | 10 ++++++++-- .../gemini/test_vertex_and_google_ai_studio_gemini.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index d49a16fa908..5eaac5e48cb 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -139,14 +139,20 @@ def _apply_gemini_metadata( ) -> PartType: """ Apply media_resolution and video_metadata parameters to a Gemini part. - Both are supported across all Gemini models (1.x, 2.x, 3+). + + - Per-part media_resolution: Gemini 3+ only (2.x uses generation_config global). + - video_metadata (fps, startOffset, endOffset): all Gemini models (1.x, 2.x, 3+). """ if model is None: return part + from .vertex_and_google_ai_studio_gemini import VertexGeminiConfig + part_dict = dict(part) - if media_resolution_enum is not None: + if media_resolution_enum is not None and VertexGeminiConfig._is_gemini_3_or_newer( + model + ): part_dict["media_resolution"] = media_resolution_enum if video_metadata is not None: 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 f77bba56cb4..1ea7486a515 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 @@ -3511,8 +3511,18 @@ def test_video_metadata_supported_for_all_gemini_models(): assert file_part is not None, f"{model}: file part should exist" assert "video_metadata" in file_part, f"{model}: video_metadata should be present" assert file_part["video_metadata"]["fps"] == 5, f"{model}: fps should be 5" + + # Per-part media_resolution is Gemini 3+ only; 2.x uses generation_config global + for model in ["gemini-3-pro-preview"]: + contents = _gemini_convert_messages_with_history(messages=messages, model=model) + file_part = next(p for p in contents[0]["parts"] if "file_data" in p) assert "media_resolution" in file_part, f"{model}: media_resolution should be present" + for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro"]: + contents = _gemini_convert_messages_with_history(messages=messages, model=model) + file_part = next(p for p in contents[0]["parts"] if "file_data" in p) + assert "media_resolution" not in file_part, f"{model}: per-part media_resolution should not be set" + def test_chunk_parser_handles_prompt_feedback_block(): """Test chunk_parser correctly handles promptFeedback.blockReason""" From 034f4fdef20fb9b8ab5c63787e2ba764ad4661cc Mon Sep 17 00:00:00 2001 From: sakenuGOD Date: Thu, 23 Apr 2026 05:06:34 +0300 Subject: [PATCH 03/15] fix(mcp_semantic_tool_filter): match tools with client-side namespace prefix (#26078) (#26117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp_semantic_tool_filter): match canonical tools that arrive with a client-side namespace prefix. `SemanticMCPToolFilter._get_tools_by_names` matched by exact equality between the canonical name stored in the router (``) and the name in the incoming `tools[]` list. MCP clients such as opencode wrap every tool name with their own additive alias prefix (`_`), so the two never matched, the filter dropped every tool to zero, and the proxy forwarded `tools: []` with `tool_choice: auto` — which strict upstream providers reject with a 400. The fix adds anchored suffix matching with a separator check: the canonical must form the complete tail of the incoming name and be preceded by `_` or `-`. Exact matches still win over suffix matches, incoming tools are returned at most once, and the original tool object is passed through unchanged so the client-facing name survives for tool-call round-trips. Seven unit tests in a new TestGetToolsByNames class cover exact match, underscore- and dash-prefixed variants, non-separator-anchored suffixes (which must not match), exact-wins-over-prefixed precedence, deduplication when two canonicals suffix-match the same incoming tool, and ordering-follows-router-output. Fixes #26078 * review: strengthen the suffix-fallback tie-breaker and the deduplication regression test (Greptile comments on #26117) - test_same_tool_not_returned_twice now passes two distinct canonicals ("read_file" and "file") that both suffix-match the same incoming tool, rather than the same canonical twice, so the assertion actually exercises the used_ids dedup path instead of the duplicate-input-list path. - The suffix fallback in _get_tools_by_names now prefers the shortest incoming name that still qualifies under the separator-anchored match. In the one-prefix-per-client opencode scenario this is a no-op, but in multi-namespace configurations the shortest qualifying name is the least-wrapped one and is the most defensible deterministic choice, replacing the dict-insertion-order fallback. - Adds test_suffix_fallback_prefers_shortest_candidate covering the new tie-breaker directly. Still 15 tests passing locally (was 14). * review(#26117): gate suffix-matching on canonical containing MCP_TOOL_PREFIX_SEPARATOR @krrish-berri-2 flagged a possible collision in the suffix fallback: a local user function whose name happens to end in a bare canonical substring (e.g. my_firecrawl_scrape vs canonical firecrawl_scrape) would be spuriously selected. Server-registered MCP tools are always emitted as via add_server_prefix_to_name, so a canonical without the separator is not a namespaced MCP tool and does not warrant suffix matching. Added that guard to _name_matches_canonical with a regression test (test_does_not_collide_with_local_function_on_unprefixed_canonical) that reproduces the collision before the fix and is pinned after. Pre-existing TestGetToolsByNames fixtures that relied on bare canonicals (get_weather, search, read_file, write/delete/read) were switched to realistic server-prefixed ones so they continue to exercise the suffix-fallback path under the new guard. The opencode scenario (client prefix on already-server-prefixed canonical) is unchanged. --------- Co-authored-by: sakenuGOD Co-authored-by: Krrish Dholakia --- .../mcp_server/semantic_tool_filter.py | 90 ++++++++- .../mcp_server/test_semantic_tool_filter.py | 190 ++++++++++++++++++ 2 files changed, 270 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index 0e32bfd7026..a9c4d2ece46 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -7,6 +7,7 @@ Filters MCP tools semantically for /chat/completions and /responses endpoints. from typing import TYPE_CHECKING, Any, Dict, List, Optional from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.utils import MCP_TOOL_PREFIX_SEPARATOR if TYPE_CHECKING: from semantic_router.routers import SemanticRouter @@ -214,20 +215,89 @@ class SemanticMCPToolFilter: return [] + @staticmethod + def _name_matches_canonical(client_name: str, canonical: str) -> bool: + """ + Return True if a client-side tool name refers to the given canonical + MCP tool name. + + MCP clients (e.g. opencode) commonly wrap the proxy's canonical tool + name with an additive namespace prefix of their own + (````). The prefix can use either a + dash or an underscore as separator regardless of what + ``MCP_TOOL_PREFIX_SEPARATOR`` is set to on the proxy, because the + client doesn't know the proxy's separator. + + The match is anchored: ``canonical`` must form the complete suffix + of ``client_name`` and be preceded by a separator character, so + ``rain_gear`` does not match canonical ``ear``. + + Suffix matching is additionally gated on ``canonical`` itself + containing ``MCP_TOOL_PREFIX_SEPARATOR``. Server-registered MCP + tools are always emitted as + ```` (see + ``add_server_prefix_to_name``), so a canonical without the + separator is not a namespaced MCP tool and falling back to + suffix matching would spuriously collide with unrelated local + user functions whose names end in the same characters. + """ + if client_name == canonical: + return True + if MCP_TOOL_PREFIX_SEPARATOR not in canonical: + return False + if len(client_name) <= len(canonical): + return False + if not client_name.endswith(canonical): + return False + separator = client_name[-len(canonical) - 1] + return separator in ("_", "-") + def _get_tools_by_names( self, tool_names: List[str], available_tools: List[Any] ) -> List[Any]: - """Get tools from available_tools by their names, preserving order.""" - # Match tools from available_tools (preserves format - dict or MCPTool) - matched_tools = [] - for tool in available_tools: - tool_name, _ = self._extract_tool_info(tool) - if tool_name in tool_names: - matched_tools.append(tool) + """ + Get tools from available_tools by their names, preserving the + semantic router's ordering. - # Reorder to match semantic router's ordering - tool_map = {self._extract_tool_info(t)[0]: t for t in matched_tools} - return [tool_map[name] for name in tool_names if name in tool_map] + Matching is tolerant of client-side namespace prefixes: if an + incoming tool arrived as ``_`` while the + router returned ```` (see + ``_name_matches_canonical``), that tool is still selected. The + returned tool object is the original from ``available_tools``, so + the client-facing name is preserved for tool-call round-trips. + """ + # Build an index of incoming tools by their client-facing name. + # Exact matches win over suffix matches when both are present, and + # each incoming tool is returned at most once even if two canonical + # names happen to be tail-compatible with the same incoming name. + available_by_name: Dict[str, Any] = {} + for tool in available_tools: + client_name, _ = self._extract_tool_info(tool) + if client_name and client_name not in available_by_name: + available_by_name[client_name] = tool + + matched: List[Any] = [] + used_ids: set = set() + for canonical in tool_names: + tool = available_by_name.get(canonical) + if tool is None: + # Prefer the shortest qualifying name. When several + # incoming tools suffix-match the same canonical (e.g. + # "my_search" and "my_tag_search" both end in "search"), + # the one closest in length to the canonical is the + # least-wrapped and most likely the intended target. + best_name: Optional[str] = None + for client_name in available_by_name: + if not self._name_matches_canonical(client_name, canonical): + continue + if best_name is None or len(client_name) < len(best_name): + best_name = client_name + if best_name is not None: + tool = available_by_name[best_name] + if tool is not None and id(tool) not in used_ids: + matched.append(tool) + used_ids.add(id(tool)) + return matched def extract_user_query(self, messages: List[Dict[str, Any]]) -> str: """ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index 3acd5c112e3..2558df8533b 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -450,3 +450,193 @@ async def test_semantic_filter_hook_skips_no_tools(): # Should return None (no modification) assert result is None, "Hook should skip requests without tools" print("✅ Hook correctly skips requests without tools") + + +class TestGetToolsByNames: + """ + Regression coverage for SemanticMCPToolFilter._get_tools_by_names + name-matching behavior (issue #26078). + + The canonical name stored in the router is what the proxy's MCP + registry emits (e.g. ``fc_web_search-firecrawl_scrape``). Some MCP + clients — notably opencode — wrap every tool name with their own + additive namespace prefix before sending it back in ``tools[]``, so + the incoming name is ``litellm_fc_web_search-firecrawl_scrape``. + + Exact-equality matching against the canonical dropped every such + tool, the proxy forwarded ``tools: []`` with ``tool_choice: auto``, + and strict upstream providers returned 400. + """ + + def _make_filter(self): + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticMCPToolFilter, + ) + + return SemanticMCPToolFilter( + embedding_model="text-embedding-3-small", + litellm_router_instance=Mock(), + top_k=5, + similarity_threshold=0.3, + enabled=True, + ) + + def test_exact_match_unchanged(self): + """Incoming name equals canonical — the historical path still works.""" + filter_instance = self._make_filter() + available_tools = [ + {"name": "get_weather", "description": "fetch weather"}, + {"name": "send_email", "description": "send mail"}, + ] + + matched = filter_instance._get_tools_by_names( + ["send_email"], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == "send_email" + + def test_client_prefix_with_underscore_separator(self): + """Client wraps canonical with ``_`` (opencode pattern).""" + filter_instance = self._make_filter() + canonical = "fc_web_search-firecrawl_scrape" + client_name = "litellm_" + canonical + available_tools = [{"name": client_name, "description": "scrape"}] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + # Must return the incoming tool unchanged so the client-facing + # name survives, otherwise tool-call round-trips break client-side. + assert matched[0]["name"] == client_name + + def test_client_prefix_with_dash_separator(self): + """Some clients use dash as alias separator; accept that too.""" + filter_instance = self._make_filter() + canonical = "weather_svc-get_weather" + available_tools = [ + {"name": "mcp-" + canonical, "description": "weather"} + ] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == "mcp-" + canonical + + def test_suffix_without_separator_does_not_match(self): + """ + A bare-substring suffix must not match — ``rain_gear`` is not a + namespaced version of canonical ``ear`` and the user would be + surprised to see it selected. + """ + filter_instance = self._make_filter() + available_tools = [{"name": "rain_gear", "description": "raincoat"}] + + matched = filter_instance._get_tools_by_names(["ear"], available_tools) + + assert matched == [] + + def test_exact_match_preferred_over_prefixed(self): + """ + When both a bare canonical and a client-prefixed variant are + present, the bare one wins so ordering is stable. + """ + filter_instance = self._make_filter() + canonical = "search" + available_tools = [ + {"name": canonical, "description": "plain"}, + {"name": "litellm_" + canonical, "description": "wrapped"}, + ] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == canonical + + def test_same_tool_not_returned_twice(self): + """ + Two distinct canonicals that both suffix-match the same incoming + tool must not produce a duplicate in the output list. + ``fs-read_file`` and ``api-fs-read_file`` are both valid + separator-anchored suffixes of ``litellm_api-fs-read_file``. + """ + filter_instance = self._make_filter() + available_tools = [ + {"name": "litellm_api-fs-read_file", "description": "read"} + ] + + matched = filter_instance._get_tools_by_names( + ["fs-read_file", "api-fs-read_file"], available_tools + ) + + assert len(matched) == 1 + + def test_suffix_fallback_prefers_shortest_candidate(self): + """ + When no exact match exists and several incoming tools + suffix-match the same canonical, the one closest in length to + the canonical (i.e. the least-wrapped) should be chosen. + """ + filter_instance = self._make_filter() + canonical = "svc-search" + available_tools = [ + {"name": "my_tag_" + canonical, "description": "tag search"}, + {"name": "my_" + canonical, "description": "plain search"}, + ] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == "my_" + canonical + + def test_ordering_follows_router_output(self): + """Returned tools follow the order the semantic router chose.""" + filter_instance = self._make_filter() + available_tools = [ + {"name": "litellm_fs-read", "description": "read"}, + {"name": "litellm_fs-write", "description": "write"}, + {"name": "litellm_fs-delete", "description": "delete"}, + ] + + matched = filter_instance._get_tools_by_names( + ["fs-write", "fs-delete", "fs-read"], available_tools + ) + + names = [t["name"] for t in matched] + assert names == [ + "litellm_fs-write", + "litellm_fs-delete", + "litellm_fs-read", + ] + + def test_does_not_collide_with_local_function_on_unprefixed_canonical(self): + """ + Guard against the collision @krrish-berri-2 flagged on #26117: + if the canonical name from the router is not server-prefixed + (i.e. does not contain ``MCP_TOOL_PREFIX_SEPARATOR``), suffix + matching must not kick in. Otherwise an unrelated local user + function whose name happens to end in the canonical substring + would be spuriously selected. + """ + filter_instance = self._make_filter() + available_tools = [ + { + "name": "my_firecrawl_scrape", + "description": "unrelated local function", + }, + ] + + matched = filter_instance._get_tools_by_names( + ["firecrawl_scrape"], # no MCP_TOOL_PREFIX_SEPARATOR in canonical + available_tools, + ) + + assert matched == [] From b42b86df7a428cc6b3d628eaf313a513c0fe4c34 Mon Sep 17 00:00:00 2001 From: Vigilans Date: Thu, 23 Apr 2026 10:19:54 +0800 Subject: [PATCH 04/15] fix(adapter): normalize reasoning effort with graceful degradation (#26111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(model-info): include reasoning effort support fields in get_model_info _get_model_info_helper constructs ModelInfoBase explicitly but never reads supports_xhigh/minimal/none_reasoning_effort from the cost map JSON. Add the three fields so get_model_info() returns them correctly. Also add supports_minimal_reasoning_effort to the ModelInfo TypedDict (xhigh and none were already declared, minimal was missing). * fix(model-registry): add missing reasoning effort fields for claude 4.6/4.7 Claude Opus 4.7 supports max reasoning effort (above xhigh). The field was present for Opus 4.6 but missing for all Opus 4.7 entries (base, dated, Bedrock, Vertex AI, Azure AI). All Claude 4.6/4.7 models (Opus 4.6, Sonnet 4.6, Opus 4.7) support minimal reasoning effort via adaptive thinking. Add the field to all provider variants. * fix(adapter): map output_config.effort to reasoning_effort (#25079) Anthropic's adaptive thinking (thinking.type="adaptive") and output_config.effort were silently dropped when translating to OpenAI format, resulting in no reasoning_effort on the outgoing request. Adapter changes (format translation): - adapters/transformation.py: add "adaptive" branch to translate_anthropic_thinking_to_reasoning_effort(); pass through output_config.effort as-is in _translate_thinking_to_openai(); add "output_config" to translatable_anthropic_params - adapters/handler.py: extract output_config from extra_kwargs into request_data so it reaches the translation layer - responses_adapters/transformation.py: add "adaptive" branch and output_config param to translate_thinking_to_reasoning() Handler changes (model-aware normalization): - utils.py: add normalize_reasoning_effort_value() that uses get_model_info() to map "max" → "xhigh"/"high" and "minimal" → "minimal"/"low" based on model capabilities - adapters/handler.py: call normalization before responses routing - responses_adapters/handler.py: call normalization after translation Relates to BerriAI/litellm#25079 * test(reasoning-effort): add tests for effort capability fields and normalize logic Test coverage for: - get_model_info returning supports_minimal/max_reasoning_effort fields - JSON registry entries for claude 4.6/4.7 across all providers - normalize_reasoning_effort_value degradation chains and exception fallback - Adapter translation of adaptive thinking + output_config.effort * fix: forward custom_llm_provider to normalize_reasoning_effort_value in responses adapter --- .../adapters/handler.py | 52 ++++ .../adapters/transformation.py | 12 + .../responses_adapters/handler.py | 17 ++ .../responses_adapters/transformation.py | 40 ++- .../experimental_pass_through/utils.py | 44 +++ ...odel_prices_and_context_window_backup.json | 106 +++++-- litellm/types/utils.py | 2 + litellm/utils.py | 6 + model_prices_and_context_window.json | 106 +++++-- .../test_reasoning_effort_fields.py | 287 ++++++++++++++++++ 10 files changed, 598 insertions(+), 74 deletions(-) create mode 100644 tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 897ca3bf893..d16f5afb45c 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -106,6 +106,44 @@ class LiteLLMMessagesToCompletionTransformationHandler: updated_reasoning_effort["summary"] = effective_summary completion_kwargs["reasoning_effort"] = updated_reasoning_effort + @staticmethod + def _normalize_reasoning_effort( + completion_kwargs: Dict[str, Any], + ) -> None: + """ + Normalize reasoning_effort values based on target model capabilities. + + Handles both string ("max") and dict ({"effort": "max", "summary": ...}) + formats. Uses model registry to check supports_xhigh/supports_minimal. + """ + from litellm.llms.anthropic.experimental_pass_through.utils import ( + normalize_reasoning_effort_value, + ) + + reasoning_effort = completion_kwargs.get("reasoning_effort") + if reasoning_effort is None: + return + + model = cast(str, completion_kwargs.get("model", "")) + custom_llm_provider = completion_kwargs.get("custom_llm_provider") + + if isinstance(reasoning_effort, str): + normalized = normalize_reasoning_effort_value( + reasoning_effort, model=model, custom_llm_provider=custom_llm_provider + ) + if normalized != reasoning_effort: + completion_kwargs["reasoning_effort"] = normalized + elif isinstance(reasoning_effort, dict) and "effort" in reasoning_effort: + effort = reasoning_effort["effort"] + normalized = normalize_reasoning_effort_value( + effort, model=model, custom_llm_provider=custom_llm_provider + ) + if normalized != effort: + completion_kwargs["reasoning_effort"] = { + **reasoning_effort, + "effort": normalized, + } + @staticmethod def _prepare_completion_kwargs( *, @@ -163,6 +201,12 @@ class LiteLLMMessagesToCompletionTransformationHandler: if output_format: request_data["output_format"] = output_format + # Extract output_config from extra_kwargs so the translator can use it + # (e.g. output_config.effort for adaptive thinking → reasoning_effort) + extra_kwargs = extra_kwargs or {} + if "output_config" in extra_kwargs: + request_data["output_config"] = extra_kwargs["output_config"] + ( openai_request, tool_name_mapping, @@ -202,6 +246,14 @@ class LiteLLMMessagesToCompletionTransformationHandler: ): completion_kwargs[key] = value + # Normalize reasoning_effort based on model capabilities + # (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported) + # Must run BEFORE _route_openai_thinking, which prepends "responses/" + # to the model name and would break get_model_info() lookups. + LiteLLMMessagesToCompletionTransformationHandler._normalize_reasoning_effort( + completion_kwargs + ) + LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( completion_kwargs, thinking=thinking, diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 072ae7c3bbe..e5d2b4ce782 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -317,6 +317,7 @@ class LiteLLMAnthropicMessagesAdapter: "tools", "thinking", "output_format", + "output_config", ] def _is_web_search_tool(self, tool: Dict[str, Any]) -> bool: @@ -694,6 +695,11 @@ class LiteLLMAnthropicMessagesAdapter: return "low" else: return "minimal" + elif thinking_type == "adaptive": + # Adaptive thinking: effort is controlled by output_config.effort, + # not budget_tokens. Return a default; caller should override with + # output_config.effort when available. + return "medium" return None @@ -1041,6 +1047,12 @@ class LiteLLMAnthropicMessagesAdapter: if not reasoning_effort: return + # For adaptive thinking, override with output_config.effort if available + if isinstance(thinking, dict) and thinking.get("type") == "adaptive": + output_config = anthropic_message_request.get("output_config") + if isinstance(output_config, dict) and output_config.get("effort"): + reasoning_effort = output_config["effort"] + summary = thinking.get("summary") if isinstance(thinking, dict) else None auto_summary = is_reasoning_auto_summary_enabled() if summary: diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py index 198ebe1ff8c..5be16dcbf16 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py @@ -72,6 +72,23 @@ def _build_responses_kwargs( anthropic_request = AnthropicMessagesRequest(**request_data) # type: ignore[typeddict-item] responses_kwargs = _ADAPTER.translate_request(anthropic_request) + # Normalize reasoning effort based on model capabilities + # (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported) + reasoning = responses_kwargs.get("reasoning") + if isinstance(reasoning, dict) and "effort" in reasoning: + from litellm.llms.anthropic.experimental_pass_through.utils import ( + normalize_reasoning_effort_value, + ) + + effort = reasoning["effort"] + normalized = normalize_reasoning_effort_value( + effort, + model=model, + custom_llm_provider=(extra_kwargs or {}).get("custom_llm_provider"), + ) + if normalized != effort: + responses_kwargs["reasoning"] = {**reasoning, "effort": normalized} + if stream: responses_kwargs["stream"] = True diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 913470e7088..2badc2a3276 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -251,25 +251,41 @@ class LiteLLMAnthropicToResponsesAPIAdapter: @staticmethod def translate_thinking_to_reasoning( - thinking: Dict[str, Any] + thinking: Dict[str, Any], + output_config: Optional[Dict[str, Any]] = None, ) -> Optional[Dict[str, Any]]: """ Convert Anthropic thinking param to Responses API reasoning param. thinking.budget_tokens maps to reasoning effort: >= 10000 -> high, >= 5000 -> medium, >= 2000 -> low, < 2000 -> minimal + + For adaptive thinking, uses output_config.effort if available, + otherwise defaults to medium. """ - if not isinstance(thinking, dict) or thinking.get("type") != "enabled": + if not isinstance(thinking, dict): return None - budget = thinking.get("budget_tokens", 0) - if budget >= 10000: - effort = "high" - elif budget >= 5000: + + thinking_type = thinking.get("type") + + if thinking_type == "adaptive": + # Use output_config.effort if available effort = "medium" - elif budget >= 2000: - effort = "low" + if isinstance(output_config, dict) and output_config.get("effort"): + effort = output_config["effort"] + elif thinking_type == "enabled": + budget = thinking.get("budget_tokens", 0) + if budget >= 10000: + effort = "high" + elif budget >= 5000: + effort = "medium" + elif budget >= 2000: + effort = "low" + else: + effort = "minimal" else: - effort = "minimal" + return None + auto_summary = is_reasoning_auto_summary_enabled() result: Dict[str, Any] = {"effort": effort} summary = thinking.get("summary") @@ -346,7 +362,11 @@ class LiteLLMAnthropicToResponsesAPIAdapter: # thinking -> reasoning thinking = anthropic_request.get("thinking") if isinstance(thinking, dict): - reasoning = self.translate_thinking_to_reasoning(thinking) + output_config = anthropic_request.get("output_config") + reasoning = self.translate_thinking_to_reasoning( + thinking, + output_config=cast(Optional[Dict[str, Any]], output_config), + ) if reasoning: responses_kwargs["reasoning"] = reasoning diff --git a/litellm/llms/anthropic/experimental_pass_through/utils.py b/litellm/llms/anthropic/experimental_pass_through/utils.py index 6c1db6017b2..d975bee0bc2 100644 --- a/litellm/llms/anthropic/experimental_pass_through/utils.py +++ b/litellm/llms/anthropic/experimental_pass_through/utils.py @@ -1,4 +1,5 @@ import os +from typing import Optional import litellm @@ -9,3 +10,46 @@ def is_reasoning_auto_summary_enabled() -> bool: litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true" ) + + +def normalize_reasoning_effort_value( + effort: str, + model: str, + custom_llm_provider: Optional[str] = None, +) -> str: + """ + Normalize a reasoning effort value based on model capabilities. + + Degradation chains: + - "max" → max / xhigh / high + - "xhigh" → xhigh / high + - "minimal" → minimal / low + - other values pass through unchanged + """ + if effort not in ("max", "xhigh", "minimal"): + return effort + + from litellm.utils import get_model_info + + try: + model_info = get_model_info( + model=model, custom_llm_provider=custom_llm_provider + ) + except Exception: + model_info = {} + + if effort == "max": + if model_info.get("supports_max_reasoning_effort"): + return "max" + if model_info.get("supports_xhigh_reasoning_effort"): + return "xhigh" + return "high" + elif effort == "xhigh": + if model_info.get("supports_xhigh_reasoning_effort"): + return "xhigh" + return "high" + elif effort == "minimal": + if model_info.get("supports_minimal_reasoning_effort"): + return "minimal" + return "low" + return "medium" diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 04b68b8f4ec..05b59d45f99 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1006,7 +1006,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.25e-06, @@ -1034,7 +1035,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1062,7 +1064,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1090,7 +1093,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1118,7 +1122,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -1146,7 +1151,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -1174,7 +1181,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1202,7 +1211,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1230,7 +1241,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1258,7 +1271,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1285,7 +1300,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1312,7 +1328,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1339,7 +1356,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1366,7 +1384,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1393,7 +1412,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -1911,7 +1931,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-7": { "input_cost_per_token": 5e-06, @@ -1939,7 +1960,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, @@ -2003,7 +2026,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "azure/computer-use-preview": { "input_cost_per_token": 3e-06, @@ -8909,7 +8933,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -9103,7 +9128,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 6.25e-06, @@ -9135,7 +9161,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -9167,7 +9194,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7-20260416": { "cache_creation_input_token_cost": 6.25e-06, @@ -9199,7 +9228,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", @@ -25052,7 +25083,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, @@ -25090,7 +25122,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -30118,7 +30151,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_minimal_reasoning_effort": true }, "vercel_ai_gateway/anthropic/claude-sonnet-4": { "cache_creation_input_token_cost": 3.75e-06, @@ -31345,7 +31379,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31372,7 +31407,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -31399,7 +31435,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31426,7 +31464,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5": { "cache_creation_input_token_cost": 3.75e-06, @@ -31478,7 +31518,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5@20250929": { "cache_creation_input_token_cost": 3.75e-06, @@ -38345,7 +38386,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "duckduckgo/search": { "litellm_provider": "duckduckgo", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index e3058d106a6..c347956cba7 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -139,7 +139,9 @@ class ProviderSpecificModelInfo(TypedDict, total=False): supports_reasoning: Optional[bool] supports_url_context: Optional[bool] supports_none_reasoning_effort: Optional[bool] + supports_minimal_reasoning_effort: Optional[bool] supports_xhigh_reasoning_effort: Optional[bool] + supports_max_reasoning_effort: Optional[bool] class SearchContextCostPerQuery(TypedDict, total=False): diff --git a/litellm/utils.py b/litellm/utils.py index c4aee792972..7a9f62afa09 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5893,9 +5893,15 @@ def _get_model_info_helper( # noqa: PLR0915 supports_none_reasoning_effort=_model_info.get( "supports_none_reasoning_effort", None ), + supports_minimal_reasoning_effort=_model_info.get( + "supports_minimal_reasoning_effort", None + ), supports_xhigh_reasoning_effort=_model_info.get( "supports_xhigh_reasoning_effort", None ), + supports_max_reasoning_effort=_model_info.get( + "supports_max_reasoning_effort", None + ), supports_computer_use=_model_info.get("supports_computer_use", None), search_context_cost_per_query=_model_info.get( "search_context_cost_per_query", None diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 386532f07a3..8a28235f985 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1006,7 +1006,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.25e-06, @@ -1034,7 +1035,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1062,7 +1064,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1090,7 +1093,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1118,7 +1122,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -1146,7 +1151,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-mythos-preview": { "input_cost_per_token": 0, @@ -1188,7 +1195,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1216,7 +1225,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1244,7 +1255,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1272,7 +1285,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1299,7 +1314,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1326,7 +1342,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1353,7 +1370,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1380,7 +1398,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1407,7 +1426,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -1925,7 +1945,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-7": { "input_cost_per_token": 5e-06, @@ -1953,7 +1974,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, @@ -2017,7 +2040,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "azure/computer-use-preview": { "input_cost_per_token": 3e-06, @@ -8923,7 +8947,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -9117,7 +9142,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 6.25e-06, @@ -9149,7 +9175,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -9181,7 +9208,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7-20260416": { "cache_creation_input_token_cost": 6.25e-06, @@ -9213,7 +9242,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", @@ -25066,7 +25097,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, @@ -25104,7 +25136,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -30132,7 +30165,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_minimal_reasoning_effort": true }, "vercel_ai_gateway/anthropic/claude-sonnet-4": { "cache_creation_input_token_cost": 3.75e-06, @@ -31359,7 +31393,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31386,7 +31421,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -31413,7 +31449,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31440,7 +31478,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5": { "cache_creation_input_token_cost": 3.75e-06, @@ -31492,7 +31532,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5@20250929": { "cache_creation_input_token_cost": 3.75e-06, @@ -38386,7 +38427,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "duckduckgo/search": { "litellm_provider": "duckduckgo", diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py new file mode 100644 index 00000000000..d42d109f21b --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py @@ -0,0 +1,287 @@ +""" +Tests for reasoning effort capability fields and normalize_reasoning_effort_value. + +Covers: +- Commit 1: get_model_info returns supports_minimal/supports_max fields +- Commit 2: Model registry entries have correct reasoning effort fields +- Commit 3: normalize_reasoning_effort_value degradation chains + adapter translation +""" + +import json +import os +from typing import Any, Dict, Optional +from unittest.mock import patch + +import pytest + +from litellm.llms.anthropic.experimental_pass_through.utils import ( + normalize_reasoning_effort_value, +) +from litellm.utils import get_model_info + + +def _load_model_registry() -> Dict[str, Any]: + """Load the root model_prices_and_context_window.json.""" + json_path = os.path.join( + os.path.dirname(__file__), + "../../../../../model_prices_and_context_window.json", + ) + with open(json_path) as f: + return json.load(f) + + +# --------------------------------------------------------------------------- +# Commit 1: get_model_info returns supports_minimal and supports_max fields +# --------------------------------------------------------------------------- + + +class TestGetModelInfoReasoningEffortFields: + """get_model_info should expose supports_minimal_reasoning_effort and + supports_max_reasoning_effort from the model registry.""" + + def test_opus_4_6_has_supports_minimal(self): + info = get_model_info("claude-opus-4-6") + assert "supports_minimal_reasoning_effort" in info + + def test_opus_4_6_has_supports_max(self): + info = get_model_info("claude-opus-4-6") + assert "supports_max_reasoning_effort" in info + + def test_opus_4_7_has_supports_minimal(self): + info = get_model_info("claude-opus-4-7") + assert "supports_minimal_reasoning_effort" in info + + def test_opus_4_7_has_supports_max(self): + info = get_model_info("claude-opus-4-7") + assert "supports_max_reasoning_effort" in info + + +# --------------------------------------------------------------------------- +# Commit 2: JSON registry has correct reasoning effort fields +# --------------------------------------------------------------------------- + + +class TestModelRegistryReasoningEffortFields: + """Verify specific models have the expected reasoning effort capability + values in the JSON registry file.""" + + @pytest.fixture(autouse=True) + def _load_registry(self): + self.registry = _load_model_registry() + + def test_opus_4_7_supports_max(self): + entry = self.registry["claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + + def test_opus_4_6_supports_max(self): + entry = self.registry["claude-opus-4-6"] + assert entry.get("supports_max_reasoning_effort") is True + + def test_opus_4_7_supports_minimal(self): + entry = self.registry["claude-opus-4-7"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_opus_4_6_supports_minimal(self): + entry = self.registry["claude-opus-4-6"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_sonnet_4_6_supports_minimal(self): + entry = self.registry["anthropic.claude-sonnet-4-6"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_bedrock_opus_4_7_supports_max(self): + entry = self.registry["anthropic.claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_vertex_opus_4_7_supports_max(self): + entry = self.registry["vertex_ai/claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_vertex_opus_4_6_supports_max(self): + entry = self.registry["vertex_ai/claude-opus-4-6"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_azure_ai_opus_4_6_supports_minimal(self): + entry = self.registry["azure_ai/claude-opus-4-6"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_azure_ai_opus_4_7_supports_max(self): + entry = self.registry["azure_ai/claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + +# --------------------------------------------------------------------------- +# Commit 3: normalize_reasoning_effort_value +# --------------------------------------------------------------------------- + + +def _mock_model_info(**flags): + """Return a mock model_info dict with given capability flags.""" + return flags + + +class TestNormalizeReasoningEffortValue: + """Test degradation chains for normalize_reasoning_effort_value.""" + + # --- "max" degradation chain --- + + def test_max_stays_max_when_supported(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info( + supports_max_reasoning_effort=True, + supports_xhigh_reasoning_effort=True, + ), + ): + assert normalize_reasoning_effort_value("max", model="test") == "max" + + def test_max_degrades_to_xhigh(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info( + supports_max_reasoning_effort=False, + supports_xhigh_reasoning_effort=True, + ), + ): + assert normalize_reasoning_effort_value("max", model="test") == "xhigh" + + def test_max_degrades_to_high(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info( + supports_max_reasoning_effort=False, + supports_xhigh_reasoning_effort=False, + ), + ): + assert normalize_reasoning_effort_value("max", model="test") == "high" + + # --- "xhigh" degradation chain --- + + def test_xhigh_stays_xhigh_when_supported(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_xhigh_reasoning_effort=True), + ): + assert normalize_reasoning_effort_value("xhigh", model="test") == "xhigh" + + def test_xhigh_degrades_to_high(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_xhigh_reasoning_effort=False), + ): + assert normalize_reasoning_effort_value("xhigh", model="test") == "high" + + # --- "minimal" degradation chain --- + + def test_minimal_stays_minimal_when_supported(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_minimal_reasoning_effort=True), + ): + assert ( + normalize_reasoning_effort_value("minimal", model="test") == "minimal" + ) + + def test_minimal_degrades_to_low(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_minimal_reasoning_effort=False), + ): + assert normalize_reasoning_effort_value("minimal", model="test") == "low" + + # --- passthrough values --- + + def test_high_passes_through(self): + assert normalize_reasoning_effort_value("high", model="test") == "high" + + def test_medium_passes_through(self): + assert normalize_reasoning_effort_value("medium", model="test") == "medium" + + def test_low_passes_through(self): + assert normalize_reasoning_effort_value("low", model="test") == "low" + + # --- exception fallback --- + + def test_exception_fallback_uses_empty_model_info(self): + """When get_model_info raises, treat model_info as {} (no capabilities).""" + with patch( + "litellm.utils.get_model_info", + side_effect=Exception("model not found"), + ): + # "max" with no capabilities -> "high" + assert normalize_reasoning_effort_value("max", model="unknown") == "high" + # "minimal" with no capabilities -> "low" + assert normalize_reasoning_effort_value("minimal", model="unknown") == "low" + + +# --------------------------------------------------------------------------- +# Commit 3: Adapter translation — adaptive thinking + output_config.effort +# --------------------------------------------------------------------------- + + +class TestAdapterAdaptiveThinking: + """Test that adaptive thinking type maps correctly through the adapters.""" + + def test_messages_adapter_adaptive_returns_medium_default(self): + """Adaptive thinking returns 'medium' as default reasoning_effort.""" + from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( + LiteLLMAnthropicMessagesAdapter, + ) + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_thinking_to_reasoning_effort( + {"type": "adaptive"} + ) + assert result == "medium" + + def test_messages_adapter_adaptive_overridden_by_output_config(self): + """For adaptive thinking, output_config.effort overrides reasoning_effort.""" + from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( + LiteLLMAnthropicMessagesAdapter, + ) + from litellm.types.llms.anthropic import AnthropicMessagesRequest + + adapter = LiteLLMAnthropicMessagesAdapter() + request = AnthropicMessagesRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + max_tokens=1024, + thinking={"type": "adaptive"}, + output_config={"effort": "high"}, + ) + openai_kwargs, _ = adapter.translate_anthropic_to_openai(request) + # reasoning_effort should be set (either as string or dict with effort) + re = openai_kwargs.get("reasoning_effort") + if isinstance(re, dict): + assert re["effort"] == "high" + else: + assert re == "high" + + def test_responses_adapter_adaptive_with_output_config(self): + """Responses adapter: adaptive thinking + output_config.effort.""" + from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import ( + LiteLLMAnthropicToResponsesAPIAdapter, + ) + + result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning( + thinking={"type": "adaptive"}, + output_config={"effort": "xhigh"}, + ) + assert result is not None + assert result["effort"] == "xhigh" + + def test_responses_adapter_adaptive_default_medium(self): + """Responses adapter: adaptive thinking without output_config defaults to medium.""" + from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import ( + LiteLLMAnthropicToResponsesAPIAdapter, + ) + + result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning( + thinking={"type": "adaptive"}, + ) + assert result is not None + assert result["effort"] == "medium" From 0e23aa739097e11ce8c5e5bc5cb79bbba07a3c95 Mon Sep 17 00:00:00 2001 From: Anmol Jaiswal <68013660+anmolg1997@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:52:38 +0530 Subject: [PATCH 05/15] fix(anthropic): tolerate non-OpenAI file content blocks in file-id discovery (#26228) `get_file_ids_from_messages` and `update_messages_with_model_file_ids` assume every content block with `type: "file"` has a nested `file` dict in the OpenAI Chat Completions shape. That assumption is too strong: `type: "file"` is a public content-block discriminator and several real producers emit blocks that use it without the OpenAI `file` sub-dict. For example, LangChain v1's `_normalize_messages` rewrites OpenAI file blocks into `{"type":"file","id":"...","base64":"...","mime_type":"...","extras":{}}` before they reach LiteLLM. `AnthropicConfig.validate_environment` calls both helpers unconditionally on every Anthropic (and Anthropic-via-Vertex) request, so any such block raises `KeyError: 'file'` which the Vertex partner layer then wraps as a `500 InternalServerError` before the LLM is even contacted. This patch switches both helpers from `c["file"]` to a defensive `c.get("file")` + dict check. When the block does not match the OpenAI shape there is no file_id to extract or remap, so we skip it and leave the block untouched for the downstream provider transformer to handle. Adds 5 regression tests covering the LangChain v1 shape, the OpenAI happy path, mixed shapes in one message, `file` set to a non-dict value, and the remap path for non-OpenAI blocks. Related to #24503, which proposed raising `BadRequestError` in the same spots. For these two discovery functions specifically, the skip semantics is strictly more permissive: well-formed OpenAI blocks still yield their file_id, and legitimate non-OpenAI blocks stop crashing the request. --- .../prompt_templates/common_utils.py | 16 ++- ...ore_utils_prompt_templates_common_utils.py | 113 ++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 46e60c24d39..b234e6c8f77 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -452,7 +452,14 @@ def update_messages_with_model_file_ids( for c in content: if c["type"] == "file": file_object = cast(ChatCompletionFileObject, c) - file_object_file_field = file_object["file"] + file_object_file_field = file_object.get("file") + if not isinstance(file_object_file_field, dict): + # Content block has `type: "file"` but not the + # OpenAI Chat Completions shape (e.g. a LangChain + # v1 standardized file block, or a provider-native + # shape that also uses `type: "file"`). Nothing to + # remap here, so skip instead of crashing. + continue file_id = file_object_file_field.get("file_id") format = file_object_file_field.get( "format", get_format_from_file_id(file_id) @@ -1060,7 +1067,12 @@ def get_file_ids_from_messages(messages: List[AllMessageValues]) -> List[str]: for c in content: if c["type"] == "file": file_object = cast(ChatCompletionFileObject, c) - file_object_file_field = file_object["file"] + file_object_file_field = file_object.get("file") + if not isinstance(file_object_file_field, dict): + # Content block has `type: "file"` but not the + # OpenAI Chat Completions shape. No file_id to + # extract, so skip instead of raising KeyError. + continue file_id = file_object_file_field.get("file_id") if file_id: file_ids.append(file_id) diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 8c34a50c4fa..22d2610eecb 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -11,6 +11,7 @@ sys.path.insert( from litellm.litellm_core_utils.prompt_templates.common_utils import ( add_system_prompt_to_messages, + get_file_ids_from_messages, get_format_from_file_id, handle_any_messages_to_chat_completion_str_messages_conversion, split_concatenated_json_objects, @@ -254,3 +255,115 @@ def test_split_concatenated_json_invalid_raises(): """Completely invalid JSON raises JSONDecodeError.""" with pytest.raises(json.JSONDecodeError): split_concatenated_json_objects("not json at all") + + +# --------------------------------------------------------------------------- +# Regression tests for non-OpenAI file content blocks. +# +# `type: "file"` is a public content-block discriminator. Several producers +# (LangChain v1, provider-native shapes, custom user code) emit blocks with +# `type: "file"` but without the OpenAI Chat Completions `file` sub-dict. +# The discovery helpers below are used unconditionally inside +# `AnthropicConfig.validate_environment`, so any crash there surfaces as a +# `500 InternalServerError` before the request is even dispatched. +# --------------------------------------------------------------------------- + + +def test_get_file_ids_from_messages_skips_langchain_v1_file_block(): + """A LangChain v1 standardized file block must not crash file-id discovery.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "summarise this PDF"}, + # LangChain v1 shape produced by `_normalize_messages`. + # No `file` sub-dict: the discriminator is `type: "file"` but + # the payload lives on `base64`/`mime_type` siblings. + { + "type": "file", + "id": "lc_1", + "base64": "JVBERi0xLjQK", + "mime_type": "application/pdf", + "extras": {"file_format": "application/pdf"}, + }, + ], + } + ] + + assert get_file_ids_from_messages(messages) == [] + + +def test_get_file_ids_from_messages_still_extracts_from_openai_shape(): + """Well-formed OpenAI file blocks still yield their file_id.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this?"}, + {"type": "file", "file": {"file_id": "file-abc"}}, + ], + } + ] + + assert get_file_ids_from_messages(messages) == ["file-abc"] + + +def test_get_file_ids_from_messages_mixed_shapes(): + """Mixed OpenAI and non-OpenAI file blocks: extract from the former, + ignore the latter.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "file", "file": {"file_id": "file-keep"}}, + { + "type": "file", + "id": "lc_2", + "base64": "AAA", + "mime_type": "application/pdf", + }, + ], + } + ] + + assert get_file_ids_from_messages(messages) == ["file-keep"] + + +def test_get_file_ids_from_messages_file_field_not_dict(): + """`file` set to a non-dict value (e.g. stringified payload) must not crash.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "file", "file": "unexpectedly-a-string"}, + ], + } + ] + + assert get_file_ids_from_messages(messages) == [] + + +def test_update_messages_with_model_file_ids_skips_non_openai_file_blocks(): + """`update_messages_with_model_file_ids` is also called on user content + before provider dispatch. It must tolerate non-OpenAI file blocks the same + way.""" + langchain_v1_block = { + "type": "file", + "id": "lc_3", + "base64": "AAA", + "mime_type": "application/pdf", + } + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + langchain_v1_block, + ], + } + ] + + updated = update_messages_with_model_file_ids(messages, "model-1", {}) + + # Messages pass through unchanged when there is no `file` sub-dict to remap. + assert updated == messages From c0c7048903f98dc16af1167212395113a8f1c982 Mon Sep 17 00:00:00 2001 From: Vigilans Date: Thu, 23 Apr 2026 10:29:57 +0800 Subject: [PATCH 06/15] feat(messages): map reasoning_auto_summary to thinking.display for native /v1/messages (#25883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reasoning_auto_summary is enabled (via litellm_settings or env var), automatically set thinking.display="summarized" on native /v1/messages requests. This ensures thinking content is returned in the response instead of being omitted (the default on Claude 4.7+). Only applies when thinking is enabled (type != "disabled"). The existing reasoning_auto_summary flag already handles the /v1/responses path (summary="detailed") and the chat/completions adapter path — this extends coverage to the native messages handler. --- .../messages/handler.py | 13 ++ .../test_reasoning_auto_summary_messages.py | 173 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index c400d82b7cf..0c59e812e0b 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -24,6 +24,8 @@ from litellm.types.llms.anthropic_messages.anthropic_response import ( from litellm.types.router import GenericLiteLLMParams from litellm.utils import ProviderConfigManager, client +from ..utils import is_reasoning_auto_summary_enabled + from ..adapters.handler import LiteLLMMessagesToCompletionTransformationHandler from ..responses_adapters.handler import LiteLLMMessagesToResponsesAPIHandler from .interceptors import get_messages_interceptors @@ -441,6 +443,17 @@ def anthropic_messages_handler( params=local_vars ) ) + if is_reasoning_auto_summary_enabled(): + thinking_param = anthropic_messages_optional_request_params.get("thinking") + if ( + isinstance(thinking_param, dict) + and thinking_param.get("type") != "disabled" + ): + anthropic_messages_optional_request_params["thinking"] = { + **thinking_param, + "display": "summarized", + } + return base_llm_http_handler.anthropic_messages_handler( model=model, messages=messages, diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py new file mode 100644 index 00000000000..07c0012b04d --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py @@ -0,0 +1,173 @@ +""" +Tests for reasoning_auto_summary support on the native /v1/messages handler. + +When reasoning_auto_summary is enabled (via litellm.reasoning_auto_summary or +LITELLM_REASONING_AUTO_SUMMARY env var), the handler injects +thinking.display = "summarized" into the request params for active thinking +modes (type="enabled" or type="adaptive"). +""" + +import os +import sys + +import pytest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath("../../../../..")) + +import litellm +from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( + anthropic_messages_handler, +) + + +def _call_handler_and_capture_optional_params(thinking=None, **extra_kwargs): + """ + Call anthropic_messages_handler with an Anthropic model and capture the + anthropic_messages_optional_request_params dict passed to + base_llm_http_handler.anthropic_messages_handler. + + Returns the captured dict. + """ + captured = {} + + with patch( + "litellm.llms.anthropic.experimental_pass_through.messages.handler." + "base_llm_http_handler" + ) as mock_handler, patch( + "litellm.llms.anthropic.experimental_pass_through.messages.handler." + "ProviderConfigManager" + ) as mock_pcm: + # Make get_provider_anthropic_messages_config return a non-None config + # so the handler takes the native Anthropic path + mock_pcm.get_provider_anthropic_messages_config.return_value = MagicMock() + mock_handler.anthropic_messages_handler.return_value = MagicMock() + + kwargs = dict(extra_kwargs) + if thinking is not None: + kwargs["thinking"] = thinking + + try: + anthropic_messages_handler( + max_tokens=1024, + messages=[{"role": "user", "content": "Hello"}], + model="claude-sonnet-4-20250514", + custom_llm_provider="anthropic", + api_key="test-key", + **kwargs, + ) + except (ValueError, TypeError, AttributeError): + pass + + if mock_handler.anthropic_messages_handler.called: + captured = mock_handler.anthropic_messages_handler.call_args.kwargs.get( + "anthropic_messages_optional_request_params", {} + ) + + return captured + + +class TestReasoningAutoSummaryMessages: + """Tests for thinking.display injection on native /v1/messages handler.""" + + def test_adaptive_thinking_gets_display_summarized(self): + """reasoning_auto_summary=True + thinking.type='adaptive' -> display='summarized'.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={"type": "adaptive", "budget_tokens": 5000} + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + assert thinking.get("type") == "adaptive" + assert thinking.get("budget_tokens") == 5000 + + def test_enabled_thinking_gets_display_summarized(self): + """reasoning_auto_summary=True + thinking.type='enabled' -> display='summarized'.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={"type": "enabled", "budget_tokens": 10000} + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + assert thinking.get("type") == "enabled" + + def test_disabled_thinking_no_display(self): + """reasoning_auto_summary=True + thinking.type='disabled' -> display NOT set.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={"type": "disabled"} + ) + thinking = params.get("thinking", {}) + assert "display" not in thinking + + def test_no_injection_when_flag_false(self): + """reasoning_auto_summary=False + active thinking -> display NOT set.""" + with patch.object(litellm, "reasoning_auto_summary", False): + params = _call_handler_and_capture_optional_params( + thinking={"type": "enabled", "budget_tokens": 10000} + ) + thinking = params.get("thinking", {}) + assert "display" not in thinking + + def test_no_thinking_param_no_crash(self): + """reasoning_auto_summary=True but no thinking param -> nothing changes.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params() + thinking = params.get("thinking") + if thinking is not None: + assert "display" not in thinking + + def test_env_var_enables_auto_summary(self): + """LITELLM_REASONING_AUTO_SUMMARY=true env var enables the feature.""" + with patch.object(litellm, "reasoning_auto_summary", False), patch.dict( + os.environ, {"LITELLM_REASONING_AUTO_SUMMARY": "true"} + ): + params = _call_handler_and_capture_optional_params( + thinking={"type": "adaptive", "budget_tokens": 5000} + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + + def test_existing_display_summarized_preserved(self): + """User already passes display='summarized' -> preserved as-is.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={ + "type": "enabled", + "budget_tokens": 10000, + "display": "summarized", + } + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + + def test_existing_display_summarized_without_flag(self): + """User passes display='summarized' + flag=False -> preserved as-is.""" + with patch.object(litellm, "reasoning_auto_summary", False): + params = _call_handler_and_capture_optional_params( + thinking={ + "type": "enabled", + "budget_tokens": 10000, + "display": "summarized", + } + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + + def test_omitted_overridden_to_summarized(self): + """User passes display='omitted' + reasoning_auto_summary=True -> overridden. + + Documents current behavior: the code unconditionally sets + display='summarized' when auto_summary is enabled and thinking is active, + regardless of any pre-existing display value. + """ + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={ + "type": "enabled", + "budget_tokens": 10000, + "display": "omitted", + } + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" From bd145d18e17176b2328ddf18739cfc4bab17058f Mon Sep 17 00:00:00 2001 From: Elias <55650958+eliasto@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:33:58 -0400 Subject: [PATCH 07/15] fix(ovhcloud): Fix tool calling not working (#25948) * fix(ovhcloud): fix tool calling * fix import order --- litellm/llms/ovhcloud/chat/transformation.py | 31 +---------------- .../test_ovhcloud_chat_transformation.py | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index 342ad700e00..ae9271ddb16 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -8,9 +8,8 @@ More information on our website: https://endpoints.ai.cloud.ovh.net from typing import Optional, Union, List import httpx -from litellm.utils import ModelResponseStream, _get_model_info_helper +from litellm.utils import ModelResponseStream from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig -from litellm._logging import verbose_logger from litellm.llms.ovhcloud.utils import OVHCloudException from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -22,34 +21,6 @@ class OVHCloudChatConfig(OpenAIGPTConfig): def custom_llm_provider(self) -> Optional[str]: return "ovhcloud" - def get_supported_openai_params(self, model: str) -> list: - """ - Details about function calling support can be found here: - https://help.ovhcloud.com/csm/en-gb-public-cloud-ai-endpoints-function-calling?id=kb_article_view&sysparm_article=KB0071907 - """ - supports_function_calling: Optional[bool] = None - try: - model_info = _get_model_info_helper(model, custom_llm_provider="ovhcloud") - supports_function_calling = model_info.get( - "supports_function_calling", None - ) - if supports_function_calling is None: - supports_function_calling = False - except Exception as e: - verbose_logger.debug(f"Error getting supported OpenAI params: {e}") - supports_function_calling = False - - optional_params = super().get_supported_openai_params(model) - if supports_function_calling is not True: - verbose_logger.debug( - "You can see our models supporting function_calling in our catalog: https://endpoints.ai.cloud.ovh.net/catalog " - ) - optional_params.remove("tools") - optional_params.remove("tool_choice") - optional_params.remove("function_call") - optional_params.remove("response_format") - return optional_params - def get_complete_url( self, api_base: Optional[str], diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py index c2d597a28ea..a1b3b31f786 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py @@ -8,6 +8,7 @@ import sys import pytest from litellm.llms.ovhcloud.utils import OVHCloudException +from litellm.utils import get_optional_params sys.path.insert( 0, os.path.abspath("../../../../..") @@ -144,6 +145,38 @@ class TestOVHCloudConfig: assert error.message == "Test error" assert error.status_code == 400 + @pytest.mark.parametrize( + "model", + [ + "Meta-Llama-3_3-70B-Instruct", + "Meta-Llama-3_1-70B-Instruct", + "Mixtral-8x7B-Instruct-v0.1", + "gpt-oss-120b", + "some-model-not-in-the-cost-map", + ], + ) + def test_tools_not_filtered_by_static_model_map(self, model): + """ + OVHCloud AI Endpoints are OpenAI-compatible; tools/tool_choice must pass + through for any model. The server is responsible for rejecting unsupported + tool calls — LiteLLM must not strip them based on a stale static catalog. + """ + + params = get_optional_params( + model=model, + custom_llm_provider="ovhcloud", + tools=[ + { + "type": "function", + "function": {"name": "x", "parameters": {}}, + } + ], + tool_choice="auto", + ) + + assert "tools" in params + assert "tool_choice" in params + def test_ovhcloud_integration(): import os From 947931858eb7ba0ce4cc0e952912ddae54b98586 Mon Sep 17 00:00:00 2001 From: BillionToken Date: Thu, 23 Apr 2026 10:36:18 +0800 Subject: [PATCH 08/15] fix(anthropic): handle tool_choice type 'none' in messages API (#24457) * fix(anthropic): handle tool_choice type 'none' in messages API * test(anthropic): add regression test for tool_choice type 'none' --------- Co-authored-by: BillionClaw <267901332+BillionClaw@users.noreply.github.com> Co-authored-by: Krrish Dholakia --- .../adapters/transformation.py | 2 ++ ...al_pass_through_adapters_transformation.py | 29 ++++++++++--------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index e5d2b4ce782..20fa4f125de 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -782,6 +782,8 @@ class LiteLLMAnthropicMessagesAdapter: return ChatCompletionToolChoiceObjectParam( type="function", function=tc_function_param ) + elif tool_choice["type"] == "none": + return "none" else: raise ValueError( "Incompatible tool choice param submitted - {}".format(tool_choice) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index e6e96868f33..670388b7c03 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -2162,16 +2162,19 @@ class TestTranslateAnthropicOutputFormatToOpenAI: assert sorted(schema["required"]) == ["age", "email", "name"] def test_invalid_output_format_returns_none(self): - assert ( - self.adapter.translate_anthropic_output_format_to_openai("invalid") is None - ) - assert ( - self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) - is None - ) - assert ( - self.adapter.translate_anthropic_output_format_to_openai( - {"type": "json_schema"} - ) - is None - ) + assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None + assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None + assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None + + +def test_translate_anthropic_tool_choice_none(): + """ + Regression test for issue #24443. + + tool_choice={"type": "none"} should be translated to "none" for OpenAI format, + not raise a ValueError. + """ + adapter = LiteLLMAnthropicMessagesAdapter() + + result = adapter.translate_anthropic_tool_choice_to_openai({"type": "none"}) + assert result == "none" From 4b2fd870ca3d2df8dc4e104d10496ece5ce62e10 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:39:24 +0800 Subject: [PATCH 09/15] fix(ui): Fetch button ignores active filters on Request Logs page (#25788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When backend filters (e.g. Key Alias) are active on the Request Logs page, the manual Fetch button called logs.refetch() which re-runs the main TanStack Query. That query does not carry backend-only filter params such as key_alias, so the button had two problems: 1. It fired a redundant API request without the active filters. 2. It did not refresh the filtered result set — backendFilteredLogs stayed frozen at the last debounce-triggered fetch. Fix: expose refetchWithFilters() from useLogFilterLogic and route the Fetch button through it when hasBackendFilters is true. This cancels any in-flight debounce and calls performSearch with the current filter state, keeping all active filters intact. Co-authored-by: Bytechoreographer Co-authored-by: Claude Sonnet 4 (1M context) --- .../src/components/view_logs/index.tsx | 10 +++++++++- .../src/components/view_logs/log_filter_logic.tsx | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 97e24cb516a..8205c0b4b86 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -243,6 +243,7 @@ export default function SpendLogsTable({ allTeams, handleFilterChange, handleFilterReset: handleFilterResetFromHook, + refetchWithFilters, } = useLogFilterLogic({ logs: logsData, accessToken, @@ -363,7 +364,14 @@ export default function SpendLogsTable({ // Add this function to handle manual refresh const handleRefresh = () => { - logs.refetch(); + if (hasBackendFilters) { + // When backend filters (e.g. Key Alias) are active the main TanStack Query + // is disabled and its params do not include filter values like key_alias. + // Route through the filter-aware refetch so all active filters are preserved. + refetchWithFilters(); + } else { + logs.refetch(); + } }; const handleRowClick = (log: LogEntry) => { diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 8c88de49d0d..efe0eca3da9 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -299,6 +299,20 @@ export function useLogFilterLogic({ setCurrentPage(1); }; + // Expose a filter-aware refetch so callers (e.g. the manual Fetch button) can + // refresh results while keeping all active backend filters intact. The plain + // `logs.refetch()` in the parent only re-runs the main TanStack Query, which + // does not carry key_alias or other backend-only filter params. + const refetchWithFilters = useCallback( + (page = currentPage) => { + if (hasBackendFilters && accessToken) { + debouncedSearch.cancel(); + performSearch(filters, page); + } + }, + [hasBackendFilters, accessToken, filters, currentPage, performSearch, debouncedSearch], + ); + return { filters, filteredLogs, @@ -306,5 +320,6 @@ export function useLogFilterLogic({ allTeams, handleFilterChange, handleFilterReset, + refetchWithFilters, }; } From c26e304abc0315606a0577d1f7bcb8b1d9c2def2 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:41:34 +0800 Subject: [PATCH 10/15] fix(ui): stale filters applied after sort/page/time change on Request Logs (#25789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The useEffect that re-fetches logs on sort/page/time changes: useEffect(() => { if (hasBackendFilters && accessToken) { performSearch(filters, currentPage); } }, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]); intentionally omits `filters` and `hasBackendFilters` from its dep array to avoid double-fetches when a filter is applied. The side-effect is a stale-closure bug: the effect captures `filters` and `hasBackendFilters` from the render where its deps last changed, not from the render where the user selected, e.g., a Key Alias. Reproduce: set Key Alias → results appear correctly → change page or sort → the effect fires with the OLD `filters` snapshot (no key_alias) → API request is sent without the filter → table shows unfiltered data. Fix: store the latest `filters` and `hasBackendFilters` in refs that are kept in sync on every render. The sort/page/time effect reads from the refs instead of the closure so it always uses the current filter state without altering the dep array. Co-authored-by: Bytechoreographer Co-authored-by: Claude Sonnet 4 (1M context) --- .../components/view_logs/log_filter_logic.tsx | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index efe0eca3da9..a538872bfd9 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -71,6 +71,14 @@ export function useLogFilterLogic({ const [filters, setFilters] = useState(defaultFilters); const [backendFilteredLogs, setBackendFilteredLogs] = useState(null); const lastSearchTimestamp = useRef(0); + + // Refs that always hold the latest filters and hasBackendFilters values. + // The sort/page/time effect below intentionally omits these from its dep array + // to avoid double-fetches when a filter changes; reading from refs instead of + // the closure prevents stale-closure bugs (e.g. the effect using a snapshot of + // filters taken before the user selected Key Alias). + const filtersRef = useRef(filters); + const hasBackendFiltersRef = useRef(false); const performSearch = useCallback( async (filters: LogFilterState, page = 1) => { if (!accessToken) return; @@ -152,18 +160,25 @@ export function useLogFilterLogic({ [filters], ); + // Keep refs in sync on every render so the sort/page/time effect always reads + // the latest values without those values being in its dep array. + useEffect(() => { + filtersRef.current = filters; + hasBackendFiltersRef.current = hasBackendFilters; + }, [filters, hasBackendFilters]); + // Refetch when sort, page, or time range changes (backend filters use their own fetch, not the main query) useEffect(() => { - if (hasBackendFilters && accessToken) { + if (hasBackendFiltersRef.current && accessToken) { // Cancel any pending debounced search to prevent it from overwriting this page's results debouncedSearch.cancel(); - performSearch(filters, currentPage); + performSearch(filtersRef.current, currentPage); } - // Intentionally omitted from deps: - // - `filters` / `debouncedSearch` / `performSearch`: filter changes are handled by - // handleFilterChange → debouncedSearch; adding them here would double-fetch on filter apply. - // - `hasBackendFilters` / `accessToken`: stable across sort/page/time changes; including them - // would cause spurious re-runs when the filter state first becomes active. + // filters / hasBackendFilters are read via refs — avoids stale-closure bugs + // when sort/page/time changes after a filter (e.g. Key Alias) was set. + // debouncedSearch / performSearch: filter changes go through handleFilterChange + // → debouncedSearch; adding them here would cause double-fetches on filter apply. + // accessToken: stable across sort/page/time changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]); From d26bcda52a034dd0a836395af8c739ac3913d1fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Braulio=20Vargas=20L=C3=B3pez?= <9081114+BraulioV@users.noreply.github.com> Date: Thu, 23 Apr 2026 04:55:00 +0200 Subject: [PATCH 11/15] refactor: replace substring check with startswith in is_model_gpt_5_model (#25793) The original check `"gpt-5-chat" not in model` already correctly classifies all current gpt-5 variants (including gpt-5.3-chat and gpt-5.1-chat, which do NOT contain the substring "gpt-5-chat"). This change replaces it with an explicit `startswith("gpt-5-chat")` prefix test on the provider-prefix-stripped model name. The new check is functionally equivalent for all existing model names but makes the classification boundary unambiguous and forward-safe: future model names that might contain "gpt-5-chat" as an interior substring won't accidentally be excluded from the GPT-5 reasoning path. Also moves the new regression test from tests/ root to tests/test_litellm/llms/openai/ so it is included in `make test-unit`. --- .../llms/azure/chat/gpt_5_transformation.py | 17 +- .../llms/openai/chat/gpt_5_transformation.py | 18 ++- .../llms/openai/test_is_model_gpt_5_model.py | 151 ++++++++++++++++++ 3 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py diff --git a/litellm/llms/azure/chat/gpt_5_transformation.py b/litellm/llms/azure/chat/gpt_5_transformation.py index bc7483bf64d..e94f50380c0 100644 --- a/litellm/llms/azure/chat/gpt_5_transformation.py +++ b/litellm/llms/azure/chat/gpt_5_transformation.py @@ -40,9 +40,22 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): Accepts both explicit gpt-5 model names and the ``gpt5_series/`` prefix used for manual routing. """ - # gpt-5-chat* is a chat model and shouldn't go through GPT-5 reasoning restrictions. + # The gpt-5-chat* family (gpt-5-chat, gpt-5-chat-latest, gpt-5-chat-2025-08-07, + # …) are regular chat models: they support temperature and tool_choice but NOT + # reasoning_effort. They must NOT be routed through the GPT-5 reasoning path. + # + # Versioned chat models such as gpt-5.3-chat and gpt-5.1-chat ARE reasoning + # models and must stay on the GPT-5 path. The distinguishing feature is that + # the gpt-5-chat family has a literal "-chat" immediately after "gpt-5" + # (i.e. "gpt-5-chat…"), while versioned chat models interpose a minor version + # number (i.e. "gpt-5.-chat"). + # + # Using a startswith("gpt-5-chat") prefix check on the normalized name (rather + # than a substring check) makes this boundary explicit and avoids any ambiguity + # if future model names coincidentally contain "gpt-5-chat" as an interior run. + _normalized = model.split("/")[-1] # strip provider prefix, e.g. "azure/" return ( - "gpt-5" in model and "gpt-5-chat" not in model + "gpt-5" in model and not _normalized.startswith("gpt-5-chat") ) or "gpt5_series" in model def get_supported_openai_params(self, model: str) -> List[str]: diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index fc48704cd10..34941a545eb 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -53,9 +53,21 @@ class OpenAIGPT5Config(OpenAIGPTConfig): @classmethod def is_model_gpt_5_model(cls, model: str) -> bool: - # gpt-5-chat* behaves like a regular chat model (supports temperature, etc.) - # Don't route it through GPT-5 reasoning-specific parameter restrictions. - return "gpt-5" in model and "gpt-5-chat" not in model + # The gpt-5-chat* family (gpt-5-chat, gpt-5-chat-latest, gpt-5-chat-2025-08-07, + # …) are regular chat models: they support temperature and tool_choice but NOT + # reasoning_effort. They must NOT be routed through the GPT-5 reasoning path. + # + # Versioned chat models such as gpt-5.3-chat and gpt-5.1-chat ARE reasoning + # models and must stay on the GPT-5 path. The distinguishing feature is that + # the gpt-5-chat family has a literal "-chat" immediately after "gpt-5" + # (i.e. "gpt-5-chat…"), while versioned chat models interpose a minor version + # number (i.e. "gpt-5.-chat"). + # + # Using a startswith("gpt-5-chat") prefix check on the normalized name (rather + # than a substring check) makes this boundary explicit and avoids any ambiguity + # if future model names coincidentally contain "gpt-5-chat" as an interior run. + _normalized = model.split("/")[-1] # strip provider prefix, e.g. "openai/" + return "gpt-5" in model and not _normalized.startswith("gpt-5-chat") @classmethod def is_model_gpt_5_search_model(cls, model: str) -> bool: diff --git a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py new file mode 100644 index 00000000000..1d262872955 --- /dev/null +++ b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py @@ -0,0 +1,151 @@ +""" +Regression tests for is_model_gpt_5_model() in both OpenAI and Azure GPT-5 config +classes. + +Background +---------- +In v1.82.3 a substring check was introduced:: + + return "gpt-5" in model and "gpt-5-chat" not in model + +This inadvertently treated versioned chat models like ``gpt-5.3-chat`` and +``gpt-5.1-chat`` as *non*-GPT-5 models, because the string ``"gpt-5-chat"`` is +a substring of ``"gpt-5.3-chat"``. Those models were then routed through the +regular Azure chat path which does not suppress ``parallel_tool_calls``, causing +Azure to return ``finish_reason="stop"`` together with tool_calls and breaking +n8n AI-agent workflows. + +There are two distinct families: + +* **gpt-5-chat family** (``gpt-5-chat``, ``gpt-5-chat-latest``, + ``gpt-5-chat-2025-08-07``, …) — regular chat models that support ``temperature`` + and ``tool_choice`` but NOT ``reasoning_effort``. Must NOT be on the GPT-5 + reasoning path. + +* **Versioned chat models** (``gpt-5.1-chat``, ``gpt-5.2-chat``, + ``gpt-5.3-chat``, …) — ARE GPT-5 reasoning models and must stay on the GPT-5 + path. + +The fix uses a prefix check (``startswith("gpt-5-chat")``) on the normalised model +name instead of a substring check, which correctly distinguishes the two families. +""" + +import pytest + +from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config +from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config + +# --------------------------------------------------------------------------- +# Parametrized fixtures +# --------------------------------------------------------------------------- + +# Models that MUST be classified as GPT-5 (routed through GPT-5 reasoning path) +GPT5_MODELS = [ + "gpt-5", + "gpt-5.1", + "gpt-5.2", + "gpt-5.3", + "gpt-5.4", + "gpt-5.1-chat", # versioned chat — THE KEY REGRESSION CASE + "gpt-5.2-chat", # versioned chat — also a regression case + "gpt-5.3-chat", # versioned chat — THE KEY REGRESSION CASE + "gpt-5.2-chat-latest", # versioned chat with date suffix + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1-mini", + "gpt-5-nano", + "gpt-5-mini", + "gpt-5-codex", +] + +# Models that must NOT be classified as GPT-5 (regular chat path) +NON_GPT5_MODELS = [ + "gpt-5-chat", # gpt-5-chat family — regular chat path + "gpt-5-chat-latest", # gpt-5-chat family with alias suffix + "gpt-5-chat-2025-08-07", # gpt-5-chat family with date suffix + "gpt-4", + "gpt-4o", + "gpt-4-turbo", + "gpt-3.5-turbo", + "o1", + "o3", + "o3-mini", +] + + +# --------------------------------------------------------------------------- +# OpenAIGPT5Config +# --------------------------------------------------------------------------- + + +class TestOpenAIGPT5ConfigIsModelGpt5Model: + + @pytest.mark.parametrize("model", GPT5_MODELS) + def test_gpt5_models_are_classified_as_gpt5(self, model: str): + assert OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' to be classified as a GPT-5 model" + + @pytest.mark.parametrize("model", NON_GPT5_MODELS) + def test_non_gpt5_models_are_not_classified_as_gpt5(self, model: str): + assert not OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' NOT to be classified as a GPT-5 model" + + def test_versioned_chat_models_are_not_excluded_by_prefix(self): + """Core regression guard: gpt-5-chat prefix must not match versioned models.""" + versioned_chat_models = ["gpt-5.1-chat", "gpt-5.2-chat", "gpt-5.3-chat"] + for model in versioned_chat_models: + assert OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Regression: '{model}' was incorrectly excluded from GPT-5 path" + + def test_gpt5_chat_family_is_excluded(self): + """gpt-5-chat family should stay on the regular chat path.""" + for model in ["gpt-5-chat", "gpt-5-chat-latest", "gpt-5-chat-2025-08-07"]: + assert not OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path" + + +# --------------------------------------------------------------------------- +# AzureOpenAIGPT5Config +# --------------------------------------------------------------------------- + + +class TestAzureOpenAIGPT5ConfigIsModelGpt5Model: + + @pytest.mark.parametrize("model", GPT5_MODELS) + def test_gpt5_models_are_classified_as_gpt5(self, model: str): + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' to be classified as a GPT-5 model" + + @pytest.mark.parametrize("model", NON_GPT5_MODELS) + def test_non_gpt5_models_are_not_classified_as_gpt5(self, model: str): + assert not AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' NOT to be classified as a GPT-5 model" + + def test_versioned_chat_models_are_not_excluded_by_prefix(self): + """Core regression guard: gpt-5-chat prefix must not match versioned models.""" + versioned_chat_models = ["gpt-5.1-chat", "gpt-5.2-chat", "gpt-5.3-chat"] + for model in versioned_chat_models: + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Regression: Azure '{model}' was incorrectly excluded from GPT-5 path" + + def test_gpt5_chat_family_is_excluded(self): + """gpt-5-chat family should stay on the regular chat path.""" + for model in ["gpt-5-chat", "gpt-5-chat-latest", "gpt-5-chat-2025-08-07"]: + assert not AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path" + + def test_gpt5_series_routing_prefix_is_always_classified_as_gpt5(self): + """Models using the gpt5_series/ manual-routing prefix must always match.""" + series_models = ["gpt5_series/my-deployment", "gpt5_series/prod"] + for model in series_models: + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Azure '{model}' with gpt5_series/ prefix should be classified as GPT-5" From fcf917df6d8c4eb790acfa61963c3a448e56093c Mon Sep 17 00:00:00 2001 From: "Zark ." <87560774+Alpha-Zark@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:03:46 +0800 Subject: [PATCH 12/15] Feat(dashscope): add image generation support for qwen-image-2.0 and qwen-image-2.0-pro (#25672) * feat: add dashscope/qwen-image-2.0 and qwen-image-2.0-pro to model cost map * feat: implement DashScope image generation transformation class * feat: register DashScope in ProviderConfigManager for image generation * feat: add DashScope to image generation provider routing * feat: auto-route qwen-image /chat/completions requests to /images/generations * test: add unit tests for DashScope image generation (22 cases) * refactor: remove proxy-layer qwen-image auto-routing * feat: auto-redirect image_generation models in acompletion() * test: add acompletion auto-redirect test for image_generation models * fix: remove unused Union import in DashScope transformation * fix: scope acompletion redirect to dashscope and narrow exception handler * fix: move get_str_from_messages to module-level import and forward n param to aimage_generation * refactor: remove acompletion image_generation auto-redirect for dashscope * test: remove acompletion auto-redirect test for dashscope image models --------- Co-authored-by: zark.lin --- litellm/images/main.py | 1 + .../dashscope/image_generation/__init__.py | 9 + .../image_generation/transformation.py | 187 ++++++++++ ...odel_prices_and_context_window_backup.json | 16 + litellm/proxy/proxy_server.py | 1 + litellm/utils.py | 6 + model_prices_and_context_window.json | 16 + .../test_dashscope_image_generation.py | 328 ++++++++++++++++++ 8 files changed, 564 insertions(+) create mode 100644 litellm/llms/dashscope/image_generation/__init__.py create mode 100644 litellm/llms/dashscope/image_generation/transformation.py create mode 100644 tests/test_litellm/test_dashscope_image_generation.py diff --git a/litellm/images/main.py b/litellm/images/main.py index 0d3b2e97294..d95b7287d20 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -410,6 +410,7 @@ def image_generation( # noqa: PLR0915 litellm.LlmProviders.RUNWAYML, litellm.LlmProviders.VERTEX_AI, litellm.LlmProviders.OPENROUTER, + litellm.LlmProviders.DASHSCOPE, ): if image_generation_config is None: raise ValueError( diff --git a/litellm/llms/dashscope/image_generation/__init__.py b/litellm/llms/dashscope/image_generation/__init__.py new file mode 100644 index 00000000000..9fdb46586e6 --- /dev/null +++ b/litellm/llms/dashscope/image_generation/__init__.py @@ -0,0 +1,9 @@ +from litellm.llms.base_llm.image_generation.transformation import BaseImageGenerationConfig + +from .transformation import DashScopeImageGenerationConfig + +__all__ = ["DashScopeImageGenerationConfig"] + + +def get_dashscope_image_generation_config(model: str) -> BaseImageGenerationConfig: + return DashScopeImageGenerationConfig() diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py new file mode 100644 index 00000000000..feac811df83 --- /dev/null +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -0,0 +1,187 @@ +""" +DashScope Image Generation Configuration + +Handles transformation between OpenAI-compatible format and DashScope multimodal-generation API. + +API endpoint: POST https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation + +Request format: +{ + "model": "qwen-image-2.0-pro", + "input": { + "messages": [{"role": "user", "content": [{"text": ""}]}] + }, + "parameters": {"size": "1024*1024", ...} +} + +Response format: +{ + "output": { + "choices": [{"message": {"content": [{"image": ""}]}}] + }, + "usage": {"input_tokens": 0, "output_tokens": 0, "width": 1024, "height": 1024, "image_count": 1} +} +""" + +from typing import TYPE_CHECKING, Any, List, Optional + +import httpx + +from litellm.llms.base_llm.image_generation.transformation import BaseImageGenerationConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import AllMessageValues, OpenAIImageGenerationOptionalParams +from litellm.types.utils import ImageObject, ImageResponse + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + +DEFAULT_API_BASE = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" + +# Maps OpenAI size strings (WxH) to DashScope size strings (W*H) +OPENAI_TO_DASHSCOPE_SIZE: dict = { + "256x256": "256*256", + "512x512": "512*512", + "1024x1024": "1024*1024", + "1792x1024": "1792*1024", + "1024x1792": "1024*1792", + "2048x2048": "2048*2048", +} + + +class DashScopeImageGenerationConfig(BaseImageGenerationConfig): + """ + Configuration for DashScope image generation (qwen-image-2.0, qwen-image-2.0-pro). + """ + + def get_supported_openai_params( + self, model: str + ) -> List[OpenAIImageGenerationOptionalParams]: + return ["n", "size"] + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + supported_params = self.get_supported_openai_params(model) + mapped: dict = {} + for k, v in non_default_params.items(): + if k in optional_params: + continue + if k not in supported_params: + continue + if k == "size": + # Convert "WxH" → "W*H" + mapped["size"] = OPENAI_TO_DASHSCOPE_SIZE.get(v, v.replace("x", "*")) + elif k == "n": + mapped["image_count"] = v + return mapped + + 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: + return ( + api_base + or get_secret_str("DASHSCOPE_API_BASE_IMAGE") + or DEFAULT_API_BASE + ) + + 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: + final_api_key = api_key or get_secret_str("DASHSCOPE_API_KEY") + if not final_api_key: + raise ValueError("DASHSCOPE_API_KEY is not set") + headers["Authorization"] = f"Bearer {final_api_key}" + headers["Content-Type"] = "application/json" + return headers + + def transform_image_generation_request( + self, + model: str, + prompt: str, + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + """ + Transform OpenAI-style image generation request to DashScope multimodal-generation format. + """ + parameters: dict = {} + for k, v in optional_params.items(): + parameters[k] = v + + return { + "model": model, + "input": { + "messages": [ + { + "role": "user", + "content": [{"text": prompt}], + } + ] + }, + "parameters": parameters, + } + + def transform_image_generation_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ImageResponse, + logging_obj: LiteLLMLoggingObj, + request_data: dict, + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: Optional[str] = None, + json_mode: Optional[bool] = None, + ) -> ImageResponse: + """ + Transform DashScope response to litellm ImageResponse. + + DashScope response: output.choices[0].message.content[0].image + OpenAI response: data[0].url + """ + try: + response_data = raw_response.json() + except Exception as e: + raise self.get_error_class( + error_message=f"Failed to parse DashScope image generation response: {e}", + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + + if not model_response.data: + model_response.data = [] + + choices = response_data.get("output", {}).get("choices", []) + for choice in choices: + content_list = ( + choice.get("message", {}).get("content", []) + ) + for content_item in content_list: + image_url = content_item.get("image") + if image_url: + model_response.data.append(ImageObject(url=image_url)) + + return model_response diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 05b59d45f99..5f6f4331676 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -10383,6 +10383,22 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "dashscope/qwen-image-2.0": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "dashscope/qwen-image-2.0-pro": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "databricks/databricks-bge-large-en": { "input_cost_per_token": 1.0003e-07, "input_dbu_cost_per_token": 1.429e-06, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 0efa1d452d2..ebe38705d9c 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -7246,6 +7246,7 @@ async def chat_completion( # noqa: PLR0915 and user_api_key_dict.agent_id is not None ): data["metadata"]["agent_id"] = user_api_key_dict.agent_id + base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data) try: result = await base_llm_response_processor.base_process_llm_request( diff --git a/litellm/utils.py b/litellm/utils.py index 7a9f62afa09..e1ad1db63ef 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8952,6 +8952,12 @@ class ProviderConfigManager: ) return get_openrouter_image_generation_config(model) + elif LlmProviders.DASHSCOPE == provider: + from litellm.llms.dashscope.image_generation import ( + get_dashscope_image_generation_config, + ) + + return get_dashscope_image_generation_config(model) return None @staticmethod diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 8a28235f985..98723b80aa3 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -10397,6 +10397,22 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "dashscope/qwen-image-2.0": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "dashscope/qwen-image-2.0-pro": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "databricks/databricks-bge-large-en": { "input_cost_per_token": 1.0003e-07, "input_dbu_cost_per_token": 1.429e-06, diff --git a/tests/test_litellm/test_dashscope_image_generation.py b/tests/test_litellm/test_dashscope_image_generation.py new file mode 100644 index 00000000000..b7680a7e7fd --- /dev/null +++ b/tests/test_litellm/test_dashscope_image_generation.py @@ -0,0 +1,328 @@ +""" +Unit tests for DashScope image generation support (qwen-image-2.0, qwen-image-2.0-pro). + +Run in docker: pytest tests/test_litellm/test_dashscope_image_generation.py -v +""" + +import json +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +import litellm +from litellm.llms.dashscope.image_generation.transformation import ( + DashScopeImageGenerationConfig, + DEFAULT_API_BASE, +) +from litellm.types.utils import ImageObject, ImageResponse +from litellm.utils import get_llm_provider + + +# --------------------------------------------------------------------------- +# 1. Provider detection +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model_string", + [ + "dashscope/qwen-image-2.0", + "dashscope/qwen-image-2.0-pro", + ], +) +def test_get_llm_provider_returns_dashscope(model_string: str): + model, provider, _, _ = get_llm_provider(model_string) + assert provider == "dashscope", f"Expected 'dashscope', got '{provider}'" + assert "qwen-image" in model + + +# --------------------------------------------------------------------------- +# 2. Model info: mode == "image_generation" +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model_string, custom_provider", + [ + ("dashscope/qwen-image-2.0", "dashscope"), + ("dashscope/qwen-image-2.0-pro", "dashscope"), + ], +) +def test_get_model_info_mode_is_image_generation(model_string: str, custom_provider: str): + import os + + prev_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP") + prev_model_cost = litellm.model_cost + try: + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + info = litellm.get_model_info(model=model_string, custom_llm_provider=custom_provider) + assert info["mode"] == "image_generation", ( + f"Expected mode='image_generation', got '{info['mode']}'" + ) + finally: + if prev_env is None: + os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None) + else: + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = prev_env + litellm.model_cost = prev_model_cost + + +# --------------------------------------------------------------------------- +# 3. Request transformation +# --------------------------------------------------------------------------- + + +class TestDashScopeImageGenerationConfig: + def setup_method(self): + self.cfg = DashScopeImageGenerationConfig() + + def test_get_complete_url_default(self): + url = self.cfg.get_complete_url(None, None, "qwen-image-2.0", {}, {}) + assert url == DEFAULT_API_BASE + + def test_get_complete_url_custom(self): + custom = "https://custom.endpoint/generate" + url = self.cfg.get_complete_url(custom, None, "qwen-image-2.0", {}, {}) + assert url == custom + + def test_validate_environment_sets_auth_header(self): + headers = self.cfg.validate_environment( + headers={}, + model="qwen-image-2.0", + messages=[], + optional_params={}, + litellm_params={}, + api_key="sk-test-key", + ) + assert headers["Authorization"] == "Bearer sk-test-key" + assert headers["Content-Type"] == "application/json" + + def test_validate_environment_raises_without_key(self): + with patch("litellm.llms.dashscope.image_generation.transformation.get_secret_str", return_value=None): + with pytest.raises(ValueError, match="DASHSCOPE_API_KEY"): + self.cfg.validate_environment( + headers={}, + model="qwen-image-2.0", + messages=[], + optional_params={}, + litellm_params={}, + api_key=None, + ) + + def test_transform_request_structure(self): + req = self.cfg.transform_image_generation_request( + model="qwen-image-2.0", + prompt="a puppy on green grass", + optional_params={"size": "1024*1024"}, + litellm_params={}, + headers={}, + ) + assert req["model"] == "qwen-image-2.0" + messages = req["input"]["messages"] + assert len(messages) == 1 + assert messages[0]["role"] == "user" + assert messages[0]["content"][0]["text"] == "a puppy on green grass" + assert req["parameters"]["size"] == "1024*1024" + + def test_transform_request_empty_params(self): + req = self.cfg.transform_image_generation_request( + model="qwen-image-2.0-pro", + prompt="sunset over the ocean", + optional_params={}, + litellm_params={}, + headers={}, + ) + assert req["parameters"] == {} + + # --------------------------------------------------------------------------- + # 4. Response transformation + # --------------------------------------------------------------------------- + + def _make_mock_response(self, image_url: str) -> httpx.Response: + body = { + "status_code": 200, + "request_id": "test-request-id", + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"image": image_url}], + }, + } + ] + }, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "width": 1024, + "height": 1024, + "image_count": 1, + }, + } + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.headers = {} + mock_resp.json.return_value = body + return mock_resp + + def test_transform_response_extracts_url(self): + image_url = "https://example.oss.aliyuncs.com/generated/test.png" + mock_resp = self._make_mock_response(image_url) + model_response = ImageResponse() + result = self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert result.data is not None + assert len(result.data) == 1 + assert result.data[0].url == image_url + + def test_transform_response_multiple_images(self): + body = { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"role": "assistant", "content": [{"image": "https://example.com/img1.png"}]}}, + {"finish_reason": "stop", "message": {"role": "assistant", "content": [{"image": "https://example.com/img2.png"}]}}, + ] + }, + "usage": {}, + } + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.headers = {} + mock_resp.json.return_value = body + + model_response = ImageResponse() + result = self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert len(result.data) == 2 + assert result.data[0].url == "https://example.com/img1.png" + assert result.data[1].url == "https://example.com/img2.png" + + # --------------------------------------------------------------------------- + # 5. OpenAI → DashScope parameter mapping + # --------------------------------------------------------------------------- + + def test_map_openai_params_size_conversion(self): + mapped = self.cfg.map_openai_params( + non_default_params={"size": "1024x1024"}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["size"] == "1024*1024" + + def test_map_openai_params_n_to_image_count(self): + mapped = self.cfg.map_openai_params( + non_default_params={"n": 2}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["image_count"] == 2 + + def test_map_openai_params_unknown_size_uses_asterisk(self): + mapped = self.cfg.map_openai_params( + non_default_params={"size": "768x768"}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["size"] == "768*768" + + @pytest.mark.parametrize( + "openai_size, expected", + [ + ("256x256", "256*256"), + ("512x512", "512*512"), + ("1024x1024", "1024*1024"), + ("1792x1024", "1792*1024"), + ("1024x1792", "1024*1792"), + ("2048x2048", "2048*2048"), + ], + ) + def test_map_openai_params_size_table(self, openai_size: str, expected: str): + mapped = self.cfg.map_openai_params( + non_default_params={"size": openai_size}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["size"] == expected + + +# --------------------------------------------------------------------------- +# 6. End-to-end flow via litellm.image_generation (HTTP mocked) +# --------------------------------------------------------------------------- + + +def test_litellm_image_generation_dashscope_end_to_end(): + mock_response_body = { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [ + {"image": "https://dashscope-result.oss.aliyuncs.com/test.png"} + ], + }, + } + ] + }, + "usage": {"input_tokens": 0, "output_tokens": 0, "width": 1024, "height": 1024, "image_count": 1}, + } + + with patch( + "litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post" + ) as mock_post: + mock_http_response = MagicMock() + mock_http_response.json.return_value = mock_response_body + mock_http_response.status_code = 200 + mock_http_response.headers = {} + mock_post.return_value = mock_http_response + + response = litellm.image_generation( + model="dashscope/qwen-image-2.0", + prompt="a puppy playing on green grass", + api_key="sk-test-key", + size="1024x1024", + ) + + assert response is not None + assert response.data is not None + assert len(response.data) == 1 + assert response.data[0].url == "https://dashscope-result.oss.aliyuncs.com/test.png" + + # Verify the HTTP call was made to the DashScope endpoint + call_args = mock_post.call_args + called_url = call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "") + assert "dashscope" in called_url or "aliyuncs" in called_url + + # Verify request body contains DashScope format + call_kwargs = call_args[1] if call_args[1] else {} + if "json" in call_kwargs: + body = call_kwargs["json"] + assert "input" in body + assert "messages" in body["input"] + From 1385d46e9974a7ac59843421682268bacde06918 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 23 Apr 2026 17:17:06 +0530 Subject: [PATCH 13/15] FIx mypy issues --- .../anthropic/experimental_pass_through/utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/utils.py b/litellm/llms/anthropic/experimental_pass_through/utils.py index d975bee0bc2..4fd68ef535f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/utils.py +++ b/litellm/llms/anthropic/experimental_pass_through/utils.py @@ -2,6 +2,7 @@ import os from typing import Optional import litellm +from litellm.types.utils import ModelInfo def is_reasoning_auto_summary_enabled() -> bool: @@ -31,25 +32,26 @@ def normalize_reasoning_effort_value( from litellm.utils import get_model_info + model_info: Optional[ModelInfo] = None try: model_info = get_model_info( model=model, custom_llm_provider=custom_llm_provider ) except Exception: - model_info = {} + model_info = None if effort == "max": - if model_info.get("supports_max_reasoning_effort"): + if model_info and model_info.get("supports_max_reasoning_effort"): return "max" - if model_info.get("supports_xhigh_reasoning_effort"): + if model_info and model_info.get("supports_xhigh_reasoning_effort"): return "xhigh" return "high" elif effort == "xhigh": - if model_info.get("supports_xhigh_reasoning_effort"): + if model_info and model_info.get("supports_xhigh_reasoning_effort"): return "xhigh" return "high" elif effort == "minimal": - if model_info.get("supports_minimal_reasoning_effort"): + if model_info and model_info.get("supports_minimal_reasoning_effort"): return "minimal" return "low" return "medium" From 2e3a4bb27a27f875f303b8b66abd26dfad86c155 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 23 Apr 2026 18:32:24 +0530 Subject: [PATCH 14/15] Fix black --- .../llms/dashscope/image_generation/__init__.py | 4 +++- .../image_generation/transformation.py | 17 +++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/litellm/llms/dashscope/image_generation/__init__.py b/litellm/llms/dashscope/image_generation/__init__.py index 9fdb46586e6..aa5724b4d80 100644 --- a/litellm/llms/dashscope/image_generation/__init__.py +++ b/litellm/llms/dashscope/image_generation/__init__.py @@ -1,4 +1,6 @@ -from litellm.llms.base_llm.image_generation.transformation import BaseImageGenerationConfig +from litellm.llms.base_llm.image_generation.transformation import ( + BaseImageGenerationConfig, +) from .transformation import DashScopeImageGenerationConfig diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index feac811df83..152c4791bfa 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -27,9 +27,14 @@ from typing import TYPE_CHECKING, Any, List, Optional import httpx -from litellm.llms.base_llm.image_generation.transformation import BaseImageGenerationConfig +from litellm.llms.base_llm.image_generation.transformation import ( + BaseImageGenerationConfig, +) from litellm.secret_managers.main import get_secret_str -from litellm.types.llms.openai import AllMessageValues, OpenAIImageGenerationOptionalParams +from litellm.types.llms.openai import ( + AllMessageValues, + OpenAIImageGenerationOptionalParams, +) from litellm.types.utils import ImageObject, ImageResponse if TYPE_CHECKING: @@ -93,9 +98,7 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): stream: Optional[bool] = None, ) -> str: return ( - api_base - or get_secret_str("DASHSCOPE_API_BASE_IMAGE") - or DEFAULT_API_BASE + api_base or get_secret_str("DASHSCOPE_API_BASE_IMAGE") or DEFAULT_API_BASE ) def validate_environment( @@ -176,9 +179,7 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): choices = response_data.get("output", {}).get("choices", []) for choice in choices: - content_list = ( - choice.get("message", {}).get("content", []) - ) + content_list = choice.get("message", {}).get("content", []) for content_item in content_list: image_url = content_item.get("image") if image_url: From 2d1cc68e228cc43264d6e7e21a7f5ffc855ebf5f Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 23 Apr 2026 18:41:01 +0530 Subject: [PATCH 15/15] fix(dashscope): fail fast on image generation API errors Prevent silent empty image responses by raising provider errors for non-200 HTTP statuses and DashScope API-level error payloads, with regression tests covering both paths. Made-with: Cursor --- .../image_generation/transformation.py | 16 +++ .../test_dashscope_image_generation.py | 97 ++++++++++++++++--- 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index 152c4791bfa..77676b11d51 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -165,6 +165,13 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): DashScope response: output.choices[0].message.content[0].image OpenAI response: data[0].url """ + if raw_response.status_code != 200: + raise self.get_error_class( + error_message=raw_response.text, + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + try: response_data = raw_response.json() except Exception as e: @@ -174,6 +181,15 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): headers=raw_response.headers, ) + # DashScope can return API-level errors in a 200 response body. + # Example: {"code": "InvalidParameter", "message": "Size not supported"} + if "code" in response_data and "output" not in response_data: + raise self.get_error_class( + error_message=str(response_data.get("message", response_data)), + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + if not model_response.data: model_response.data = [] diff --git a/tests/test_litellm/test_dashscope_image_generation.py b/tests/test_litellm/test_dashscope_image_generation.py index b7680a7e7fd..af95e2ca6b4 100644 --- a/tests/test_litellm/test_dashscope_image_generation.py +++ b/tests/test_litellm/test_dashscope_image_generation.py @@ -49,7 +49,9 @@ def test_get_llm_provider_returns_dashscope(model_string: str): ("dashscope/qwen-image-2.0-pro", "dashscope"), ], ) -def test_get_model_info_mode_is_image_generation(model_string: str, custom_provider: str): +def test_get_model_info_mode_is_image_generation( + model_string: str, custom_provider: str +): import os prev_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP") @@ -58,10 +60,12 @@ def test_get_model_info_mode_is_image_generation(model_string: str, custom_provi os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" litellm.model_cost = litellm.get_model_cost_map(url="") - info = litellm.get_model_info(model=model_string, custom_llm_provider=custom_provider) - assert info["mode"] == "image_generation", ( - f"Expected mode='image_generation', got '{info['mode']}'" + info = litellm.get_model_info( + model=model_string, custom_llm_provider=custom_provider ) + assert ( + info["mode"] == "image_generation" + ), f"Expected mode='image_generation', got '{info['mode']}'" finally: if prev_env is None: os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None) @@ -101,7 +105,10 @@ class TestDashScopeImageGenerationConfig: assert headers["Content-Type"] == "application/json" def test_validate_environment_raises_without_key(self): - with patch("litellm.llms.dashscope.image_generation.transformation.get_secret_str", return_value=None): + with patch( + "litellm.llms.dashscope.image_generation.transformation.get_secret_str", + return_value=None, + ): with pytest.raises(ValueError, match="DASHSCOPE_API_KEY"): self.cfg.validate_environment( headers={}, @@ -192,8 +199,20 @@ class TestDashScopeImageGenerationConfig: body = { "output": { "choices": [ - {"finish_reason": "stop", "message": {"role": "assistant", "content": [{"image": "https://example.com/img1.png"}]}}, - {"finish_reason": "stop", "message": {"role": "assistant", "content": [{"image": "https://example.com/img2.png"}]}}, + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"image": "https://example.com/img1.png"}], + }, + }, + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"image": "https://example.com/img2.png"}], + }, + }, ] }, "usage": {}, @@ -218,6 +237,49 @@ class TestDashScopeImageGenerationConfig: assert result.data[0].url == "https://example.com/img1.png" assert result.data[1].url == "https://example.com/img2.png" + def test_transform_response_raises_on_non_200_status(self): + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 400 + mock_resp.headers = {} + mock_resp.text = '{"code":"InvalidParameter","message":"Size not supported"}' + mock_resp.json.return_value = { + "code": "InvalidParameter", + "message": "Size not supported", + } + + with pytest.raises(Exception): + self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=ImageResponse(), + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + + def test_transform_response_raises_on_api_error_body(self): + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.headers = {} + mock_resp.json.return_value = { + "code": "InvalidParameter", + "message": "Size not supported", + } + + with pytest.raises(Exception): + self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=ImageResponse(), + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + # --------------------------------------------------------------------------- # 5. OpenAI → DashScope parameter mapping # --------------------------------------------------------------------------- @@ -284,13 +346,21 @@ def test_litellm_image_generation_dashscope_end_to_end(): "message": { "role": "assistant", "content": [ - {"image": "https://dashscope-result.oss.aliyuncs.com/test.png"} + { + "image": "https://dashscope-result.oss.aliyuncs.com/test.png" + } ], }, } ] }, - "usage": {"input_tokens": 0, "output_tokens": 0, "width": 1024, "height": 1024, "image_count": 1}, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "width": 1024, + "height": 1024, + "image_count": 1, + }, } with patch( @@ -312,11 +382,15 @@ def test_litellm_image_generation_dashscope_end_to_end(): assert response is not None assert response.data is not None assert len(response.data) == 1 - assert response.data[0].url == "https://dashscope-result.oss.aliyuncs.com/test.png" + assert ( + response.data[0].url == "https://dashscope-result.oss.aliyuncs.com/test.png" + ) # Verify the HTTP call was made to the DashScope endpoint call_args = mock_post.call_args - called_url = call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "") + called_url = ( + call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "") + ) assert "dashscope" in called_url or "aliyuncs" in called_url # Verify request body contains DashScope format @@ -325,4 +399,3 @@ def test_litellm_image_generation_dashscope_end_to_end(): body = call_kwargs["json"] assert "input" in body assert "messages" in body["input"] -