From 53377d8f304b2b77113aa6422208d5eea28c505b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:16:43 -0700 Subject: [PATCH] fix(anthropic): unwrap tool-emulated json_schema envelopes on streams and tool-name wrappers --- litellm/llms/anthropic/chat/handler.py | 87 +- litellm/llms/anthropic/chat/transformation.py | 52 +- litellm/llms/azure_ai/anthropic/handler.py | 4 + litellm/llms/base_llm/base_utils.py | 75 +- litellm/llms/bedrock/chat/invoke_handler.py | 40 +- .../anthropic/output_params_utils.py | 2 +- ...vertex_anthropic_structured_output_wire.py | 1069 +++++++++++++++++ .../chat/test_anthropic_chat_handler.py | 110 ++ .../test_anthropic_chat_transformation.py | 94 +- .../llms/bedrock/chat/test_invoke_handler.py | 87 ++ ...partner_models_anthropic_transformation.py | 6 + 11 files changed, 1562 insertions(+), 64 deletions(-) create mode 100644 tests/integration/providers/test_vertex_anthropic_structured_output_wire.py diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index a110cc5b15d..2b77834b5b1 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -4,6 +4,7 @@ Calling + translation logic for anthropic's `/v1/messages` endpoint import copy import json +import re from collections.abc import Callable, Mapping from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, Union, cast @@ -20,6 +21,7 @@ from litellm.anthropic_beta_headers_manager import ( from litellm.constants import RESPONSE_FORMAT_TOOL_NAME from litellm.litellm_core_utils.core_helpers import map_finish_reason from litellm.litellm_core_utils.json_fragment_accumulator import JSONFragmentAccumulator +from litellm.llms.base_llm.base_utils import RESPONSE_FORMAT_ENVELOPE_NAME_KEYS, ResponseFormatSchema from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -57,7 +59,7 @@ from litellm.types.utils import ( from ...base import BaseLLM from ..common_utils import AnthropicError, process_anthropic_headers -from .transformation import ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY, AnthropicConfig +from .transformation import ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY, AnthropicConfig, JsonModeRequest if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -69,6 +71,30 @@ def _loads_stream_chunk(payload: str) -> dict[str, object]: return json.loads(payload) +_EnvelopeVerdict = Literal["undecided", "pass_through", "hold"] +_FIRST_OBJECT_KEY: Final = re.compile(r'^\s*\{\s*"((?:[^"\\]|\\.)*)"\s*:') +_OBJECT_KEY_PREFIX: Final = re.compile(r'^\s*(?:\{\s*(?:"(?:[^"\\]|\\.)*\\?(?:"\s*)?)?)?$') +_ENVELOPE_NAME_KEY_PATTERN: Final = "|".join(sorted(RESPONSE_FORMAT_ENVELOPE_NAME_KEYS)) +_FIRST_NAME_VALUE: Final = re.compile(r'^\s*\{\s*"(?:' + _ENVELOPE_NAME_KEY_PATTERN + r')"\s*:\s*"((?:[^"\\]|\\.)*)"') +_NAME_VALUE_PREFIX: Final = re.compile( + r'^\s*\{\s*"(?:' + _ENVELOPE_NAME_KEY_PATTERN + r')"\s*:\s*(?:"(?:[^"\\]|\\.)*\\?)?$' +) + + +def _envelope_verdict(buffered_json: str, schema: ResponseFormatSchema, tool_name: str) -> _EnvelopeVerdict: + first_key: Final = _FIRST_OBJECT_KEY.match(buffered_json) + if first_key is None: + return "undecided" if _OBJECT_KEY_PREFIX.match(buffered_json) else "pass_through" + if first_key.group(1) not in schema.property_names: + return "hold" + if first_key.group(1) not in RESPONSE_FORMAT_ENVELOPE_NAME_KEYS: + return "pass_through" + first_value: Final = _FIRST_NAME_VALUE.match(buffered_json) + if first_value is None: + return "undecided" if _NAME_VALUE_PREFIX.match(buffered_json) else "pass_through" + return "hold" if first_value.group(1) == tool_name else "pass_through" + + async def make_call( client: AsyncHTTPHandler | None, api_base: str, @@ -81,6 +107,7 @@ async def make_call( json_mode: bool, speed: str | None = None, tool_name_reverse_map: dict[str, str] | None = None, + response_format_schema: ResponseFormatSchema | None = None, ) -> tuple["ModelResponseIterator", httpx.Headers]: if client is None: client = litellm.module_level_aclient @@ -116,6 +143,7 @@ async def make_call( json_mode=json_mode, speed=speed, tool_name_reverse_map=tool_name_reverse_map, + response_format_schema=response_format_schema, ) # LOGGING @@ -141,6 +169,7 @@ def make_sync_call( json_mode: bool, speed: str | None = None, tool_name_reverse_map: dict[str, str] | None = None, + response_format_schema: ResponseFormatSchema | None = None, ) -> tuple["ModelResponseIterator", httpx.Headers]: if client is None: client = litellm.module_level_client # re-use a module level client @@ -184,6 +213,7 @@ def make_sync_call( json_mode=json_mode, speed=speed, tool_name_reverse_map=tool_name_reverse_map, + response_format_schema=response_format_schema, ) # LOGGING @@ -241,6 +271,9 @@ class AnthropicChatCompletion(BaseLLM): tool_name_reverse_map=( litellm_params.get(ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY) if isinstance(litellm_params, dict) else None ), + response_format_schema=( + JsonModeRequest.model_validate(optional_params).tool_schema() if json_mode and optional_params else None + ), ) streamwrapper: Final = CustomStreamWrapper( completion_stream=completion_stream, @@ -485,6 +518,9 @@ class AnthropicChatCompletion(BaseLLM): if isinstance(litellm_params, dict) else None ), + response_format_schema=( + JsonModeRequest.model_validate(optional_params).tool_schema() if json_mode else None + ), ) return CustomStreamWrapper( completion_stream=completion_stream, @@ -550,6 +586,7 @@ class ModelResponseIterator: json_mode: bool | None = False, speed: str | None = None, tool_name_reverse_map: dict[str, str] | None = None, + response_format_schema: ResponseFormatSchema | None = None, ): self.streaming_response = streaming_response self.response_iterator = self.streaming_response @@ -573,6 +610,9 @@ class ModelResponseIterator: self.is_response_format_tool: bool = False # Track if we've converted any response_format tools (affects finish_reason) self.converted_response_format_tool: bool = False + self.response_format_schema = response_format_schema + self._response_format_buffer: str = "" + self._response_format_verdict: _EnvelopeVerdict = "undecided" # For handling partial JSON chunks from fragmentation # See: https://github.com/BerriAI/litellm/issues/17473 @@ -955,6 +995,8 @@ class ModelResponseIterator: except (json.JSONDecodeError, TypeError): pass self._current_server_tool_id = None + if self.is_response_format_tool: + text = self._flush_response_format_buffer() # Reset response_format tool tracking when block stops self.is_response_format_tool = False # Reset current content block type @@ -1072,6 +1114,10 @@ class ModelResponseIterator: tool_name: Final = tool_use.get("function", {}).get("name", "") self.is_response_format_tool = tool_name == RESPONSE_FORMAT_TOOL_NAME + if self.is_response_format_tool and self.response_format_schema is not None: + self.converted_response_format_tool = True + return self._stream_response_format_fragment(tool_use, self.response_format_schema), None + # Convert tool to content if we're tracking a response_format tool if self.is_response_format_tool: message: Final = AnthropicConfig.convert_tool_response_to_message(tool_calls=[tool_use]) @@ -1083,6 +1129,45 @@ class ModelResponseIterator: return text, tool_use + def _stream_response_format_fragment( + self, tool_use: ChatCompletionToolCallChunk, schema: ResponseFormatSchema + ) -> str: + if tool_use.get("id") is not None: + self._response_format_buffer = "" + self._response_format_verdict = "undecided" + return "" + fragment: Final = tool_use.get("function", {}).get("arguments") or "" + if self._response_format_verdict == "pass_through": + return fragment + self._response_format_buffer = self._response_format_buffer + fragment + if self._response_format_verdict == "hold": + return "" + self._response_format_verdict = _envelope_verdict( + self._response_format_buffer, schema, RESPONSE_FORMAT_TOOL_NAME + ) + return self._response_format_buffer if self._response_format_verdict == "pass_through" else "" + + def _flush_response_format_buffer(self) -> str: + if self.response_format_schema is None or self._response_format_verdict == "pass_through": + return "" + held: Final = self._response_format_buffer + self._response_format_buffer = "" + self._response_format_verdict = "undecided" + if not held: + return "" + message: Final = AnthropicConfig.convert_tool_response_to_message( + tool_calls=[ + ChatCompletionToolCallChunk( + id=None, + type="function", + function=ChatCompletionToolCallFunctionChunk(name=None, arguments=held), + index=self.tool_index, + ) + ], + response_format_schema=self.response_format_schema, + ) + return (message.content if message is not None else None) or held + def _handle_message_delta(self, chunk: dict) -> tuple[str, Usage | None, dict[str, Any] | None]: """ Handle message_delta event for finish_reason, usage, and container. diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 54ff7663359..e328cc66f08 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -38,6 +38,7 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import from litellm.llms.base_llm.base_utils import ( ResponseFormatSchema, as_json_object, + response_format_schema, type_to_response_format_param, unwrap_response_format_envelope, ) @@ -1751,7 +1752,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): else: _input_schema.update(cast(AnthropicInputSchema, json_schema)) - _tool: Final = AnthropicMessagesTool(name=RESPONSE_FORMAT_TOOL_NAME, input_schema=_input_schema) + _tool: Final = AnthropicMessagesTool( + name=RESPONSE_FORMAT_TOOL_NAME, + description="Return the final answer as this structured object, filling every required field.", + input_schema=_input_schema, + ) return _tool def should_strip_billing_metadata(self) -> bool: @@ -2835,11 +2840,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): prefix_prompt=prefix_prompt, speed=speed, tool_name_reverse_map=tool_name_reverse_map, - response_format_schema=( - _response_format_tool_schema(_ResponseFormatRequest.model_validate(optional_params)) - if json_mode - else None - ), + response_format_schema=JsonModeRequest.model_validate(optional_params).tool_schema() if json_mode else None, ) return model_response @@ -2861,17 +2862,17 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): except json.JSONDecodeError: # json decode error does occur, return the original tool response str return litellm.Message(content=json_mode_content_str) - payload_object: Final = as_json_object(payload) - if payload_object is not None and (values := payload_object.get("values")) is not None: - return litellm.Message(content=json.dumps(values)) + if response_format_schema is not None: + return litellm.Message( + content=json.dumps( + unwrap_response_format_envelope(payload, response_format_schema, RESPONSE_FORMAT_TOOL_NAME) + ) + ) # a lot of the times the `values` key is not present in the tool response # relevant issue: https://github.com/BerriAI/litellm/issues/6741 - content: Final = ( - payload - if response_format_schema is None - else unwrap_response_format_envelope(payload, response_format_schema) - ) - return litellm.Message(content=json.dumps(content)) + payload_object: Final = as_json_object(payload) + values: Final = None if payload_object is None else payload_object.get("values") + return litellm.Message(content=json.dumps(payload if values is None else values)) @classmethod def convert_tool_response_to_message( @@ -2889,22 +2890,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) -class _ResponseFormatRequest(BaseModel): +_REQUEST_TOOLS: Final = TypeAdapter(tuple[Mapping[str, object], ...]) + + +class JsonModeRequest(BaseModel): model_config = ConfigDict(frozen=True) tools: object = None - -_REQUEST_TOOLS: Final = TypeAdapter(tuple[Mapping[str, object], ...]) - - -def _response_format_tool_schema(request: _ResponseFormatRequest) -> ResponseFormatSchema | None: - try: - tools: Final = _REQUEST_TOOLS.validate_python(request.tools) + def tool_schema(self) -> ResponseFormatSchema | None: + try: + tools: Final = _REQUEST_TOOLS.validate_python(self.tools) + except ValidationError: + return None json_tool: Final = next((tool for tool in tools if tool.get("name") == RESPONSE_FORMAT_TOOL_NAME), None) - return None if json_tool is None else ResponseFormatSchema.model_validate(json_tool.get("input_schema")) - except ValidationError: - return None + return None if json_tool is None else response_format_schema(json_tool.get("input_schema")) def _valid_user_id(user_id: str) -> bool: diff --git a/litellm/llms/azure_ai/anthropic/handler.py b/litellm/llms/azure_ai/anthropic/handler.py index 6065c56d083..ca4dad5ee3e 100644 --- a/litellm/llms/azure_ai/anthropic/handler.py +++ b/litellm/llms/azure_ai/anthropic/handler.py @@ -11,6 +11,7 @@ import httpx from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.anthropic.chat.handler import AnthropicChatCompletion +from litellm.llms.anthropic.chat.transformation import JsonModeRequest from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -162,6 +163,9 @@ class AzureAnthropicChatCompletion(AnthropicChatCompletion): logging_obj=logging_obj, timeout=timeout, json_mode=json_mode, + response_format_schema=( + JsonModeRequest.model_validate(optional_params).tool_schema() if json_mode else None + ), ) from litellm.llms.anthropic.common_utils import ( process_anthropic_headers, diff --git a/litellm/llms/base_llm/base_utils.py b/litellm/llms/base_llm/base_utils.py index 0577ebb4bae..5e3919cb19c 100644 --- a/litellm/llms/base_llm/base_utils.py +++ b/litellm/llms/base_llm/base_utils.py @@ -6,10 +6,11 @@ import copy import json from abc import ABC, abstractmethod from collections.abc import Mapping +from dataclasses import dataclass from typing import Any, Final from openai.lib import _parsing, _pydantic # pyright: ignore[reportPrivateUsage] # SDK parser internals -from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError +from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError from litellm._logging import verbose_logger from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk @@ -157,37 +158,81 @@ def convert_tool_response_to_message( _convert_tool_response_to_message = convert_tool_response_to_message -class ResponseFormatSchema(BaseModel): - model_config = ConfigDict(frozen=True) +@dataclass(frozen=True, slots=True) +class ResponseFormatSchema: + document: Mapping[str, JsonValue] + property_names: frozenset[str] + required: tuple[str, ...] - properties: Mapping[str, object] | None = None - required: tuple[str, ...] = () + def accepts(self, value: Mapping[str, JsonValue]) -> bool: + from jsonschema.validators import validator_for + from referencing import Registry + from referencing.exceptions import Unresolvable + + validator: Final = validator_for(self.document)(dict(self.document), registry=Registry()) + try: + return validator.is_valid(dict(value)) + except Unresolvable: + return False -_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object]) +_JSON_OBJECT: Final = TypeAdapter(Mapping[str, JsonValue]) +_REQUIRED_NAMES: Final = TypeAdapter(tuple[str, ...]) -def as_json_object(value: object) -> Mapping[str, object] | None: +def as_json_object(value: object) -> Mapping[str, JsonValue] | None: try: return _JSON_OBJECT.validate_python(value) except ValidationError: return None -def _is_schema_object( - value: Mapping[str, object], schema: ResponseFormatSchema, property_names: frozenset[str] -) -> bool: - return not property_names.isdisjoint(value) and all(key in value for key in schema.required) +def response_format_schema(value: object) -> ResponseFormatSchema | None: + document: Final = as_json_object(value) + if document is None: + return None + try: + required: Final = _REQUIRED_NAMES.validate_python(document.get("required", ())) + except ValidationError: + return None + return ResponseFormatSchema( + document=document, + property_names=frozenset(as_json_object(document.get("properties")) or ()), + required=required, + ) -def unwrap_response_format_envelope(payload: object, schema: ResponseFormatSchema) -> object: - property_names: Final = frozenset(schema.properties or ()) +def _is_schema_object(value: Mapping[str, JsonValue], schema: ResponseFormatSchema) -> bool: + return not schema.property_names.isdisjoint(value) and all(key in value for key in schema.required) + + +RESPONSE_FORMAT_ENVELOPE_NAME_KEYS: Final = frozenset({"name", "tool_name"}) + + +def _tool_call_envelope_payload( + payload_object: Mapping[str, JsonValue], tool_name: str +) -> Mapping[str, JsonValue] | None: + name_keys: Final = frozenset( + key for key in RESPONSE_FORMAT_ENVELOPE_NAME_KEYS if payload_object.get(key) == tool_name + ) + other_values: Final = tuple(as_json_object(value) for key, value in payload_object.items() if key not in name_keys) + if not name_keys or len(other_values) != 1: + return None + return other_values[0] + + +def unwrap_response_format_envelope(payload: object, schema: ResponseFormatSchema, tool_name: str) -> object: payload_object: Final = as_json_object(payload) - if payload_object is None or _is_schema_object(payload_object, schema, property_names): + if payload_object is None: + return payload + tool_call_payload: Final = _tool_call_envelope_payload(payload_object, tool_name) + if tool_call_payload is not None: + return tool_call_payload + if schema.accepts(payload_object) or _is_schema_object(payload_object, schema): return payload nested_objects: Final = tuple(as_json_object(value) for value in payload_object.values()) candidates: Final = tuple( - nested for nested in nested_objects if nested is not None and _is_schema_object(nested, schema, property_names) + nested for nested in nested_objects if nested is not None and _is_schema_object(nested, schema) ) return candidates[0] if len(candidates) == 1 else payload diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py index 4682a47de6b..d5565fed54d 100644 --- a/litellm/llms/bedrock/chat/invoke_handler.py +++ b/litellm/llms/bedrock/chat/invoke_handler.py @@ -1,9 +1,10 @@ +import json import types from collections.abc import AsyncIterator, Iterator, Mapping from typing import TYPE_CHECKING, Final, cast import httpx -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError import litellm from litellm import verbose_logger @@ -16,6 +17,8 @@ from litellm.litellm_core_utils.optional_imports import ensure_optional_import from litellm.llms.anthropic.chat.handler import ( ModelResponseIterator as AnthropicModelResponseIterator, ) +from litellm.llms.anthropic.chat.transformation import JsonModeRequest +from litellm.llms.base_llm.base_utils import ResponseFormatSchema from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -174,15 +177,34 @@ class AmazonCohereChatConfig: return optional_params +_REQUEST_BODY: Final = TypeAdapter(Mapping[str, object]) + + +def response_format_schema_from_request(data: str, json_mode: bool | None) -> ResponseFormatSchema | None: + if not json_mode: + return None + try: + body: Final = _REQUEST_BODY.validate_python(json.loads(data)) + except (json.JSONDecodeError, ValidationError): + return None + return JsonModeRequest.model_validate(body).tool_schema() + + def _stream_decoder( bedrock_invoke_provider: litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL | None, *, model: str, json_mode: bool | None, sync_stream: bool, + response_format_schema: ResponseFormatSchema | None = None, ) -> "AWSEventStreamDecoder": if bedrock_invoke_provider == "anthropic": - return AmazonAnthropicClaudeStreamDecoder(model=model, sync_stream=sync_stream, json_mode=json_mode) + return AmazonAnthropicClaudeStreamDecoder( + model=model, + sync_stream=sync_stream, + json_mode=json_mode, + response_format_schema=response_format_schema, + ) if bedrock_invoke_provider == "deepseek_r1": return AmazonDeepSeekR1StreamDecoder(model=model, sync_stream=sync_stream) if bedrock_invoke_provider == "moonshot": @@ -250,7 +272,11 @@ async def make_call( ) else: decoder: Final = _stream_decoder( - bedrock_invoke_provider, model=model, json_mode=json_mode, sync_stream=False + bedrock_invoke_provider, + model=model, + json_mode=json_mode, + sync_stream=False, + response_format_schema=response_format_schema_from_request(data, json_mode), ) completion_stream = decoder.aiter_bytes( response.aiter_bytes(chunk_size=stream_chunk_size), response_headers=response.headers @@ -341,7 +367,11 @@ def make_sync_call( ) else: decoder: Final = _stream_decoder( - bedrock_invoke_provider, model=model, json_mode=json_mode, sync_stream=True + bedrock_invoke_provider, + model=model, + json_mode=json_mode, + sync_stream=True, + response_format_schema=response_format_schema_from_request(data, json_mode), ) completion_stream = decoder.iter_bytes( response.iter_bytes(chunk_size=stream_chunk_size), response_headers=response.headers @@ -878,6 +908,7 @@ class AmazonAnthropicClaudeStreamDecoder(AWSEventStreamDecoder): model: str, sync_stream: bool, json_mode: bool | None = None, + response_format_schema: ResponseFormatSchema | None = None, ) -> None: """ Child class of AWSEventStreamDecoder that handles the streaming response from the Anthropic family of models @@ -889,6 +920,7 @@ class AmazonAnthropicClaudeStreamDecoder(AWSEventStreamDecoder): streaming_response=None, sync_stream=sync_stream, json_mode=json_mode, + response_format_schema=response_format_schema, ) def _chunk_parser(self, chunk_data: dict) -> ModelResponseStream: diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py index 6cf093761e5..99400d72124 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py @@ -47,7 +47,7 @@ def _as_output_config(value: object) -> Mapping[str, object] | None: def _fold_output_format_into_output_config( output_config: Mapping[str, object] | None, output_format: object ) -> Mapping[str, object] | None: - if output_format is None or (output_config is not None and "format" in output_config): + if output_format is None or (output_config is not None and output_config.get("format") is not None): return output_config return {**(output_config or {}), "format": output_format} diff --git a/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py b/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py new file mode 100644 index 00000000000..e7b091fbc2e --- /dev/null +++ b/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py @@ -0,0 +1,1069 @@ +import asyncio +import functools +import json +import re +import signal +import threading +import uuid +from collections.abc import Callable, Mapping +from concurrent.futures import ThreadPoolExecutor +from contextlib import ExitStack +from dataclasses import dataclass +from pathlib import Path +from queue import SimpleQueue +from typing import Final + +import anthropic +import httpx +import openai +import psutil +import pytest +import yaml +from integration._support.client import Gateway, Scenario, eventually, list_value, object_value, string_value +from integration._support.database import read_rows +from integration._support.process import graceful_stop_seconds, owned_proxy_process +from integration._support.vertex import service_account_json +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter + +_JSON_TOOL: Final = "json_tool_call" +_USER_TOOL: Final = "lookup" +_STRUCTURED_OUTPUTS_BETA: Final = "structured-outputs-2025-11-13" +_TOOL_MODEL: Final = "claude-sonnet-5" +_EFFORT_GATED_MODEL: Final = "claude-haiku-4-5" +_NATIVE_MODEL: Final = "claude-opus-5-5" +_UNMAPPED_MODEL: Final = "claude-scripted-9" +_BEDROCK_INVOKE_MODEL: Final = "anthropic.claude-sonnet-5" +_PROJECT: Final = "scripted-project" +_LOCATION: Final = "us-east5" +_VERTEX_MODELS: Final = f"/v1/projects/{_PROJECT}/locations/{_LOCATION}/publishers/anthropic/models/" +_VERTEX_STREAM_SUFFIX: Final = ":streamRawPredict?alt=sse" +_ANTHROPIC_KEY: Final = "synthetic-anthropic-key" +_AZURE_KEY: Final = "synthetic-azure-ai-key" +_BEDROCK_TOKEN: Final = "synthetic-bedrock-bearer" +_PLATFORM_KEY: Final = "synthetic-platform-key" +_OWNED_MODEL: Final = "partner-claude" +_DEPRECATED_OUTPUT_FORMAT: Final = "output_format: this field is deprecated, use output_config.format" +_BAD_OUTPUT_CONFIG: Final = "output_config: Input should be a valid dictionary" +_BAD_FORMAT: Final = "output_config.format: Input should be a json_schema object" +_SCHEMA: Final[dict[str, JsonValue]] = { + "type": "object", + "properties": {"tier": {"type": "integer"}, "summary": {"type": "string"}}, + "required": ["tier", "summary"], + "additionalProperties": False, +} +_RESPONSE_FORMAT: Final[dict[str, JsonValue]] = { + "type": "json_schema", + "json_schema": {"name": "risk", "schema": _SCHEMA}, +} +_TEXT_FORMAT: Final[dict[str, JsonValue]] = { + "format": {"type": "json_schema", "name": "risk", "schema": _SCHEMA}, +} +_OUTPUT_FORMAT: Final[dict[str, JsonValue]] = {"type": "json_schema", "schema": _SCHEMA} +_USER_TOOL_SPEC: Final[dict[str, JsonValue]] = { + "type": "function", + "function": { + "name": _USER_TOOL, + "parameters": {"type": "object", "properties": {"q": {"type": "string"}}, "required": ["q"]}, + }, +} +_MARKER: Final = re.compile(r"\b([a-z_]+) ([0-9a-f]{32})\b") +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]") +_OWNED_PROXY_CELL_SECONDS: Final = 2 * graceful_stop_seconds() + 120 +_UNWRAPPED_SHAPES: Final = frozenset({"plain", "wrapped", "named", "values"}) +_BURST_SHAPES: Final = ("wrapped", "plain", "named", "values") + + +@dataclass(frozen=True, slots=True) +class _Delivery: + target: str + beta: str | None + body: dict[str, JsonValue] + + +def _payload(marker_hex: str) -> dict[str, JsonValue]: + return {"tier": 2, "summary": marker_hex} + + +def _nest(payload: JsonValue, depth: int) -> JsonValue: + return functools.reduce(lambda inner, _: {"parameters": inner}, range(depth), payload) + + +def _tool_input(shape: str, marker_hex: str) -> JsonValue: + payload: Final = _payload(marker_hex) + match shape: + case "plain": + return payload + case "wrapped": + return {"tool_name": _JSON_TOOL, "parameters": payload} + case "named": + return {"name": _JSON_TOOL, "parameters": payload} + case "values": + return {"values": payload} + case "large": + return {"tool_name": _JSON_TOOL, "parameters": {**payload, "summary": marker_hex + "x" * 5000}} + case "two_candidates": + return {"parameters": payload, "alternate": {**payload, "tier": 3}} + case "inner_incomplete": + return {"parameters": {"tier": 2}} + case "self_shaped": + return {**payload, "nested": payload} + case "scalar": + return marker_hex + case "empty": + return {} + case "list_input": + return [payload] + case "deep": + return _nest(payload, 60) + raise AssertionError(shape) + + +def _expected(shape: str, marker_hex: str) -> JsonValue: + match shape: + case "plain" | "wrapped" | "named" | "values": + return _payload(marker_hex) + case "large": + return {**_payload(marker_hex), "summary": marker_hex + "x" * 5000} + return _tool_input(shape, marker_hex) + + +def _message(message_id: str, content: list[JsonValue], stop_reason: str) -> dict[str, JsonValue]: + return { + "id": message_id, + "type": "message", + "role": "assistant", + "model": _TOOL_MODEL, + "content": content, + "stop_reason": stop_reason, + "stop_sequence": None, + "usage": {"input_tokens": 5, "output_tokens": 3}, + } + + +def _sse(message: dict[str, JsonValue], block: dict[str, JsonValue], delta: dict[str, JsonValue]) -> tuple[bytes, ...]: + events: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = ( + ( + "message_start", + { + "type": "message_start", + "message": { + **message, + "content": [], + "stop_reason": None, + "usage": {"input_tokens": 5, "output_tokens": 1}, + }, + }, + ), + ("content_block_start", {"type": "content_block_start", "index": 0, "content_block": block}), + ("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": delta}), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": message["stop_reason"], "stop_sequence": None}, + "usage": {"output_tokens": 3}, + }, + ), + ("message_stop", {"type": "message_stop"}), + ) + return tuple(f"event: {name}\ndata: {json.dumps(data)}\n\n".encode() for name, data in events) + + +def _error(status: int, message: str) -> Reply: + body: Final[dict[str, JsonValue]] = { + "type": "error", + "error": {"type": "invalid_request_error", "message": message}, + } + return Reply(status=status, body=json.dumps(body).encode()) + + +def _format_fault(body: Mapping[str, JsonValue], vertex: bool) -> str | None: + if vertex and "output_format" in body: + return _DEPRECATED_OUTPUT_FORMAT + output_config: Final = body.get("output_config") + if output_config is None: + return None + if not isinstance(output_config, dict): + return _BAD_OUTPUT_CONFIG + output_format: Final = output_config.get("format") + if output_format is None: + return None + if not isinstance(output_format, dict) or output_format.get("type") != "json_schema": + return _BAD_FORMAT + return None if isinstance(output_format.get("schema"), dict) else _BAD_FORMAT + + +def _marker_of(body: Mapping[str, JsonValue]) -> tuple[str, str]: + found: Final = _MARKER.search(json.dumps(body.get("messages"))) + assert found is not None, body + return found.group(1), found.group(2) + + +def _tool_names(body: Mapping[str, JsonValue]) -> frozenset[str]: + return frozenset(string_value(object_value(tool)["name"]) for tool in list_value(body.get("tools", []))) + + +def _tool_use(block_id: str, name: str, tool_input: JsonValue) -> dict[str, JsonValue]: + return {"type": "tool_use", "id": block_id, "name": name, "input": tool_input} + + +def _tool_reply(message_id: str, shape: str, marker_hex: str, user_tool: bool, stream: bool) -> Reply: + tool_input: Final = _tool_input(shape, marker_hex) + json_block: Final = _tool_use(f"toolu_{marker_hex}", _JSON_TOOL, tool_input) + user_blocks: Final[list[JsonValue]] = ( + [_tool_use(f"toolu_user_{marker_hex}", _USER_TOOL, {"q": marker_hex})] if user_tool else [] + ) + message: Final = _message(message_id, [*user_blocks, json_block], "tool_use") + if not stream: + return Reply(body=json.dumps(message).encode()) + return Reply( + content_type="text/event-stream", + chunks=_sse( + message, {**json_block, "input": {}}, {"type": "input_json_delta", "partial_json": json.dumps(tool_input)} + ), + ) + + +def _text_reply(message_id: str, text: str, stream: bool) -> Reply: + message: Final = _message(message_id, [{"type": "text", "text": text}], "end_turn") + if not stream: + return Reply(body=json.dumps(message).encode()) + return Reply( + content_type="text/event-stream", + chunks=_sse(message, {"type": "text", "text": ""}, {"type": "text_delta", "text": text}), + ) + + +def _scripted_target(request: Request) -> bool: + return ( + request.target.startswith(_VERTEX_MODELS) + or request.target.endswith("/v1/messages") + or request.target.endswith("/invoke") + ) + + +def _structured(body: Mapping[str, JsonValue]) -> bool: + output_config: Final = body.get("output_config") + return "output_format" in body or (isinstance(output_config, dict) and "format" in output_config) + + +def _peer(request: Request) -> Reply: + vertex: Final = request.target.startswith(_VERTEX_MODELS) + if vertex: + assert request.headers["authorization"] == "Bearer scripted-token", request.headers + elif not _scripted_target(request): + return _error(404, f"unscripted target {request.target}") + body: Final = _JSON_OBJECT.validate_json(request.body) + fault: Final = _format_fault(body, vertex) + if fault is not None: + return _error(400, fault) + shape, marker_hex = _marker_of(body) + stream: Final = request.target.endswith(_VERTEX_STREAM_SUFFIX) or body.get("stream") is True + tools: Final = _tool_names(body) + message_id: Final = f"msg_{marker_hex}" + if _JSON_TOOL in tools: + return _tool_reply(message_id, shape, marker_hex, _USER_TOOL in tools, stream) + if _structured(body): + return _text_reply(message_id, json.dumps(_payload(marker_hex)), stream) + return _text_reply(message_id, f"plain text {marker_hex}", stream) + + +def _failing(status: int) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + _, marker_hex = _marker_of(_JSON_OBJECT.validate_json(request.body)) + return _error(status, f"scripted {status} {marker_hex}") + + return respond + + +def _holding(held: SimpleQueue[str], release: threading.Event) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + held.put(request.target) + assert release.wait(timeout=60), "Held request was never released" + return _peer(request) + + return respond + + +def _delivered(wire: Wire) -> tuple[_Delivery, ...]: + return tuple( + _Delivery(request.target, request.headers.get("anthropic-beta"), _JSON_OBJECT.validate_json(request.body)) + for request in wire.drain() + ) + + +def _one_delivery(wire: Wire, marker_hex: str) -> _Delivery: + (sent,) = _delivered(wire) + assert marker_hex in json.dumps(sent.body), sent.body + return sent + + +def _json_tool_schema(delivery: _Delivery) -> dict[str, JsonValue]: + tools: Final = tuple(object_value(tool) for tool in list_value(delivery.body["tools"])) + (json_tool,) = tuple(tool for tool in tools if tool["name"] == _JSON_TOOL) + return object_value(json_tool["input_schema"]) + + +def _folded_format(delivery: _Delivery) -> dict[str, JsonValue]: + assert "output_format" not in delivery.body, delivery.body + return object_value(object_value(delivery.body["output_config"])["format"]) + + +def _marker(shape: str) -> tuple[str, str]: + marker_hex: Final = uuid.uuid4().hex + return f"{shape} {marker_hex}", marker_hex + + +def _chat_body( + model: str, + marker: str, + *, + stream: bool = False, + response_format: JsonValue = _RESPONSE_FORMAT, + tools: JsonValue = None, +) -> dict[str, JsonValue]: + body: Final[dict[str, JsonValue]] = { + "model": model, + "messages": [{"role": "user", "content": marker}], + "stream": stream, + "response_format": response_format, + } + return body if tools is None else {**body, "tools": tools} + + +def _messages_body(model: str, marker: str, *, stream: bool = False, **extra: JsonValue) -> dict[str, JsonValue]: + return { + "model": model, + "max_tokens": 64, + "messages": [{"role": "user", "content": marker}], + "stream": stream, + **extra, + } + + +def _responses_body(model: str, marker: str) -> dict[str, JsonValue]: + return {"model": model, "input": marker, "text": _TEXT_FORMAT} + + +def _chat_message(response: httpx.Response) -> dict[str, JsonValue]: + assert response.status_code == 200, response.text + choice: Final = object_value(list_value(_JSON_OBJECT.validate_json(response.content)["choices"])[0]) + assert choice["finish_reason"] == "stop", response.text + return object_value(choice["message"]) + + +def _chat_content(response: httpx.Response) -> JsonValue: + return json.loads(string_value(_chat_message(response)["content"])) + + +def _response_id(response: httpx.Response) -> str: + return string_value(_JSON_OBJECT.validate_json(response.content)["id"]) + + +def _delta_content(frame: str) -> str: + choices: Final = list_value(_JSON_OBJECT.validate_json(frame).get("choices", [])) + if not choices: + return "" + content: Final = object_value(object_value(choices[0])["delta"]).get("content") + return "" if content is None else string_value(content) + + +def _stream_content(response: httpx.Response) -> JsonValue: + assert response.status_code == 200, response.text + assert response.text.rstrip().endswith("data: [DONE]"), response.text + frames: Final = tuple( + line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: {") + ) + return json.loads("".join(_delta_content(frame) for frame in frames)) + + +def _responses_text(response: httpx.Response) -> JsonValue: + assert response.status_code == 200, response.text + items: Final = tuple( + object_value(item) for item in list_value(_JSON_OBJECT.validate_json(response.content)["output"]) + ) + (message,) = tuple(item for item in items if item["type"] == "message") + return json.loads(string_value(object_value(list_value(message["content"])[0])["text"])) + + +def _text_block_json(message: Mapping[str, JsonValue]) -> JsonValue: + block: Final = object_value(list_value(message["content"])[0]) + return json.loads(string_value(block["text"])) + + +def _messages_text(response: httpx.Response) -> JsonValue: + assert response.status_code == 200, response.text + return _text_block_json(_JSON_OBJECT.validate_json(response.content)) + + +def _messages_stream_text(response: httpx.Response) -> JsonValue: + assert response.status_code == 200, response.text + assert "event: message_stop" in response.text, response.text + frames: Final = tuple( + _JSON_OBJECT.validate_json(line.removeprefix("data: ")) + for line in response.text.splitlines() + if line.startswith("data: {") + ) + deltas: Final = tuple(object_value(frame["delta"]) for frame in frames if frame["type"] == "content_block_delta") + return json.loads("".join(string_value(delta["text"]) for delta in deltas if delta["type"] == "text_delta")) + + +def _vertex_deployment(gateway: Gateway, scenario: Scenario, api_base: str, backend: str) -> str: + return scenario.model( + model=f"vertex_ai/{backend}", + api_base=api_base, + api_key=None, + vertex_project=_PROJECT, + vertex_location=_LOCATION, + vertex_credentials=service_account_json(_PROJECT, gateway.upstream_url.rstrip("/")), + ) + + +def _family_deployment(gateway: Gateway, scenario: Scenario, api_base: str, member: str) -> str: + match member: + case "anthropic_unmapped": + return scenario.model(model=f"anthropic/{_UNMAPPED_MODEL}", api_base=api_base, api_key=_ANTHROPIC_KEY) + case "azure_ai_unmapped": + return scenario.model(model=f"azure_ai/{_UNMAPPED_MODEL}", api_base=api_base, api_key=_AZURE_KEY) + case "bedrock_invoke": + return scenario.model( + model=f"bedrock/invoke/{_BEDROCK_INVOKE_MODEL}", + api_base=api_base, + api_key=_BEDROCK_TOKEN, + aws_region_name="us-east-1", + ) + case "claude_platform_unmapped": + return scenario.model( + model=f"bedrock/claude_platform/{_UNMAPPED_MODEL}", + api_base=api_base, + api_key=_PLATFORM_KEY, + aws_region_name="us-east-1", + workspace_id="scripted-workspace", + ) + case "vertex_haiku": + return _vertex_deployment(gateway, scenario, api_base, _EFFORT_GATED_MODEL) + raise AssertionError(member) + + +def _owned_config(path: Path, gateway: Gateway, api_base: str) -> Path: + config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + path.write_text( + yaml.safe_dump( + { + **config, + "model_list": [ + { + "model_name": _OWNED_MODEL, + "litellm_params": { + "model": f"vertex_ai/{_TOOL_MODEL}", + "api_base": api_base, + "vertex_project": _PROJECT, + "vertex_location": _LOCATION, + "vertex_credentials": service_account_json(_PROJECT, gateway.upstream_url.rstrip("/")), + }, + } + ], + } + ) + ) + return path + + +def _clients(stack: ExitStack, base_url: str, count: int) -> tuple[httpx.Client, ...]: + return tuple( + stack.enter_context(httpx.Client(base_url=base_url, timeout=60, trust_env=False)) for _ in range(count) + ) + + +def _post(client: httpx.Client, key: str, path: str, body: Mapping[str, JsonValue]) -> httpx.Response: + return client.post(path, json=dict(body), headers={"Authorization": f"Bearer {key}"}) + + +def _post_or_dropped(client: httpx.Client, key: str, path: str, body: Mapping[str, JsonValue]) -> httpx.Response | None: + try: + return _post(client, key, path, body) + except httpx.TransportError: + return None + + +def _local_port(client: httpx.Client) -> int: + with client.stream("GET", "/health/liveliness") as response: + port: Final = int(response.extensions["network_stream"].get_extra_info("client_addr")[1]) + response.read() + assert response.status_code == 200, response.text + return port + + +def _accepted_client_ports(pid: int, proxy_port: int) -> frozenset[int]: + return frozenset( + connection.raddr.port + for connection in psutil.Process(pid).net_connections(kind="tcp") + if connection.raddr and connection.laddr.port == proxy_port + ) + + +def _seen_once(delivered: tuple[_Delivery, ...], marker_hexes: tuple[str, ...]) -> None: + bodies: Final = tuple(json.dumps(sent.body) for sent in delivered) + seen: Final = tuple(sum(marker_hex in body for body in bodies) for marker_hex in marker_hexes) + assert seen == (1,) * len(marker_hexes), seen + + +def _spend_rows(request_id: str) -> list[dict[str, JsonValue]]: + return eventually( + lambda: read_rows('SELECT request_id, model FROM "LiteLLM_SpendLogs" WHERE request_id = %s', (request_id,)), + lambda rows: len(rows) == 1, + seconds=70, + ) + + +def _openai_client(gateway: Gateway) -> openai.OpenAI: + return openai.OpenAI( + base_url=f"{gateway.client.base_url}/v1", + api_key=gateway.key, + max_retries=0, + http_client=httpx.Client(trust_env=False, timeout=60), + ) + + +def _anthropic_client(gateway: Gateway) -> anthropic.Anthropic: + return anthropic.Anthropic( + base_url=str(gateway.client.base_url), + api_key=gateway.key, + max_retries=0, + http_client=httpx.Client(trust_env=False, timeout=60), + ) + + +@pytest.mark.parametrize( + "shape", + [ + "plain", + "wrapped", + "named", + "values", + "large", + "two_candidates", + "inner_incomplete", + "self_shaped", + "scalar", + "empty", + "list_input", + "deep", + ], +) +def test_vertex_chat_json_schema_content_is_the_schema_object(gateway: Gateway, shape: str) -> None: + marker, marker_hex = _marker(shape) + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker)) + assert _chat_content(response) == _expected(shape, marker_hex), response.text + assert "tool_calls" not in _chat_message(response) or _chat_message(response)["tool_calls"] is None + sent: Final = _one_delivery(wire, marker_hex) + assert sent.target == f"{_VERTEX_MODELS}{_TOOL_MODEL}:rawPredict", sent.target + assert _json_tool_schema(sent)["properties"] == _SCHEMA["properties"], sent.body + assert "output_config" not in sent.body and "output_format" not in sent.body, sent.body + + +def test_vertex_chat_through_the_openai_sdk_unwraps_the_envelope(gateway: Gateway) -> None: + marker, marker_hex = _marker("wrapped") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + with _openai_client(gateway) as client: + reply: Final = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": marker}], + response_format={"type": "json_schema", "json_schema": {"name": "risk", "schema": _SCHEMA}}, + ) + assert json.loads(reply.choices[0].message.content or "") == _payload(marker_hex), reply + _one_delivery(wire, marker_hex) + + +def test_vertex_chat_through_the_async_openai_sdk_unwraps_the_envelope(gateway: Gateway) -> None: + marker, marker_hex = _marker("wrapped") + + async def generate(model: str) -> str | None: + async with openai.AsyncOpenAI( + base_url=f"{gateway.client.base_url}/v1", + api_key=gateway.key, + max_retries=0, + http_client=httpx.AsyncClient(trust_env=False, timeout=60), + ) as client: + reply: Final = await client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": marker}], + response_format={"type": "json_schema", "json_schema": {"name": "risk", "schema": _SCHEMA}}, + ) + return reply.choices[0].message.content + + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + content: Final = asyncio.run(generate(model)) + assert json.loads(content or "") == _payload(marker_hex), content + _one_delivery(wire, marker_hex) + + +def test_vertex_responses_json_schema_output_text_is_the_schema_object(gateway: Gateway) -> None: + marker, marker_hex = _marker("wrapped") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + response: Final = gateway.request("POST", "/v1/responses", _responses_body(model, marker)) + assert _responses_text(response) == _payload(marker_hex), response.text + sent: Final = _one_delivery(wire, marker_hex) + assert _json_tool_schema(sent)["properties"] == _SCHEMA["properties"], sent.body + + +def test_vertex_chat_stream_json_schema_unwraps_the_envelope_when_the_block_ends(gateway: Gateway) -> None: + marker, marker_hex = _marker("wrapped") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker, stream=True)) + assert _stream_content(response) == _payload(marker_hex), response.text + sent: Final = _one_delivery(wire, marker_hex) + assert sent.target == f"{_VERTEX_MODELS}{_TOOL_MODEL}{_VERTEX_STREAM_SUFFIX}", sent.target + + +def test_vertex_chat_json_schema_next_to_a_user_tool_unwraps_the_envelope_and_keeps_the_tool_call( + gateway: Gateway, +) -> None: + marker, marker_hex = _marker("wrapped") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + response: Final = gateway.request( + "POST", "/v1/chat/completions", _chat_body(model, marker, tools=[_USER_TOOL_SPEC]) + ) + assert response.status_code == 200, response.text + message: Final = object_value( + object_value(list_value(_JSON_OBJECT.validate_json(response.content)["choices"])[0])["message"] + ) + assert json.loads(string_value(message["content"])) == _payload(marker_hex), response.text + (tool_call,) = tuple(object_value(call) for call in list_value(message["tool_calls"])) + function: Final = object_value(tool_call["function"]) + assert function["name"] == _USER_TOOL, response.text + assert json.loads(string_value(function["arguments"])) == {"q": marker_hex}, response.text + sent: Final = _one_delivery(wire, marker_hex) + assert _tool_names(sent.body) == frozenset({_USER_TOOL, _JSON_TOOL}), sent.body + + +@pytest.mark.parametrize( + "member", + ["anthropic_unmapped", "azure_ai_unmapped", "bedrock_invoke", "claude_platform_unmapped", "vertex_haiku"], +) +def test_tool_path_family_unwraps_the_envelope(gateway: Gateway, member: str) -> None: + marker, marker_hex = _marker("wrapped") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _family_deployment(gateway, scenario, wire.url, member) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker)) + assert _chat_content(response) == _payload(marker_hex), response.text + sent: Final = _one_delivery(wire, marker_hex) + assert _json_tool_schema(sent)["properties"] == _SCHEMA["properties"], sent.body + + +@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"]) +def test_vertex_chat_json_schema_native_folds_output_format_into_output_config(gateway: Gateway, stream: bool) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker, stream=stream)) + content: Final = _stream_content(response) if stream else _chat_content(response) + assert content == _payload(marker_hex), response.text + sent: Final = _one_delivery(wire, marker_hex) + assert _folded_format(sent) == _OUTPUT_FORMAT, sent.body + assert "tools" not in sent.body, sent.body + + +def test_vertex_chat_native_through_the_openai_sdk_folds_output_format(gateway: Gateway) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + with _openai_client(gateway) as client: + reply: Final = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": marker}], + response_format={"type": "json_schema", "json_schema": {"name": "risk", "schema": _SCHEMA}}, + ) + assert json.loads(reply.choices[0].message.content or "") == _payload(marker_hex), reply + assert _folded_format(_one_delivery(wire, marker_hex)) == _OUTPUT_FORMAT + + +def test_vertex_responses_native_folds_output_format(gateway: Gateway) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + response: Final = gateway.request("POST", "/v1/responses", _responses_body(model, marker)) + assert _responses_text(response) == _payload(marker_hex), response.text + assert _folded_format(_one_delivery(wire, marker_hex)) == _OUTPUT_FORMAT + + +@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"]) +def test_vertex_messages_folds_output_format_into_output_config(gateway: Gateway, stream: bool) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + response: Final = gateway.request( + "POST", + "/v1/messages", + _messages_body(model, marker, stream=stream, output_format=_OUTPUT_FORMAT), + headers={"anthropic-beta": _STRUCTURED_OUTPUTS_BETA}, + ) + text: Final = _messages_stream_text(response) if stream else _messages_text(response) + assert text == _payload(marker_hex), response.text + assert f"msg_{marker_hex}" in response.text, response.text + sent: Final = _one_delivery(wire, marker_hex) + assert _folded_format(sent) == _OUTPUT_FORMAT, sent.body + assert sent.target.endswith(_VERTEX_STREAM_SUFFIX if stream else ":rawPredict"), sent.target + + +def test_vertex_messages_through_the_anthropic_sdk_folds_output_format(gateway: Gateway) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + with _anthropic_client(gateway) as client: + reply: Final = client.beta.messages.create( + model=model, + max_tokens=64, + messages=[{"role": "user", "content": marker}], + output_format={"type": "json_schema", "schema": _SCHEMA}, + betas=[_STRUCTURED_OUTPUTS_BETA], + ) + assert reply.id == f"msg_{marker_hex}", reply + assert _text_block_json(_JSON_OBJECT.validate_json(reply.model_dump_json())) == _payload(marker_hex), reply + assert _folded_format(_one_delivery(wire, marker_hex)) == _OUTPUT_FORMAT + + +def test_vertex_messages_through_the_async_anthropic_sdk_folds_output_format(gateway: Gateway) -> None: + marker, marker_hex = _marker("native") + + async def generate(model: str) -> tuple[str, str]: + async with anthropic.AsyncAnthropic( + base_url=str(gateway.client.base_url), + api_key=gateway.key, + max_retries=0, + http_client=httpx.AsyncClient(trust_env=False, timeout=60), + ) as client: + reply: Final = await client.beta.messages.create( + model=model, + max_tokens=64, + messages=[{"role": "user", "content": marker}], + output_format={"type": "json_schema", "schema": _SCHEMA}, + betas=[_STRUCTURED_OUTPUTS_BETA], + ) + return reply.id, reply.model_dump_json() + + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + reply_id, dumped = asyncio.run(generate(model)) + assert reply_id == f"msg_{marker_hex}", dumped + assert _text_block_json(_JSON_OBJECT.validate_json(dumped)) == _payload(marker_hex), dumped + assert _folded_format(_one_delivery(wire, marker_hex)) == _OUTPUT_FORMAT + + +def test_vertex_messages_keeps_the_callers_output_config_format_over_output_format(gateway: Gateway) -> None: + marker, marker_hex = _marker("native") + callers_format: Final[dict[str, JsonValue]] = { + "type": "json_schema", + "schema": {**_SCHEMA, "title": "callers"}, + } + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + response: Final = gateway.request( + "POST", + "/v1/messages", + _messages_body(model, marker, output_format=_OUTPUT_FORMAT, output_config={"format": callers_format}), + ) + assert _messages_text(response) == _payload(marker_hex), response.text + assert _folded_format(_one_delivery(wire, marker_hex)) == callers_format + + +def test_vertex_messages_replaces_a_non_dict_output_config_with_the_folded_format(gateway: Gateway) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + response: Final = gateway.request( + "POST", + "/v1/messages", + _messages_body(model, marker, output_format=_OUTPUT_FORMAT, output_config="bogus"), + ) + assert _messages_text(response) == _payload(marker_hex), response.text + sent: Final = _one_delivery(wire, marker_hex) + assert sent.body["output_config"] == {"format": _OUTPUT_FORMAT}, sent.body + + +def test_vertex_messages_drops_effort_but_keeps_the_folded_format_on_haiku(gateway: Gateway) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _EFFORT_GATED_MODEL) + response: Final = gateway.request( + "POST", + "/v1/messages", + _messages_body(model, marker, output_format=_OUTPUT_FORMAT, output_config={"effort": "high"}), + ) + assert _messages_text(response) == _payload(marker_hex), response.text + sent: Final = _one_delivery(wire, marker_hex) + assert sent.body["output_config"] == {"format": _OUTPUT_FORMAT}, sent.body + + +def test_vertex_messages_forwards_an_explicit_output_config_format_unchanged(gateway: Gateway) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + response: Final = gateway.request( + "POST", "/v1/messages", _messages_body(model, marker, output_config={"format": _OUTPUT_FORMAT}) + ) + assert _messages_text(response) == _payload(marker_hex), response.text + sent: Final = _one_delivery(wire, marker_hex) + assert sent.body["output_config"] == {"format": _OUTPUT_FORMAT}, sent.body + + +def test_anthropic_messages_forwards_top_level_output_format_verbatim(gateway: Gateway) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_NATIVE_MODEL}", api_base=wire.url, api_key=_ANTHROPIC_KEY) + response: Final = gateway.request( + "POST", + "/v1/messages", + _messages_body(model, marker, output_format=_OUTPUT_FORMAT), + headers={"anthropic-beta": _STRUCTURED_OUTPUTS_BETA}, + ) + assert _messages_text(response) == _payload(marker_hex), response.text + sent: Final = _one_delivery(wire, marker_hex) + assert sent.target == "/v1/messages", sent.target + assert sent.body["output_format"] == _OUTPUT_FORMAT, sent.body + assert "output_config" not in sent.body, sent.body + + +def test_vertex_chat_without_response_format_sends_no_tool_and_no_output_config(gateway: Gateway) -> None: + marker, marker_hex = _marker("plain_text") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + response: Final = gateway.request( + "POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": marker}]} + ) + assert response.status_code == 200, response.text + assert f"plain text {marker_hex}" in response.text, response.text + sent: Final = _one_delivery(wire, marker_hex) + assert not {"tools", "output_config", "output_format"} & sent.body.keys(), sent.body + + +@pytest.mark.parametrize( + "output_format", + [5, [], {}, "x" * 5000], + ids=["int", "list", "empty", "5kb"], +) +def test_vertex_messages_malformed_output_format_reaches_the_caller_as_the_peer_400( + gateway: Gateway, output_format: JsonValue +) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + response: Final = gateway.request( + "POST", "/v1/messages", _messages_body(model, marker, output_format=output_format) + ) + assert response.status_code == 400, response.text + assert _BAD_FORMAT in response.text, response.text + sent: Final = _one_delivery(wire, marker_hex) + assert "output_format" not in sent.body, sent.body + assert object_value(sent.body["output_config"])["format"] == output_format, sent.body + assert gateway.request("GET", "/health/liveliness").status_code == 200 + + +def test_vertex_messages_duplicate_output_format_keys_fold_the_last_value(gateway: Gateway) -> None: + marker, marker_hex = _marker("native") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL) + raw: Final = ( + json.dumps(_messages_body(model, marker, output_format=5))[:-1] + + f', "output_format": {json.dumps(_OUTPUT_FORMAT)}}}' + ) + response: Final = gateway.client.post( + "/v1/messages", + content=raw, + headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"}, + ) + assert _messages_text(response) == _payload(marker_hex), response.text + assert _folded_format(_one_delivery(wire, marker_hex)) == _OUTPUT_FORMAT + + +@pytest.mark.parametrize( + "schema", + [ + {"type": "object", "properties": _SCHEMA["properties"], "required": "tier"}, + {"type": "object", "properties": ["tier", "summary"]}, + {}, + ], + ids=["required_string", "properties_list", "empty_schema"], +) +def test_vertex_chat_malformed_response_format_schema_keeps_the_tool_input(gateway: Gateway, schema: JsonValue) -> None: + marker, marker_hex = _marker("wrapped") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + _chat_body( + model, + marker, + response_format={"type": "json_schema", "json_schema": {"name": "risk", "schema": schema}}, + ), + ) + assert _chat_content(response) == _tool_input("wrapped", marker_hex), response.text + _one_delivery(wire, marker_hex) + assert gateway.request("GET", "/health/liveliness").status_code == 200 + + +@pytest.mark.parametrize("status", [400, 403, 404]) +def test_vertex_chat_peer_error_reaches_the_caller_and_the_deployment_keeps_serving( + gateway: Gateway, status: int +) -> None: + marker, marker_hex = _marker("wrapped") + after, after_hex = _marker("wrapped") + with gateway.scenario() as scenario: + with wire_server(_failing(status)) as failing: + port: Final = int(failing.url.rsplit(":", 1)[1]) + model: Final = _vertex_deployment(gateway, scenario, failing.url, _TOOL_MODEL) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker)) + assert response.status_code == status, response.text + assert f"scripted {status} {marker_hex}" in response.text, response.text + _one_delivery(failing, marker_hex) + with wire_server(_peer, port=port) as wire: + recovered: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, after)) + assert _chat_content(recovered) == _payload(after_hex), recovered.text + _one_delivery(wire, after_hex) + + +def test_vertex_chat_unauthenticated_request_never_reaches_the_peer(gateway: Gateway) -> None: + marker, _ = _marker("wrapped") + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + response: Final = gateway.request( + "POST", "/v1/chat/completions", _chat_body(model, marker), key="sk-not-a-key-this-proxy-issued" + ) + assert response.status_code == 401, response.text + assert wire.drain() == () + + +def test_vertex_chat_repeated_requests_each_reach_the_peer_and_log_spend_once(gateway: Gateway) -> None: + markers: Final = tuple(_marker("wrapped") for _ in range(2)) + with wire_server(_peer) as wire, gateway.scenario() as scenario: + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + responses: Final = tuple( + gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker)) for marker, _ in markers + ) + for response, (_, marker_hex) in zip(responses, markers, strict=True): + assert _chat_content(response) == _payload(marker_hex), response.text + delivered: Final = _delivered(wire) + assert len(delivered) == 2, delivered + _seen_once(delivered, tuple(marker_hex for _, marker_hex in markers)) + request_ids: Final = tuple(_response_id(response) for response in responses) + assert len(set(request_ids)) == 2, request_ids + for request_id in request_ids: + assert _spend_rows(request_id)[0]["request_id"] == request_id + + +def test_concurrent_burst_across_both_workers_unwraps_every_envelope(gateway: Gateway) -> None: + with ExitStack() as stack: + wire: Final = stack.enter_context(wire_server(_peer)) + scenario: Final = stack.enter_context(gateway.scenario()) + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + shapes: Final = tuple(_BURST_SHAPES[index % len(_BURST_SHAPES)] for index in range(12)) + markers: Final = tuple(_marker(shape) for shape in shapes) + streams: Final = tuple(index % 6 == 5 for index in range(len(markers))) + bodies: Final = tuple( + _chat_body(model, marker, stream=stream) for (marker, _), stream in zip(markers, streams, strict=True) + ) + clients: Final = _clients(stack, str(gateway.client.base_url), len(bodies)) + pool: Final = stack.enter_context(ThreadPoolExecutor(max_workers=len(bodies))) + responses: Final = tuple( + pool.map( + lambda pair: _post(pair[0], gateway.key, "/v1/chat/completions", pair[1]), + zip(clients, bodies, strict=True), + ) + ) + for response, shape, (_, marker_hex), stream in zip(responses, shapes, markers, streams, strict=True): + observed: Final = _stream_content(response) if stream else _chat_content(response) + expected: Final = _expected(shape, marker_hex) + assert observed == expected, (shape, stream, response.text) + _seen_once(_delivered(wire), tuple(marker_hex for _, marker_hex in markers)) + + +def test_upstream_outage_mid_run_recovers_with_envelopes_still_unwrapped(gateway: Gateway) -> None: + with ExitStack() as stack: + clients: Final = _clients(stack, str(gateway.client.base_url), 8) + pool: Final = stack.enter_context(ThreadPoolExecutor(max_workers=len(clients))) + scenario: Final = stack.enter_context(gateway.scenario()) + + def wave() -> tuple[tuple[httpx.Response | None, str], ...]: + markers: Final = tuple(_marker("wrapped") for _ in clients) + responses: Final = tuple( + pool.map( + lambda pair: _post_or_dropped( + pair[0], gateway.key, "/v1/chat/completions", _chat_body(model, pair[1][0]) + ), + zip(clients, markers, strict=True), + ) + ) + return tuple(zip(responses, tuple(marker_hex for _, marker_hex in markers), strict=True)) + + def unwrapped(wire: Wire, results: tuple[tuple[httpx.Response | None, str], ...]) -> None: + for response, marker_hex in results: + assert response is not None, results + assert _chat_content(response) == _payload(marker_hex), response.text + _seen_once(_delivered(wire), tuple(marker_hex for _, marker_hex in results)) + + with wire_server(_peer) as wire: + port: Final = int(wire.url.rsplit(":", 1)[1]) + model: Final = _vertex_deployment(gateway, scenario, wire.url, _TOOL_MODEL) + unwrapped(wire, wave()) + outage: Final = wave() + assert all(response is not None and response.status_code >= 500 for response, _ in outage), outage + assert gateway.request("GET", "/health/liveliness").status_code == 200 + with wire_server(_peer, port=port) as revived: + unwrapped(revived, wave()) + + +@pytest.mark.timeout(_OWNED_PROXY_CELL_SECONDS) +def test_worker_sigkill_mid_burst_leaves_the_replacement_unwrapping_envelopes(gateway: Gateway, tmp_path: Path) -> None: + held: Final[SimpleQueue[str]] = SimpleQueue() + release: Final = threading.Event() + with ExitStack() as stack: + wire: Final = stack.enter_context(wire_server(_holding(held, release))) + stack.callback(release.set) + config: Final = _owned_config(tmp_path / "structured-output-worker-kill.yaml", gateway, wire.url) + owned: Final = stack.enter_context(owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2)) + proxy_url: Final = owned.gateway.client.base_url + workers: Final = eventually( + lambda: tuple(int(pid) for pid in _STARTED_WORKER.findall(owned.log.read_text())), + lambda pids: len(pids) == 2, + seconds=30, + ) + clients: Final = _clients(stack, str(proxy_url), 12) + pool: Final = stack.enter_context(ThreadPoolExecutor(max_workers=len(clients))) + ports: Final = tuple(_local_port(client) for client in clients) + markers: Final = tuple(_marker("wrapped") for _ in clients) + futures: Final = tuple( + pool.submit(_post_or_dropped, client, gateway.key, "/v1/chat/completions", _chat_body(_OWNED_MODEL, marker)) + for client, (marker, _) in zip(clients, markers, strict=True) + ) + eventually(held.qsize, lambda size: size == len(clients), seconds=30) + shares: Final = {pid: _accepted_client_ports(pid, proxy_url.port or 0) & frozenset(ports) for pid in workers} + assert sum(map(len, shares.values())) == len(clients), shares + victim: Final = min((pid for pid in workers if shares[pid]), key=lambda pid: len(shares[pid])) + psutil.Process(victim).send_signal(signal.SIGKILL) + release.set() + results: Final = tuple(future.result(timeout=60) for future in futures) + for port, result, (_, marker_hex) in zip(ports, results, markers, strict=True): + if port in shares[victim]: + assert result is None, (port, result, shares) + else: + assert result is not None and _chat_content(result) == _payload(marker_hex), (port, result, shares) + eventually(lambda: len(_STARTED_WORKER.findall(owned.log.read_text())), lambda started: started >= 3, 120) + second_wave: Final = tuple(_marker("wrapped") for _ in range(6)) + for marker, marker_hex in second_wave: + after: Final = _post( + owned.gateway.client, gateway.key, "/v1/chat/completions", _chat_body(_OWNED_MODEL, marker) + ) + assert _chat_content(after) == _payload(marker_hex), after.text + _seen_once(_delivered(wire), tuple(marker_hex for _, marker_hex in markers + second_wave)) + assert owned.process.poll() is None diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py index 6d7d62e47aa..63cec8bc318 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -12,6 +12,7 @@ from litellm._uuid import uuid from litellm.constants import RESPONSE_FORMAT_TOOL_NAME from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt from litellm.llms.anthropic.chat.handler import ModelResponseIterator, make_call +from litellm.llms.base_llm.base_utils import response_format_schema from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.types.llms.openai import ( ChatCompletionToolCallChunk, @@ -2367,3 +2368,112 @@ def test_served_model_reaches_assembled_stream_through_custom_stream_wrapper(): assert chunk._hidden_params["provider_response_model"] == served_model assembled: Final = litellm.stream_chunk_builder(chunks=list(chunks), messages=[{"role": "user", "content": "hi"}]) assert assembled._hidden_params["provider_response_model"] == served_model + + +_STREAMED_RISK_SCHEMA: Final = { + "type": "object", + "properties": {"tier": {"type": "integer"}, "summary": {"type": "string"}}, + "required": ["tier", "summary"], +} +_STREAMED_RISK_OBJECT: Final = {"tier": 3, "summary": "Two-hour read-only window on the loyalty APIs"} +_STREAMED_LOOSE_SCHEMA: Final = { + "type": "object", + "properties": {"name": {"type": "string"}, "email": {"type": "string"}}, +} +_STREAMED_CONTACT: Final = {"name": "Ada", "email": "ada@example.com"} + + +def _json_tool_events(fragments: tuple[str, ...]) -> tuple[dict[str, object], ...]: + return ( + { + "type": "message_start", + "message": { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-emulated-structured-output", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "tool_use", "id": "toolu_1", "name": RESPONSE_FORMAT_TOOL_NAME, "input": {}}, + }, + *( + {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": fragment}} + for fragment in fragments + ), + {"type": "content_block_stop", "index": 0}, + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use", "stop_sequence": None}, + "usage": {"output_tokens": 20}, + }, + ) + + +def _streamed_json_mode_contents( + fragments: tuple[str, ...], schema: dict[str, object] = _STREAMED_RISK_SCHEMA +) -> tuple[str, ...]: + iterator = ModelResponseIterator( + streaming_response=MagicMock(), + sync_stream=True, + json_mode=True, + response_format_schema=response_format_schema(schema), + ) + chunks = tuple(iterator.chunk_parser(event) for event in _json_tool_events(fragments)) + assert all(chunk.choices[0].delta.tool_calls is None for chunk in chunks) + assert chunks[-1].choices[0].finish_reason == "stop" + return tuple(chunk.choices[0].delta.content or "" for chunk in chunks) + + +def test_streamed_json_mode_envelope_is_unwrapped_when_the_block_ends(): + envelope = json.dumps({"name": RESPONSE_FORMAT_TOOL_NAME, "parameters": _STREAMED_RISK_OBJECT}) + fragments = (envelope[:9], envelope[9:40], envelope[40:]) + + contents = _streamed_json_mode_contents(fragments) + + assert json.loads("".join(contents)) == _STREAMED_RISK_OBJECT + assert contents[:-2] == ("",) * (len(contents) - 2) + + +def test_streamed_json_mode_schema_object_streams_as_it_arrives(): + answer = json.dumps(_STREAMED_RISK_OBJECT) + fragments = (answer[:4], answer[4:12], answer[12:]) + + contents = _streamed_json_mode_contents(fragments) + + assert "".join(contents) == answer + assert contents[3] == answer[:12] + assert contents[4] == answer[12:] + assert contents[5] == "" + + +def test_streamed_json_mode_truncated_output_is_passed_through_untouched(): + contents = _streamed_json_mode_contents(('{"na', 'me": "json_tool_call", "parameters": {"tier": 3')) + + assert "".join(contents) == '{"name": "json_tool_call", "parameters": {"tier": 3' + + +def test_streamed_json_mode_tool_name_envelope_is_unwrapped_when_name_is_a_schema_property(): + envelope = json.dumps({"name": RESPONSE_FORMAT_TOOL_NAME, "parameters": _STREAMED_CONTACT}) + + contents = _streamed_json_mode_contents((envelope[:6], envelope[6:24], envelope[24:]), _STREAMED_LOOSE_SCHEMA) + + assert json.loads("".join(contents)) == _STREAMED_CONTACT + assert contents[:-2] == ("",) * (len(contents) - 2) + + +def test_streamed_json_mode_name_property_answer_streams_once_its_value_is_known(): + answer = json.dumps(_STREAMED_CONTACT) + + contents = _streamed_json_mode_contents((answer[:6], answer[6:16], answer[16:]), _STREAMED_LOOSE_SCHEMA) + + assert "".join(contents) == answer + assert contents[2] == "" + assert contents[3] == answer[:16] + assert contents[4] == answer[16:] diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py index 264d534c2d1..901578cdb51 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -23,7 +23,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messag from litellm.llms.anthropic.chat import ModelResponseIterator from litellm.llms.anthropic.common_utils import process_anthropic_headers from litellm.llms.anthropic.chat.transformation import AnthropicConfig -from litellm.llms.base_llm.base_utils import ResponseFormatSchema +from litellm.llms.base_llm.base_utils import ResponseFormatSchema, response_format_schema from litellm.llms.anthropic.pass_through.messages.transformation import ( AnthropicMessagesConfig, ) @@ -7278,12 +7278,30 @@ _PERSON_SCHEMA: Final = { "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, "required": ["name", "age"], } -_OPTIONAL_FIELDS_SCHEMA: Final = {"type": "object", "properties": {"note": {"type": "string"}, "score": {"type": "integer"}}} +_OPTIONAL_FIELDS_SCHEMA: Final = { + "type": "object", + "properties": {"note": {"type": "string"}, "score": {"type": "integer"}}, +} _TREE_SCHEMA: Final = { "type": "object", "properties": {"value": {"type": "integer"}, "child": {"type": "object"}}, "required": ["value"], } +_OPEN_SCORE_SCHEMA: Final = { + "type": "object", + "properties": {"score": {"type": "integer"}}, + "additionalProperties": True, +} +_VALUES_SCHEMA: Final = {"type": "object", "properties": {"values": {"type": "array"}}, "required": ["values"]} +_REMOTE_REF_SCHEMA: Final = {**_PERSON_SCHEMA, "$ref": "https://schemas.example.com/person.json"} +_LOOSE_SCHEMA: Final = {"type": "object", "properties": {"name": {"type": "string"}, "email": {"type": "string"}}} +_CONTACT: Final = {"name": "Ada", "email": "ada@example.com"} + + +def _schema(document: dict[str, object]) -> ResponseFormatSchema: + schema = response_format_schema(document) + assert schema is not None + return schema def _json_tool_call(arguments: object) -> list[ChatCompletionToolCallChunk]: @@ -7291,7 +7309,9 @@ def _json_tool_call(arguments: object) -> list[ChatCompletionToolCallChunk]: ChatCompletionToolCallChunk( id="toolu_1", type="function", - function=ChatCompletionToolCallFunctionChunk(name=RESPONSE_FORMAT_TOOL_NAME, arguments=json.dumps(arguments)), + function=ChatCompletionToolCallFunctionChunk( + name=RESPONSE_FORMAT_TOOL_NAME, arguments=json.dumps(arguments) + ), index=0, ) ] @@ -7314,7 +7334,37 @@ def _json_tool_call(arguments: object) -> list[ChatCompletionToolCallChunk]: {"name": "Ada", "age": 36}, id="envelope_key_shared_with_a_schema_property", ), - pytest.param(_OPTIONAL_FIELDS_SCHEMA, {"input": {"score": 7}}, {"score": 7}, id="optional_fields_wrapper"), + pytest.param( + _OPTIONAL_FIELDS_SCHEMA, + {"input": {"score": 7}}, + {"input": {"score": 7}}, + id="wrapper_the_schema_itself_accepts", + ), + pytest.param( + _OPEN_SCORE_SCHEMA, + {"meta": {"score": 7}, "label": "keep"}, + {"meta": {"score": 7}, "label": "keep"}, + id="valid_answer_with_extra_keys_and_a_schema_shaped_nested_object", + ), + pytest.param(_VALUES_SCHEMA, {"values": [1, 2]}, {"values": [1, 2]}, id="values_is_a_schema_property"), + pytest.param( + _REMOTE_REF_SCHEMA, + {"input": {"name": "Ada", "age": 36}}, + {"name": "Ada", "age": 36}, + id="remote_ref_schema_falls_back_to_key_matching", + ), + pytest.param( + _LOOSE_SCHEMA, + {"name": RESPONSE_FORMAT_TOOL_NAME, "parameters": _CONTACT}, + _CONTACT, + id="tool_name_envelope_when_the_schema_lists_no_required_keys", + ), + pytest.param( + _LOOSE_SCHEMA, + {"name": RESPONSE_FORMAT_TOOL_NAME, "email": "ada@example.com"}, + {"name": RESPONSE_FORMAT_TOOL_NAME, "email": "ada@example.com"}, + id="tool_name_value_without_a_nested_object_stays", + ), pytest.param(_RISK_SCHEMA, _RISK_OBJECT, _RISK_OBJECT, id="plain_schema_object"), pytest.param( _TREE_SCHEMA, @@ -7322,7 +7372,9 @@ def _json_tool_call(arguments: object) -> list[ChatCompletionToolCallChunk]: {"value": 1, "child": {"value": 2}}, id="schema_object_whose_property_holds_a_schema_shaped_object", ), - pytest.param(_RISK_SCHEMA, {"input": {"tier": 3}}, {"input": {"tier": 3}}, id="inner_object_missing_required_key"), + pytest.param( + _RISK_SCHEMA, {"input": {"tier": 3}}, {"input": {"tier": 3}}, id="inner_object_missing_required_key" + ), pytest.param( _RISK_SCHEMA, {"first": _RISK_OBJECT, "second": _RISK_OBJECT}, @@ -7348,7 +7400,7 @@ def test_convert_tool_response_to_message_unwraps_only_an_unambiguous_schema_env ): message = AnthropicConfig.convert_tool_response_to_message( tool_calls=_json_tool_call(tool_input), - response_format_schema=ResponseFormatSchema.model_validate(schema), + response_format_schema=_schema(schema), ) assert message is not None @@ -7366,7 +7418,7 @@ def test_json_mode_with_user_tool_call_unwraps_the_envelope_into_content(): replacement, remaining_tool_calls, extra_content = AnthropicConfig()._resolve_json_mode_non_streaming( json_mode=True, tool_calls=[*_json_tool_call({"input": _RISK_OBJECT}), user_tool_call], - response_format_schema=ResponseFormatSchema.model_validate(_RISK_SCHEMA), + response_format_schema=_schema(_RISK_SCHEMA), ) assert replacement is None @@ -7402,6 +7454,18 @@ def test_vertex_claude_tool_emulated_response_format_returns_the_object_the_mode drop_params=False, ) json_mode = optional_params.pop("json_mode") + request = config.transform_request( + model="claude-emulated-structured-output", + messages=[{"role": "user", "content": "Assess this change request"}], + optional_params={**optional_params, "is_vertex_request": True}, + litellm_params={}, + headers={}, + ) + json_tool = next(tool for tool in request["tools"] if tool["name"] == RESPONSE_FORMAT_TOOL_NAME) + assert json_tool["input_schema"]["required"] == ["tier", "summary"] + assert json_tool["description"] + assert "output_format" not in request + assert "output_config" not in request completion_response = { "id": "msg_1", "type": "message", @@ -7425,7 +7489,7 @@ def test_vertex_claude_tool_emulated_response_format_returns_the_object_the_mode raw_response=httpx.Response(200, json=completion_response), model_response=ModelResponse(), logging_obj=MagicMock(), - request_data={}, + request_data=request, messages=[{"role": "user", "content": "Assess this change request"}], optional_params=optional_params, litellm_params={}, @@ -7749,9 +7813,7 @@ async def test_anthropic_api_max_completion_tokens(model: str): print("request_body: ", request_body) assert request_body == { - "messages": [ - {"role": "user", "content": [{"type": "text", "text": "Hello!"}]} - ], + "messages": [{"role": "user", "content": [{"type": "text", "text": "Hello!"}]}], "max_tokens": 10, "model": model.split("/")[-1], } @@ -7969,9 +8031,7 @@ async def test_anthropic_api_prompt_caching_with_content_str(): "cache_control": {"type": "ephemeral"}, }, ] - translated_system_message = litellm.AnthropicConfig().translate_system_message( - messages=system_message - ) + translated_system_message = litellm.AnthropicConfig().translate_system_message(messages=system_message) assert translated_system_message == [ # System Message @@ -8041,9 +8101,9 @@ async def test_anthropic_api_prompt_caching_with_content_str(): assert len(translated_messages) == len(expected_messages) for idx, i in enumerate(translated_messages): - assert ( - i == expected_messages[idx] - ), "Error on idx={}. Got={}, Expected={}".format(idx, i, expected_messages[idx]) + assert i == expected_messages[idx], "Error on idx={}. Got={}, Expected={}".format( + idx, i, expected_messages[idx] + ) @pytest.fixture diff --git a/tests/unit/llms/bedrock/chat/test_invoke_handler.py b/tests/unit/llms/bedrock/chat/test_invoke_handler.py index d5fe5d8cf65..f723f6e80b0 100644 --- a/tests/unit/llms/bedrock/chat/test_invoke_handler.py +++ b/tests/unit/llms/bedrock/chat/test_invoke_handler.py @@ -20,8 +20,10 @@ from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper from litellm.llms.bedrock.chat.invoke_handler import ( AmazonOpenAICompatibleStreamDecoder, AWSEventStreamDecoder, + _stream_decoder, make_call, make_sync_call, + response_format_schema_from_request, ) from litellm.llms.bedrock.common_utils import BedrockError, get_bedrock_stream_event_statuses from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler @@ -1442,3 +1444,88 @@ def test_invoke_decoder_reports_only_missing_aws_dependency(missing): assert error.value.__cause__ is failure else: assert error.value is failure + + +_INVOKE_RISK_SCHEMA: Final = { + "type": "object", + "properties": {"tier": {"type": "integer"}, "summary": {"type": "string"}}, + "required": ["tier", "summary"], +} +_INVOKE_RISK_OBJECT: Final = {"tier": 2, "summary": "Weekend database migration with a tested rollback"} + + +def _invoke_json_mode_request(tools: Sequence[Mapping[str, object]]) -> str: + return json.dumps( + { + "anthropic_version": "bedrock-2023-05-31", + "messages": [{"role": "user", "content": "Assess this change request"}], + "tools": list(tools), + "tool_choice": {"type": "tool", "name": "json_tool_call"}, + } + ) + + +def _invoke_json_tool_events(fragments: Sequence[str]) -> tuple[dict[str, object], ...]: + deltas = tuple( + {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": fragment}} + for fragment in fragments + ) + return ( + {"type": "message_start", "message": {"id": "msg_1", "usage": {"input_tokens": 1, "output_tokens": 0}}}, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "tool_use", "id": "t1", "name": "json_tool_call", "input": {}}, + }, + *deltas, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"output_tokens": 9}}, + ) + + +def _invoke_streamed_json_mode_contents(data: str, fragments: Sequence[str], json_mode: bool = True) -> tuple[str, ...]: + decoder = _stream_decoder( + "anthropic", + model="anthropic.claude-sonnet-4", + json_mode=True, + sync_stream=True, + response_format_schema=response_format_schema_from_request(data, json_mode), + ) + chunks = tuple(decoder._chunk_parser(event) for event in _invoke_json_tool_events(fragments)) + assert all(not chunk.choices[0].delta.tool_calls for chunk in chunks) + return tuple(chunk.choices[0].delta.content or "" for chunk in chunks) + + +def test_invoke_streamed_json_mode_envelope_is_unwrapped_with_the_schema_from_the_request_body(): + envelope: Final = json.dumps({"tool_name": "json_tool_call", "parameters": _INVOKE_RISK_OBJECT}) + data: Final = _invoke_json_mode_request([{"name": "json_tool_call", "input_schema": _INVOKE_RISK_SCHEMA}]) + + contents = _invoke_streamed_json_mode_contents(data, (envelope[:17], envelope[17:48], envelope[48:])) + + assert json.loads("".join(contents)) == _INVOKE_RISK_OBJECT + + +@pytest.mark.parametrize( + ("data", "json_mode"), + [ + pytest.param("not json", True, id="unparseable_body"), + pytest.param(json.dumps([1, 2]), True, id="body_is_not_an_object"), + pytest.param( + _invoke_json_mode_request([{"name": "other_tool", "input_schema": _INVOKE_RISK_SCHEMA}]), + True, + id="no_json_tool", + ), + pytest.param( + _invoke_json_mode_request([{"name": "json_tool_call", "input_schema": _INVOKE_RISK_SCHEMA}]), + False, + id="json_mode_off", + ), + ], +) +def test_invoke_stream_keeps_the_wrapper_when_the_request_body_carries_no_json_tool_schema(data: str, json_mode: bool): + envelope: Final = json.dumps({"tool_name": "json_tool_call", "parameters": _INVOKE_RISK_OBJECT}) + + assert response_format_schema_from_request(data, json_mode) is None + contents = _invoke_streamed_json_mode_contents(data, (envelope[:17], envelope[17:]), json_mode) + + assert json.loads("".join(contents)) == {"tool_name": "json_tool_call", "parameters": _INVOKE_RISK_OBJECT} diff --git a/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py b/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py index cdd9f0526d2..f323e1a7c68 100644 --- a/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py +++ b/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py @@ -787,6 +787,12 @@ _CALLER_FORMAT: Final = { {"format": _CALLER_FORMAT}, id="caller_output_config_format_wins", ), + pytest.param( + "claude-opus-4-6", + {"output_format": _STRUCTURED_FORMAT, "output_config": {"format": None}}, + {"format": _STRUCTURED_FORMAT}, + id="null_output_config_format_counts_as_absent", + ), pytest.param( "claude-opus-4-6", {"output_format": _STRUCTURED_FORMAT, "output_config": "not-a-dict"},