feat: support anthropic structured outputs [LET-6190] (#6200)
This commit is contained in:
committed by
Caren Thomas
parent
2addd4eb0d
commit
c18af2bc81
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user