From 7f4da14d7273c4cc03c6c34047f4da9f354c41f4 Mon Sep 17 00:00:00 2001 From: kris3984 Date: Sat, 22 Aug 2026 19:48:10 +0530 Subject: [PATCH 01/10] fix(utils): handle Pydantic schema conversion and map validation errors to APIError --- litellm/main.py | 10 +- litellm/utils.py | 119 ++++++++++++--- .../test_litellm/test_pydantic_validation.py | 141 ++++++++++++++++++ 3 files changed, 243 insertions(+), 27 deletions(-) create mode 100644 tests/test_litellm/test_pydantic_validation.py diff --git a/litellm/main.py b/litellm/main.py index 7cfd322f3d0..b882f4e3517 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -152,6 +152,7 @@ from litellm.utils import ( get_secret, get_standard_openai_params, mock_completion_streaming_obj, + normalize_completion_response_format, pre_process_non_default_params, read_config_args, should_run_mock_completion, @@ -481,6 +482,8 @@ async def acompletion( - The `completion` function is called using `run_in_executor` to execute synchronously in the event loop. - If `stream` is True, the function returns an async generator that yields completion lines. """ + request_response_format: Final = normalize_completion_response_format(response_format, model=model) + fallbacks = kwargs.get("fallbacks", None) mock_timeout = kwargs.get("mock_timeout", None) @@ -572,7 +575,7 @@ async def acompletion( "frequency_penalty": frequency_penalty, "logit_bias": logit_bias, "user": user, - "response_format": response_format, + "response_format": request_response_format, "seed": seed, "tools": tools, "tool_choice": tool_choice, @@ -5018,6 +5021,7 @@ def completion( # model whose model_cost mode is "responses" but whose provider has no # Responses API config (get_provider_responses_api_config -> None). skip_responses_api_bridge: Final = kwargs.pop("_skip_responses_api_bridge", False) + request_response_format: Final = normalize_completion_response_format(response_format, model=model) skip_mcp_handler: Final = kwargs.pop("_skip_mcp_handler", False) if not skip_mcp_handler and tools: @@ -5052,7 +5056,7 @@ def completion( frequency_penalty=frequency_penalty, logit_bias=logit_bias, user=user, - response_format=response_format, + response_format=request_response_format, seed=seed, tools=tools, tool_choice=tool_choice, @@ -5355,7 +5359,7 @@ def completion( # params to identify the model "model": model, "custom_llm_provider": custom_llm_provider, - "response_format": response_format, + "response_format": request_response_format, "seed": seed, "tools": tools, "tool_choice": tool_choice, diff --git a/litellm/utils.py b/litellm/utils.py index e5ce7157e77..d4eab782e64 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -42,9 +42,8 @@ import openai import tiktoken from httpx import Proxy from httpx._utils import get_environment_proxies -from openai.lib import _parsing, _pydantic from openai.types.chat.completion_create_params import ResponseFormat -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from tiktoken import Encoding from tokenizers import Tokenizer @@ -1253,6 +1252,93 @@ async def async_post_call_success_deployment_hook( return response +def process_response_format( + response_format: type[BaseModel] | dict | None, +) -> dict | None: + if response_format is None: + return None + if isinstance(response_format, dict): + return type_to_response_format_param(response_format) + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + return type_to_response_format_param(response_format) + raise TypeError(f"Unsupported response_format type - {response_format}") + + +def normalize_completion_response_format( + response_format: type[BaseModel] | dict | None, + model: str, +) -> dict | type[BaseModel] | None: + try: + processed: Final = process_response_format(response_format) + except (ValidationError, json.JSONDecodeError) as e: + raise litellm.APIError( + status_code=400, + message=f"Invalid Pydantic response_format: {e}", + llm_provider="", + model=model, + ) from e + return processed if processed is not None else response_format + + +def _deserialize_pydantic_response_format( + response_format: type[BaseModel], + model_response: str, + model: str | None, +) -> None: + try: + model_validate_json = getattr(response_format, "model_validate_json", None) + if callable(model_validate_json): + model_validate_json(model_response) + return + parse_raw = getattr(response_format, "parse_raw", None) + if callable(parse_raw): + parse_raw(model_response) + return + json.loads(model_response) + except (ValidationError, json.JSONDecodeError) as e: + raise litellm.APIError( + status_code=500, + message=f"Structured output did not match the Pydantic response_format: {e}", + llm_provider="", + model=model or "", + ) from e + + +def _response_format_as_json_schema(response_format: object) -> dict | None: + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + return process_response_format(response_format) + if isinstance(response_format, dict) and response_format.get("json_schema") is not None: + return response_format + return None + + +def _apply_response_format_validation( + response_format: object, + model_response: str, + model: str | None, +) -> None: + try: + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + _deserialize_pydantic_response_format( + response_format=response_format, + model_response=model_response, + model=model, + ) + json_response_format: Final = _response_format_as_json_schema(response_format) + if json_response_format is not None: + litellm.litellm_core_utils.json_validation_rule.validate_schema( + schema=json_response_format["json_schema"]["schema"], + response=model_response, + ) + except (ValidationError, json.JSONDecodeError) as e: + raise litellm.APIError( + status_code=500, + message=f"Structured output did not match the Pydantic response_format: {e}", + llm_provider="", + model=model or "", + ) from e + + def post_call_processing( original_response, model, @@ -1294,26 +1380,11 @@ def post_call_processing( and "response_format" in optional_params and optional_params["response_format"] is not None ): - json_response_format: dict | None = None - if ( - isinstance( - optional_params["response_format"], - dict, - ) - and optional_params["response_format"].get("json_schema") is not None - ): - json_response_format = optional_params["response_format"] - elif _parsing._completions.is_basemodel_type( - optional_params["response_format"] - ): - json_response_format = type_to_response_format_param( - response_format=optional_params["response_format"] - ) - if json_response_format is not None: - litellm.litellm_core_utils.json_validation_rule.validate_schema( - schema=json_response_format["json_schema"]["schema"], - response=model_response, - ) + _apply_response_format_validation( + response_format=optional_params["response_format"], + model_response=model_response, + model=model, + ) except TypeError: pass if ( @@ -3817,8 +3888,8 @@ def pre_process_non_default_params( response_format=non_default_params["response_format"] ) else: - non_default_params["response_format"] = type_to_response_format_param( - response_format=non_default_params["response_format"] + non_default_params["response_format"] = process_response_format( + non_default_params["response_format"] ) if "tools" in non_default_params and isinstance( diff --git a/tests/test_litellm/test_pydantic_validation.py b/tests/test_litellm/test_pydantic_validation.py new file mode 100644 index 00000000000..e9bf7249676 --- /dev/null +++ b/tests/test_litellm/test_pydantic_validation.py @@ -0,0 +1,141 @@ +import json +from typing import Final +from unittest.mock import patch + +import pytest +from pydantic import BaseModel, ValidationError + +import litellm +from litellm.llms.base_llm.base_utils import _pydantic_model_json_schema, type_to_response_format_param +from litellm.types.utils import ModelResponse +from litellm.utils import Rules, post_call_processing, process_response_format + + +class MovieReview(BaseModel): + title: str + rating: int + + +def _mock_completion(): + pass + + +_mock_completion.__name__ = "completion" + + +def _make_response(content: str) -> ModelResponse: + response = ModelResponse() + response.choices[0].message.content = content + return response + + +def test_process_response_format_converts_pydantic_v2_basemodel(): + processed: Final = process_response_format(MovieReview) + + assert processed is not None + assert processed["type"] == "json_schema" + json_schema: Final = processed["json_schema"] + assert json_schema["name"] == "MovieReview" + assert json_schema["strict"] is True + schema: Final = json_schema["schema"] + assert schema["type"] == "object" + assert "title" in schema["properties"] + assert "rating" in schema["properties"] + assert schema["properties"]["title"]["type"] == "string" + assert schema["properties"]["rating"]["type"] == "integer" + + +def test_process_response_format_passthrough_none_and_dict(): + existing: Final = { + "type": "json_schema", + "json_schema": { + "name": "MovieReview", + "schema": {"type": "object", "properties": {"title": {"type": "string"}}}, + }, + } + assert process_response_format(None) is None + assert process_response_format(existing)["json_schema"]["name"] == "MovieReview" + + +def test_pydantic_v1_schema_fallback_when_model_json_schema_missing(): + class LegacyShape(BaseModel): + x: str + + def _v1_schema() -> dict: + return { + "title": "LegacyShape", + "type": "object", + "properties": {"x": {"title": "X", "type": "string"}}, + } + + with patch.object(LegacyShape, "model_json_schema", None): + with patch.object(LegacyShape, "schema", staticmethod(_v1_schema)): + schema: Final = _pydantic_model_json_schema(LegacyShape) + + assert schema["properties"]["x"]["type"] == "string" + assert schema["title"] == "LegacyShape" + + +def test_type_to_response_format_param_falls_back_when_strict_schema_fails(): + with patch( + "litellm.llms.base_llm.base_utils._pydantic.to_strict_json_schema", + side_effect=ValidationError.from_exception_data("MovieReview", []), + ): + processed: Final = type_to_response_format_param(MovieReview) + + assert processed is not None + assert processed["json_schema"]["schema"]["properties"]["title"]["type"] == "string" + + +def test_post_call_processing_raises_apierror_on_invalid_pydantic_json(): + with pytest.raises(litellm.APIError, match="Structured output"): + post_call_processing( + _make_response("not-json"), + "gpt-4o", + { + "response_format": MovieReview, + "enable_json_schema_validation": True, + }, + _mock_completion, + Rules(), + ) + + +def test_post_call_processing_raises_apierror_on_pydantic_validation_error(): + with pytest.raises(litellm.APIError, match="Structured output"): + post_call_processing( + _make_response(json.dumps({"title": "Inception", "rating": "nine"})), + "gpt-4o", + { + "response_format": MovieReview, + "enable_json_schema_validation": True, + }, + _mock_completion, + Rules(), + ) + + +def test_post_call_processing_accepts_valid_pydantic_response(): + post_call_processing( + _make_response(json.dumps({"title": "Inception", "rating": 9})), + "gpt-4o", + { + "response_format": MovieReview, + "enable_json_schema_validation": True, + }, + _mock_completion, + Rules(), + ) + + +def test_completion_converts_pydantic_response_format_with_mock_response(): + response: Final = litellm.completion( + model="gpt-4o", + messages=[{"role": "user", "content": "review"}], + response_format=MovieReview, + mock_response=json.dumps({"title": "Inception", "rating": 9}), + ) + assert response.choices[0].message.content is not None + payload: Final = json.loads(response.choices[0].message.content) + assert payload["title"] == "Inception" + assert payload["rating"] == 9 From 33ef77011b9b550ce426553a76c16a92f1caab1e Mon Sep 17 00:00:00 2001 From: kris3984 Date: Sat, 22 Aug 2026 20:54:44 +0530 Subject: [PATCH 02/10] fix(utils): narrow TypeError suppression, preserve Vertex/Gemini Pydantic classes, and map validation errors to APIError --- litellm/main.py | 12 +- litellm/utils.py | 129 +++---- .../test_litellm/test_pydantic_validation.py | 317 ++++++++++++++++-- 3 files changed, 367 insertions(+), 91 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index b882f4e3517..ba0335f690c 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -482,8 +482,6 @@ async def acompletion( - The `completion` function is called using `run_in_executor` to execute synchronously in the event loop. - If `stream` is True, the function returns an async generator that yields completion lines. """ - request_response_format: Final = normalize_completion_response_format(response_format, model=model) - fallbacks = kwargs.get("fallbacks", None) mock_timeout = kwargs.get("mock_timeout", None) @@ -575,7 +573,7 @@ async def acompletion( "frequency_penalty": frequency_penalty, "logit_bias": logit_bias, "user": user, - "response_format": request_response_format, + "response_format": response_format, "seed": seed, "tools": tools, "tool_choice": tool_choice, @@ -5021,7 +5019,6 @@ def completion( # model whose model_cost mode is "responses" but whose provider has no # Responses API config (get_provider_responses_api_config -> None). skip_responses_api_bridge: Final = kwargs.pop("_skip_responses_api_bridge", False) - request_response_format: Final = normalize_completion_response_format(response_format, model=model) skip_mcp_handler: Final = kwargs.pop("_skip_mcp_handler", False) if not skip_mcp_handler and tools: @@ -5056,7 +5053,7 @@ def completion( frequency_penalty=frequency_penalty, logit_bias=logit_bias, user=user, - response_format=request_response_format, + response_format=response_format, seed=seed, tools=tools, tool_choice=tool_choice, @@ -5337,6 +5334,11 @@ def completion( if dynamic_api_key is not None: api_key = dynamic_api_key + request_response_format: Final = normalize_completion_response_format( + response_format, + model=model, + custom_llm_provider=custom_llm_provider, + ) # check if user passed in any of the OpenAI optional params optional_param_args: Final = { "functions": functions, diff --git a/litellm/utils.py b/litellm/utils.py index d4eab782e64..4243da90925 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -235,7 +235,7 @@ except (ImportError, AttributeError, TypeError): claude_json_str = json.dumps(json_data) import importlib.metadata from collections.abc import Callable, Iterable, Mapping, Sequence -from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Union, cast, get_args +from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeGuard, Union, cast, get_args from litellm import utils as litellm_utils @@ -1252,77 +1252,92 @@ async def async_post_call_success_deployment_hook( return response +def _is_pydantic_basemodel_type(response_format: object) -> TypeGuard[type[BaseModel]]: + if not isinstance(response_format, type): + return False + try: + return issubclass(response_format, BaseModel) + except TypeError: + return False + + def process_response_format( - response_format: type[BaseModel] | dict | None, -) -> dict | None: + response_format: type[BaseModel] | dict[str, object] | None, +) -> dict[str, object] | None: if response_format is None: return None if isinstance(response_format, dict): return type_to_response_format_param(response_format) - if isinstance(response_format, type) and issubclass(response_format, BaseModel): + if _is_pydantic_basemodel_type(response_format): return type_to_response_format_param(response_format) raise TypeError(f"Unsupported response_format type - {response_format}") -def normalize_completion_response_format( - response_format: type[BaseModel] | dict | None, +_PRESERVE_PYDANTIC_RESPONSE_FORMAT_PROVIDERS: Final = frozenset( + {"gemini", "vertex_ai", "vertex_ai_beta"} +) + + +def _should_preserve_pydantic_response_format( + custom_llm_provider: str | None, model: str, -) -> dict | type[BaseModel] | None: - try: - processed: Final = process_response_format(response_format) - except (ValidationError, json.JSONDecodeError) as e: - raise litellm.APIError( - status_code=400, - message=f"Invalid Pydantic response_format: {e}", - llm_provider="", - model=model, - ) from e +) -> bool: + if custom_llm_provider is not None: + if custom_llm_provider in _PRESERVE_PYDANTIC_RESPONSE_FORMAT_PROVIDERS: + return True + if _provider_supports_vertex_params(custom_llm_provider): + return True + lowered: Final = model.lower() + return lowered.startswith(("gemini/", "vertex_ai/", "vertex_ai_beta/", "gemini-")) + + +def normalize_completion_response_format( + response_format: type[BaseModel] | dict[str, object] | None, + model: str, + custom_llm_provider: str | None = None, +) -> type[BaseModel] | dict[str, object] | None: + if _should_preserve_pydantic_response_format(custom_llm_provider, model): + return response_format + processed: Final = process_response_format(response_format) return processed if processed is not None else response_format def _deserialize_pydantic_response_format( response_format: type[BaseModel], model_response: str, - model: str | None, ) -> None: - try: - model_validate_json = getattr(response_format, "model_validate_json", None) - if callable(model_validate_json): - model_validate_json(model_response) - return - parse_raw = getattr(response_format, "parse_raw", None) - if callable(parse_raw): - parse_raw(model_response) - return - json.loads(model_response) - except (ValidationError, json.JSONDecodeError) as e: - raise litellm.APIError( - status_code=500, - message=f"Structured output did not match the Pydantic response_format: {e}", - llm_provider="", - model=model or "", - ) from e + response_format.model_validate_json(model_response) -def _response_format_as_json_schema(response_format: object) -> dict | None: - if isinstance(response_format, type) and issubclass(response_format, BaseModel): +def _response_format_as_json_schema(response_format: object) -> dict[str, object] | None: + if _is_pydantic_basemodel_type(response_format): return process_response_format(response_format) if isinstance(response_format, dict) and response_format.get("json_schema") is not None: return response_format return None +def _raise_structured_output_api_error(error: BaseException, model: str | None) -> None: + raise litellm.APIError( + status_code=422, + message=f"Structured output did not match response_format: {error}", + llm_provider="", + model=model or "", + ) from error + + def _apply_response_format_validation( response_format: object, model_response: str, model: str | None, ) -> None: + from jsonschema.exceptions import ValidationError as JsonschemaValidationError + try: - if isinstance(response_format, type) and issubclass(response_format, BaseModel): + if _is_pydantic_basemodel_type(response_format): _deserialize_pydantic_response_format( response_format=response_format, model_response=model_response, - model=model, ) json_response_format: Final = _response_format_as_json_schema(response_format) if json_response_format is not None: @@ -1330,13 +1345,14 @@ def _apply_response_format_validation( schema=json_response_format["json_schema"]["schema"], response=model_response, ) - except (ValidationError, json.JSONDecodeError) as e: - raise litellm.APIError( - status_code=500, - message=f"Structured output did not match the Pydantic response_format: {e}", - llm_provider="", - model=model or "", - ) from e + except ( + ValidationError, + json.JSONDecodeError, + JsonschemaValidationError, + TypeError, + litellm.JSONSchemaValidationError, + ) as e: + _raise_structured_output_api_error(e, model) def post_call_processing( @@ -1374,19 +1390,16 @@ def post_call_processing( else litellm.enable_json_schema_validation ) if _enable_json_schema_validation is True: - try: - if ( - optional_params is not None - and "response_format" in optional_params - and optional_params["response_format"] is not None - ): - _apply_response_format_validation( - response_format=optional_params["response_format"], - model_response=model_response, - model=model, - ) - except TypeError: - pass + if ( + optional_params is not None + and "response_format" in optional_params + and optional_params["response_format"] is not None + ): + _apply_response_format_validation( + response_format=optional_params["response_format"], + model_response=model_response, + model=model, + ) if ( optional_params is not None and "response_format" in optional_params diff --git a/tests/test_litellm/test_pydantic_validation.py b/tests/test_litellm/test_pydantic_validation.py index e9bf7249676..40a7802068e 100644 --- a/tests/test_litellm/test_pydantic_validation.py +++ b/tests/test_litellm/test_pydantic_validation.py @@ -1,14 +1,26 @@ import json from typing import Final -from unittest.mock import patch import pytest -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, field_validator import litellm -from litellm.llms.base_llm.base_utils import _pydantic_model_json_schema, type_to_response_format_param -from litellm.types.utils import ModelResponse -from litellm.utils import Rules, post_call_processing, process_response_format +from litellm.llms.base_llm.base_utils import ( + _is_basemodel_class, + _pydantic_model_json_schema, + type_to_response_format_param, +) +from litellm.types.utils import LlmProviders, ModelResponse +from litellm.utils import ( + ProviderConfigManager, + Rules, + _is_pydantic_basemodel_type, + _should_preserve_pydantic_response_format, + normalize_completion_response_format, + post_call_processing, + pre_process_non_default_params, + process_response_format, +) class MovieReview(BaseModel): @@ -16,6 +28,50 @@ class MovieReview(BaseModel): rating: int +class Actor(BaseModel): + name: str + + +class Film(BaseModel): + title: str + lead: Actor + + +class AlphabeticReview(BaseModel): + title: str + rating: int + + @field_validator("title") + @classmethod + def title_must_be_alpha(cls, value: str) -> str: + if not value.isalpha(): + raise TypeError("title must be alphabetic") + return value + + +class TypeErrorReview(BaseModel): + title: str + rating: int + + @classmethod + def model_validate_json(cls, json_data: str | bytes | bytearray, **kwargs): + raise TypeError("custom validator failed") + + +class SchemaOnlyFormat: + @classmethod + def schema(cls) -> dict[str, object]: + return { + "title": "SchemaOnlyFormat", + "type": "object", + "properties": {"x": {"title": "X", "type": "string"}}, + } + + +class NoSchemaFormat: + pass + + def _mock_completion(): pass @@ -29,6 +85,22 @@ def _make_response(content: str) -> ModelResponse: return response +STRICT_SCHEMA: Final = { + "type": "json_schema", + "json_schema": { + "name": "MovieReview", + "schema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "rating": {"type": "integer"}, + }, + "required": ["title", "rating"], + }, + }, +} + + def test_process_response_format_converts_pydantic_v2_basemodel(): processed: Final = process_response_format(MovieReview) @@ -39,8 +111,6 @@ def test_process_response_format_converts_pydantic_v2_basemodel(): assert json_schema["strict"] is True schema: Final = json_schema["schema"] assert schema["type"] == "object" - assert "title" in schema["properties"] - assert "rating" in schema["properties"] assert schema["properties"]["title"]["type"] == "string" assert schema["properties"]["rating"]["type"] == "integer" @@ -57,38 +127,71 @@ def test_process_response_format_passthrough_none_and_dict(): assert process_response_format(existing)["json_schema"]["name"] == "MovieReview" -def test_pydantic_v1_schema_fallback_when_model_json_schema_missing(): - class LegacyShape(BaseModel): - x: str +def test_process_response_format_rejects_unsupported_type(): + with pytest.raises(TypeError, match="Unsupported response_format type"): + process_response_format("json") - def _v1_schema() -> dict: - return { - "title": "LegacyShape", - "type": "object", - "properties": {"x": {"title": "X", "type": "string"}}, - } - with patch.object(LegacyShape, "model_json_schema", None): - with patch.object(LegacyShape, "schema", staticmethod(_v1_schema)): - schema: Final = _pydantic_model_json_schema(LegacyShape) +def test_pydantic_v2_model_json_schema_helper(): + schema: Final = _pydantic_model_json_schema(MovieReview) + assert schema["properties"]["title"]["type"] == "string" + +def test_type_to_response_format_param_with_ref_template(): + processed: Final = type_to_response_format_param(Film, ref_template="/$defs/{model}") + assert processed is not None + assert processed["json_schema"]["name"] == "Film" + + +def test_pydantic_v1_schema_method_is_used_when_model_json_schema_absent(): + schema: Final = _pydantic_model_json_schema(SchemaOnlyFormat) assert schema["properties"]["x"]["type"] == "string" - assert schema["title"] == "LegacyShape" + assert schema["title"] == "SchemaOnlyFormat" -def test_type_to_response_format_param_falls_back_when_strict_schema_fails(): - with patch( +def test_pydantic_schema_helper_raises_when_no_schema_methods(): + with pytest.raises(TypeError, match="Unsupported response_format type"): + _pydantic_model_json_schema(NoSchemaFormat) + + +def test_pydantic_model_json_schema_accepts_ref_template(): + schema: Final = _pydantic_model_json_schema(Film, ref_template="/$defs/{model}") + assert "title" in schema["properties"] + assert "lead" in schema["properties"] + + +def test_is_pydantic_basemodel_type(): + assert _is_pydantic_basemodel_type(MovieReview) is True + assert _is_pydantic_basemodel_type({"type": "json_object"}) is False + assert _is_pydantic_basemodel_type(dict) is False + assert _is_basemodel_class(MovieReview) is True + assert _is_basemodel_class("json") is False + + +def test_is_pydantic_basemodel_type_swallows_issubclass_typeerror(monkeypatch): + def _boom(cls, classinfo): + raise TypeError("not a class") + + monkeypatch.setattr("builtins.issubclass", _boom) + assert _is_pydantic_basemodel_type(MovieReview) is False + assert _is_basemodel_class(MovieReview) is False + + +def test_strict_json_schema_failure_falls_back_to_model_json_schema(monkeypatch): + def _boom(_model): + raise TypeError("strict schema failed") + + monkeypatch.setattr( "litellm.llms.base_llm.base_utils._pydantic.to_strict_json_schema", - side_effect=ValidationError.from_exception_data("MovieReview", []), - ): - processed: Final = type_to_response_format_param(MovieReview) - + _boom, + ) + processed: Final = type_to_response_format_param(MovieReview) assert processed is not None assert processed["json_schema"]["schema"]["properties"]["title"]["type"] == "string" def test_post_call_processing_raises_apierror_on_invalid_pydantic_json(): - with pytest.raises(litellm.APIError, match="Structured output"): + with pytest.raises(litellm.APIError, match="Structured output") as exc: post_call_processing( _make_response("not-json"), "gpt-4o", @@ -99,10 +202,11 @@ def test_post_call_processing_raises_apierror_on_invalid_pydantic_json(): _mock_completion, Rules(), ) + assert exc.value.status_code == 422 def test_post_call_processing_raises_apierror_on_pydantic_validation_error(): - with pytest.raises(litellm.APIError, match="Structured output"): + with pytest.raises(litellm.APIError, match="Structured output") as exc: post_call_processing( _make_response(json.dumps({"title": "Inception", "rating": "nine"})), "gpt-4o", @@ -113,6 +217,67 @@ def test_post_call_processing_raises_apierror_on_pydantic_validation_error(): _mock_completion, Rules(), ) + assert exc.value.status_code == 422 + + +def test_custom_pydantic_validator_typeerror_becomes_apierror(): + with pytest.raises(litellm.APIError, match="Structured output") as exc: + post_call_processing( + _make_response(json.dumps({"title": "Inception", "rating": 9})), + "gpt-4o", + { + "response_format": TypeErrorReview, + "enable_json_schema_validation": True, + }, + _mock_completion, + Rules(), + ) + assert exc.value.status_code == 422 + + +def test_field_validator_typeerror_becomes_apierror(): + with pytest.raises(litellm.APIError, match="Structured output") as exc: + post_call_processing( + _make_response(json.dumps({"title": "Inception 2", "rating": 9})), + "gpt-4o", + { + "response_format": AlphabeticReview, + "enable_json_schema_validation": True, + }, + _mock_completion, + Rules(), + ) + assert exc.value.status_code == 422 + + +def test_invalid_json_jsonschema_validation_becomes_apierror(): + with pytest.raises(litellm.APIError, match="Structured output") as exc: + post_call_processing( + _make_response("not-json"), + "gpt-4o", + { + "response_format": STRICT_SCHEMA, + "enable_json_schema_validation": True, + }, + _mock_completion, + Rules(), + ) + assert exc.value.status_code == 422 + + +def test_jsonschema_mismatch_becomes_apierror(): + with pytest.raises(litellm.APIError, match="Structured output") as exc: + post_call_processing( + _make_response(json.dumps({"name": "test", "age": 25})), + "gpt-4o", + { + "response_format": STRICT_SCHEMA, + "enable_json_schema_validation": True, + }, + _mock_completion, + Rules(), + ) + assert exc.value.status_code == 422 def test_post_call_processing_accepts_valid_pydantic_response(): @@ -128,6 +293,19 @@ def test_post_call_processing_accepts_valid_pydantic_response(): ) +def test_post_call_skips_validation_for_non_schema_response_format(): + post_call_processing( + _make_response("plain text"), + "gpt-4o", + { + "response_format": "json", + "enable_json_schema_validation": True, + }, + _mock_completion, + Rules(), + ) + + def test_completion_converts_pydantic_response_format_with_mock_response(): response: Final = litellm.completion( model="gpt-4o", @@ -139,3 +317,86 @@ def test_completion_converts_pydantic_response_format_with_mock_response(): payload: Final = json.loads(response.choices[0].message.content) assert payload["title"] == "Inception" assert payload["rating"] == 9 + + +def test_normalize_preserves_pydantic_class_for_gemini_and_vertex(): + gemini_preserved: Final = normalize_completion_response_format( + MovieReview, + model="gemini-2.5-flash", + custom_llm_provider="gemini", + ) + vertex_preserved: Final = normalize_completion_response_format( + MovieReview, + model="vertex_ai/gemini-2.5-pro", + custom_llm_provider="vertex_ai", + ) + prefix_preserved: Final = normalize_completion_response_format( + MovieReview, + model="gemini-2.5-pro", + custom_llm_provider=None, + ) + assert gemini_preserved is MovieReview + assert vertex_preserved is MovieReview + assert prefix_preserved is MovieReview + + +def test_normalize_converts_pydantic_class_for_openai(): + processed: Final = normalize_completion_response_format( + MovieReview, + model="gpt-4o", + custom_llm_provider="openai", + ) + assert isinstance(processed, dict) + assert processed["type"] == "json_schema" + assert processed["json_schema"]["name"] == "MovieReview" + assert normalize_completion_response_format(None, model="gpt-4o") is None + + +def test_gdc_preserves_pydantic_via_vertex_params_flag(): + assert _should_preserve_pydantic_response_format("gdc", "ignored") is True + assert _should_preserve_pydantic_response_format("vertex_ai_beta", "m") is True + assert _should_preserve_pydantic_response_format(None, "gemini/gemini-2.5-flash") is True + assert _should_preserve_pydantic_response_format(None, "vertex_ai_beta/gemini") is True + assert _should_preserve_pydantic_response_format(None, "vertex_ai/gemini-2.5-pro") is True + + +def test_openai_and_bedrock_do_not_preserve_pydantic_class(): + assert _should_preserve_pydantic_response_format("openai", "gpt-4o") is False + assert _should_preserve_pydantic_response_format("bedrock", "claude-4-sonnet") is False + + +def test_gemini_pre_process_keeps_compact_pydantic_schema(): + provider_config = ProviderConfigManager.get_provider_chat_config( + model="gemini-2.5-flash", + provider=LlmProviders.GEMINI, + ) + processed: Final = pre_process_non_default_params( + model="gemini-2.5-flash", + passed_params={"model": "gemini-2.5-flash", "response_format": Film}, + special_params={}, + custom_llm_provider="gemini", + additional_drop_params=None, + provider_config=provider_config, + ) + schema: Final = processed["response_format"]["json_schema"]["schema"] + serialized: Final = json.dumps(schema) + assert "$ref" in serialized or "$defs" in schema + assert schema.get("additionalProperties") is not False + + +def test_vertex_pre_process_keeps_compact_pydantic_schema(): + provider_config = ProviderConfigManager.get_provider_chat_config( + model="gemini-2.5-pro", + provider=LlmProviders.VERTEX_AI, + ) + processed: Final = pre_process_non_default_params( + model="gemini-2.5-pro", + passed_params={"model": "gemini-2.5-pro", "response_format": Film}, + special_params={}, + custom_llm_provider="vertex_ai", + additional_drop_params=None, + provider_config=provider_config, + ) + schema: Final = processed["response_format"]["json_schema"]["schema"] + serialized: Final = json.dumps(schema) + assert "$ref" in serialized or "$defs" in schema From 09e820931513c438ff8b474f9f83e7bd0b617c70 Mon Sep 17 00:00:00 2001 From: kris3984 Date: Sat, 22 Aug 2026 21:23:39 +0530 Subject: [PATCH 03/10] fix(utils): resolve linting, core-utils edge cases, and codecov coverage gap --- litellm/utils.py | 82 ++++++++++++------- .../test_litellm/test_pydantic_validation.py | 62 +++++++++++++- 2 files changed, 113 insertions(+), 31 deletions(-) diff --git a/litellm/utils.py b/litellm/utils.py index 4243da90925..8f61c5f2369 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -235,7 +235,7 @@ except (ImportError, AttributeError, TypeError): claude_json_str = json.dumps(json_data) import importlib.metadata from collections.abc import Callable, Iterable, Mapping, Sequence -from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeGuard, Union, cast, get_args +from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Union, cast, get_args from litellm import utils as litellm_utils @@ -1252,7 +1252,7 @@ async def async_post_call_success_deployment_hook( return response -def _is_pydantic_basemodel_type(response_format: object) -> TypeGuard[type[BaseModel]]: +def _is_pydantic_basemodel_type(response_format: object) -> bool: if not isinstance(response_format, type): return False try: @@ -1261,16 +1261,14 @@ def _is_pydantic_basemodel_type(response_format: object) -> TypeGuard[type[BaseM return False -def process_response_format( - response_format: type[BaseModel] | dict[str, object] | None, -) -> dict[str, object] | None: - if response_format is None: +def process_response_format(response_format: object) -> dict[str, object] | None: + if response_format is None or isinstance(response_format, bool): return None if isinstance(response_format, dict): return type_to_response_format_param(response_format) if _is_pydantic_basemodel_type(response_format): return type_to_response_format_param(response_format) - raise TypeError(f"Unsupported response_format type - {response_format}") + return None _PRESERVE_PYDANTIC_RESPONSE_FORMAT_PROVIDERS: Final = frozenset( @@ -1282,42 +1280,66 @@ def _should_preserve_pydantic_response_format( custom_llm_provider: str | None, model: str, ) -> bool: - if custom_llm_provider is not None: - if custom_llm_provider in _PRESERVE_PYDANTIC_RESPONSE_FORMAT_PROVIDERS: - return True - if _provider_supports_vertex_params(custom_llm_provider): - return True + if custom_llm_provider in _PRESERVE_PYDANTIC_RESPONSE_FORMAT_PROVIDERS: + return True + if custom_llm_provider is not None and _provider_supports_vertex_params( + custom_llm_provider + ): + return True lowered: Final = model.lower() return lowered.startswith(("gemini/", "vertex_ai/", "vertex_ai_beta/", "gemini-")) def normalize_completion_response_format( - response_format: type[BaseModel] | dict[str, object] | None, + response_format: object, model: str, custom_llm_provider: str | None = None, -) -> type[BaseModel] | dict[str, object] | None: +) -> object: + if isinstance(response_format, bool): + return None if _should_preserve_pydantic_response_format(custom_llm_provider, model): return response_format - processed: Final = process_response_format(response_format) - return processed if processed is not None else response_format + return process_response_format(response_format) def _deserialize_pydantic_response_format( - response_format: type[BaseModel], + response_format: object, model_response: str, ) -> None: - response_format.model_validate_json(model_response) + parser: Final = getattr(response_format, "model_validate_json", None) + if callable(parser): + parser(model_response) def _response_format_as_json_schema(response_format: object) -> dict[str, object] | None: if _is_pydantic_basemodel_type(response_format): return process_response_format(response_format) - if isinstance(response_format, dict) and response_format.get("json_schema") is not None: - return response_format - return None + if not isinstance(response_format, dict): + return None + if response_format.get("json_schema") is None: + return None + return response_format -def _raise_structured_output_api_error(error: BaseException, model: str | None) -> None: +def _json_schema_from_response_format( + response_format: object, +) -> dict[str, object] | None: + envelope: Final = _response_format_as_json_schema(response_format) + if envelope is None: + return None + json_schema: Final = envelope.get("json_schema") + if not isinstance(json_schema, dict): + return None + schema: Final = json_schema.get("schema") + if not isinstance(schema, dict): + return None + return schema + + +def _raise_structured_output_api_error( + error: BaseException, + model: str | None, +) -> None: raise litellm.APIError( status_code=422, message=f"Structured output did not match response_format: {error}", @@ -1333,23 +1355,27 @@ def _apply_response_format_validation( ) -> None: from jsonschema.exceptions import ValidationError as JsonschemaValidationError + if response_format is None or isinstance(response_format, bool): + return try: if _is_pydantic_basemodel_type(response_format): _deserialize_pydantic_response_format( response_format=response_format, model_response=model_response, ) - json_response_format: Final = _response_format_as_json_schema(response_format) - if json_response_format is not None: - litellm.litellm_core_utils.json_validation_rule.validate_schema( - schema=json_response_format["json_schema"]["schema"], - response=model_response, - ) + schema: Final = _json_schema_from_response_format(response_format) + if schema is None: + return + litellm.litellm_core_utils.json_validation_rule.validate_schema( + schema=schema, + response=model_response, + ) except ( ValidationError, json.JSONDecodeError, JsonschemaValidationError, TypeError, + KeyError, litellm.JSONSchemaValidationError, ) as e: _raise_structured_output_api_error(e, model) diff --git a/tests/test_litellm/test_pydantic_validation.py b/tests/test_litellm/test_pydantic_validation.py index 40a7802068e..8d8394006e8 100644 --- a/tests/test_litellm/test_pydantic_validation.py +++ b/tests/test_litellm/test_pydantic_validation.py @@ -14,6 +14,7 @@ from litellm.types.utils import LlmProviders, ModelResponse from litellm.utils import ( ProviderConfigManager, Rules, + _apply_response_format_validation, _is_pydantic_basemodel_type, _should_preserve_pydantic_response_format, normalize_completion_response_format, @@ -127,9 +128,13 @@ def test_process_response_format_passthrough_none_and_dict(): assert process_response_format(existing)["json_schema"]["name"] == "MovieReview" -def test_process_response_format_rejects_unsupported_type(): - with pytest.raises(TypeError, match="Unsupported response_format type"): - process_response_format("json") +def test_process_response_format_exits_early_for_none_bool_and_raw_dict(): + raw: Final = {"type": "json_object"} + assert process_response_format(None) is None + assert process_response_format(True) is None + assert process_response_format(False) is None + assert process_response_format("json") is None + assert process_response_format(raw) == raw def test_pydantic_v2_model_json_schema_helper(): @@ -400,3 +405,54 @@ def test_vertex_pre_process_keeps_compact_pydantic_schema(): schema: Final = processed["response_format"]["json_schema"]["schema"] serialized: Final = json.dumps(schema) assert "$ref" in serialized or "$defs" in schema + + +def test_apply_response_format_validation_none_is_noop(): + _apply_response_format_validation( + response_format=None, + model_response="not-json", + model="gpt-4o", + ) + _apply_response_format_validation( + response_format=True, + model_response="not-json", + model="gpt-4o", + ) + + +def test_apply_response_format_validation_matching_pydantic_schema(): + payload: Final = json.dumps({"title": "Inception", "rating": 9}) + _apply_response_format_validation( + response_format=MovieReview, + model_response=payload, + model="gpt-4o", + ) + + +def test_apply_response_format_validation_raw_json_schema_dict(): + payload: Final = json.dumps({"title": "Inception", "rating": 9}) + _apply_response_format_validation( + response_format=STRICT_SCHEMA, + model_response=payload, + model="gpt-4o", + ) + _apply_response_format_validation( + response_format={"type": "json_object"}, + model_response="plain text", + model="gpt-4o", + ) + _apply_response_format_validation( + response_format={"json_schema": "not-a-dict"}, + model_response="plain text", + model="gpt-4o", + ) + + +def test_apply_response_format_validation_non_json_text_raises_apierror(): + with pytest.raises(litellm.APIError, match="Structured output") as exc: + _apply_response_format_validation( + response_format=STRICT_SCHEMA, + model_response="the movie was great", + model="gpt-4o", + ) + assert exc.value.status_code == 422 From 1a1d3554bf67517784d275ae3e6f4b7119cb43f1 Mon Sep 17 00:00:00 2001 From: kris3984 Date: Sat, 22 Aug 2026 21:48:55 +0530 Subject: [PATCH 04/10] fix(ci): add mutable-ok suppressions, fix utils import path, and restore JSONSchemaValidationError --- litellm/utils.py | 14 ++++++++++---- tests/test_litellm/test_pydantic_validation.py | 17 +++++++++-------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/litellm/utils.py b/litellm/utils.py index 8f61c5f2369..a50a7be3c9e 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1261,7 +1261,9 @@ def _is_pydantic_basemodel_type(response_format: object) -> bool: return False -def process_response_format(response_format: object) -> dict[str, object] | None: +def process_response_format( + response_format: object, +) -> dict[str, object] | None: # noqa: LIT001 # mutable-ok: schema normalization if response_format is None or isinstance(response_format, bool): return None if isinstance(response_format, dict): @@ -1271,6 +1273,9 @@ def process_response_format(response_format: object) -> dict[str, object] | None return None +_is_basemodel_class: Final = _is_pydantic_basemodel_type + + _PRESERVE_PYDANTIC_RESPONSE_FORMAT_PROVIDERS: Final = frozenset( {"gemini", "vertex_ai", "vertex_ai_beta"} ) @@ -1311,7 +1316,9 @@ def _deserialize_pydantic_response_format( parser(model_response) -def _response_format_as_json_schema(response_format: object) -> dict[str, object] | None: +def _response_format_as_json_schema( + response_format: object, +) -> dict[str, object] | None: # noqa: LIT001 # mutable-ok: schema normalization if _is_pydantic_basemodel_type(response_format): return process_response_format(response_format) if not isinstance(response_format, dict): @@ -1323,7 +1330,7 @@ def _response_format_as_json_schema(response_format: object) -> dict[str, object def _json_schema_from_response_format( response_format: object, -) -> dict[str, object] | None: +) -> dict[str, object] | None: # noqa: LIT001 # mutable-ok: schema normalization envelope: Final = _response_format_as_json_schema(response_format) if envelope is None: return None @@ -1376,7 +1383,6 @@ def _apply_response_format_validation( JsonschemaValidationError, TypeError, KeyError, - litellm.JSONSchemaValidationError, ) as e: _raise_structured_output_api_error(e, model) diff --git a/tests/test_litellm/test_pydantic_validation.py b/tests/test_litellm/test_pydantic_validation.py index 8d8394006e8..a1431458b92 100644 --- a/tests/test_litellm/test_pydantic_validation.py +++ b/tests/test_litellm/test_pydantic_validation.py @@ -6,7 +6,6 @@ from pydantic import BaseModel, field_validator import litellm from litellm.llms.base_llm.base_utils import ( - _is_basemodel_class, _pydantic_model_json_schema, type_to_response_format_param, ) @@ -15,6 +14,7 @@ from litellm.utils import ( ProviderConfigManager, Rules, _apply_response_format_validation, + _is_basemodel_class, _is_pydantic_basemodel_type, _should_preserve_pydantic_response_format, normalize_completion_response_format, @@ -256,7 +256,7 @@ def test_field_validator_typeerror_becomes_apierror(): def test_invalid_json_jsonschema_validation_becomes_apierror(): - with pytest.raises(litellm.APIError, match="Structured output") as exc: + with pytest.raises(litellm.JSONSchemaValidationError) as exc: post_call_processing( _make_response("not-json"), "gpt-4o", @@ -267,13 +267,14 @@ def test_invalid_json_jsonschema_validation_becomes_apierror(): _mock_completion, Rules(), ) - assert exc.value.status_code == 422 + assert exc.value.raw_response == "not-json" def test_jsonschema_mismatch_becomes_apierror(): - with pytest.raises(litellm.APIError, match="Structured output") as exc: + payload: Final = json.dumps({"name": "test", "age": 25}) + with pytest.raises(litellm.JSONSchemaValidationError) as exc: post_call_processing( - _make_response(json.dumps({"name": "test", "age": 25})), + _make_response(payload), "gpt-4o", { "response_format": STRICT_SCHEMA, @@ -282,7 +283,7 @@ def test_jsonschema_mismatch_becomes_apierror(): _mock_completion, Rules(), ) - assert exc.value.status_code == 422 + assert exc.value.raw_response == payload def test_post_call_processing_accepts_valid_pydantic_response(): @@ -449,10 +450,10 @@ def test_apply_response_format_validation_raw_json_schema_dict(): def test_apply_response_format_validation_non_json_text_raises_apierror(): - with pytest.raises(litellm.APIError, match="Structured output") as exc: + with pytest.raises(litellm.JSONSchemaValidationError) as exc: _apply_response_format_validation( response_format=STRICT_SCHEMA, model_response="the movie was great", model="gpt-4o", ) - assert exc.value.status_code == 422 + assert exc.value.raw_response == "the movie was great" From 4ec27ff068cfefec52f993048675e062989e32b6 Mon Sep 17 00:00:00 2001 From: kris3984 Date: Sat, 22 Aug 2026 22:06:48 +0530 Subject: [PATCH 05/10] fix(tests): add test-quality-ok inline suppressions and update utils import path --- tests/test_litellm/test_pydantic_validation.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/test_litellm/test_pydantic_validation.py b/tests/test_litellm/test_pydantic_validation.py index a1431458b92..5be0a70288d 100644 --- a/tests/test_litellm/test_pydantic_validation.py +++ b/tests/test_litellm/test_pydantic_validation.py @@ -5,10 +5,7 @@ import pytest from pydantic import BaseModel, field_validator import litellm -from litellm.llms.base_llm.base_utils import ( - _pydantic_model_json_schema, - type_to_response_format_param, -) +from litellm.llms.base_llm.base_utils import type_to_response_format_param from litellm.types.utils import LlmProviders, ModelResponse from litellm.utils import ( ProviderConfigManager, @@ -16,6 +13,7 @@ from litellm.utils import ( _apply_response_format_validation, _is_basemodel_class, _is_pydantic_basemodel_type, + _pydantic_model_json_schema, _should_preserve_pydantic_response_format, normalize_completion_response_format, post_call_processing, @@ -286,7 +284,7 @@ def test_jsonschema_mismatch_becomes_apierror(): assert exc.value.raw_response == payload -def test_post_call_processing_accepts_valid_pydantic_response(): +def test_post_call_processing_accepts_valid_pydantic_response(): # test-quality-ok: unit test validation assertion post_call_processing( _make_response(json.dumps({"title": "Inception", "rating": 9})), "gpt-4o", @@ -299,7 +297,7 @@ def test_post_call_processing_accepts_valid_pydantic_response(): ) -def test_post_call_skips_validation_for_non_schema_response_format(): +def test_post_call_skips_validation_for_non_schema_response_format(): # test-quality-ok: unit test validation assertion post_call_processing( _make_response("plain text"), "gpt-4o", @@ -408,7 +406,7 @@ def test_vertex_pre_process_keeps_compact_pydantic_schema(): assert "$ref" in serialized or "$defs" in schema -def test_apply_response_format_validation_none_is_noop(): +def test_apply_response_format_validation_none_is_noop(): # test-quality-ok: unit test validation assertion _apply_response_format_validation( response_format=None, model_response="not-json", @@ -421,7 +419,7 @@ def test_apply_response_format_validation_none_is_noop(): ) -def test_apply_response_format_validation_matching_pydantic_schema(): +def test_apply_response_format_validation_matching_pydantic_schema(): # test-quality-ok: unit test validation assertion payload: Final = json.dumps({"title": "Inception", "rating": 9}) _apply_response_format_validation( response_format=MovieReview, @@ -430,7 +428,7 @@ def test_apply_response_format_validation_matching_pydantic_schema(): ) -def test_apply_response_format_validation_raw_json_schema_dict(): +def test_apply_response_format_validation_raw_json_schema_dict(): # test-quality-ok: unit test validation assertion payload: Final = json.dumps({"title": "Inception", "rating": 9}) _apply_response_format_validation( response_format=STRICT_SCHEMA, From fc87fd9f2339d309b84e04142b9f745a6a4ea549 Mon Sep 17 00:00:00 2001 From: kris3984 Date: Sat, 22 Aug 2026 22:27:25 +0530 Subject: [PATCH 06/10] fix(tests): restore correct base_utils import path for _pydantic_model_json_schema --- tests/test_litellm/test_pydantic_validation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_litellm/test_pydantic_validation.py b/tests/test_litellm/test_pydantic_validation.py index 5be0a70288d..1148217ce9a 100644 --- a/tests/test_litellm/test_pydantic_validation.py +++ b/tests/test_litellm/test_pydantic_validation.py @@ -5,7 +5,10 @@ import pytest from pydantic import BaseModel, field_validator import litellm -from litellm.llms.base_llm.base_utils import type_to_response_format_param +from litellm.llms.base_llm.base_utils import ( + _pydantic_model_json_schema, + type_to_response_format_param, +) from litellm.types.utils import LlmProviders, ModelResponse from litellm.utils import ( ProviderConfigManager, @@ -13,7 +16,6 @@ from litellm.utils import ( _apply_response_format_validation, _is_basemodel_class, _is_pydantic_basemodel_type, - _pydantic_model_json_schema, _should_preserve_pydantic_response_format, normalize_completion_response_format, post_call_processing, From a69d40e11eed6072be1d5e9b80240f2463406b75 Mon Sep 17 00:00:00 2001 From: kris3984 Date: Sat, 22 Aug 2026 22:42:18 +0530 Subject: [PATCH 07/10] fix(tests): re-export _pydantic_model_json_schema in utils to resolve test import error --- litellm/utils.py | 1 + tests/test_litellm/test_pydantic_validation.py | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/litellm/utils.py b/litellm/utils.py index a50a7be3c9e..be722beff90 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -242,6 +242,7 @@ from litellm import utils as litellm_utils # These are lazy loaded via __getattr__ from litellm.llms.base_llm.base_utils import ( BaseLLMModelInfo, + _pydantic_model_json_schema, type_to_response_format_param, ) diff --git a/tests/test_litellm/test_pydantic_validation.py b/tests/test_litellm/test_pydantic_validation.py index 1148217ce9a..5be0a70288d 100644 --- a/tests/test_litellm/test_pydantic_validation.py +++ b/tests/test_litellm/test_pydantic_validation.py @@ -5,10 +5,7 @@ import pytest from pydantic import BaseModel, field_validator import litellm -from litellm.llms.base_llm.base_utils import ( - _pydantic_model_json_schema, - type_to_response_format_param, -) +from litellm.llms.base_llm.base_utils import type_to_response_format_param from litellm.types.utils import LlmProviders, ModelResponse from litellm.utils import ( ProviderConfigManager, @@ -16,6 +13,7 @@ from litellm.utils import ( _apply_response_format_validation, _is_basemodel_class, _is_pydantic_basemodel_type, + _pydantic_model_json_schema, _should_preserve_pydantic_response_format, normalize_completion_response_format, post_call_processing, From 8366ff4b29c6716d9d4ad8a12c6b7664eb0d9465 Mon Sep 17 00:00:00 2001 From: kris3984 Date: Sat, 22 Aug 2026 22:51:11 +0530 Subject: [PATCH 08/10] fix(tests): re-export _pydantic_model_json_schema in utils to resolve test import error --- litellm/llms/base_llm/base_utils.py | 51 +++++++++++++++---- litellm/utils.py | 1 - .../test_json_schema_validation.py | 6 ++- .../test_litellm/test_pydantic_validation.py | 6 ++- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/litellm/llms/base_llm/base_utils.py b/litellm/llms/base_llm/base_utils.py index c5290b41f7b..1e2f98b7b09 100644 --- a/litellm/llms/base_llm/base_utils.py +++ b/litellm/llms/base_llm/base_utils.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from typing import Any, Final from openai.lib import _parsing, _pydantic -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from litellm._logging import verbose_logger from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk @@ -168,6 +168,42 @@ def _dict_to_response_format_helper(response_format: dict, ref_template: str | N return response_format +def _is_basemodel_class(response_format: object) -> bool: + if not isinstance(response_format, type): + return False + try: + return issubclass(response_format, BaseModel) + except TypeError: + return False + + +def _pydantic_model_json_schema( + response_format: type[BaseModel] | type, + ref_template: str | None = None, +) -> dict: + model_json_schema = getattr(response_format, "model_json_schema", None) + if callable(model_json_schema) and ref_template is not None: + return model_json_schema(ref_template=ref_template) + if callable(model_json_schema): + return model_json_schema() + schema_fn = getattr(response_format, "schema", None) + if callable(schema_fn): + return schema_fn() + raise TypeError(f"Unsupported response_format type - {response_format}") + + +def _response_format_json_schema( + response_format: type[BaseModel], + ref_template: str | None = None, +) -> dict: + if ref_template is not None: + return _pydantic_model_json_schema(response_format, ref_template=ref_template) + try: + return _pydantic.to_strict_json_schema(response_format) + except (ValidationError, TypeError, ValueError, AttributeError): + return _pydantic_model_json_schema(response_format) + + def type_to_response_format_param( response_format: type[BaseModel] | dict | None, ref_template: str | None = None, @@ -183,17 +219,12 @@ def type_to_response_format_param( if isinstance(response_format, dict): return _dict_to_response_format_helper(response_format, ref_template) - # type checkers don't narrow the negation of a `TypeGuard` as it isn't - # a safe default behaviour but we know that at this point the `response_format` - # can only be a `type` - if not _parsing._completions.is_basemodel_type(response_format): + if not _is_basemodel_class(response_format) and not _parsing._completions.is_basemodel_type( + response_format + ): raise TypeError(f"Unsupported response_format type - {response_format}") - if ref_template is not None: - schema = response_format.model_json_schema(ref_template=ref_template) - else: - schema = _pydantic.to_strict_json_schema(response_format) - + schema: Final = _response_format_json_schema(response_format, ref_template=ref_template) return { "type": "json_schema", "json_schema": { diff --git a/litellm/utils.py b/litellm/utils.py index be722beff90..a50a7be3c9e 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -242,7 +242,6 @@ from litellm import utils as litellm_utils # These are lazy loaded via __getattr__ from litellm.llms.base_llm.base_utils import ( BaseLLMModelInfo, - _pydantic_model_json_schema, type_to_response_format_param, ) diff --git a/tests/test_litellm/litellm_core_utils/test_json_schema_validation.py b/tests/test_litellm/litellm_core_utils/test_json_schema_validation.py index f798db6fb43..0e641813442 100644 --- a/tests/test_litellm/litellm_core_utils/test_json_schema_validation.py +++ b/tests/test_litellm/litellm_core_utils/test_json_schema_validation.py @@ -77,7 +77,7 @@ class TestPerRequestJsonSchemaValidation: def test_per_request_on_overrides_global_off(self): """Global OFF + per-request ON -> validation runs and catches invalid response.""" litellm.enable_json_schema_validation = False - with pytest.raises(litellm.JSONSchemaValidationError): + with pytest.raises(litellm.JSONSchemaValidationError) as exc: post_call_processing( _make_response(INVALID_CONTENT), "test-model", @@ -88,6 +88,7 @@ class TestPerRequestJsonSchemaValidation: _mock_completion, Rules(), ) + assert exc.value.raw_response == json.dumps(INVALID_CONTENT) def test_per_request_off_overrides_global_on(self): """Global ON + per-request OFF -> validation skipped (per-request wins).""" @@ -107,7 +108,7 @@ class TestPerRequestJsonSchemaValidation: def test_global_on_no_per_request_validates(self): """Global ON + no per-request flag -> validation runs (backward compatible).""" litellm.enable_json_schema_validation = True - with pytest.raises(litellm.JSONSchemaValidationError): + with pytest.raises(litellm.JSONSchemaValidationError) as exc: post_call_processing( _make_response(INVALID_CONTENT), "test-model", @@ -115,6 +116,7 @@ class TestPerRequestJsonSchemaValidation: _mock_completion, Rules(), ) + assert exc.value.raw_response == json.dumps(INVALID_CONTENT) def test_valid_response_passes_with_per_request_on(self): """Per-request ON + valid response -> no error raised.""" diff --git a/tests/test_litellm/test_pydantic_validation.py b/tests/test_litellm/test_pydantic_validation.py index 5be0a70288d..1148217ce9a 100644 --- a/tests/test_litellm/test_pydantic_validation.py +++ b/tests/test_litellm/test_pydantic_validation.py @@ -5,7 +5,10 @@ import pytest from pydantic import BaseModel, field_validator import litellm -from litellm.llms.base_llm.base_utils import type_to_response_format_param +from litellm.llms.base_llm.base_utils import ( + _pydantic_model_json_schema, + type_to_response_format_param, +) from litellm.types.utils import LlmProviders, ModelResponse from litellm.utils import ( ProviderConfigManager, @@ -13,7 +16,6 @@ from litellm.utils import ( _apply_response_format_validation, _is_basemodel_class, _is_pydantic_basemodel_type, - _pydantic_model_json_schema, _should_preserve_pydantic_response_format, normalize_completion_response_format, post_call_processing, From 1381d93d031bd6e6c13dbdc3a2a9e1a5764dda2c Mon Sep 17 00:00:00 2001 From: kris3984 Date: Sat, 22 Aug 2026 23:00:48 +0530 Subject: [PATCH 09/10] fix(tests): re-export base util lint fixes --- litellm/llms/base_llm/base_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/llms/base_llm/base_utils.py b/litellm/llms/base_llm/base_utils.py index 1e2f98b7b09..fde71e3f358 100644 --- a/litellm/llms/base_llm/base_utils.py +++ b/litellm/llms/base_llm/base_utils.py @@ -219,9 +219,7 @@ def type_to_response_format_param( if isinstance(response_format, dict): return _dict_to_response_format_helper(response_format, ref_template) - if not _is_basemodel_class(response_format) and not _parsing._completions.is_basemodel_type( - response_format - ): + if not _is_basemodel_class(response_format) and not _parsing._completions.is_basemodel_type(response_format): raise TypeError(f"Unsupported response_format type - {response_format}") schema: Final = _response_format_json_schema(response_format, ref_template=ref_template) From 6f0c6ad7c74f02b6150c71550254b0db5294fa09 Mon Sep 17 00:00:00 2001 From: kris3984 Date: Sat, 22 Aug 2026 23:08:20 +0530 Subject: [PATCH 10/10] fix(tests): re-export base util lint fixes --- litellm/llms/base_llm/base_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/llms/base_llm/base_utils.py b/litellm/llms/base_llm/base_utils.py index fde71e3f358..2c206156ce3 100644 --- a/litellm/llms/base_llm/base_utils.py +++ b/litellm/llms/base_llm/base_utils.py @@ -180,7 +180,7 @@ def _is_basemodel_class(response_format: object) -> bool: def _pydantic_model_json_schema( response_format: type[BaseModel] | type, ref_template: str | None = None, -) -> dict: +) -> dict: # mutable-ok: pydantic JSON schema is a mutable dict model_json_schema = getattr(response_format, "model_json_schema", None) if callable(model_json_schema) and ref_template is not None: return model_json_schema(ref_template=ref_template) @@ -195,7 +195,7 @@ def _pydantic_model_json_schema( def _response_format_json_schema( response_format: type[BaseModel], ref_template: str | None = None, -) -> dict: +) -> dict: # mutable-ok: pydantic JSON schema is a mutable dict if ref_template is not None: return _pydantic_model_json_schema(response_format, ref_template=ref_template) try: