Skip to content

Commit a55ccc3

Browse files
refactor: migrate Anthropic to native structured outputs API and remove raw_event metadata (#40)
This commit includes two major improvements: 1. Anthropic Structured Outputs Migration: - Migrate from tool-based structured outputs to Anthropic's native structured outputs API - Use output_format parameter with json_schema type instead of tools/tool_choice - Simplify streaming implementation by removing tool_use block tracking - Remove complex tool_use parsing logic (203 lines removed from streaming.py) - Add anthropic-beta header for structured-outputs-2025-11-13 feature - Update OutputSchemaMapper to use native output_format instead of tools - Remove OUTPUT_SCHEMA constraint from model definitions (now handled via API) 2. Remove raw_event Metadata: - Remove metadata={"raw_event": event} from all streaming chunk creations - Violates metadata principle: metadata should not contain content fields - Reduces memory usage by not duplicating event data - Affects: OpenAI, Anthropic, XAI, Cohere, Mistral, Google text generation providers - Also removes from image intelligence and speech generation providers - ElevenLabs: removed raw_event wrapper but kept content_length metadata Benefits: - Cleaner codebase with 313 lines removed - Better adherence to metadata design principles - Reduced memory footprint in streaming scenarios - Simpler Anthropic integration using official API
1 parent 93b135e commit a55ccc3

7 files changed

Lines changed: 70 additions & 313 deletions

File tree

packages/image-generation/src/celeste_image_generation/client.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,7 @@ def _output_class(cls) -> type[ImageGenerationOutput]:
6666
def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]:
6767
"""Build metadata dictionary from response data."""
6868
metadata = super()._build_metadata(response_data)
69-
metadata["raw_response"] = (
70-
response_data # Filtered response data (content fields removed by providers before calling super)
71-
)
69+
metadata["raw_response"] = response_data
7270
return metadata
7371

7472
@abstractmethod

packages/text-generation/src/celeste_text_generation/client.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,7 @@ def _output_class(cls) -> type[TextGenerationOutput]:
6666
def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]:
6767
"""Build metadata dictionary from response data."""
6868
metadata = super()._build_metadata(response_data)
69-
metadata["raw_response"] = (
70-
response_data
71-
)
69+
metadata["raw_response"] = response_data
7270
return metadata
7371

7472
@abstractmethod

packages/text-generation/src/celeste_text_generation/providers/anthropic/client.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
TextGenerationInput,
1515
TextGenerationUsage,
1616
)
17-
from celeste_text_generation.parameters import TextGenerationParameters
17+
from celeste_text_generation.parameters import (
18+
TextGenerationParameter,
19+
TextGenerationParameters,
20+
)
1821

1922
from . import config
2023
from .parameters import ANTHROPIC_PARAMETER_MAPPERS
@@ -62,14 +65,6 @@ def _parse_content(
6265
msg = "No content blocks in response"
6366
raise ValueError(msg)
6467

65-
output_schema = parameters.get("output_schema")
66-
if output_schema is not None:
67-
for content_block in content:
68-
if content_block.get("type") == "tool_use":
69-
tool_input = content_block.get("input")
70-
if tool_input is not None:
71-
return self._transform_output(tool_input, **parameters)
72-
7368
text_content = ""
7469
for content_block in content:
7570
if content_block.get("type") == "text":
@@ -112,6 +107,9 @@ async def _make_request(
112107
"Content-Type": ApplicationMimeType.JSON,
113108
}
114109

110+
if parameters.get(TextGenerationParameter.OUTPUT_SCHEMA) is not None:
111+
headers[config.ANTHROPIC_BETA_HEADER] = config.STRUCTURED_OUTPUTS_BETA
112+
115113
return await self.http_client.post(
116114
f"{config.BASE_URL}{config.ENDPOINT}",
117115
headers=headers,
@@ -138,6 +136,9 @@ def _make_stream_request(
138136
"Content-Type": ApplicationMimeType.JSON,
139137
}
140138

139+
if parameters.get(TextGenerationParameter.OUTPUT_SCHEMA) is not None:
140+
headers[config.ANTHROPIC_BETA_HEADER] = config.STRUCTURED_OUTPUTS_BETA
141+
141142
return self.http_client.stream_post(
142143
f"{config.BASE_URL}{config.STREAM_ENDPOINT}",
143144
headers=headers,

packages/text-generation/src/celeste_text_generation/providers/anthropic/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@
1212
# API Version Header (required by Anthropic)
1313
ANTHROPIC_VERSION_HEADER = "anthropic-version"
1414
ANTHROPIC_VERSION = "2023-06-01"
15+
16+
# Beta Features
17+
ANTHROPIC_BETA_HEADER = "anthropic-beta"
18+
STRUCTURED_OUTPUTS_BETA = "structured-outputs-2025-11-13"

packages/text-generation/src/celeste_text_generation/providers/anthropic/models.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
streaming=True,
2323
parameter_constraints={
2424
TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=32000),
25-
TextGenerationParameter.OUTPUT_SCHEMA: Schema(),
2625
},
2726
),
2827
Model(
@@ -42,7 +41,6 @@
4241
streaming=True,
4342
parameter_constraints={
4443
TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=64000),
45-
TextGenerationParameter.OUTPUT_SCHEMA: Schema(),
4644
},
4745
),
4846
Model(
@@ -52,7 +50,6 @@
5250
streaming=True,
5351
parameter_constraints={
5452
TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=32000),
55-
TextGenerationParameter.OUTPUT_SCHEMA: Schema(),
5653
},
5754
),
5855
]

