diff --git a/packages/image-generation/src/celeste_image_generation/client.py b/packages/image-generation/src/celeste_image_generation/client.py index 360d548..b18dc22 100644 --- a/packages/image-generation/src/celeste_image_generation/client.py +++ b/packages/image-generation/src/celeste_image_generation/client.py @@ -66,9 +66,7 @@ def _output_class(cls) -> type[ImageGenerationOutput]: def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: """Build metadata dictionary from response data.""" metadata = super()._build_metadata(response_data) - metadata["raw_response"] = ( - response_data # Filtered response data (content fields removed by providers before calling super) - ) + metadata["raw_response"] = response_data return metadata @abstractmethod diff --git a/packages/text-generation/src/celeste_text_generation/client.py b/packages/text-generation/src/celeste_text_generation/client.py index 2479f4f..30aeba8 100644 --- a/packages/text-generation/src/celeste_text_generation/client.py +++ b/packages/text-generation/src/celeste_text_generation/client.py @@ -66,9 +66,7 @@ def _output_class(cls) -> type[TextGenerationOutput]: def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: """Build metadata dictionary from response data.""" metadata = super()._build_metadata(response_data) - metadata["raw_response"] = ( - response_data - ) + metadata["raw_response"] = response_data return metadata @abstractmethod diff --git a/packages/text-generation/src/celeste_text_generation/providers/anthropic/client.py b/packages/text-generation/src/celeste_text_generation/providers/anthropic/client.py index cf0a508..153e5ef 100644 --- a/packages/text-generation/src/celeste_text_generation/providers/anthropic/client.py +++ b/packages/text-generation/src/celeste_text_generation/providers/anthropic/client.py @@ -14,7 +14,10 @@ TextGenerationInput, TextGenerationUsage, ) -from celeste_text_generation.parameters import TextGenerationParameters +from celeste_text_generation.parameters import ( + TextGenerationParameter, + TextGenerationParameters, +) from . import config from .parameters import ANTHROPIC_PARAMETER_MAPPERS @@ -62,14 +65,6 @@ def _parse_content( msg = "No content blocks in response" raise ValueError(msg) - output_schema = parameters.get("output_schema") - if output_schema is not None: - for content_block in content: - if content_block.get("type") == "tool_use": - tool_input = content_block.get("input") - if tool_input is not None: - return self._transform_output(tool_input, **parameters) - text_content = "" for content_block in content: if content_block.get("type") == "text": @@ -112,6 +107,9 @@ async def _make_request( "Content-Type": ApplicationMimeType.JSON, } + if parameters.get(TextGenerationParameter.OUTPUT_SCHEMA) is not None: + headers[config.ANTHROPIC_BETA_HEADER] = config.STRUCTURED_OUTPUTS_BETA + return await self.http_client.post( f"{config.BASE_URL}{config.ENDPOINT}", headers=headers, @@ -138,6 +136,9 @@ def _make_stream_request( "Content-Type": ApplicationMimeType.JSON, } + if parameters.get(TextGenerationParameter.OUTPUT_SCHEMA) is not None: + headers[config.ANTHROPIC_BETA_HEADER] = config.STRUCTURED_OUTPUTS_BETA + return self.http_client.stream_post( f"{config.BASE_URL}{config.STREAM_ENDPOINT}", headers=headers, diff --git a/packages/text-generation/src/celeste_text_generation/providers/anthropic/config.py b/packages/text-generation/src/celeste_text_generation/providers/anthropic/config.py index 7c1e897..a46aff1 100644 --- a/packages/text-generation/src/celeste_text_generation/providers/anthropic/config.py +++ b/packages/text-generation/src/celeste_text_generation/providers/anthropic/config.py @@ -12,3 +12,7 @@ # API Version Header (required by Anthropic) ANTHROPIC_VERSION_HEADER = "anthropic-version" ANTHROPIC_VERSION = "2023-06-01" + +# Beta Features +ANTHROPIC_BETA_HEADER = "anthropic-beta" +STRUCTURED_OUTPUTS_BETA = "structured-outputs-2025-11-13" diff --git a/packages/text-generation/src/celeste_text_generation/providers/anthropic/models.py b/packages/text-generation/src/celeste_text_generation/providers/anthropic/models.py index 14b085c..ba7ed7f 100644 --- a/packages/text-generation/src/celeste_text_generation/providers/anthropic/models.py +++ b/packages/text-generation/src/celeste_text_generation/providers/anthropic/models.py @@ -22,7 +22,6 @@ streaming=True, parameter_constraints={ TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=32000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( @@ -42,7 +41,6 @@ streaming=True, parameter_constraints={ TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=64000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( @@ -52,7 +50,6 @@ streaming=True, parameter_constraints={ TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=32000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), }, ), ] diff --git a/packages/text-generation/src/celeste_text_generation/providers/anthropic/parameters.py b/packages/text-generation/src/celeste_text_generation/providers/anthropic/parameters.py index 9cc8483..d23b2d7 100644 --- a/packages/text-generation/src/celeste_text_generation/providers/anthropic/parameters.py +++ b/packages/text-generation/src/celeste_text_generation/providers/anthropic/parameters.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, TypeAdapter -from celeste.exceptions import ConstraintViolationError, ValidationError +from celeste.exceptions import ConstraintViolationError from celeste.models import Model from celeste.parameters import ParameterMapper from celeste_text_generation.parameters import TextGenerationParameter @@ -62,7 +62,7 @@ def map( class OutputSchemaMapper(ParameterMapper): - """Map output_schema parameter to Anthropic tools parameter (tool-based structured output).""" + """Map output_schema parameter to Anthropic native structured outputs (output_format).""" name: StrEnum = TextGenerationParameter.OUTPUT_SCHEMA @@ -72,55 +72,44 @@ def map( value: object, model: Model, ) -> dict[str, Any]: - """Transform output_schema into provider request. + """Transform output_schema into provider request using native structured outputs. - Converts unified output_schema to Anthropic tools parameter: - - Creates a single tool definition with input_schema matching the output schema - - Sets tool_choice to force tool use + Converts unified output_schema to Anthropic output_format parameter: + - Uses output_format with type: "json_schema" and schema definition - Handles both BaseModel and list[BaseModel] types + - For list[BaseModel], schema is array type directly Args: request: Provider request dict. value: output_schema value (type[BaseModel] | None). model: Model instance containing parameter_constraints for validation. - model: Model instance with parameter constraints for validation. Returns: - Updated request dict with tools and tool_choice if value provided. + Updated request dict with output_format if value provided. """ validated_value = self._validate_value(value, model) if validated_value is None: return request - # Convert Pydantic model to JSON Schema - schema = self._convert_to_anthropic_schema(validated_value) - tool_name = self._get_tool_name(validated_value) + schema = self._convert_to_json_schema(validated_value) - # Create tool definition with input_schema matching output schema - tool_def = { - "name": tool_name, - "description": f"Extract structured data conforming to {self._get_schema_description(validated_value)}", - "input_schema": schema, + request["output_format"] = { + "type": "json_schema", + "schema": schema, } - # Add tools array to request - request["tools"] = [tool_def] - - # Force tool use by setting tool_choice - request["tool_choice"] = {"type": "tool", "name": tool_name} - return request def parse_output( self, content: str | dict[str, Any], value: object | None ) -> str | BaseModel: - """Parse tool_use blocks from response to BaseModel instance. + """Parse JSON text from response to BaseModel instance. - Extracts structured data from tool_use.input field and converts to BaseModel. - For list[BaseModel], extracts the "items" array from the wrapped object. + With native structured outputs, content is direct JSON text in content[0].text. + For list[BaseModel], content is array directly. Args: - content: Either tool_use.input dict (from tool_use block) or JSON string. + content: JSON string from content[0].text. value: Original output_schema parameter value. Returns: @@ -129,72 +118,69 @@ def parse_output( if value is None: return content if isinstance(content, str) else json.dumps(content) - # If content is already a dict (from tool_use.input), use it directly if isinstance(content, dict): parsed_json = content else: - # Otherwise parse as JSON string parsed_json = json.loads(content) - # Check if value is list[BaseModel] and content is wrapped in object - origin = get_origin(value) - if origin is list: - # Handle empty dict case FIRST - convert to empty array before checking for "items" - if isinstance(parsed_json, dict) and not parsed_json: - # Empty dict when expecting list - convert to empty array - parsed_json = [] - elif isinstance(parsed_json, dict) and "items" in parsed_json: - # Extract items array from wrapped format - parsed_json = parsed_json["items"] - # If it's already an array (backward compatibility), use it directly - # parsed_json is now the array, ready for TypeAdapter - elif isinstance(parsed_json, dict) and not parsed_json: - # Empty dict for BaseModel (not list) - this is invalid, raise error - msg = "Empty tool_use input dict cannot be converted to BaseModel" - raise ValidationError(msg) - - # Parse to BaseModel instance using TypeAdapter - # TypeAdapter handles both BaseModel and list[BaseModel] return TypeAdapter(value).validate_json(json.dumps(parsed_json)) - def _convert_to_anthropic_schema(self, output_schema: Any) -> dict[str, Any]: # noqa: ANN401 - """Convert Pydantic BaseModel or list[BaseModel] to Anthropic JSON Schema format. + def _convert_to_json_schema(self, output_schema: Any) -> dict[str, Any]: # noqa: ANN401 + """Convert Pydantic BaseModel or list[BaseModel] to JSON Schema format. - Anthropic requires input_schema to always be an object type. - For list[T], wraps array schema in an object with "items" property. + For native structured outputs, list[T] is array type directly. + Ensures all object types have additionalProperties: false as required by Anthropic. Args: output_schema: Pydantic BaseModel class or list[BaseModel] type. Returns: - JSON Schema dictionary compatible with Anthropic (always object type). + JSON Schema dictionary compatible with Anthropic structured outputs. """ origin = get_origin(output_schema) if origin is list: - # For list[T], wrap array schema in an object (Anthropic requirement) inner_type = get_args(output_schema)[0] items_schema = inner_type.model_json_schema() - # Resolve refs in items schema first items_schema = self._resolve_refs(items_schema) - # Wrap in object with "items" property json_schema = { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": items_schema, - }, - }, - "required": ["items"], + "type": "array", + "items": items_schema, } else: - # For BaseModel, use model_json_schema directly json_schema = output_schema.model_json_schema() - # Resolve $ref references inline (Anthropic may not support $ref) json_schema = self._resolve_refs(json_schema) + json_schema = self._ensure_additional_properties(json_schema) return json_schema + def _ensure_additional_properties(self, schema: dict[str, Any]) -> dict[str, Any]: + """Ensure all object types have additionalProperties: false.""" + if not isinstance(schema, dict): + return schema + + schema = schema.copy() + + if schema.get("type") == "object": + schema["additionalProperties"] = False + + for key in ["properties", "items"]: + if key in schema: + if key == "properties": + schema[key] = { + k: self._ensure_additional_properties(v) + for k, v in schema[key].items() + } + else: + schema[key] = self._ensure_additional_properties(schema[key]) + + for key in ["anyOf", "allOf"]: + if key in schema: + schema[key] = [ + self._ensure_additional_properties(item) for item in schema[key] + ] + + return schema + def _resolve_refs(self, schema: dict[str, Any]) -> dict[str, Any]: """Resolve all $ref references and inline definitions. @@ -250,36 +236,6 @@ def resolve(value: Any) -> Any: # noqa: ANN401 return resolve(schema) - def _get_tool_name(self, output_schema: Any) -> str: # noqa: ANN401 - """Derive tool name from model class name. - - Args: - output_schema: Pydantic BaseModel class or list[BaseModel] type. - - Returns: - Tool name (lowercase class name or "extract_data" as fallback). - """ - origin = get_origin(output_schema) - if origin is list: - inner_type = get_args(output_schema)[0] - return inner_type.__name__.lower() or "extract_data" - return output_schema.__name__.lower() or "extract_data" - - def _get_schema_description(self, output_schema: Any) -> str: # noqa: ANN401 - """Get description for tool definition. - - Args: - output_schema: Pydantic BaseModel class or list[BaseModel] type. - - Returns: - Schema description string. - """ - origin = get_origin(output_schema) - if origin is list: - inner_type = get_args(output_schema)[0] - return f"array of {inner_type.__name__}" - return output_schema.__name__ - ANTHROPIC_PARAMETER_MAPPERS: list[ParameterMapper] = [ ThinkingBudgetMapper(), diff --git a/packages/text-generation/src/celeste_text_generation/providers/anthropic/streaming.py b/packages/text-generation/src/celeste_text_generation/providers/anthropic/streaming.py index e84cba1..8c516ca 100644 --- a/packages/text-generation/src/celeste_text_generation/providers/anthropic/streaming.py +++ b/packages/text-generation/src/celeste_text_generation/providers/anthropic/streaming.py @@ -1,10 +1,8 @@ """Anthropic streaming for text generation.""" -import json from collections.abc import Callable from typing import Any, Unpack -from celeste.exceptions import ValidationError from celeste.io import Chunk from celeste_text_generation.io import ( TextGenerationChunk, @@ -34,10 +32,6 @@ def __init__( """ super().__init__(sse_iterator, **parameters) self._transform_output = transform_output - # Track tool_use blocks for structured output - self._tool_use_blocks: list[dict[str, Any]] = [] - self._current_tool_use: dict[str, Any] | None = None - self._current_tool_use_partial_json: str = "" self._last_finish_reason: TextGenerationFinishReason | None = None def _parse_chunk(self, event: dict[str, Any]) -> Chunk | None: @@ -46,68 +40,8 @@ def _parse_chunk(self, event: dict[str, Any]) -> Chunk | None: if not event_type: return None - # Parse content_block_start for tool_use blocks - if event_type == "content_block_start": - content_block = event.get("content_block", {}) - if content_block.get("type") == "tool_use": - # Initialize new tool_use block - self._current_tool_use = { - "type": "tool_use", - "id": content_block.get("id"), - "name": content_block.get("name"), - "input": {}, - } - return None # No chunk yet, waiting for deltas - - # Parse content_block_delta for tool_use and text if event_type == "content_block_delta": delta = event.get("delta", {}) - - # Handle input_json_delta for structured output (Anthropic sends input_json_delta, not tool_use_delta) - if ( - delta.get("type") == "input_json_delta" - and self._current_tool_use is not None - ): - partial_json = delta.get("partial_json") - if partial_json is not None: - # Accumulate partial JSON string fragments - self._current_tool_use_partial_json += partial_json - # Emit chunk with accumulated JSON for UI live rendering (only when output_schema is provided) - output_schema = self._parameters.get("output_schema") - if ( - output_schema is not None - and self._current_tool_use_partial_json - ): - return TextGenerationChunk( - content=self._current_tool_use_partial_json, - finish_reason=None, - usage=None, - ) - return None - - # Handle tool_use_delta for backward compatibility (older API versions) - if ( - delta.get("type") == "tool_use_delta" - and self._current_tool_use is not None - ): - partial_json = delta.get("partial_json") - if partial_json is not None: - # Accumulate partial JSON string fragments - self._current_tool_use_partial_json += partial_json - # Emit chunk with accumulated JSON for UI live rendering (only when output_schema is provided) - output_schema = self._parameters.get("output_schema") - if ( - output_schema is not None - and self._current_tool_use_partial_json - ): - return TextGenerationChunk( - content=self._current_tool_use_partial_json, - finish_reason=None, - usage=None, - ) - return None - - # Handle text_delta for regular text content if delta.get("type") == "text_delta": text_delta = delta.get("text") if text_delta is not None: @@ -117,91 +51,6 @@ def _parse_chunk(self, event: dict[str, Any]) -> Chunk | None: usage=None, ) - # Parse content_block_stop to finalize tool_use blocks - if event_type == "content_block_stop": - if self._current_tool_use is not None: - # Tool use block completed - parse accumulated JSON - tool_id = self._current_tool_use.get("id") - # Check if we already have this tool_use block from message_start - existing_block = None - for block in self._tool_use_blocks: - if block.get("id") == tool_id: - existing_block = block - break - - # Emit final chunk with complete JSON for UI (only when output_schema is provided) - output_schema = self._parameters.get("output_schema") - emit_final_chunk = False - final_json_content = "" - - if self._current_tool_use_partial_json: - final_json_content = self._current_tool_use_partial_json - try: - parsed_input = json.loads(self._current_tool_use_partial_json) - if existing_block: - # Update existing block from message_start - existing_block["input"] = parsed_input - else: - # New block from content_block_start - self._current_tool_use["input"] = parsed_input - self._tool_use_blocks.append(self._current_tool_use) - emit_final_chunk = output_schema is not None - except json.JSONDecodeError: - # If JSON parsing fails, only update if we have existing block - if existing_block: - existing_block["input"] = {} - else: - self._current_tool_use["input"] = {} - self._tool_use_blocks.append(self._current_tool_use) - emit_final_chunk = output_schema is not None - else: - # No partial_json - only add if we don't have this block already - if not existing_block: - self._current_tool_use["input"] = {} - self._tool_use_blocks.append(self._current_tool_use) - - # Emit final chunk with complete JSON before clearing - if emit_final_chunk and final_json_content: - chunk = TextGenerationChunk( - content=final_json_content, - finish_reason=None, - usage=None, - ) - else: - chunk = None - - self._current_tool_use = None - self._current_tool_use_partial_json = "" - - return chunk - return None - - # Parse message_start to capture initial content blocks (includes tool_use) - # Note: In streaming, message_start may contain tool_use blocks with complete input - # or empty input (which will be filled by content_block_delta events) - if event_type == "message_start": - message = event.get("message", {}) - content_blocks = message.get("content", []) - # Extract tool_use blocks from initial message - # If input is already populated, use it; otherwise it will be filled by deltas - for block in content_blocks: - if block.get("type") == "tool_use": - tool_input = block.get("input") - # If input is already complete (not empty), use it directly - # Otherwise, content_block_start/content_block_stop will fill it - if tool_input and isinstance(tool_input, dict) and tool_input: - # Complete tool_use block from message_start - self._tool_use_blocks.append( - { - "type": "tool_use", - "id": block.get("id"), - "name": block.get("name"), - "input": tool_input, - } - ) - return None - - # Parse message delta event for finish reason and usage if event_type == "message_delta": delta = event.get("delta", {}) stop_reason = delta.get("stop_reason") @@ -280,55 +129,9 @@ def _parse_output( chunks: list[TextGenerationChunk], **parameters: Unpack[TextGenerationParameters], ) -> TextGenerationOutput: - """Assemble chunks into final output with structured output support. - - Checks for tool_use blocks first (structured output), then falls back - to concatenated text chunks. - """ - # Check if output_schema is provided (tool-based structured output) - output_schema = self._parameters.get("output_schema") - - if output_schema is not None and self._tool_use_blocks: - # Extract structured data from tool_use blocks - # Use the first tool_use block's input - # For list[BaseModel], tool_input will be wrapped format {"items": [...]} - # _transform_output will call OutputSchemaMapper.parse_output which handles empty dicts - tool_input = self._tool_use_blocks[0].get("input") - # Check if tool_input is valid (not None and not empty dict for BaseModel) - # Empty dict is OK for list[BaseModel] (converts to []), but invalid for BaseModel - if tool_input is not None: - # For BaseModel (not list), empty dict is invalid - try to find text chunks as fallback - if isinstance(tool_input, dict) and not tool_input: - from typing import get_origin - - origin = get_origin(output_schema) - if origin is not list: - # Empty dict for BaseModel - try text chunks, but if none, raise error - text_content = "".join(chunk.content for chunk in chunks) - if text_content: - content = self._transform_output(text_content, **parameters) - else: - msg = "Empty tool_use input dict and no text chunks available for BaseModel" - raise ValidationError(msg) - else: - # Empty dict for list[BaseModel] - OK, parse_output will convert to [] - content = self._transform_output(tool_input, **parameters) - else: - # Valid tool_input - transform to BaseModel - content = self._transform_output(tool_input, **parameters) - else: - # Fallback: concatenate text chunks - text_content = "".join(chunk.content for chunk in chunks) - if text_content: - content = self._transform_output(text_content, **parameters) - else: - msg = "No tool_use input and no text chunks available" - raise ValidationError(msg) - else: - # No tool_use blocks or no output_schema: concatenate text chunks - content = "".join(chunk.content for chunk in chunks) - # Apply parameter transformations (e.g., JSON → BaseModel if output_schema provided) - content = self._transform_output(content, **parameters) + """Assemble chunks into final output with structured output support.""" + content = "".join(chunk.content for chunk in chunks) + content = self._transform_output(content, **parameters) usage = self._parse_usage(chunks) finish_reason = chunks[-1].finish_reason if chunks else None