Refactor _supports_native_structured_outputs to use standard supports_* utility pattern

Addresses Greptile review feedback: replace direct litellm.model_cost
lookup with the standard _supports_factory infrastructure used by
supports_reasoning, supports_native_streaming, etc.

- Add supports_native_structured_output() utility in litellm/utils.py
- Add supports_native_structured_output field to ModelInfoBase type
- Wire field into _get_model_info_helper return dict
- Delegate from Bedrock _supports_native_structured_outputs to utility
- Add field to JSON schema validator in test_utils.py
This commit is contained in:
Nicholas Gigliotti 2026-03-26 21:46:52 -04:00
parent b45ef7f6b9
commit 92654bad37
7 changed files with 76 additions and 60 deletions

View File

@ -703,28 +703,21 @@ class AmazonConverseConfig(BaseConfig):
return _tool
@staticmethod
def _supports_native_structured_outputs(model: str) -> bool:
def _supports_native_structured_outputs(
model: str, custom_llm_provider: Optional[str] = None
) -> bool:
"""Check if the Bedrock model supports native structured outputs (outputConfig.textFormat).
Looks up the ``supports_native_structured_output`` flag in
``litellm.model_cost`` (set in the cost JSON).
Delegates to the standard ``supports_native_structured_output`` utility
which looks up the flag in ``litellm.model_cost`` via
``_get_model_info_helper``.
Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html
"""
from litellm.llms.bedrock.common_utils import get_bedrock_base_model
from litellm.utils import supports_native_structured_output
base_model = get_bedrock_base_model(model)
# Try direct lookup
info = litellm.model_cost.get(base_model)
# Try without version suffix (e.g. "model-v1:0" -> "model-v1")
if info is None and ":" in base_model:
info = litellm.model_cost.get(base_model.rsplit(":", 1)[0])
if info is not None:
return info.get("supports_native_structured_output", False) is True
return False
return supports_native_structured_output(
model=model, custom_llm_provider=custom_llm_provider
)
@staticmethod
def _add_additional_properties_to_schema(schema: dict) -> dict:
@ -951,7 +944,7 @@ class AmazonConverseConfig(BaseConfig):
if "type" in value and value["type"] == "text":
return optional_params
if self._supports_native_structured_outputs(model) and json_schema is not None:
if self._supports_native_structured_outputs(model, self.custom_llm_provider) and json_schema is not None:
# Use Bedrock's native structured outputs API (outputConfig.textFormat)
# No synthetic tool injection, no fake_stream needed.
# Requires an explicit schema — json_object with no schema falls through

View File

@ -31179,9 +31179,7 @@
"mode": "chat",
"output_cost_per_token": 3.2e-06,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#glm-models",
"supported_regions": [
"global"
],
"supported_regions": ["global"],
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,

View File

@ -132,6 +132,7 @@ class ProviderSpecificModelInfo(TypedDict, total=False):
supports_audio_output: Optional[bool]
supports_pdf_input: Optional[bool]
supports_native_streaming: Optional[bool]
supports_native_structured_output: Optional[bool]
supports_parallel_function_calling: Optional[bool]
supports_web_search: Optional[bool]
supports_reasoning: Optional[bool]

View File

@ -2735,6 +2735,19 @@ def supports_reasoning(model: str, custom_llm_provider: Optional[str] = None) ->
)
def supports_native_structured_output(
model: str, custom_llm_provider: Optional[str] = None
) -> bool:
"""
Check if the given model supports native structured outputs and return a boolean value.
"""
return _supports_factory(
model=model,
custom_llm_provider=custom_llm_provider,
key="supports_native_structured_output",
)
def get_supported_regions(
model: str, custom_llm_provider: Optional[str] = None
) -> Optional[List[str]]:
@ -5831,6 +5844,9 @@ def _get_model_info_helper( # noqa: PLR0915
supports_native_streaming=_model_info.get(
"supports_native_streaming", None
),
supports_native_structured_output=_model_info.get(
"supports_native_structured_output", None
),
supports_web_search=_model_info.get("supports_web_search", None),
supports_url_context=_model_info.get("supports_url_context", None),
supports_reasoning=_model_info.get("supports_reasoning", None),

View File

@ -31179,9 +31179,7 @@
"mode": "chat",
"output_cost_per_token": 3.2e-06,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#glm-models",
"supported_regions": [
"global"
],
"supported_regions": ["global"],
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,

View File

@ -2656,8 +2656,6 @@ def test_supports_native_structured_outputs():
assert config._supports_native_structured_outputs("anthropic.claude-sonnet-4-5-20250929-v1:0")
assert config._supports_native_structured_outputs("anthropic.claude-haiku-4-5-20251001-v1:0")
assert config._supports_native_structured_outputs("anthropic.claude-opus-4-6-v1")
# Version suffix (:0) is stripped when looking up models without it in cost JSON
assert config._supports_native_structured_outputs("anthropic.claude-opus-4-6-v1:0")
# Regional prefix is stripped by get_bedrock_base_model
assert config._supports_native_structured_outputs("eu.anthropic.claude-opus-4-5-20251101-v1:0")
# Claude 4.6 Sonnet
@ -2728,46 +2726,57 @@ def test_create_output_config_for_response_format():
def test_translate_response_format_native_output_config():
"""For supported models, _translate_response_format_param should produce outputConfig."""
config = AmazonConverseConfig()
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
old_cost = litellm.model_cost
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
try:
config = AmazonConverseConfig()
response_format = {
"type": "json_schema",
"json_schema": {
"name": "WeatherResult",
"description": "Weather info",
"schema": {
"type": "object",
"properties": {
"temp": {"type": "number"},
response_format = {
"type": "json_schema",
"json_schema": {
"name": "WeatherResult",
"description": "Weather info",
"schema": {
"type": "object",
"properties": {
"temp": {"type": "number"},
},
"required": ["temp"],
},
"required": ["temp"],
},
},
}
}
optional_params: dict = {}
result = config._translate_response_format_param(
value=response_format,
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
optional_params=optional_params,
non_default_params={"response_format": response_format},
is_thinking_enabled=False,
)
optional_params: dict = {}
result = config._translate_response_format_param(
value=response_format,
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
optional_params=optional_params,
non_default_params={"response_format": response_format},
is_thinking_enabled=False,
)
# Should have outputConfig, NOT tools
assert "outputConfig" in result
assert "tools" not in result
assert "tool_choice" not in result
assert result["json_mode"] is True
# No fake_stream for native approach
assert "fake_stream" not in result
# Should have outputConfig, NOT tools
assert "outputConfig" in result
assert "tools" not in result
assert "tool_choice" not in result
assert result["json_mode"] is True
# No fake_stream for native approach
assert "fake_stream" not in result
# Verify the schema content (additionalProperties: false is added by normalization)
schema_str = result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"]
parsed_schema = json.loads(schema_str)
expected_schema = {**response_format["json_schema"]["schema"], "additionalProperties": False}
assert parsed_schema == expected_schema
assert result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] == "WeatherResult"
# Verify the schema content (additionalProperties: false is added by normalization)
schema_str = result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"]
parsed_schema = json.loads(schema_str)
expected_schema = {**response_format["json_schema"]["schema"], "additionalProperties": False}
assert parsed_schema == expected_schema
assert result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] == "WeatherResult"
finally:
litellm.model_cost = old_cost
if old_env is None:
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
else:
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
def test_translate_response_format_fallback_tool_call():

View File

@ -831,6 +831,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
},
},
"supports_native_streaming": {"type": "boolean"},
"supports_native_structured_output": {"type": "boolean"},
"tiered_pricing": {
"type": "array",
"items": {