packages/text-generation/src/celeste_text_generation/providers/anthropic/parameters.py

Lines changed: 51 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from pydantic import BaseModel, TypeAdapter
88

9-
from celeste.exceptions import ConstraintViolationError, ValidationError
9+
from celeste.exceptions import ConstraintViolationError
1010
from celeste.models import Model
1111
from celeste.parameters import ParameterMapper
1212
from celeste_text_generation.parameters import TextGenerationParameter
@@ -62,7 +62,7 @@ def map(
6262

6363

6464
class OutputSchemaMapper(ParameterMapper):
65-
"""Map output_schema parameter to Anthropic tools parameter (tool-based structured output)."""
65+
"""Map output_schema parameter to Anthropic native structured outputs (output_format)."""
6666

6767
name: StrEnum = TextGenerationParameter.OUTPUT_SCHEMA
6868

@@ -72,55 +72,44 @@ def map(
7272
value: object,
7373
model: Model,
7474
) -> dict[str, Any]:
75-
"""Transform output_schema into provider request.
75+
"""Transform output_schema into provider request using native structured outputs.
7676
77-
Converts unified output_schema to Anthropic tools parameter:
78-
- Creates a single tool definition with input_schema matching the output schema
79-
- Sets tool_choice to force tool use
77+
Converts unified output_schema to Anthropic output_format parameter:
78+
- Uses output_format with type: "json_schema" and schema definition
8079
- Handles both BaseModel and list[BaseModel] types
80+
- For list[BaseModel], schema is array type directly
8181
8282
Args:
8383
request: Provider request dict.
8484
value: output_schema value (type[BaseModel] | None).
8585
model: Model instance containing parameter_constraints for validation.
86-
model: Model instance with parameter constraints for validation.
8786
8887
Returns:
89-
Updated request dict with tools and tool_choice if value provided.
88+
Updated request dict with output_format if value provided.
9089
"""
9190
validated_value = self._validate_value(value, model)
9291
if validated_value is None:
9392
return request
9493

95-
# Convert Pydantic model to JSON Schema
96-
schema = self._convert_to_anthropic_schema(validated_value)
97-
tool_name = self._get_tool_name(validated_value)
94+
schema = self._convert_to_json_schema(validated_value)
9895

99-
# Create tool definition with input_schema matching output schema
100-
tool_def = {
101-
"name": tool_name,
102-
"description": f"Extract structured data conforming to {self._get_schema_description(validated_value)}",
103-
"input_schema": schema,
96+
request["output_format"] = {
97+
"type": "json_schema",
98+
"schema": schema,
10499
}
105100

106-
# Add tools array to request
107-
request["tools"] = [tool_def]
108-
109-
# Force tool use by setting tool_choice
110-
request["tool_choice"] = {"type": "tool", "name": tool_name}
111-
112101
return request
113102

114103
def parse_output(
115104
self, content: str | dict[str, Any], value: object | None
116105
) -> str | BaseModel:
117-
"""Parse tool_use blocks from response to BaseModel instance.
106+
"""Parse JSON text from response to BaseModel instance.
118107
119-
Extracts structured data from tool_use.input field and converts to BaseModel.
120-
For list[BaseModel], extracts the "items" array from the wrapped object.
108+
With native structured outputs, content is direct JSON text in content[0].text.
109+
For list[BaseModel], content is array directly.
121110
122111
Args:
123-
content: Either tool_use.input dict (from tool_use block) or JSON string.
112+
content: JSON string from content[0].text.
124113
value: Original output_schema parameter value.
125114
126115
Returns:
@@ -129,72 +118,69 @@ def parse_output(
129118
if value is None:
130119
return content if isinstance(content, str) else json.dumps(content)
131120

132-
# If content is already a dict (from tool_use.input), use it directly
133121
if isinstance(content, dict):
134122
parsed_json = content
135123
else:
136-
# Otherwise parse as JSON string
137124
parsed_json = json.loads(content)
138125

139-
# Check if value is list[BaseModel] and content is wrapped in object
140-
origin = get_origin(value)
141-
if origin is list:
142-
# Handle empty dict case FIRST - convert to empty array before checking for "items"
143-
if isinstance(parsed_json, dict) and not parsed_json:
144-
# Empty dict when expecting list - convert to empty array
145-
parsed_json = []
146-
elif isinstance(parsed_json, dict) and "items" in parsed_json:
147-
# Extract items array from wrapped format
148-
parsed_json = parsed_json["items"]
149-
# If it's already an array (backward compatibility), use it directly
150-
# parsed_json is now the array, ready for TypeAdapter
151-
elif isinstance(parsed_json, dict) and not parsed_json:
152-
# Empty dict for BaseModel (not list) - this is invalid, raise error
153-
msg = "Empty tool_use input dict cannot be converted to BaseModel"
154-
raise ValidationError(msg)
155-
156-
# Parse to BaseModel instance using TypeAdapter
157-
# TypeAdapter handles both BaseModel and list[BaseModel]
158126
return TypeAdapter(value).validate_json(json.dumps(parsed_json))
159127

160-
def _convert_to_anthropic_schema(self, output_schema: Any) -> dict[str, Any]: # noqa: ANN401
161-
"""Convert Pydantic BaseModel or list[BaseModel] to Anthropic JSON Schema format.
128+
def _convert_to_json_schema(self, output_schema: Any) -> dict[str, Any]: # noqa: ANN401
129+
"""Convert Pydantic BaseModel or list[BaseModel] to JSON Schema format.
162130
163-
Anthropic requires input_schema to always be an object type.
164-
For list[T], wraps array schema in an object with "items" property.
131+
For native structured outputs, list[T] is array type directly.
132+
Ensures all object types have additionalProperties: false as required by Anthropic.
165133
166134
Args:
167135
output_schema: Pydantic BaseModel class or list[BaseModel] type.
168136
169137
Returns:
170-
JSON Schema dictionary compatible with Anthropic (always object type).
138+
JSON Schema dictionary compatible with Anthropic structured outputs.
171139
"""
172140
origin = get_origin(output_schema)
173141
if origin is list:
174-
# For list[T], wrap array schema in an object (Anthropic requirement)
175142
inner_type = get_args(output_schema)[0]
176143
items_schema = inner_type.model_json_schema()
177-
# Resolve refs in items schema first
178144
items_schema = self._resolve_refs(items_schema)
179-
# Wrap in object with "items" property
180145
json_schema = {
181-
"type": "object",
182-
"properties": {
183-
"items": {
184-
"type": "array",
185-
"items": items_schema,
186-
},
187-
},
188-
"required": ["items"],
146+
"type": "array",
147+
"items": items_schema,
189148
}
190149
else:
191-
# For BaseModel, use model_json_schema directly
192150
json_schema = output_schema.model_json_schema()
193-
# Resolve $ref references inline (Anthropic may not support $ref)
194151
json_schema = self._resolve_refs(json_schema)
195152

153+
json_schema = self._ensure_additional_properties(json_schema)
196154
return json_schema
197155

156+
def _ensure_additional_properties(self, schema: dict[str, Any]) -> dict[str, Any]:
157+
"""Ensure all object types have additionalProperties: false."""
158+
if not isinstance(schema, dict):
159+
return schema
160+
161+
schema = schema.copy()
162+
163+
if schema.get("type") == "object":
164+
schema["additionalProperties"] = False
165+
166+
for key in ["properties", "items"]:
167+
if key in schema:
168+
if key == "properties":
169+
schema[key] = {
170+
k: self._ensure_additional_properties(v)
171+
for k, v in schema[key].items()
172+
}
173+
else:
174+
schema[key] = self._ensure_additional_properties(schema[key])
175+
176+
for key in ["anyOf", "allOf"]:
177+
if key in schema:
178+
schema[key] = [
179+
self._ensure_additional_properties(item) for item in schema[key]
180+
]
181+
182+
return schema
183+
198184
def _resolve_refs(self, schema: dict[str, Any]) -> dict[str, Any]:
199185
"""Resolve all $ref references and inline definitions.
200186
@@ -250,36 +236,6 @@ def resolve(value: Any) -> Any: # noqa: ANN401
250236

251237
return resolve(schema)
252238

253-
def _get_tool_name(self, output_schema: Any) -> str: # noqa: ANN401
254-
"""Derive tool name from model class name.
255-
256-
Args:
257-
output_schema: Pydantic BaseModel class or list[BaseModel] type.
258-
259-
Returns:
260-
Tool name (lowercase class name or "extract_data" as fallback).
261-
"""
262-
origin = get_origin(output_schema)
263-
if origin is list:
264-
inner_type = get_args(output_schema)[0]
265-
return inner_type.__name__.lower() or "extract_data"
266-
return output_schema.__name__.lower() or "extract_data"
267-
268-
def _get_schema_description(self, output_schema: Any) -> str: # noqa: ANN401
269-
"""Get description for tool definition.
270-
271-
Args:
272-
output_schema: Pydantic BaseModel class or list[BaseModel] type.
273-
274-
Returns:
275-
Schema description string.
276-
"""
277-
origin = get_origin(output_schema)
278-
if origin is list:
279-
inner_type = get_args(output_schema)[0]
280-
return f"array of {inner_type.__name__}"
281-
return output_schema.__name__
282-
283239

284240
ANTHROPIC_PARAMETER_MAPPERS: list[ParameterMapper] = [
285241
ThinkingBudgetMapper(),

0 commit comments

Comments
 (0)