feat: support anthropic structured outputs [LET-6190] (#6200)

This commit is contained in:
Sarah Wooders
2025-11-17 11:18:03 -08:00
committed by Caren Thomas
parent 2addd4eb0d
commit c18af2bc81
3 changed files with 79 additions and 10 deletions

View File

@@ -19502,6 +19502,36 @@
"budget_tokens": 1024 "budget_tokens": 1024
} }
}, },
"output_format": {
"anyOf": [
{
"oneOf": [
{
"$ref": "#/components/schemas/TextResponseFormat"
},
{
"$ref": "#/components/schemas/JsonSchemaResponseFormat"
},
{
"$ref": "#/components/schemas/JsonObjectResponseFormat"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"json_object": "#/components/schemas/JsonObjectResponseFormat",
"json_schema": "#/components/schemas/JsonSchemaResponseFormat",
"text": "#/components/schemas/TextResponseFormat"
}
}
},
{
"type": "null"
}
],
"title": "Output Format",
"description": "The structured output format for the model."
},
"verbosity": { "verbosity": {
"anyOf": [ "anyOf": [
{ {

View File

@@ -71,6 +71,9 @@ class AnthropicClient(LLMClientBase):
betas.append("context-1m-2025-08-07") betas.append("context-1m-2025-08-07")
except Exception: except Exception:
pass pass
# Structured outputs beta (always enabled for output_format and strict tool support)
if self.supports_structured_output(llm_config):
betas.append("structured-outputs-2025-11-13")
if betas: if betas:
response = client.beta.messages.create(**request_data, betas=betas) response = client.beta.messages.create(**request_data, betas=betas)
@@ -98,6 +101,9 @@ class AnthropicClient(LLMClientBase):
except Exception: except Exception:
pass pass
# Structured outputs beta (always enabled for output_format and strict tool support)
betas.append("structured-outputs-2025-11-13")
if betas: if betas:
response = await client.beta.messages.create(**request_data, betas=betas) response = await client.beta.messages.create(**request_data, betas=betas)
else: else:
@@ -131,6 +137,9 @@ class AnthropicClient(LLMClientBase):
except Exception: except Exception:
pass pass
# Structured outputs beta (always enabled for output_format and strict tool support)
betas.append("structured-outputs-2025-11-13")
return await client.beta.messages.create(**request_data, betas=betas) return await client.beta.messages.create(**request_data, betas=betas)
@trace_method @trace_method
@@ -253,7 +262,6 @@ class AnthropicClient(LLMClientBase):
"max_tokens": max_output_tokens, "max_tokens": max_output_tokens,
"temperature": llm_config.temperature, "temperature": llm_config.temperature,
} }
# Extended Thinking # Extended Thinking
if self.is_reasoning_model(llm_config) and llm_config.enable_reasoner: if self.is_reasoning_model(llm_config) and llm_config.enable_reasoner:
thinking_budget = max(llm_config.max_reasoning_tokens, 1024) thinking_budget = max(llm_config.max_reasoning_tokens, 1024)
@@ -319,7 +327,7 @@ class AnthropicClient(LLMClientBase):
if tools_for_request and len(tools_for_request) > 0: if tools_for_request and len(tools_for_request) > 0:
# TODO eventually enable parallel tool use # TODO eventually enable parallel tool use
data["tools"] = convert_tools_to_anthropic_format(tools_for_request) data["tools"] = convert_tools_to_anthropic_format(tools_for_request, strict=self.supports_structured_output(llm_config))
# Messages # Messages
inner_thoughts_xml_tag = "thinking" inner_thoughts_xml_tag = "thinking"
@@ -443,7 +451,7 @@ class AnthropicClient(LLMClientBase):
if messages and len(messages) == 0: if messages and len(messages) == 0:
messages = None messages = None
if tools and len(tools) > 0: if tools and len(tools) > 0:
anthropic_tools = convert_tools_to_anthropic_format(tools) anthropic_tools = convert_tools_to_anthropic_format(tools, strict=False)
else: else:
anthropic_tools = None anthropic_tools = None
@@ -519,7 +527,7 @@ class AnthropicClient(LLMClientBase):
try: try:
count_params = { count_params = {
"model": model or "claude-3-7-sonnet-20250219", "model": model or "claude-haiku-4-5",
"messages": messages_for_counting or [{"role": "user", "content": "hi"}], "messages": messages_for_counting or [{"role": "user", "content": "hi"}],
"tools": anthropic_tools or [], "tools": anthropic_tools or [],
} }
@@ -561,7 +569,9 @@ class AnthropicClient(LLMClientBase):
or llm_config.model.startswith("claude-haiku-4-5") or llm_config.model.startswith("claude-haiku-4-5")
) )
@trace_method def supports_structured_output(self, llm_config: LLMConfig) -> bool:
return llm_config.model.startswith("claude-opus-4-1") or llm_config.model.startswith("claude-sonnet-4-5")
def handle_llm_error(self, e: Exception) -> Exception: def handle_llm_error(self, e: Exception) -> Exception:
# make sure to check for overflow errors, regardless of error type # make sure to check for overflow errors, regardless of error type
error_str = str(e).lower() error_str = str(e).lower()
@@ -809,7 +819,7 @@ class AnthropicClient(LLMClientBase):
return system_content return system_content
def convert_tools_to_anthropic_format(tools: List[OpenAITool]) -> List[dict]: def convert_tools_to_anthropic_format(tools: List[OpenAITool], strict: bool = False) -> List[dict]:
"""See: https://docs.anthropic.com/claude/docs/tool-use """See: https://docs.anthropic.com/claude/docs/tool-use
OpenAI style: OpenAI style:
@@ -866,7 +876,7 @@ def convert_tools_to_anthropic_format(tools: List[OpenAITool]) -> List[dict]:
cleaned_properties = {} cleaned_properties = {}
for prop_name, prop_schema in input_schema.get("properties", {}).items(): for prop_name, prop_schema in input_schema.get("properties", {}).items():
if isinstance(prop_schema, dict): if isinstance(prop_schema, dict):
cleaned_properties[prop_name] = _clean_property_schema(prop_schema) cleaned_properties[prop_name] = _clean_property_schema(prop_schema, strict=strict)
else: else:
cleaned_properties[prop_name] = prop_schema cleaned_properties[prop_name] = prop_schema
@@ -879,21 +889,41 @@ def convert_tools_to_anthropic_format(tools: List[OpenAITool]) -> List[dict]:
# Only add required field if it exists and is non-empty # Only add required field if it exists and is non-empty
if "required" in input_schema and input_schema["required"]: if "required" in input_schema and input_schema["required"]:
cleaned_input_schema["required"] = input_schema["required"] cleaned_input_schema["required"] = input_schema["required"]
# Add additionalProperties from input_schema if present, or set to false for strict mode
if "additionalProperties" in input_schema:
cleaned_input_schema["additionalProperties"] = input_schema["additionalProperties"]
elif strict:
# Strict mode requires additionalProperties: false
cleaned_input_schema["additionalProperties"] = False
else: else:
cleaned_input_schema = input_schema cleaned_input_schema = input_schema
# Ensure additionalProperties is set for strict mode even when schema is not cleaned
if strict and isinstance(cleaned_input_schema, dict) and cleaned_input_schema.get("type") == "object":
if "additionalProperties" not in cleaned_input_schema:
cleaned_input_schema = cleaned_input_schema.copy()
cleaned_input_schema["additionalProperties"] = False
formatted_tool = { formatted_tool = {
"name": tool.function.name, "name": tool.function.name,
"description": tool.function.description if tool.function.description else "", "description": tool.function.description if tool.function.description else "",
"input_schema": cleaned_input_schema, "input_schema": cleaned_input_schema,
} }
if strict:
formatted_tool["strict"] = True
formatted_tools.append(formatted_tool) formatted_tools.append(formatted_tool)
return formatted_tools return formatted_tools
def _clean_property_schema(prop_schema: dict) -> dict: def _clean_property_schema(prop_schema: dict, strict: bool = False) -> dict:
"""Clean up a property schema by removing defaults and simplifying union types.""" """Clean up a property schema by removing defaults and simplifying union types.
Args:
prop_schema: The property schema to clean
strict: If True, adds additionalProperties: false to object types for strict mode compliance
"""
cleaned = {} cleaned = {}
# Handle type field - simplify union types like ["null", "string"] to just "string" # Handle type field - simplify union types like ["null", "string"] to just "string"
@@ -919,10 +949,17 @@ def _clean_property_schema(prop_schema: dict) -> dict:
if key not in ["type", "default"]: # Skip 'default' field if key not in ["type", "default"]: # Skip 'default' field
if key == "properties" and isinstance(value, dict): if key == "properties" and isinstance(value, dict):
# Recursively clean nested properties # Recursively clean nested properties
cleaned["properties"] = {k: _clean_property_schema(v) if isinstance(v, dict) else v for k, v in value.items()} cleaned["properties"] = {
k: _clean_property_schema(v, strict=strict) if isinstance(v, dict) else v for k, v in value.items()
}
else: else:
cleaned[key] = value cleaned[key] = value
# For strict mode, ensure object types have additionalProperties: false
if strict and cleaned.get("type") == "object":
if "additionalProperties" not in cleaned:
cleaned["additionalProperties"] = False
return cleaned return cleaned

View File

@@ -261,6 +261,7 @@ class AnthropicModelSettings(ModelSettings):
thinking: AnthropicThinking = Field( thinking: AnthropicThinking = Field(
AnthropicThinking(type="enabled", budget_tokens=1024), description="The thinking configuration for the model." AnthropicThinking(type="enabled", budget_tokens=1024), description="The thinking configuration for the model."
) )
output_format: Optional[ResponseFormatUnion] = Field(None, description="The structured output format for the model.")
# gpt-5 models only # gpt-5 models only
verbosity: Optional[Literal["low", "medium", "high"]] = Field( verbosity: Optional[Literal["low", "medium", "high"]] = Field(
@@ -280,6 +281,7 @@ class AnthropicModelSettings(ModelSettings):
"thinking_budget_tokens": self.thinking.budget_tokens, "thinking_budget_tokens": self.thinking.budget_tokens,
"verbosity": self.verbosity, "verbosity": self.verbosity,
"parallel_tool_calls": self.parallel_tool_calls, "parallel_tool_calls": self.parallel_tool_calls,
"output_format": self.output_format,
} }