diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 1ce802075527..0b56eb86d9cc 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1506,9 +1506,21 @@ def map_openai_params( # noqa: PLR0915 optional_params["metadata"] = {"user_id": value} elif param == "thinking": optional_params["thinking"] = value - elif param == "reasoning_effort" and isinstance(value, str): + elif param == "reasoning_effort": + # Accept both string ("low") and dict ({"effort": "low", + # "summary": "concise"}). The Responses->Chat parser keeps the + # full dict when `summary` is set (see #25359), so a dict here + # is the standard shape Otto/OpenAI-Responses-Bridge callers + # send. Coerce to the effort string before mapping — same + # shape-tolerance the GPT-5 path already implements in + # `_normalize_reasoning_effort_for_chat_completion`. + effort_value = value + if isinstance(effort_value, dict): + effort_value = effort_value.get("effort") + if not isinstance(effort_value, str): + continue mapped_thinking = AnthropicConfig._map_reasoning_effort( - reasoning_effort=value, + reasoning_effort=effort_value, model=model, llm_provider=self.custom_llm_provider or "anthropic", ) @@ -1519,12 +1531,12 @@ def map_openai_params( # noqa: PLR0915 optional_params["thinking"] = mapped_thinking if AnthropicConfig._is_adaptive_thinking_model(model): mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get( - value + effort_value ) if mapped_effort is None: AnthropicConfig._raise_invalid_reasoning_effort( model=model, - value=value, + value=effort_value, llm_provider=self.custom_llm_provider or "anthropic", ) optional_params["output_config"] = {"effort": mapped_effort} diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index fb5bfa6cf4e4..6a92465532c8 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -26951,6 +26951,58 @@ "supports_web_search": true, "tpm": 800000 }, + "openrouter/google/gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "rpm": 2000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 800000 + }, "openrouter/google/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index eea6974193f7..27cdc483d4a5 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3171,7 +3171,7 @@ ] }, "post": { - "description": "Create a new agent\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/agents\" \\\n -H \"Authorization: Bearer \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"agent\": {\n \"agent_name\": \"my-custom-agent\",\n \"agent_card_params\": {\n \"protocolVersion\": \"1.0\",\n \"name\": \"Hello World Agent\",\n \"description\": \"Just a hello world agent\",\n \"url\": \"http://localhost:9999/\",\n \"version\": \"1.0.0\",\n \"defaultInputModes\": [\"text\"],\n \"defaultOutputModes\": [\"text\"],\n \"capabilities\": {\n \"streaming\": true\n },\n \"skills\": [\n {\n \"id\": \"hello_world\",\n \"name\": \"Returns hello world\",\n \"description\": \"just returns hello world\",\n \"tags\": [\"hello world\"],\n \"examples\": [\"hi\", \"hello world\"]\n }\n ]\n },\n \"litellm_params\": {\n \"make_public\": true\n }\n }\n }'\n```", + "description": "Create a new agent\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/v1/agents\" \\\n -H \"Authorization: Bearer \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"agent_name\": \"my-custom-agent\",\n \"agent_card_params\": {\n \"protocolVersion\": \"1.0\",\n \"name\": \"Hello World Agent\",\n \"description\": \"Just a hello world agent\",\n \"url\": \"http://localhost:9999/\",\n \"version\": \"1.0.0\",\n \"defaultInputModes\": [\"text\"],\n \"defaultOutputModes\": [\"text\"],\n \"capabilities\": {\n \"streaming\": true\n },\n \"skills\": [\n {\n \"id\": \"hello_world\",\n \"name\": \"Returns hello world\",\n \"description\": \"just returns hello world\",\n \"tags\": [\"hello world\"],\n \"examples\": [\"hi\", \"hello world\"]\n }\n ]\n },\n \"litellm_params\": {\n \"make_public\": true\n }\n }'\n```", "operationId": "create_agent_v1_agents_post", "requestBody": { "content": { diff --git a/litellm/router.py b/litellm/router.py index 420c9b8a8168..6ce780f49d86 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -208,6 +208,15 @@ from litellm.router_strategy.quality_router.quality_router import ( QualityRouter, ) + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject + from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseInputParam, + ResponsesAPIResponse, + ) Span = Union[_Span, Any] else: @@ -2207,6 +2216,371 @@ async def stream_with_fallbacks(): return FallbackStreamWrapper(stream_with_fallbacks()) + @staticmethod + def _extract_partial_responses_usage( + source_iterator: "BaseResponsesAPIStreamingIterator", + ) -> Optional["ResponseAPIUsage"]: + """ + Best-effort: pull partial token usage from a Responses-API streaming + iterator that errored mid-stream, normalized to ResponseAPIUsage so + the caller can combine without crossing token-naming conventions. + + Two sources, in priority order: + 1. The bridge path (LiteLLMCompletionStreamingIterator) accumulates + chat-completion chunks while streaming — feed them through + stream_chunk_builder to recover chat Usage, then translate + (prompt_tokens → input_tokens, completion_tokens → output_tokens). + 2. The native path (ResponsesAPIStreamingIterator) only has a + completed_response object if the stream reached + RESPONSE_COMPLETED before erroring — uncommon mid-stream but + worth checking. Already ResponseAPIUsage-shaped. + + Returns None when no partial usage is recoverable. + """ + from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, + ) + from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseCompletedEvent, + ResponseFailedEvent, + ResponseIncompleteEvent, + ) + + # Bridge subclass is the only iterator that accumulates chat-completion + # chunks. isinstance narrows the type so we can read the attribute + # directly instead of getattr-ing on the base class. + if isinstance(source_iterator, LiteLLMCompletionStreamingIterator): + chunks = source_iterator.collected_chat_completion_chunks + if chunks: + try: + from litellm.main import stream_chunk_builder + + built = stream_chunk_builder(chunks=chunks) + # stream_chunk_builder returns ModelResponse | + # TextCompletionResponse | None. ModelResponse sets .usage + # in __init__ rather than declaring it as a class field, so + # static narrowing doesn't expose it. Mirror the sync path + # (_completion_streaming_iterator) and pull via getattr. + chat = getattr(built, "usage", None) if built is not None else None + if chat is not None: + # getattr-with-default because the test path may + # substitute a SimpleNamespace lacking some fields; + # real Usage instances always have them. + prompt = int(getattr(chat, "prompt_tokens", 0) or 0) + completion = int(getattr(chat, "completion_tokens", 0) or 0) + total = int( + getattr(chat, "total_tokens", prompt + completion) + or (prompt + completion) + ) + return ResponseAPIUsage( + input_tokens=prompt, + output_tokens=completion, + total_tokens=total, + ) + except Exception: + # Builder is best-effort — fall through to native path. + pass + + # Native path: completed_response is set only if RESPONSE_COMPLETED + # arrived before the error (uncommon mid-stream but worth checking). + # Already ResponseAPIUsage-shaped — return as-is. + completed = source_iterator.completed_response + if isinstance( + completed, + (ResponseCompletedEvent, ResponseFailedEvent, ResponseIncompleteEvent), + ): + return completed.response.usage + return None + + @staticmethod + def _combine_responses_fallback_usage( + fallback_item: "BaseLiteLLMOpenAIResponseObject", + partial_usage: "ResponseAPIUsage", + ) -> None: + """ + Merge partial-stream usage with fallback-stream usage on a + Responses-API streaming event. + + Only mutates events that carry a `response` with a `usage` field + (response.completed / response.failed / response.incomplete). Other + events pass through unchanged. + + Both inputs are ResponseAPIUsage-shaped (see + _extract_partial_responses_usage which normalizes the bridge path), + so we can sum input_tokens / output_tokens / total_tokens directly + and produce a clean ResponseAPIUsage — no token-naming split, no + setattr bypass. + """ + from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseCompletedEvent, + ResponseFailedEvent, + ResponseIncompleteEvent, + ) + + if not isinstance( + fallback_item, + (ResponseCompletedEvent, ResponseFailedEvent, ResponseIncompleteEvent), + ): + return + response = fallback_item.response + if response.usage is None: + return + + fb = response.usage + response.usage = ResponseAPIUsage( + input_tokens=(partial_usage.input_tokens or 0) + (fb.input_tokens or 0), + output_tokens=(partial_usage.output_tokens or 0) + (fb.output_tokens or 0), + total_tokens=(partial_usage.total_tokens or 0) + (fb.total_tokens or 0), + ) + + @staticmethod + def _build_responses_continuation_input( + input_val: Optional[Union[str, "ResponseInputParam"]], + generated_content: str, + ) -> "ResponseInputParam": + """ + Convert Responses-API input + partial assistant output into a + continuation input that asks the fallback model to pick up where the + prior assistant message stopped. + + Best effort across providers. The chat-completions path uses + Anthropic's `prefix: True` prefill trick on the assistant message; + the Responses-API input schema has no direct equivalent, so we + append an instruction (developer role) plus a prior assistant + message containing the partial output. Providers without prefill + semantics (OpenAI, Vertex) treat this as conversational context + and may regenerate — same trade-off as the chat-completions path + for non-Anthropic fallbacks. + """ + # base/continuation are List[Any] because ResponseInputParam items + # are a wide Union of TypedDicts (EasyInputMessageParam, Message, + # ResponseOutputMessageParam, ...) — annotating as List[Dict[str, Any]] + # rejects the list() spread of input_val. We cast the combined list to + # ResponseInputParam at the return. + base: List[Any] + if isinstance(input_val, str): + base = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": input_val}], + } + ] + elif isinstance(input_val, list): + base = list(input_val) + else: + base = [] + continuation: List[Any] = [ + { + "type": "message", + "role": "developer", + "content": [ + { + "type": "input_text", + "text": ( + "The previous assistant response was interrupted " + "mid-stream. Continue exactly where it stopped — " + "do not repeat any of its content. Your response " + "must read as a seamless continuation." + ), + } + ], + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": generated_content}], + }, + ] + return cast("ResponseInputParam", base + continuation) + + async def _aresponses_streaming_iterator( + self, + response: "BaseResponsesAPIStreamingIterator", + initial_kwargs: Dict[str, Any], + ) -> "BaseResponsesAPIStreamingIterator": + """ + Wrap a Responses-API streaming iterator so MidStreamFallbackError + triggers the Router's fallback chain (parity with + _acompletion_streaming_iterator for the chat-completions path). + + The Responses-API streaming path goes through + _ageneric_api_call_with_fallbacks rather than _acompletion, so the + returned iterator is never wrapped by the chat completions + fallback handler. Without this wrapper, MidStreamFallbackError + raised mid-stream from the underlying CustomStreamWrapper (used by + LiteLLMCompletionStreamingIterator when the Responses API is + served via the completion bridge) propagates unhandled and the + configured cross-provider fallback never fires. + + Full parity with the chat-completions path: + - Pre-first-chunk: retry with the original input unchanged. + - Partial content: inject a developer instruction + prior + assistant message carrying the generated text so the fallback + model continues rather than restarts. + - Usage combining: merge partial-stream usage onto the fallback's + response.completed event so accounting reflects both attempts. + - Stream cleanup: shielded aclose() on both source and fallback + iterators on terminate. + """ + from litellm.exceptions import MidStreamFallbackError + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + source_iterator = response + + class FallbackResponsesStreamWrapper(BaseResponsesAPIStreamingIterator): + """ + Subclasses BaseResponsesAPIStreamingIterator only for isinstance + compatibility (proxy + interactions code paths check the type). + Bypasses the parent constructor and delegates iteration to an + async generator. + """ + + def __init__(self, async_generator: AsyncGenerator): + import time + + self._async_generator = async_generator + # Mirror every attribute BaseResponsesAPIStreamingIterator.__init__ + # would have set. The wrapper bypasses super().__init__ (it has no + # httpx.Response of its own and no provider config to drive), so + # we copy from source_iterator where applicable and use safe + # defaults elsewhere. This keeps inherited methods (e.g. + # _check_max_streaming_duration, _handle_failure) safe to call. + self.response = source_iterator.response + self.model = source_iterator.model + self.logging_obj = source_iterator.logging_obj + self.finished = False + self.responses_api_provider_config = ( + source_iterator.responses_api_provider_config + ) + self.completed_response = None + self.start_time = source_iterator.start_time + self._failure_handled = False + self._completed_response_cached = False + self._completed_response_logged = False + self._completed_response_cache_hit = None + self._persist_completed_response_before_logging = True + self._stream_created_time = time.time() + self.litellm_metadata = source_iterator.litellm_metadata + self.custom_llm_provider = source_iterator.custom_llm_provider + self.request_data = source_iterator.request_data + self.call_type = source_iterator.call_type + # Preserve hidden params so response headers (model_id, + # api_base, additional_headers) keep flowing. + self._hidden_params = dict(source_iterator._hidden_params or {}) + + def __aiter__(self): + return self + + async def __anext__(self): + return await self._async_generator.__anext__() + + async def aclose(self): + # async generators always expose aclose — no defensive check needed. + await self._async_generator.aclose() + + async def stream_with_fallbacks(): + fallback_response = None + try: + async for item in source_iterator: + yield item + except MidStreamFallbackError as e: + partial_usage = Router._extract_partial_responses_usage(source_iterator) + try: + model_group = cast(str, initial_kwargs.get("model")) + fallbacks: Optional[List] = initial_kwargs.get( + "fallbacks", self.fallbacks + ) + context_window_fallbacks: Optional[List] = initial_kwargs.get( + "context_window_fallbacks", self.context_window_fallbacks + ) + content_policy_fallbacks: Optional[List] = initial_kwargs.get( + "content_policy_fallbacks", self.content_policy_fallbacks + ) + # Re-enter via the per-attempt helper so the fallback chain + # picks deployments through + # _ageneric_api_call_with_fallbacks_helper. + # original_generic_function is preserved by the caller so + # the helper knows what underlying API to invoke per attempt. + initial_kwargs["original_function"] = ( + self._ageneric_api_call_with_fallbacks_helper + ) + if e.is_pre_first_chunk or not e.generated_content: + # No content generated before the error — retry with the + # original input. Adding a continuation prompt would + # waste tokens and confuse the model. + pass + else: + initial_kwargs["input"] = ( + Router._build_responses_continuation_input( + initial_kwargs.get("input"), + e.generated_content, + ) + ) + # The Responses-API path stores observability metadata + # under "litellm_metadata" (not the default "metadata") — + # see _ageneric_api_call_with_fallbacks. Mirroring that + # here ensures model_group, model_group_alias, and trace + # ids land in the same key litellm.aresponses reads from. + self._update_kwargs_before_fallbacks( + model=model_group, + kwargs=initial_kwargs, + metadata_variable_name="litellm_metadata", + ) + fallback_response = ( + await self.async_function_with_fallbacks_common_utils( + e=e, + disable_fallbacks=False, + fallbacks=fallbacks, + context_window_fallbacks=context_window_fallbacks, + content_policy_fallbacks=content_policy_fallbacks, + model_group=model_group, + args=(), + kwargs=initial_kwargs, + ) + ) + + if hasattr(fallback_response, "__aiter__"): + async for fallback_item in fallback_response: # type: ignore + if partial_usage is not None: + Router._combine_responses_fallback_usage( + fallback_item, partial_usage + ) + yield fallback_item + else: + yield fallback_response + except Exception as fallback_error: + verbose_router_logger.error( + f"Responses streaming fallback also failed: {fallback_error}" + ) + raise fallback_error + finally: + with anyio.CancelScope(shield=True): + if hasattr(source_iterator, "aclose"): + try: + await source_iterator.aclose() # type: ignore[func-returns-value] + except BaseException as exc: + verbose_router_logger.debug( + "stream_with_fallbacks(aresponses): error closing source: %s", + exc, + ) + if fallback_response is not None and hasattr( + fallback_response, "aclose" + ): + try: + await fallback_response.aclose() + except BaseException as exc: + verbose_router_logger.debug( + "stream_with_fallbacks(aresponses): error closing fallback: %s", + exc, + ) + + return FallbackResponsesStreamWrapper(stream_with_fallbacks()) + def _completion_streaming_iterator( # noqa: PLR0915 self, model_response: CustomStreamWrapper, @@ -4253,6 +4627,41 @@ async def _ageneric_api_call_with_fallbacks_helper( self.fail_calls[model] += 1 raise e + async def _aresponses_with_streaming_fallbacks( + self, original_function: Callable, **kwargs: Any + ) -> Union["ResponsesAPIResponse", "BaseResponsesAPIStreamingIterator"]: + """ + _ageneric_api_call_with_fallbacks for the Responses API, with the + addition of mid-stream fallback handling. + + When stream=True and the underlying call returns a + BaseResponsesAPIStreamingIterator, wrap it with + _aresponses_streaming_iterator so MidStreamFallbackError raised + during iteration triggers the Router's cross-provider fallback chain. + """ + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + # Snapshot the request kwargs before _ageneric_api_call_with_fallbacks + # mutates them. The original_generic_function is preserved so the + # per-attempt helper knows which underlying API to call on fallback. + fallback_kwargs: Dict[str, Any] = kwargs.copy() + fallback_kwargs["original_generic_function"] = original_function + + response = await self._ageneric_api_call_with_fallbacks( + original_function=original_function, **kwargs + ) + + if kwargs.get("stream") and isinstance( + response, BaseResponsesAPIStreamingIterator + ): + return await self._aresponses_streaming_iterator( + response=response, + initial_kwargs=fallback_kwargs, + ) + return response + def _generic_api_call_with_fallbacks( self, model: str, original_function: Callable, **kwargs ): @@ -5441,9 +5850,13 @@ async def async_wrapper( custom_llm_provider=custom_llm_provider, **kwargs, ) + elif call_type == "aresponses": + return await self._aresponses_with_streaming_fallbacks( + original_function=original_function, + **kwargs, + ) elif call_type in ( "anthropic_messages", - "aresponses", "_arealtime", "_aresponses_websocket", "acreate_fine_tuning_job", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 94f0f1e78d32..94fbd4700c98 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -14957,6 +14957,73 @@ "web_search_billing_unit": "per_query", "supports_service_tier": true }, + "gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, + "cache_read_input_token_cost_flex": 1.25e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "cache_read_input_token_cost_priority": 4.5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, + "input_cost_per_token_flex": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "output_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_flex": 7.5e-07, + "output_cost_per_token_priority": 2.7e-06, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "supports_service_tier": true + }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, @@ -16963,6 +17030,75 @@ "web_search_billing_unit": "per_query", "supports_service_tier": true }, + "gemini/gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, + "cache_read_input_token_cost_flex": 1.25e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "cache_read_input_token_cost_priority": 4.5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, + "input_cost_per_token_flex": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "output_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_flex": 7.5e-07, + "output_cost_per_token_priority": 2.7e-06, + "rpm": 15, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "supports_service_tier": true + }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, "input_cost_per_audio_token": 1e-06, @@ -26985,6 +27121,58 @@ "supports_web_search": true, "tpm": 800000 }, + "openrouter/google/gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "rpm": 2000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 800000 + }, "openrouter/google/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, @@ -27794,10 +27982,10 @@ "supports_tool_choice": true }, "openrouter/xiaomi/mimo-v2-flash": { - "input_cost_per_token": 9e-08, - "output_cost_per_token": 2.9e-07, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 3e-07, "cache_creation_input_token_cost": 0.0, - "cache_read_input_token_cost": 0.0, + "cache_read_input_token_cost": 1e-08, "litellm_provider": "openrouter", "max_input_tokens": 262144, "max_output_tokens": 16384, @@ -27807,7 +27995,43 @@ "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, - "supports_prompt_caching": false + "supports_prompt_caching": true + }, + "openrouter/xiaomi/mimo-v2.5-pro": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3e-06, + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "openrouter/xiaomi/mimo-v2.5": { + "input_cost_per_token": 4e-07, + "output_cost_per_token": 2e-06, + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 8e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": true, + "supports_audio_input": true, + "supports_video_input": true, + "supports_response_schema": true, + "supports_prompt_caching": true }, "openrouter/z-ai/glm-4.7": { "input_cost_per_token": 4e-07, @@ -33461,6 +33685,73 @@ }, "web_search_billing_unit": "per_query" }, + "vertex_ai/gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, + "cache_read_input_token_cost_flex": 1.25e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "cache_read_input_token_cost_priority": 4.5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, + "input_cost_per_token_flex": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "output_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_flex": 7.5e-07, + "output_cost_per_token_priority": 2.7e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "supports_service_tier": true + }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, diff --git a/tests/router_unit_tests/test_router_aresponses_streaming_fallback.py b/tests/router_unit_tests/test_router_aresponses_streaming_fallback.py new file mode 100644 index 000000000000..25bf79cd5758 --- /dev/null +++ b/tests/router_unit_tests/test_router_aresponses_streaming_fallback.py @@ -0,0 +1,268 @@ +""" +Unit tests for the Responses-API streaming-fallback helpers added to Router +in PR #28215 (fix(router): wrap aresponses streaming iterator for mid-stream +fallbacks). + +Targets the four helpers introduced on Router: + - _extract_partial_responses_usage + - _combine_responses_fallback_usage + - _build_responses_continuation_input + - _aresponses_streaming_iterator +""" + +import os +import sys +from typing import Any, AsyncIterator, List +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../..")) + +from litellm import Router +from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseCompletedEvent, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, +) + + +def _make_router() -> Router: + return Router( + model_list=[ + { + "model_name": "primary", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-test", + }, + }, + { + "model_name": "fallback", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "sk-test", + }, + }, + ] + ) + + +def _make_completed_event( + input_tokens: int, output_tokens: int, total_tokens: int +) -> ResponseCompletedEvent: + response = ResponsesAPIResponse.model_construct( + usage=ResponseAPIUsage( + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + ) + ) + return ResponseCompletedEvent.model_construct( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + response=response, + ) + + +# -------- _extract_partial_responses_usage -------- + + +def test_extract_partial_responses_usage_native_completed(): + """Native path: completed_response carries usage → returned as-is.""" + completed = _make_completed_event(11, 7, 18) + source = MagicMock() + source.completed_response = completed + + usage = Router._extract_partial_responses_usage(source) + assert usage is not None + assert usage.input_tokens == 11 + assert usage.output_tokens == 7 + assert usage.total_tokens == 18 + + +def test_extract_partial_responses_usage_no_completed_response(): + """Native path: no completed_response → returns None.""" + source = MagicMock() + source.completed_response = None + + usage = Router._extract_partial_responses_usage(source) + assert usage is None + + +# -------- _combine_responses_fallback_usage -------- + + +def test_combine_responses_fallback_usage_sums_completed_event(): + """Partial-stream usage is summed into the fallback event's usage.""" + fallback_event = _make_completed_event(5, 3, 8) + partial = ResponseAPIUsage(input_tokens=11, output_tokens=7, total_tokens=18) + + Router._combine_responses_fallback_usage(fallback_event, partial) + + combined = fallback_event.response.usage + assert combined is not None + assert combined.input_tokens == 16 + assert combined.output_tokens == 10 + assert combined.total_tokens == 26 + + +def test_combine_responses_fallback_usage_passthrough_for_unknown_event(): + """Events that are not completed/failed/incomplete are not mutated.""" + other = MagicMock() # not a ResponseCompletedEvent etc. → isinstance false + partial = ResponseAPIUsage(input_tokens=1, output_tokens=1, total_tokens=2) + Router._combine_responses_fallback_usage(other, partial) + # No mutation expected on the unknown event — call is a no-op. + + +# -------- _build_responses_continuation_input -------- + + +def test_build_responses_continuation_input_from_string(): + out = Router._build_responses_continuation_input( + "Hello world", "partial assistant text" + ) + assert len(out) == 3 + assert out[0]["role"] == "user" + assert out[0]["content"][0]["text"] == "Hello world" + assert out[1]["role"] == "developer" + assert out[2]["role"] == "assistant" + assert out[2]["content"][0]["text"] == "partial assistant text" + + +def test_build_responses_continuation_input_from_list_preserves_items(): + existing: List[Any] = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "msg1"}], + } + ] + out = Router._build_responses_continuation_input(existing, "partial") + assert len(out) == 3 + assert out[0]["content"][0]["text"] == "msg1" + assert out[1]["role"] == "developer" + assert out[2]["role"] == "assistant" + + +def test_build_responses_continuation_input_from_none(): + out = Router._build_responses_continuation_input(None, "partial") + assert len(out) == 2 + assert out[0]["role"] == "developer" + assert out[1]["role"] == "assistant" + + +# -------- _aresponses_streaming_iterator (passthrough smoke test) -------- + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_passthrough(): + """ + Without MidStreamFallbackError, the wrapper yields source events + unchanged and returns a BaseResponsesAPIStreamingIterator subclass. + """ + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + events = [_make_completed_event(1, 1, 2)] + + class _FakeSource: + """Minimal source iterator. Provides every attribute the wrapper + constructor reads from source_iterator.""" + + def __init__(self) -> None: + self._i = 0 + self.completed_response = None + self.response = MagicMock() + self.model = "openai/gpt-4o-mini" + self.logging_obj = MagicMock() + self.responses_api_provider_config = MagicMock() + self.start_time = 0.0 + self.litellm_metadata = {} + self.custom_llm_provider = "openai" + self.request_data = {} + self.call_type = "aresponses" + self._hidden_params: dict = {} + + def __aiter__(self) -> AsyncIterator[Any]: + return self + + async def __anext__(self): + if self._i >= len(events): + raise StopAsyncIteration + ev = events[self._i] + self._i += 1 + return ev + + async def aclose(self): + return None + + router = _make_router() + source = _FakeSource() + + wrapper = await router._aresponses_streaming_iterator( + source, initial_kwargs={"model": "primary"} + ) + assert isinstance(wrapper, BaseResponsesAPIStreamingIterator) + + collected = [ev async for ev in wrapper] + assert len(collected) == 1 + assert collected[0].type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED + + +# -------- _aresponses_with_streaming_fallbacks -------- + + +@pytest.mark.asyncio +async def test_aresponses_with_streaming_fallbacks_non_streaming_passthrough(): + """Non-streaming response is returned unchanged, no wrap.""" + router = _make_router() + plain_response = MagicMock() + + async def fake_original(**_kwargs): + return plain_response + + with patch.object( + router, + "_ageneric_api_call_with_fallbacks", + new=AsyncMock(return_value=plain_response), + ): + out = await router._aresponses_with_streaming_fallbacks( + original_function=fake_original, + model="primary", + stream=False, + ) + assert out is plain_response + + +@pytest.mark.asyncio +async def test_aresponses_with_streaming_fallbacks_wraps_streaming_iterator(): + """Streaming response is wrapped via _aresponses_streaming_iterator.""" + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + router = _make_router() + streaming_iter = MagicMock(spec=BaseResponsesAPIStreamingIterator) + wrapped = MagicMock(spec=BaseResponsesAPIStreamingIterator) + + async def fake_original(**_kwargs): + return streaming_iter + + with patch.object( + router, + "_ageneric_api_call_with_fallbacks", + new=AsyncMock(return_value=streaming_iter), + ), patch.object( + router, + "_aresponses_streaming_iterator", + new=AsyncMock(return_value=wrapped), + ) as mock_wrap: + out = await router._aresponses_with_streaming_fallbacks( + original_function=fake_original, + model="primary", + stream=True, + ) + assert out is wrapped + mock_wrap.assert_awaited_once() diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index a19752dc648b..7d9e4768303b 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -2476,6 +2476,120 @@ def test_reasoning_effort_does_not_set_output_config_for_older_models(): ), f"output_config should not be set for {model}" +@pytest.mark.parametrize( + "reasoning_effort_value", + [ + # String shape — what callers send when using `reasoning_effort="low"` directly. + "low", + # Dict shape with `effort` only — what the Responses->Chat parser produces + # when `reasoning={"effort": "low"}` is set without `summary`. + {"effort": "low"}, + # Dict shape with `effort` AND `summary` — what the Responses->Chat parser + # produces when callers send `Reasoning(effort="low", summary="concise")`. + # PR #25359 added the dict-keeping branch for this case, but the Anthropic + # transformation must coerce the dict back to a string before mapping. + {"effort": "low", "summary": "concise"}, + {"effort": "low", "summary": "detailed"}, + ], +) +def test_reasoning_effort_accepts_dict_shape_for_adaptive_model(reasoning_effort_value): + """ + Adaptive-thinking (Claude 4.6+) branch: dict-shape reasoning_effort must + map to ``thinking.type='adaptive'`` + ``output_config.effort``. + + Regression test for the dict-shape ``reasoning_effort`` produced by the + Responses->Chat parser when ``summary`` is set on the request's + ``reasoning`` field. Before this fix, the Anthropic transformation guarded + on ``isinstance(value, str)`` and silently dropped the param — disabling + extended thinking entirely. + """ + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"reasoning_effort": reasoning_effort_value}, + optional_params={}, + model="claude-sonnet-4-6-20260219", + drop_params=False, + ) + + # thinking must be set (adaptive for 4.6+) + assert "thinking" in result, ( + f"thinking missing for reasoning_effort={reasoning_effort_value!r}" + ) + assert result["thinking"]["type"] == "adaptive" + # output_config must carry the mapped effort + assert "output_config" in result, ( + f"output_config missing for reasoning_effort={reasoning_effort_value!r}" + ) + assert result["output_config"]["effort"] == "low" + + +@pytest.mark.parametrize( + "reasoning_effort_value", + [ + "low", + {"effort": "low"}, + {"effort": "low", "summary": "concise"}, + ], +) +def test_reasoning_effort_accepts_dict_shape_for_non_adaptive_model(reasoning_effort_value): + """ + Non-adaptive (pre-4.6) branch: dict-shape reasoning_effort must still map + to ``thinking.type='enabled'`` + ``budget_tokens``. ``output_config`` must + NOT be set on these models. + """ + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"reasoning_effort": reasoning_effort_value}, + optional_params={}, + model="claude-sonnet-4-5-20250929", + drop_params=False, + ) + + assert "thinking" in result, ( + f"thinking missing for reasoning_effort={reasoning_effort_value!r}" + ) + assert result["thinking"]["type"] == "enabled" + assert "budget_tokens" in result["thinking"] + assert result["thinking"]["budget_tokens"] > 0 + # Older models must not get adaptive-thinking output_config + assert "output_config" not in result, ( + f"output_config should not be set for non-adaptive model " + f"(reasoning_effort={reasoning_effort_value!r})" + ) + + +@pytest.mark.parametrize( + "bad_value", + [ + {"summary": "concise"}, # missing effort + {"effort": None}, # explicit None effort + {"effort": 123}, # non-string effort + ], +) +def test_reasoning_effort_unparseable_dict_is_dropped(bad_value): + """ + A dict shape that doesn't carry a usable ``effort`` key (e.g. only + ``summary`` is set, or the value is some other unexpected type) should be + silently dropped — not crash, not partially apply. + """ + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"reasoning_effort": bad_value}, + optional_params={}, + model="claude-sonnet-4-6-20260219", + drop_params=False, + ) + assert "thinking" not in result, ( + f"thinking should not be set for bad value {bad_value!r}" + ) + assert "output_config" not in result, ( + f"output_config should not be set for bad value {bad_value!r}" + ) + + @pytest.mark.parametrize( "model", [ diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 1be4abbec6ef..c22cb8fefc6a 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -2371,3 +2371,34 @@ def test_custom_pricing_without_cache_keys_preserves_legacy_behavior(): expected = 1000 * 0.0000025 + 100 * 0.000015 assert cost == pytest.approx(expected) + + +def test_openrouter_gemini_3_1_flash_lite_stable_pricing(): + """ + Test that openrouter/google/gemini-3.1-flash-lite (stable, no -preview suffix) + has a pricing entry. + + Google promoted gemini-3.1-flash-lite to GA on 2026-05-07. PR #27933 added the + stable pricing for the bare, gemini/, and vertex_ai/ prefixes but missed the + openrouter/google/ variant — every other Gemini family in the file has an + openrouter/google/ sibling (2.0-flash-001, 2.5-flash, 2.5-pro, 3-flash-preview, + 3-pro-preview, 3.1-flash-lite-preview, 3.1-pro-preview), so the gap is a + consistency issue, not a design choice. Same shape as the preview-variant gap + fixed in PR #25610. + + Pricing matches the existing -preview entry one-for-one (input $0.25/M, output + $1.50/M, cache-read $0.025/M) — Google did not change costs at the GA cutover. + """ + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + model_name = "openrouter/google/gemini-3.1-flash-lite" + model_info = litellm.model_cost.get(model_name) + + assert model_info is not None, f"Missing model pricing entry: {model_name}" + assert model_info["litellm_provider"] == "openrouter" + assert model_info["input_cost_per_token"] == 2.5e-07 + assert model_info["output_cost_per_token"] == 1.5e-06 + assert model_info["cache_read_input_token_cost"] == 2.5e-08 + assert model_info["max_input_tokens"] == 1048576 + assert model_info["max_output_tokens"] == 65536 diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index d8be527689e9..5e636b86ed6b 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -1741,6 +1741,362 @@ async def __anext__(self): assert fallback_kwargs["messages"] == messages +# --------------------------------------------------------------------------- +# Shared helpers for the _aresponses_streaming_iterator test suite. +# --------------------------------------------------------------------------- +def _make_responses_iterator( + *, + chunks=(), + error=None, + bridge=False, + model="gpt-4", + hidden_params=None, + chat_chunks=None, +): + """Build a minimal mock Responses-API streaming iterator. + + Bypasses BaseResponsesAPIStreamingIterator.__init__ but mirrors every + attribute production code reads. Yields *chunks*, then raises *error* + (or StopAsyncIteration). Set bridge=True to inherit from + LiteLLMCompletionStreamingIterator so the wrapper's bridge-path + isinstance check (used by usage extraction) matches. + """ + from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, + ) + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + base = ( + LiteLLMCompletionStreamingIterator + if bridge + else BaseResponsesAPIStreamingIterator + ) + + class _Iter(base): + def __init__(self): + self._chunks = list(chunks) + self._idx = 0 + self._hidden_params = hidden_params or {} + self.model = model + self.custom_llm_provider = "anthropic" + self.logging_obj = MagicMock() + self.litellm_metadata = None + self.responses_api_provider_config = None + self.finished = False + self.completed_response = None + self.response = None + self.start_time = None + self.request_data = {} + self.call_type = None + if chat_chunks is not None: + self.collected_chat_completion_chunks = chat_chunks + + def __aiter__(self): + return self + + async def __anext__(self): + if self._idx < len(self._chunks): + self._idx += 1 + return self._chunks[self._idx - 1] + if error is not None: + raise error + raise StopAsyncIteration + + return _Iter() + + +class _AsyncList: + """Generic async iterator over a list — used as the fallback response.""" + + def __init__(self, items=()): + self._items = list(items) + self._idx = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + if self._idx >= len(self._items): + raise StopAsyncIteration + item = self._items[self._idx] + self._idx += 1 + return item + + +def _make_router_with_fallback(primary="gpt-4", secondary="gpt-3.5-turbo"): + return litellm.Router( + model_list=[ + { + "model_name": primary, + "litellm_params": {"model": primary, "api_key": "k1"}, + }, + { + "model_name": secondary, + "litellm_params": {"model": secondary, "api_key": "k2"}, + }, + ], + fallbacks=[{primary: [secondary]}], + ) + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_fallback(): + """Catches MidStreamFallbackError, re-enters the fallback chain via + async_function_with_fallbacks_common_utils with the per-attempt helper + and original_generic_function preserved. Mirrors + test_acompletion_streaming_iterator for the aresponses path.""" + from litellm.exceptions import MidStreamFallbackError + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + router = _make_router_with_fallback( + "anthropic/claude-sonnet-4-6", "vertex_ai/claude-sonnet-4-6" + ) + src = _make_responses_iterator( + chunks=[MagicMock(type="response.created")], + error=MidStreamFallbackError( + message="anthropic socket timeout", + model="anthropic/claude-sonnet-4-6", + llm_provider="anthropic", + is_pre_first_chunk=False, + generated_content="", + ), + model="anthropic/claude-sonnet-4-6", + hidden_params={"model_id": "src-deployment-1"}, + ) + fallback_chunks = [ + MagicMock(type="response.output_text.delta"), + MagicMock(type="response.completed"), + ] + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=_AsyncList(fallback_chunks), + ) as mock_fallback_utils: + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={ + "model": "anthropic/claude-sonnet-4-6", + "stream": True, + "input": "Hi", + "original_generic_function": litellm.aresponses, + }, + ) + assert isinstance(wrapped, BaseResponsesAPIStreamingIterator) + assert wrapped._hidden_params.get("model_id") == "src-deployment-1" + collected = [c async for c in wrapped] + + assert len(collected) == 3 # 1 primary chunk + 2 fallback chunks + call_kwargs = mock_fallback_utils.call_args.kwargs + fbk = call_kwargs["kwargs"] + # Bound methods compare equal when they share the same instance + __func__. + assert fbk["original_function"] == router._ageneric_api_call_with_fallbacks_helper + assert fbk["original_generic_function"] is litellm.aresponses + assert call_kwargs["model_group"] == "anthropic/claude-sonnet-4-6" + assert call_kwargs["disable_fallbacks"] is False + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_writes_litellm_metadata_on_fallback(): + """Regression: model_group must land under "litellm_metadata" (the key + litellm.aresponses reads), not the default "metadata".""" + from litellm.exceptions import MidStreamFallbackError + + router = _make_router_with_fallback() + src = _make_responses_iterator( + error=MidStreamFallbackError( + message="boom", + model="gpt-4", + llm_provider="anthropic", + is_pre_first_chunk=True, + generated_content="", + ) + ) + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=_AsyncList(), + ) as mock_fallback_utils: + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={ + "model": "gpt-4", + "stream": True, + "input": "Hello", + "original_generic_function": litellm.aresponses, + }, + ) + async for _ in wrapped: + pass + + fbk = mock_fallback_utils.call_args.kwargs["kwargs"] + assert "litellm_metadata" in fbk, "wrong metadata_variable_name" + assert fbk["litellm_metadata"]["model_group"] == "gpt-4" + assert "model_group" not in fbk.get( + "metadata", {} + ), "model_group leaked into 'metadata' instead of 'litellm_metadata'" + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_pre_first_chunk_skips_continuation(): + """Pre-first-chunk error: original input is preserved unchanged.""" + from litellm.exceptions import MidStreamFallbackError + + router = _make_router_with_fallback() + src = _make_responses_iterator( + error=MidStreamFallbackError( + message="socket timeout before first chunk", + model="gpt-4", + llm_provider="anthropic", + is_pre_first_chunk=True, + generated_content="", + ) + ) + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=_AsyncList(), + ) as mock_fallback_utils: + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={ + "model": "gpt-4", + "stream": True, + "input": "Hello", + "original_generic_function": litellm.aresponses, + }, + ) + async for _ in wrapped: + pass + + fbk = mock_fallback_utils.call_args.kwargs["kwargs"] + assert fbk["input"] == "Hello" # original input, no continuation messages + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_partial_content_injects_continuation(): + """Mid-stream error: input is rewritten to include user prompt + + developer instruction + prior assistant message with partial output.""" + from litellm.exceptions import MidStreamFallbackError + + router = _make_router_with_fallback() + src = _make_responses_iterator( + chunks=[MagicMock(type="response.output_text.delta")], + error=MidStreamFallbackError( + message="socket reset mid-stream", + model="gpt-4", + llm_provider="anthropic", + is_pre_first_chunk=False, + generated_content="The capital of France is", + ), + ) + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=_AsyncList(), + ) as mock_fallback_utils: + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={ + "model": "gpt-4", + "stream": True, + "input": "What's the capital of France?", + "original_generic_function": litellm.aresponses, + }, + ) + async for _ in wrapped: + pass + + new_input = mock_fallback_utils.call_args.kwargs["kwargs"]["input"] + assert isinstance(new_input, list) + assert new_input[0]["role"] == "user" + assert new_input[0]["content"][0]["text"] == "What's the capital of France?" + assert new_input[1]["role"] == "developer" + assert "do not repeat" in new_input[1]["content"][0]["text"].lower() + assert new_input[2]["role"] == "assistant" + assert new_input[2]["content"][0]["type"] == "output_text" + assert new_input[2]["content"][0]["text"] == "The capital of France is" + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_combines_partial_usage(): + """Partial usage from the bridge path is normalized to ResponseAPIUsage + and summed onto the fallback's response.completed event — no token-name + split, clean ResponseAPIUsage on output.""" + from types import SimpleNamespace + + from litellm.exceptions import MidStreamFallbackError + from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseCompletedEvent, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, + ) + + router = _make_router_with_fallback() + src = _make_responses_iterator( + bridge=True, + chat_chunks=[MagicMock()], + chunks=[MagicMock(type="response.output_text.delta")], + error=MidStreamFallbackError( + message="boom", + model="gpt-4", + llm_provider="anthropic", + is_pre_first_chunk=False, + generated_content="hello", + ), + ) + + fallback_response_object = ResponsesAPIResponse( + id="resp_test", created_at=0, model="gpt-4", object="response", output=[] + ) + fallback_response_object.usage = ResponseAPIUsage( + input_tokens=20, output_tokens=15, total_tokens=35 + ) + fallback_event = ResponseCompletedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + response=fallback_response_object, + ) + + with ( + patch( + "litellm.main.stream_chunk_builder", + return_value=SimpleNamespace( + usage=SimpleNamespace(prompt_tokens=10, completion_tokens=4) + ), + ), + patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=_AsyncList([fallback_event]), + ), + ): + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={ + "model": "gpt-4", + "stream": True, + "input": "hi", + "original_generic_function": litellm.aresponses, + }, + ) + async for _ in wrapped: + pass + + merged = fallback_response_object.usage + assert isinstance(merged, ResponseAPIUsage) + assert merged.input_tokens == 30 # 10 (translated from prompt_tokens) + 20 + assert merged.output_tokens == 19 # 4 (translated from completion_tokens) + 15 + assert merged.total_tokens == 49 + + @pytest.mark.asyncio async def test_async_function_with_fallbacks_common_utils(): """Test the async_function_with_fallbacks_common_utils method""" @@ -3863,7 +4219,15 @@ def test_is_deployment_blocked_static_helper_reflects_blocked_flag(): # No model_info on deployment object → treated as not blocked assert litellm.Router._is_deployment_blocked(object()) is False missing_blocked = types.SimpleNamespace() - assert litellm.Router._is_deployment_blocked(types.SimpleNamespace(model_info=missing_blocked)) is False - assert litellm.Router._is_deployment_blocked( - types.SimpleNamespace(model_info=types.SimpleNamespace(blocked=True)) - ) is True + assert ( + litellm.Router._is_deployment_blocked( + types.SimpleNamespace(model_info=missing_blocked) + ) + is False + ) + assert ( + litellm.Router._is_deployment_blocked( + types.SimpleNamespace(model_info=types.SimpleNamespace(blocked=True)) + ) + is True + ) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx index 7a592785a44c..e98226b86bcb 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx @@ -182,16 +182,18 @@ export function ToolTestPanel({ Object.entries(values).forEach(([key, value]) => { const prop = schemaToUse.properties?.[key]; - if (prop && value !== null && value !== undefined && value !== "") { + // Strip leading/trailing whitespace from string inputs before submitting + const normalizedValue = typeof value === "string" ? value.trim() : value; + if (prop && normalizedValue !== null && normalizedValue !== undefined && normalizedValue !== "") { switch (prop.type) { case "boolean": - convertedValues[key] = value === "true" || value === true; + convertedValues[key] = normalizedValue === "true" || normalizedValue === true; break; case "number": case "integer": { - const numericValue = Number(value); + const numericValue = Number(normalizedValue); convertedValues[key] = Number.isNaN(numericValue) - ? value + ? normalizedValue : prop.type === "integer" ? Math.trunc(numericValue) : numericValue; @@ -200,28 +202,28 @@ export function ToolTestPanel({ case "object": case "array": { try { - const parsed = typeof value === "string" ? JSON.parse(value) : value; + const parsed = typeof normalizedValue === "string" ? JSON.parse(normalizedValue) : normalizedValue; const isValidObject = prop.type === "object" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed); const isValidArray = prop.type === "array" && Array.isArray(parsed); if ((prop.type === "object" && isValidObject) || (prop.type === "array" && isValidArray)) { convertedValues[key] = parsed; } else { - convertedValues[key] = value; + convertedValues[key] = normalizedValue; } } catch (err) { - convertedValues[key] = value; + convertedValues[key] = normalizedValue; } break; } case "string": - convertedValues[key] = String(value); + convertedValues[key] = String(normalizedValue); break; default: - convertedValues[key] = value; + convertedValues[key] = normalizedValue; } - } else if (value !== null && value !== undefined && value !== "") { - convertedValues[key] = value; + } else if (normalizedValue !== null && normalizedValue !== undefined && normalizedValue !== "") { + convertedValues[key] = normalizedValue; } });