From b268fe45f14e8ed5c1434b5b08a0076bdfc108bb Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 6 Jan 2026 09:51:30 -0600 Subject: [PATCH 1/5] feat: migrate Anthropic provider to native structured outputs API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the new output_format parameter and beta client for models that support structured outputs (claude-sonnet-4-5, claude-opus-4-1, claude-opus-4-5, claude-haiku-4-5). This enables streaming with data_model for these models. Older models fall back to the previous tool-based approach. Documentation: https://platform.claude.com/docs/en/build-with-claude/structured-outputs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 1 + chatlas/_provider_anthropic.py | 161 +++++++++++++----- ...-06-anthropic-structured-outputs-design.md | 135 +++++++++++++++ ...nthropic_nested_data_model_extraction.yaml | 53 +++--- .../test_data_extraction.yaml | 152 +++++++++-------- 5 files changed, 362 insertions(+), 140 deletions(-) create mode 100644 docs/plans/2026-01-06-anthropic-structured-outputs-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef1011e..7703ecb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features * `.stream()` and `.stream_async()` now support a `data_model` parameter for structured data extraction while streaming. (#262) +* `ChatAnthropic()` now uses native structured outputs API for supported models (claude-sonnet-4-5, claude-opus-4-1, claude-opus-4-5, claude-haiku-4-5), enabling streaming with `data_model`. Older models fall back to the tool-based approach. (#263) ## [0.15.0] - 2026-01-06 diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index 7a95c0de..800b604a 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -47,6 +47,34 @@ from ._turn import AssistantTurn, SystemTurn, Turn, UserTurn, user_turn from ._utils import split_http_client_kwargs +# Models that support the new structured outputs API (output_format parameter) +# https://platform.claude.com/docs/en/build-with-claude/structured-outputs +STRUCTURED_OUTPUT_MODELS = { + "claude-sonnet-4-5", + "claude-opus-4-1", + "claude-opus-4-5", + "claude-haiku-4-5", +} + +STRUCTURED_OUTPUTS_BETA = "structured-outputs-2025-11-13" + + +def use_beta_structured_output(model: str, data_model: Optional[type[BaseModel]]) -> bool: + """ + Check if we should use the beta structured outputs API. + + Returns True if data_model is provided and the model supports the new API. + """ + if data_model is None: + return False + # Handle dated model versions like "claude-sonnet-4-5-20250514" + # by checking if any supported model is a prefix + for supported in STRUCTURED_OUTPUT_MODELS: + if model.startswith(supported): + return True + return False + + if TYPE_CHECKING: from anthropic.types import ( Message, @@ -353,8 +381,14 @@ def chat_perform( data_model: Optional[type[BaseModel]] = None, kwargs: Optional["SubmitInputArgs"] = None, ): - kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs) - return self._client.messages.create(**kwargs) # type: ignore + api_kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs) + if use_beta_structured_output(self.model, data_model): + # Beta API has slightly different type signatures but is runtime compatible + return self._client.beta.messages.create( + betas=[STRUCTURED_OUTPUTS_BETA], + **api_kwargs, # type: ignore[arg-type] + ) + return self._client.messages.create(**api_kwargs) # type: ignore @overload async def chat_perform_async( @@ -387,8 +421,14 @@ async def chat_perform_async( data_model: Optional[type[BaseModel]] = None, kwargs: Optional["SubmitInputArgs"] = None, ): - kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs) - return await self._async_client.messages.create(**kwargs) # type: ignore + api_kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs) + if use_beta_structured_output(self.model, data_model): + # Beta API has slightly different type signatures but is runtime compatible + return await self._async_client.beta.messages.create( + betas=[STRUCTURED_OUTPUTS_BETA], + **api_kwargs, # type: ignore[arg-type] + ) + return await self._async_client.messages.create(**api_kwargs) # type: ignore def _chat_perform_args( self, @@ -398,42 +438,24 @@ def _chat_perform_args( data_model: Optional[type[BaseModel]] = None, kwargs: Optional["SubmitInputArgs"] = None, ) -> "SubmitInputArgs": + """Build kwargs for the Anthropic messages API.""" tool_schemas = [self._anthropic_tool_schema(tool) for tool in tools.values()] - # If data extraction is requested, add a "mock" tool with parameters inferred from the data model + use_beta = use_beta_structured_output(self.model, data_model) data_model_tool: Tool | None = None - if data_model is not None: - - def _structured_tool_call(**kwargs: Any): - """Extract structured data""" - pass - - data_model_tool = Tool.from_func(_structured_tool_call) - - data_model_schema = basemodel_to_param_schema(data_model) - - # Extract $defs from the nested schema and place at top level - # JSON Schema $ref pointers like "#/$defs/..." need $defs at the root - defs = data_model_schema.pop("$defs", None) - - params: dict[str, Any] = { - "type": "object", - "properties": { - "data": data_model_schema, - }, - } - if defs: - params["$defs"] = defs - - data_model_tool.schema["function"]["parameters"] = params + if data_model is not None and not use_beta: + # Fall back to the old tool-based approach for older models + data_model_tool = self.create_data_model_tool(data_model) tool_schemas.append(self._anthropic_tool_schema(data_model_tool)) if stream: stream = False warnings.warn( - "Anthropic does not support structured data extraction in streaming mode.", - stacklevel=2, + "Anthropic does not support structured data extraction in " + "streaming mode for older models. Consider using a newer model " + f"like {', '.join(sorted(STRUCTURED_OUTPUT_MODELS))}.", + stacklevel=4, ) kwargs_full: "SubmitInputArgs" = { @@ -445,7 +467,17 @@ def _structured_tool_call(**kwargs: Any): **(kwargs or {}), } + if use_beta and data_model is not None: + # Use the new output_format parameter for structured outputs + from anthropic import transform_schema + + kwargs_full["output_format"] = { # type: ignore[typeddict-unknown-key] + "type": "json_schema", + "schema": transform_schema(data_model), + } + if data_model_tool: + # Old approach: force tool use kwargs_full["tool_choice"] = { "type": "tool", "name": data_model_tool.name, @@ -463,6 +495,34 @@ def _structured_tool_call(**kwargs: Any): return kwargs_full + def create_data_model_tool(self, data_model: type[BaseModel]) -> Tool: + """Create a fake tool for structured data extraction (old approach).""" + + def _structured_tool_call(**kwargs: Any): + """Extract structured data""" + pass + + data_model_tool = Tool.from_func(_structured_tool_call) + + data_model_schema = basemodel_to_param_schema(data_model) + + # Extract $defs from the nested schema and place at top level + # JSON Schema $ref pointers like "#/$defs/..." need $defs at the root + defs = data_model_schema.pop("$defs", None) + + params: dict[str, Any] = { + "type": "object", + "properties": { + "data": data_model_schema, + }, + } + if defs: + params["$defs"] = defs + + data_model_tool.schema["function"]["parameters"] = params + + return data_model_tool + def stream_text(self, chunk) -> Optional[str]: if chunk.type == "content_block_delta": if chunk.delta.type == "text_delta": @@ -576,7 +636,7 @@ def _token_count_args( ) -> dict[str, Any]: turn = user_turn(*args) - kwargs = self._chat_perform_args( + api_kwargs = self._chat_perform_args( stream=False, turns=[turn], tools=tools, @@ -591,7 +651,7 @@ def _token_count_args( "tool_choice", ] - return {arg: kwargs[arg] for arg in args_to_keep if arg in kwargs} + return {arg: api_kwargs[arg] for arg in args_to_keep if arg in api_kwargs} def translate_model_params(self, params: StandardModelParams) -> "SubmitInputArgs": res: "SubmitInputArgs" = {} @@ -753,11 +813,26 @@ def _anthropic_tool_schema(tool: "Tool | ToolBuiltIn") -> "ToolUnionParam": def _as_turn(self, completion: Message, has_data_model=False) -> AssistantTurn: contents = [] + + # Detect which structured output approach was used: + # - Old approach: has a _structured_tool_call tool_use block + # - New approach: has_data_model=True but no _structured_tool_call (JSON in text) + uses_old_tool_approach = has_data_model and any( + c.type == "tool_use" and c.name == "_structured_tool_call" + for c in completion.content + ) + uses_new_output_format = has_data_model and not uses_old_tool_approach + for content in completion.content: if content.type == "text": - contents.append(ContentText(text=content.text)) + if uses_new_output_format: + # New API: JSON response is in text content + contents.append(ContentJson(value=orjson.loads(content.text))) + else: + contents.append(ContentText(text=content.text)) elif content.type == "tool_use": - if has_data_model and content.name == "_structured_tool_call": + if uses_old_tool_approach and content.name == "_structured_tool_call": + # Old API: extract from tool input if not isinstance(content.input, dict): raise ValueError( "Expected data extraction tool to return a dictionary." @@ -874,7 +949,7 @@ def batch_submit( requests: list["BatchRequest"] = [] for i, turns in enumerate(conversations): - kwargs = self._chat_perform_args( + api_kwargs = self._chat_perform_args( stream=False, turns=turns, tools={}, @@ -882,18 +957,22 @@ def batch_submit( ) params: "MessageCreateParamsNonStreaming" = { - "messages": kwargs.get("messages", {}), + "messages": api_kwargs.get("messages", {}), "model": self.model, - "max_tokens": kwargs.get("max_tokens", 4096), + "max_tokens": api_kwargs.get("max_tokens", 4096), } - # If data_model, tools/tool_choice should be present - tools = kwargs.get("tools") - tool_choice = kwargs.get("tool_choice") + # If data_model, tools/tool_choice should be present (old API) + # or output_format (new API) + tools = api_kwargs.get("tools") + tool_choice = api_kwargs.get("tool_choice") + output_format = api_kwargs.get("output_format") if tools and not isinstance(tools, NotGiven): params["tools"] = tools if tool_choice and not isinstance(tool_choice, NotGiven): params["tool_choice"] = tool_choice + if output_format and not isinstance(output_format, NotGiven): + params["output_format"] = output_format # type: ignore[typeddict-unknown-key] requests.append({"custom_id": f"request-{i}", "params": params}) diff --git a/docs/plans/2026-01-06-anthropic-structured-outputs-design.md b/docs/plans/2026-01-06-anthropic-structured-outputs-design.md new file mode 100644 index 00000000..fdd166c7 --- /dev/null +++ b/docs/plans/2026-01-06-anthropic-structured-outputs-design.md @@ -0,0 +1,135 @@ +# Migration Plan: Anthropic Structured Outputs + +## Current State + +The current implementation in `_provider_anthropic.py` uses a **tool-based workaround**: +1. Creates a fake tool `_structured_tool_call` with the Pydantic schema as parameters +2. Forces Claude to call this tool via `tool_choice` +3. Extracts structured data from `content.input["data"]` +4. **Streaming is disabled** with a warning (line 432-437) + +## New Anthropic API + +Anthropic now provides native structured outputs via: +- **`output_format`** parameter with `type: "json_schema"` +- **Beta header**: `structured-outputs-2025-11-13` +- **`client.beta.messages.create()`** or **`.parse()`** methods +- **Streaming is supported!** +- Response is plain JSON text in `response.content[0].text` + +Documentation: https://platform.claude.com/docs/en/build-with-claude/structured-outputs + +## Proposed Changes + +### 1. Update `_chat_perform_args()` to use `output_format` + +**Before:** +```python +if data_model is not None: + # Create fake tool... + data_model_tool = Tool.from_func(_structured_tool_call) + # ... add to tool_schemas, set tool_choice + if stream: + stream = False # Disable streaming +``` + +**After:** +```python +if data_model is not None: + from anthropic import transform_schema + kwargs_full["output_format"] = { + "type": "json_schema", + "schema": transform_schema(data_model), + } + # Streaming now works! +``` + +### 2. Switch to beta client for structured outputs + +Use `client.beta.messages.create()` when `data_model` is provided: + +```python +def chat_perform(self, stream, turns, tools, data_model, kwargs): + kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs) + + if data_model is not None: + # Use beta endpoint with structured outputs header + return self._client.beta.messages.create( + betas=["structured-outputs-2025-11-13"], + **kwargs + ) + else: + return self._client.messages.create(**kwargs) +``` + +### 3. Update `_as_turn()` to handle new response format + +**Before:** Extract from `content.input["data"]` of tool call +**After:** Parse JSON from `content.text` when `has_data_model=True` + +```python +def _as_turn(self, completion: Message, has_data_model=False) -> AssistantTurn: + contents = [] + for content in completion.content: + if content.type == "text": + if has_data_model: + # New: JSON response is in text content + contents.append(ContentJson(value=orjson.loads(content.text))) + else: + contents.append(ContentText(text=content.text)) + elif content.type == "tool_use": + # Remove special handling for _structured_tool_call + contents.append(ContentToolRequest(...)) +``` + +### 4. Update streaming support + +Remove the warning and allow streaming: + +```python +# DELETE these lines: +if stream: + stream = False + warnings.warn( + "Anthropic does not support structured data extraction in streaming mode.", + stacklevel=2, + ) +``` + +### 5. Model compatibility check + +The new API only supports certain models. Add validation: + +```python +STRUCTURED_OUTPUT_MODELS = { + "claude-sonnet-4-5", "claude-opus-4-1", + "claude-opus-4-5", "claude-haiku-4-5" +} + +if data_model is not None: + base_model = self.model.split("-")[0:4] # Handle dated versions + if "-".join(base_model) not in STRUCTURED_OUTPUT_MODELS: + # Fall back to old tool-based approach for older models + ... +``` + +## Migration Strategy + +| Phase | Description | +|-------|-------------| +| **Phase 1** | Add new `output_format` support alongside existing tool-based approach | +| **Phase 2** | Use new API for supported models, fallback for older models | +| **Phase 3** | Remove tool-based workaround once older models are deprecated | + +## Benefits + +1. **Streaming works** - No more disabling streaming for structured outputs +2. **Cleaner response** - JSON in text content, not fake tool calls +3. **Better validation** - Anthropic's constrained decoding guarantees schema compliance +4. **SDK integration** - Can use `transform_schema()` for Pydantic compatibility + +## Risks & Considerations + +1. **Beta API** - Feature is in public beta, may change +2. **Model restrictions** - Only works with newer Claude models +3. **Breaking change** - Response structure changes (text vs tool_use) diff --git a/tests/_vcr/test_provider_anthropic/test_anthropic_nested_data_model_extraction.yaml b/tests/_vcr/test_provider_anthropic/test_anthropic_nested_data_model_extraction.yaml index a8af35ac..2d1ba955 100644 --- a/tests/_vcr/test_provider_anthropic/test_anthropic_nested_data_model_extraction.yaml +++ b/tests/_vcr/test_provider_anthropic/test_anthropic_nested_data_model_extraction.yaml @@ -3,20 +3,19 @@ interactions: body: '{"max_tokens": 4096, "messages": [{"role": "user", "content": [{"text": "The new quantum computing breakthrough could revolutionize the tech industry.", "type": "text", "cache_control": {"type": "ephemeral", "ttl": "5m"}}]}], "model": - "claude-haiku-4-5-20251001", "stream": false, "system": [{"type": "text", "text": - "You are a friendly but terse assistant.", "cache_control": {"type": "ephemeral", - "ttl": "5m"}}], "tool_choice": {"type": "tool", "name": "_structured_tool_call"}, - "tools": [{"name": "_structured_tool_call", "input_schema": {"type": "object", - "properties": {"data": {"description": "Array of classification results. The - scores should sum to 1.", "properties": {"classifications": {"items": {"$ref": - "#/$defs/Classification"}, "type": "array"}}, "required": ["classifications"], - "type": "object", "additionalProperties": false}}, "$defs": {"Classification": - {"properties": {"name": {"description": "The category name", "enum": ["Politics", - "Sports", "Technology", "Entertainment", "Business", "Other"], "title": "Name", - "type": "string"}, "score": {"description": "The classification score for the - category, ranging from 0.0 to 1.0.", "title": "Score", "type": "number"}}, "required": - ["name", "score"], "title": "Classification", "type": "object", "additionalProperties": - false}}}, "description": "Extract structured data"}]}' + "claude-haiku-4-5-20251001", "output_format": {"type": "json_schema", "schema": + {"$defs": {"Classification": {"type": "object", "title": "Classification", "properties": + {"name": {"type": "string", "description": "The category name\n\n{enum: [''Politics'', + ''Sports'', ''Technology'', ''Entertainment'', ''Business'', ''Other'']}", "title": + "Name"}, "score": {"type": "number", "description": "The classification score + for the category, ranging from 0.0 to 1.0.", "title": "Score"}}, "additionalProperties": + false, "required": ["name", "score"]}}, "type": "object", "description": "Array + of classification results. The scores should sum to 1.", "title": "Classifications", + "properties": {"classifications": {"type": "array", "title": "Classifications", + "items": {"$ref": "#/$defs/Classification"}}}, "additionalProperties": false, + "required": ["classifications"]}}, "stream": false, "system": [{"type": "text", + "text": "You are a friendly but terse assistant.", "cache_control": {"type": + "ephemeral", "ttl": "5m"}}], "tools": []}' headers: Accept: - application/json @@ -25,13 +24,15 @@ interactions: Connection: - keep-alive Content-Length: - - '1251' + - '1172' Content-Type: - application/json Host: - api.anthropic.com X-Stainless-Async: - 'false' + anthropic-beta: + - structured-outputs-2025-11-13 anthropic-version: - '2023-06-01' x-stainless-read-timeout: @@ -39,19 +40,21 @@ interactions: x-stainless-timeout: - '600' method: POST - uri: https://api.anthropic.com/v1/messages + uri: https://api.anthropic.com/v1/messages?beta=true response: body: - string: '{"model":"claude-haiku-4-5-20251001","id":"msg_012sEhjbSWV1Bi2HHnMLA2Ec","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WQtuJ2RRnRgfEyzEnmWSMZ","name":"_structured_tool_call","input":{"data":{"classifications":[{"name":"Technology","score":0.95},{"name":"Business","score":0.05},{"name":"Politics","score":0.0},{"name":"Sports","score":0.0},{"name":"Entertainment","score":0.0},{"name":"Other","score":0.0}]}}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":855,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":181,"service_tier":"standard"}}' + string: '{"model":"claude-haiku-4-5-20251001","id":"msg_01XbxzbDzo96okuCZtSmkJKX","type":"message","role":"assistant","content":[{"type":"text","text":"{\"classifications\": + [{\"name\": \"Technology\", \"score\": 0.95}, {\"name\": \"Business\", \"score\": + 0.05}]}"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":416,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":36,"service_tier":"standard"}}' headers: CF-RAY: - - 9b653de28d671f38-DEN + - 9b9c58811b851f32-DEN Connection: - keep-alive Content-Type: - application/json Date: - - Tue, 30 Dec 2025 23:15:54 GMT + - Tue, 06 Jan 2026 15:46:03 GMT Server: - cloudflare Transfer-Encoding: @@ -63,33 +66,33 @@ interactions: anthropic-ratelimit-input-tokens-remaining: - '4000000' anthropic-ratelimit-input-tokens-reset: - - '2025-12-30T23:15:53Z' + - '2026-01-06T15:46:03Z' anthropic-ratelimit-output-tokens-limit: - '800000' anthropic-ratelimit-output-tokens-remaining: - '800000' anthropic-ratelimit-output-tokens-reset: - - '2025-12-30T23:15:54Z' + - '2026-01-06T15:46:03Z' anthropic-ratelimit-requests-limit: - '4000' anthropic-ratelimit-requests-remaining: - '3999' anthropic-ratelimit-requests-reset: - - '2025-12-30T23:15:53Z' + - '2026-01-06T15:46:00Z' anthropic-ratelimit-tokens-limit: - '4800000' anthropic-ratelimit-tokens-remaining: - '4800000' anthropic-ratelimit-tokens-reset: - - '2025-12-30T23:15:53Z' + - '2026-01-06T15:46:03Z' cf-cache-status: - DYNAMIC content-length: - - '705' + - '517' strict-transport-security: - max-age=31536000; includeSubDomains; preload x-envoy-upstream-service-time: - - '1028' + - '3106' status: code: 200 message: OK diff --git a/tests/_vcr/test_provider_anthropic/test_data_extraction.yaml b/tests/_vcr/test_provider_anthropic/test_data_extraction.yaml index 41361983..81ba518a 100644 --- a/tests/_vcr/test_provider_anthropic/test_data_extraction.yaml +++ b/tests/_vcr/test_provider_anthropic/test_data_extraction.yaml @@ -4,13 +4,12 @@ interactions: "\n# Apples are tasty\n\nBy Hadley Wickham\nApples are delicious and tasty and I like to eat them.\nExcept for red delicious, that is. They are NOT delicious.\n", "type": "text", "cache_control": {"type": "ephemeral", "ttl": "5m"}}]}], "model": - "claude-haiku-4-5-20251001", "stream": false, "system": [{"type": "text", "text": - "[empty string]", "cache_control": {"type": "ephemeral", "ttl": "5m"}}], "tool_choice": - {"type": "tool", "name": "_structured_tool_call"}, "tools": [{"name": "_structured_tool_call", - "input_schema": {"type": "object", "properties": {"data": {"description": "Summary - of the article", "properties": {"title": {"type": "string"}, "author": {"type": - "string"}}, "required": ["title", "author"], "type": "object", "additionalProperties": - false}}}, "description": "Extract structured data"}]}' + "claude-haiku-4-5-20251001", "output_format": {"type": "json_schema", "schema": + {"type": "object", "description": "Summary of the article", "title": "ArticleSummary", + "properties": {"title": {"type": "string", "title": "Title"}, "author": {"type": + "string", "title": "Author"}}, "additionalProperties": false, "required": ["title", + "author"]}}, "stream": false, "system": [{"type": "text", "text": "[empty string]", + "cache_control": {"type": "ephemeral", "ttl": "5m"}}], "tools": []}' headers: Accept: - application/json @@ -19,13 +18,15 @@ interactions: Connection: - keep-alive Content-Length: - - '826' + - '744' Content-Type: - application/json Host: - api.anthropic.com X-Stainless-Async: - 'false' + anthropic-beta: + - structured-outputs-2025-11-13 anthropic-version: - '2023-06-01' x-stainless-read-timeout: @@ -33,20 +34,20 @@ interactions: x-stainless-timeout: - '600' method: POST - uri: https://api.anthropic.com/v1/messages + uri: https://api.anthropic.com/v1/messages?beta=true response: body: - string: '{"model":"claude-haiku-4-5-20251001","id":"msg_018FouQmP8cB4csqwzr7aGt9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BKS4eTmX24bZwLdZ2bzRzH","name":"_structured_tool_call","input":{"data":{"title":"Apples - are tasty","author":"Hadley Wickham"}}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":753,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":60,"service_tier":"standard"}}' + string: '{"model":"claude-haiku-4-5-20251001","id":"msg_01JvVnRxS7sduptbqXnskPiZ","type":"message","role":"assistant","content":[{"type":"text","text":"{\"title\":\"Apples + are tasty\",\"author\":\"Hadley Wickham\"}"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":265,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":22,"service_tier":"standard"}}' headers: CF-RAY: - - 9b6c9046e89e4e6a-DEN + - 9b9c58414e5ee73d-DEN Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 31 Dec 2025 20:35:29 GMT + - Tue, 06 Jan 2026 15:45:53 GMT Server: - cloudflare Transfer-Encoding: @@ -58,33 +59,33 @@ interactions: anthropic-ratelimit-input-tokens-remaining: - '4000000' anthropic-ratelimit-input-tokens-reset: - - '2025-12-31T20:35:28Z' + - '2026-01-06T15:45:53Z' anthropic-ratelimit-output-tokens-limit: - '800000' anthropic-ratelimit-output-tokens-remaining: - '800000' anthropic-ratelimit-output-tokens-reset: - - '2025-12-31T20:35:29Z' + - '2026-01-06T15:45:53Z' anthropic-ratelimit-requests-limit: - '4000' anthropic-ratelimit-requests-remaining: - '3999' anthropic-ratelimit-requests-reset: - - '2025-12-31T20:35:28Z' + - '2026-01-06T15:45:50Z' anthropic-ratelimit-tokens-limit: - '4800000' anthropic-ratelimit-tokens-remaining: - '4800000' anthropic-ratelimit-tokens-reset: - - '2025-12-31T20:35:28Z' + - '2026-01-06T15:45:53Z' cf-cache-status: - DYNAMIC content-length: - - '541' + - '468' strict-transport-security: - max-age=31536000; includeSubDomains; preload x-envoy-upstream-service-time: - - '563' + - '2967' status: code: 200 message: OK @@ -97,14 +98,13 @@ interactions: "content": [{"text": "\n# Apples are tasty\n\nBy Hadley Wickham\nApples are delicious and tasty and I like to eat them.\nExcept for red delicious, that is. They are NOT delicious.\n", "type": "text", "cache_control": {"type": "ephemeral", - "ttl": "5m"}}]}], "model": "claude-haiku-4-5-20251001", "stream": false, "system": - [{"type": "text", "text": "[empty string]", "cache_control": {"type": "ephemeral", - "ttl": "5m"}}], "tool_choice": {"type": "tool", "name": "_structured_tool_call"}, - "tools": [{"name": "_structured_tool_call", "input_schema": {"type": "object", - "properties": {"data": {"description": "Summary of the article", "properties": - {"title": {"type": "string"}, "author": {"type": "string"}}, "required": ["title", - "author"], "type": "object", "additionalProperties": false}}}, "description": - "Extract structured data"}]}' + "ttl": "5m"}}]}], "model": "claude-haiku-4-5-20251001", "output_format": {"type": + "json_schema", "schema": {"type": "object", "description": "Summary of the article", + "title": "ArticleSummary", "properties": {"title": {"type": "string", "title": + "Title"}, "author": {"type": "string", "title": "Author"}}, "additionalProperties": + false, "required": ["title", "author"]}}, "stream": false, "system": [{"type": + "text", "text": "[empty string]", "cache_control": {"type": "ephemeral", "ttl": + "5m"}}], "tools": []}' headers: Accept: - application/json @@ -113,13 +113,15 @@ interactions: Connection: - keep-alive Content-Length: - - '1160' + - '1078' Content-Type: - application/json Host: - api.anthropic.com X-Stainless-Async: - 'false' + anthropic-beta: + - structured-outputs-2025-11-13 anthropic-version: - '2023-06-01' x-stainless-read-timeout: @@ -127,20 +129,20 @@ interactions: x-stainless-timeout: - '600' method: POST - uri: https://api.anthropic.com/v1/messages + uri: https://api.anthropic.com/v1/messages?beta=true response: body: - string: '{"model":"claude-haiku-4-5-20251001","id":"msg_01FhdEeK3eWx8HvSrSTirSqX","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WfbpyAgQxJZ76VHXh6EMZN","name":"_structured_tool_call","input":{"data":{"title":"Apples - are tasty","author":"Hadley Wickham"}}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":829,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":54,"service_tier":"standard"}}' + string: '{"model":"claude-haiku-4-5-20251001","id":"msg_012SW5MBPJxyaVLCvKVpCgoT","type":"message","role":"assistant","content":[{"type":"text","text":"{\"title\":\"Apples + are tasty\",\"author\":\"Hadley Wickham\"}"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":341,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":22,"service_tier":"standard"}}' headers: CF-RAY: - - 9b6c904bfd64a3cc-DEN + - 9b9c5855fffb7c32-DEN Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 31 Dec 2025 20:35:30 GMT + - Tue, 06 Jan 2026 15:45:55 GMT Server: - cloudflare Transfer-Encoding: @@ -152,33 +154,33 @@ interactions: anthropic-ratelimit-input-tokens-remaining: - '4000000' anthropic-ratelimit-input-tokens-reset: - - '2025-12-31T20:35:30Z' + - '2026-01-06T15:45:54Z' anthropic-ratelimit-output-tokens-limit: - '800000' anthropic-ratelimit-output-tokens-remaining: - '800000' anthropic-ratelimit-output-tokens-reset: - - '2025-12-31T20:35:30Z' + - '2026-01-06T15:45:55Z' anthropic-ratelimit-requests-limit: - '4000' anthropic-ratelimit-requests-remaining: - '3999' anthropic-ratelimit-requests-reset: - - '2025-12-31T20:35:29Z' + - '2026-01-06T15:45:53Z' anthropic-ratelimit-tokens-limit: - '4800000' anthropic-ratelimit-tokens-remaining: - '4800000' anthropic-ratelimit-tokens-reset: - - '2025-12-31T20:35:30Z' + - '2026-01-06T15:45:54Z' cf-cache-status: - DYNAMIC content-length: - - '541' + - '468' strict-transport-security: - max-age=31536000; includeSubDomains; preload x-envoy-upstream-service-time: - - '1113' + - '1467' status: code: 200 message: OK @@ -194,13 +196,12 @@ interactions: [{"text": "{\"title\":\"Apples are tasty\",\"author\":\"Hadley Wickham\"}", "type": "text"}]}, {"role": "user", "content": [{"text": "Generate the name and age of a random person.", "type": "text", "cache_control": {"type": "ephemeral", - "ttl": "5m"}}]}], "model": "claude-haiku-4-5-20251001", "stream": false, "system": - [{"type": "text", "text": "[empty string]", "cache_control": {"type": "ephemeral", - "ttl": "5m"}}], "tool_choice": {"type": "tool", "name": "_structured_tool_call"}, - "tools": [{"name": "_structured_tool_call", "input_schema": {"type": "object", - "properties": {"data": {"properties": {"name": {"type": "string"}, "age": {"type": - "integer"}}, "required": ["name", "age"], "type": "object", "additionalProperties": - false}}}, "description": "Extract structured data"}]}' + "ttl": "5m"}}]}], "model": "claude-haiku-4-5-20251001", "output_format": {"type": + "json_schema", "schema": {"type": "object", "title": "Person", "properties": + {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": + "Age"}}, "additionalProperties": false, "required": ["name", "age"]}}, "stream": + false, "system": [{"type": "text", "text": "[empty string]", "cache_control": + {"type": "ephemeral", "ttl": "5m"}}], "tools": []}' headers: Accept: - application/json @@ -209,13 +210,15 @@ interactions: Connection: - keep-alive Content-Length: - - '1334' + - '1240' Content-Type: - application/json Host: - api.anthropic.com X-Stainless-Async: - 'false' + anthropic-beta: + - structured-outputs-2025-11-13 anthropic-version: - '2023-06-01' x-stainless-read-timeout: @@ -223,20 +226,20 @@ interactions: x-stainless-timeout: - '600' method: POST - uri: https://api.anthropic.com/v1/messages + uri: https://api.anthropic.com/v1/messages?beta=true response: body: - string: '{"model":"claude-haiku-4-5-20251001","id":"msg_01C2jMVW8cDPFSpVQfSwZ2CW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BMikkxjwrerR95ckaCRgSt","name":"_structured_tool_call","input":{"data":{"name":"Emily - Chen","age":34}}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":855,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":44,"service_tier":"standard"}}' + string: '{"model":"claude-haiku-4-5-20251001","id":"msg_01JdBGqwEcDKeXhqLDBDrFqF","type":"message","role":"assistant","content":[{"type":"text","text":"{\"name\":\"James + Mitchell\",\"age\":34}"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":361,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":13,"service_tier":"standard"}}' headers: CF-RAY: - - 9b6c90548a59e653-DEN + - 9b9c58614b99e66e-DEN Connection: - keep-alive Content-Type: - application/json Date: - - Wed, 31 Dec 2025 20:35:31 GMT + - Tue, 06 Jan 2026 15:45:57 GMT Server: - cloudflare Transfer-Encoding: @@ -248,33 +251,33 @@ interactions: anthropic-ratelimit-input-tokens-remaining: - '4000000' anthropic-ratelimit-input-tokens-reset: - - '2025-12-31T20:35:31Z' + - '2026-01-06T15:45:57Z' anthropic-ratelimit-output-tokens-limit: - '800000' anthropic-ratelimit-output-tokens-remaining: - '800000' anthropic-ratelimit-output-tokens-reset: - - '2025-12-31T20:35:31Z' + - '2026-01-06T15:45:57Z' anthropic-ratelimit-requests-limit: - '4000' anthropic-ratelimit-requests-remaining: - '3999' anthropic-ratelimit-requests-reset: - - '2025-12-31T20:35:30Z' + - '2026-01-06T15:45:55Z' anthropic-ratelimit-tokens-limit: - '4800000' anthropic-ratelimit-tokens-remaining: - '4800000' anthropic-ratelimit-tokens-reset: - - '2025-12-31T20:35:31Z' + - '2026-01-06T15:45:57Z' cf-cache-status: - DYNAMIC content-length: - - '517' + - '446' strict-transport-security: - max-age=31536000; includeSubDomains; preload x-envoy-upstream-service-time: - - '691' + - '2498' status: code: 200 message: OK @@ -290,7 +293,7 @@ interactions: [{"text": "{\"title\":\"Apples are tasty\",\"author\":\"Hadley Wickham\"}", "type": "text"}]}, {"role": "user", "content": [{"text": "Generate the name and age of a random person.", "type": "text"}]}, {"role": "assistant", "content": - [{"text": "{\"name\":\"Emily Chen\",\"age\":34}", "type": "text"}]}, {"role": + [{"text": "{\"name\":\"James Mitchell\",\"age\":34}", "type": "text"}]}, {"role": "user", "content": [{"text": "What is the name of the person?", "type": "text", "cache_control": {"type": "ephemeral", "ttl": "5m"}}]}], "model": "claude-haiku-4-5-20251001", "stream": true, "system": [{"type": "text", "text": "[empty string]", "cache_control": @@ -303,7 +306,7 @@ interactions: Connection: - keep-alive Content-Length: - - '1187' + - '1191' Content-Type: - application/json Host: @@ -322,23 +325,23 @@ interactions: body: string: 'event: message_start - data: {"type":"message_start","message":{"model":"claude-haiku-4-5-20251001","id":"msg_0151Qof1JPFmJfXrsbQPNb1R","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":197,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}} } + data: {"type":"message_start","message":{"model":"claude-haiku-4-5-20251001","id":"msg_015uq7xrvHJHc4eTUgoY8aTE","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":197,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}} } event: content_block_start - data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} } + data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: content_block_delta - data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The"} } + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The"} } event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" - name"} } + name"} } event: ping @@ -349,7 +352,7 @@ interactions: event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" - of the person is Emily Chen."} } + of the person is James Mitchell."} } event: content_block_stop @@ -359,18 +362,19 @@ interactions: event: message_delta - data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":197,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":12} } + data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":197,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":12} + } event: message_stop - data: {"type":"message_stop" } + data: {"type":"message_stop"} ' headers: CF-RAY: - - 9b6c905a6c74e73b-DEN + - 9b9c58735c737b30-DEN Cache-Control: - no-cache Connection: @@ -378,7 +382,7 @@ interactions: Content-Type: - text/event-stream; charset=utf-8 Date: - - Wed, 31 Dec 2025 20:35:32 GMT + - Tue, 06 Jan 2026 15:45:59 GMT Server: - cloudflare Transfer-Encoding: @@ -390,31 +394,31 @@ interactions: anthropic-ratelimit-input-tokens-remaining: - '4000000' anthropic-ratelimit-input-tokens-reset: - - '2025-12-31T20:35:31Z' + - '2026-01-06T15:45:58Z' anthropic-ratelimit-output-tokens-limit: - '800000' anthropic-ratelimit-output-tokens-remaining: - '800000' anthropic-ratelimit-output-tokens-reset: - - '2025-12-31T20:35:31Z' + - '2026-01-06T15:45:58Z' anthropic-ratelimit-requests-limit: - '4000' anthropic-ratelimit-requests-remaining: - '3999' anthropic-ratelimit-requests-reset: - - '2025-12-31T20:35:31Z' + - '2026-01-06T15:45:58Z' anthropic-ratelimit-tokens-limit: - '4800000' anthropic-ratelimit-tokens-remaining: - '4800000' anthropic-ratelimit-tokens-reset: - - '2025-12-31T20:35:31Z' + - '2026-01-06T15:45:58Z' cf-cache-status: - DYNAMIC strict-transport-security: - max-age=31536000; includeSubDomains; preload x-envoy-upstream-service-time: - - '409' + - '1258' status: code: 200 message: OK From e0a335a2a8442c1872a2fb1bf27c0babb7aa8c04 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 6 Jan 2026 11:11:26 -0600 Subject: [PATCH 2/5] refactor: address PR feedback for Anthropic structured outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename use_beta_structured_output to _supports_structured_outputs - Inline STRUCTURED_OUTPUT_MODELS into the function - Remove streaming fallback/warning for older models (let it fail) - Make create_data_model_tool a staticmethod - Revert unnecessary kwargs->api_kwargs rename in _token_count_args - Remove redundant comments about Beta API type signatures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- chatlas/_provider_anthropic.py | 51 ++++++++++++---------------------- 1 file changed, 17 insertions(+), 34 deletions(-) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index 800b604a..00e91a28 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -47,29 +47,22 @@ from ._turn import AssistantTurn, SystemTurn, Turn, UserTurn, user_turn from ._utils import split_http_client_kwargs -# Models that support the new structured outputs API (output_format parameter) -# https://platform.claude.com/docs/en/build-with-claude/structured-outputs -STRUCTURED_OUTPUT_MODELS = { - "claude-sonnet-4-5", - "claude-opus-4-1", - "claude-opus-4-5", - "claude-haiku-4-5", -} - STRUCTURED_OUTPUTS_BETA = "structured-outputs-2025-11-13" -def use_beta_structured_output(model: str, data_model: Optional[type[BaseModel]]) -> bool: +def _supports_structured_outputs(model: str) -> bool: """ - Check if we should use the beta structured outputs API. + Check if the model supports the beta structured outputs API. - Returns True if data_model is provided and the model supports the new API. + https://platform.claude.com/docs/en/build-with-claude/structured-outputs """ - if data_model is None: - return False - # Handle dated model versions like "claude-sonnet-4-5-20250514" - # by checking if any supported model is a prefix - for supported in STRUCTURED_OUTPUT_MODELS: + supported_models = { + "claude-sonnet-4-5", + "claude-opus-4-1", + "claude-opus-4-5", + "claude-haiku-4-5", + } + for supported in supported_models: if model.startswith(supported): return True return False @@ -382,8 +375,7 @@ def chat_perform( kwargs: Optional["SubmitInputArgs"] = None, ): api_kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs) - if use_beta_structured_output(self.model, data_model): - # Beta API has slightly different type signatures but is runtime compatible + if data_model is not None and _supports_structured_outputs(self.model): return self._client.beta.messages.create( betas=[STRUCTURED_OUTPUTS_BETA], **api_kwargs, # type: ignore[arg-type] @@ -422,8 +414,7 @@ async def chat_perform_async( kwargs: Optional["SubmitInputArgs"] = None, ): api_kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs) - if use_beta_structured_output(self.model, data_model): - # Beta API has slightly different type signatures but is runtime compatible + if data_model is not None and _supports_structured_outputs(self.model): return await self._async_client.beta.messages.create( betas=[STRUCTURED_OUTPUTS_BETA], **api_kwargs, # type: ignore[arg-type] @@ -441,7 +432,7 @@ def _chat_perform_args( """Build kwargs for the Anthropic messages API.""" tool_schemas = [self._anthropic_tool_schema(tool) for tool in tools.values()] - use_beta = use_beta_structured_output(self.model, data_model) + use_beta = _supports_structured_outputs(self.model) data_model_tool: Tool | None = None if data_model is not None and not use_beta: @@ -449,15 +440,6 @@ def _chat_perform_args( data_model_tool = self.create_data_model_tool(data_model) tool_schemas.append(self._anthropic_tool_schema(data_model_tool)) - if stream: - stream = False - warnings.warn( - "Anthropic does not support structured data extraction in " - "streaming mode for older models. Consider using a newer model " - f"like {', '.join(sorted(STRUCTURED_OUTPUT_MODELS))}.", - stacklevel=4, - ) - kwargs_full: "SubmitInputArgs" = { "stream": stream, "messages": self._as_message_params(turns), @@ -495,7 +477,8 @@ def _chat_perform_args( return kwargs_full - def create_data_model_tool(self, data_model: type[BaseModel]) -> Tool: + @staticmethod + def create_data_model_tool(data_model: type[BaseModel]) -> Tool: """Create a fake tool for structured data extraction (old approach).""" def _structured_tool_call(**kwargs: Any): @@ -636,7 +619,7 @@ def _token_count_args( ) -> dict[str, Any]: turn = user_turn(*args) - api_kwargs = self._chat_perform_args( + kwargs = self._chat_perform_args( stream=False, turns=[turn], tools=tools, @@ -651,7 +634,7 @@ def _token_count_args( "tool_choice", ] - return {arg: api_kwargs[arg] for arg in args_to_keep if arg in api_kwargs} + return {arg: kwargs[arg] for arg in args_to_keep if arg in kwargs} def translate_model_params(self, params: StandardModelParams) -> "SubmitInputArgs": res: "SubmitInputArgs" = {} From b39d1e85c900aa6ed81fdaf41b6bf678b7ab3a29 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 6 Jan 2026 11:13:05 -0600 Subject: [PATCH 3/5] Remove plan from git history --- ...-06-anthropic-structured-outputs-design.md | 135 ------------------ 1 file changed, 135 deletions(-) delete mode 100644 docs/plans/2026-01-06-anthropic-structured-outputs-design.md diff --git a/docs/plans/2026-01-06-anthropic-structured-outputs-design.md b/docs/plans/2026-01-06-anthropic-structured-outputs-design.md deleted file mode 100644 index fdd166c7..00000000 --- a/docs/plans/2026-01-06-anthropic-structured-outputs-design.md +++ /dev/null @@ -1,135 +0,0 @@ -# Migration Plan: Anthropic Structured Outputs - -## Current State - -The current implementation in `_provider_anthropic.py` uses a **tool-based workaround**: -1. Creates a fake tool `_structured_tool_call` with the Pydantic schema as parameters -2. Forces Claude to call this tool via `tool_choice` -3. Extracts structured data from `content.input["data"]` -4. **Streaming is disabled** with a warning (line 432-437) - -## New Anthropic API - -Anthropic now provides native structured outputs via: -- **`output_format`** parameter with `type: "json_schema"` -- **Beta header**: `structured-outputs-2025-11-13` -- **`client.beta.messages.create()`** or **`.parse()`** methods -- **Streaming is supported!** -- Response is plain JSON text in `response.content[0].text` - -Documentation: https://platform.claude.com/docs/en/build-with-claude/structured-outputs - -## Proposed Changes - -### 1. Update `_chat_perform_args()` to use `output_format` - -**Before:** -```python -if data_model is not None: - # Create fake tool... - data_model_tool = Tool.from_func(_structured_tool_call) - # ... add to tool_schemas, set tool_choice - if stream: - stream = False # Disable streaming -``` - -**After:** -```python -if data_model is not None: - from anthropic import transform_schema - kwargs_full["output_format"] = { - "type": "json_schema", - "schema": transform_schema(data_model), - } - # Streaming now works! -``` - -### 2. Switch to beta client for structured outputs - -Use `client.beta.messages.create()` when `data_model` is provided: - -```python -def chat_perform(self, stream, turns, tools, data_model, kwargs): - kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs) - - if data_model is not None: - # Use beta endpoint with structured outputs header - return self._client.beta.messages.create( - betas=["structured-outputs-2025-11-13"], - **kwargs - ) - else: - return self._client.messages.create(**kwargs) -``` - -### 3. Update `_as_turn()` to handle new response format - -**Before:** Extract from `content.input["data"]` of tool call -**After:** Parse JSON from `content.text` when `has_data_model=True` - -```python -def _as_turn(self, completion: Message, has_data_model=False) -> AssistantTurn: - contents = [] - for content in completion.content: - if content.type == "text": - if has_data_model: - # New: JSON response is in text content - contents.append(ContentJson(value=orjson.loads(content.text))) - else: - contents.append(ContentText(text=content.text)) - elif content.type == "tool_use": - # Remove special handling for _structured_tool_call - contents.append(ContentToolRequest(...)) -``` - -### 4. Update streaming support - -Remove the warning and allow streaming: - -```python -# DELETE these lines: -if stream: - stream = False - warnings.warn( - "Anthropic does not support structured data extraction in streaming mode.", - stacklevel=2, - ) -``` - -### 5. Model compatibility check - -The new API only supports certain models. Add validation: - -```python -STRUCTURED_OUTPUT_MODELS = { - "claude-sonnet-4-5", "claude-opus-4-1", - "claude-opus-4-5", "claude-haiku-4-5" -} - -if data_model is not None: - base_model = self.model.split("-")[0:4] # Handle dated versions - if "-".join(base_model) not in STRUCTURED_OUTPUT_MODELS: - # Fall back to old tool-based approach for older models - ... -``` - -## Migration Strategy - -| Phase | Description | -|-------|-------------| -| **Phase 1** | Add new `output_format` support alongside existing tool-based approach | -| **Phase 2** | Use new API for supported models, fallback for older models | -| **Phase 3** | Remove tool-based workaround once older models are deprecated | - -## Benefits - -1. **Streaming works** - No more disabling streaming for structured outputs -2. **Cleaner response** - JSON in text content, not fake tool calls -3. **Better validation** - Anthropic's constrained decoding guarantees schema compliance -4. **SDK integration** - Can use `transform_schema()` for Pydantic compatibility - -## Risks & Considerations - -1. **Beta API** - Feature is in public beta, may change -2. **Model restrictions** - Only works with newer Claude models -3. **Breaking change** - Response structure changes (text vs tool_use) From 110df49eadd5ef36aaad682f17ea903699b20e70 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 6 Jan 2026 11:22:38 -0600 Subject: [PATCH 4/5] refactor: simplify structured outputs logic in _chat_perform_args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move STRUCTURED_OUTPUTS_BETA and supports_structured_outputs after TYPE_CHECKING - Make supports_structured_outputs public (remove leading underscore) - Consolidate data_model handling into single block - Add TODO for removing legacy tool-based approach 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- chatlas/_provider_anthropic.py | 89 ++++++++++++++++------------------ 1 file changed, 41 insertions(+), 48 deletions(-) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index 00e91a28..15de00e0 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -47,27 +47,6 @@ from ._turn import AssistantTurn, SystemTurn, Turn, UserTurn, user_turn from ._utils import split_http_client_kwargs -STRUCTURED_OUTPUTS_BETA = "structured-outputs-2025-11-13" - - -def _supports_structured_outputs(model: str) -> bool: - """ - Check if the model supports the beta structured outputs API. - - https://platform.claude.com/docs/en/build-with-claude/structured-outputs - """ - supported_models = { - "claude-sonnet-4-5", - "claude-opus-4-1", - "claude-opus-4-5", - "claude-haiku-4-5", - } - for supported in supported_models: - if model.startswith(supported): - return True - return False - - if TYPE_CHECKING: from anthropic.types import ( Message, @@ -105,6 +84,27 @@ def _supports_structured_outputs(model: str) -> bool: RawMessageStreamEvent = object +STRUCTURED_OUTPUTS_BETA = "structured-outputs-2025-11-13" + + +def supports_structured_outputs(model: str) -> bool: + """ + Check if the model supports the beta structured outputs API. + + https://platform.claude.com/docs/en/build-with-claude/structured-outputs + """ + supported_models = { + "claude-sonnet-4-5", + "claude-opus-4-1", + "claude-opus-4-5", + "claude-haiku-4-5", + } + for supported in supported_models: + if model.startswith(supported): + return True + return False + + def ChatAnthropic( *, system_prompt: Optional[str] = None, @@ -375,7 +375,7 @@ def chat_perform( kwargs: Optional["SubmitInputArgs"] = None, ): api_kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs) - if data_model is not None and _supports_structured_outputs(self.model): + if data_model is not None and supports_structured_outputs(self.model): return self._client.beta.messages.create( betas=[STRUCTURED_OUTPUTS_BETA], **api_kwargs, # type: ignore[arg-type] @@ -414,7 +414,7 @@ async def chat_perform_async( kwargs: Optional["SubmitInputArgs"] = None, ): api_kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs) - if data_model is not None and _supports_structured_outputs(self.model): + if data_model is not None and supports_structured_outputs(self.model): return await self._async_client.beta.messages.create( betas=[STRUCTURED_OUTPUTS_BETA], **api_kwargs, # type: ignore[arg-type] @@ -429,17 +429,8 @@ def _chat_perform_args( data_model: Optional[type[BaseModel]] = None, kwargs: Optional["SubmitInputArgs"] = None, ) -> "SubmitInputArgs": - """Build kwargs for the Anthropic messages API.""" tool_schemas = [self._anthropic_tool_schema(tool) for tool in tools.values()] - use_beta = _supports_structured_outputs(self.model) - data_model_tool: Tool | None = None - - if data_model is not None and not use_beta: - # Fall back to the old tool-based approach for older models - data_model_tool = self.create_data_model_tool(data_model) - tool_schemas.append(self._anthropic_tool_schema(data_model_tool)) - kwargs_full: "SubmitInputArgs" = { "stream": stream, "messages": self._as_message_params(turns), @@ -449,21 +440,25 @@ def _chat_perform_args( **(kwargs or {}), } - if use_beta and data_model is not None: - # Use the new output_format parameter for structured outputs - from anthropic import transform_schema - - kwargs_full["output_format"] = { # type: ignore[typeddict-unknown-key] - "type": "json_schema", - "schema": transform_schema(data_model), - } + if data_model is not None: + if supports_structured_outputs(self.model): + from anthropic import transform_schema - if data_model_tool: - # Old approach: force tool use - kwargs_full["tool_choice"] = { - "type": "tool", - "name": data_model_tool.name, - } + kwargs_full["output_format"] = { # type: ignore[typeddict-unknown-key] + "type": "json_schema", + "schema": transform_schema(data_model), + } + else: + # TODO: when structured outputs are generally available, + # we can remove this legacy tool-based approach + data_model_tool = self.create_data_model_tool(data_model) + cast(list, kwargs_full["tools"]).append( + self._anthropic_tool_schema(data_model_tool) + ) + kwargs_full["tool_choice"] = { + "type": "tool", + "name": data_model_tool.name, + } if "system" not in kwargs_full: if len(turns) > 0 and isinstance(turns[0], SystemTurn): @@ -479,8 +474,6 @@ def _chat_perform_args( @staticmethod def create_data_model_tool(data_model: type[BaseModel]) -> Tool: - """Create a fake tool for structured data extraction (old approach).""" - def _structured_tool_call(**kwargs: Any): """Extract structured data""" pass From a1a21d2335500e613331f28f51a2723231546e2d Mon Sep 17 00:00:00 2001 From: Carson Sievert Date: Tue, 6 Jan 2026 11:26:15 -0600 Subject: [PATCH 5/5] Apply suggestions from code review --- chatlas/_provider_anthropic.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index 15de00e0..f81b1f83 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -802,13 +802,11 @@ def _as_turn(self, completion: Message, has_data_model=False) -> AssistantTurn: for content in completion.content: if content.type == "text": if uses_new_output_format: - # New API: JSON response is in text content contents.append(ContentJson(value=orjson.loads(content.text))) else: contents.append(ContentText(text=content.text)) elif content.type == "tool_use": if uses_old_tool_approach and content.name == "_structured_tool_call": - # Old API: extract from tool input if not isinstance(content.input, dict): raise ValueError( "Expected data extraction tool to return a dictionary."