Skip to content

Commit d76c802

Browse files
cpsievertclaude
andcommitted
feat: migrate Anthropic provider to native structured outputs API
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 <[email protected]>
1 parent 525dc4c commit d76c802

File tree

6 files changed

+386
-149
lines changed

6 files changed

+386
-149
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### New features
1313

14+
* `.stream()` and `.stream_async()` now support a `data_model` parameter for structured data extraction while streaming. (#262)
15+
* `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)
1416
* `ChatOpenAI()`, `ChatAnthropic()`, and `ChatGoogle()` gain a new `reasoning` parameter to easily opt-into, and fully customize, reasoning capabilities. (#202, #260)
1517
* A new `ContentThinking` content type was added and captures the "thinking" portion of a reasoning model. (#192)
1618
* Added "built-in" web search and URL fetch tools `tool_web_search()` and `tool_web_fetch()`:

chatlas/_provider_anthropic.py

Lines changed: 139 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,28 @@
4747
from ._turn import AssistantTurn, SystemTurn, Turn, UserTurn, user_turn
4848
from ._utils import split_http_client_kwargs
4949

50+
# Models that support the new structured outputs API (output_format parameter)
51+
# https://platform.claude.com/docs/en/build-with-claude/structured-outputs
52+
STRUCTURED_OUTPUT_MODELS = {
53+
"claude-sonnet-4-5",
54+
"claude-opus-4-1",
55+
"claude-opus-4-5",
56+
"claude-haiku-4-5",
57+
}
58+
59+
STRUCTURED_OUTPUTS_BETA = "structured-outputs-2025-11-13"
60+
61+
62+
def _supports_structured_outputs(model: str) -> bool:
63+
"""Check if a model supports the new structured outputs API."""
64+
# Handle dated model versions like "claude-sonnet-4-5-20250514"
65+
# by checking if any supported model is a prefix
66+
for supported in STRUCTURED_OUTPUT_MODELS:
67+
if model.startswith(supported):
68+
return True
69+
return False
70+
71+
5072
if TYPE_CHECKING:
5173
from anthropic.types import (
5274
Message,
@@ -353,8 +375,16 @@ def chat_perform(
353375
data_model: Optional[type[BaseModel]] = None,
354376
kwargs: Optional["SubmitInputArgs"] = None,
355377
):
356-
kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs)
357-
return self._client.messages.create(**kwargs) # type: ignore
378+
api_kwargs, use_beta = self._chat_perform_args(
379+
stream, turns, tools, data_model, kwargs
380+
)
381+
if use_beta:
382+
# Beta API has slightly different type signatures but is runtime compatible
383+
return self._client.beta.messages.create(
384+
betas=[STRUCTURED_OUTPUTS_BETA],
385+
**api_kwargs, # type: ignore[arg-type]
386+
)
387+
return self._client.messages.create(**api_kwargs) # type: ignore
358388

359389
@overload
360390
async def chat_perform_async(
@@ -387,8 +417,16 @@ async def chat_perform_async(
387417
data_model: Optional[type[BaseModel]] = None,
388418
kwargs: Optional["SubmitInputArgs"] = None,
389419
):
390-
kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs)
391-
return await self._async_client.messages.create(**kwargs) # type: ignore
420+
api_kwargs, use_beta = self._chat_perform_args(
421+
stream, turns, tools, data_model, kwargs
422+
)
423+
if use_beta:
424+
# Beta API has slightly different type signatures but is runtime compatible
425+
return await self._async_client.beta.messages.create(
426+
betas=[STRUCTURED_OUTPUTS_BETA],
427+
**api_kwargs, # type: ignore[arg-type]
428+
)
429+
return await self._async_client.messages.create(**api_kwargs) # type: ignore
392430

393431
def _chat_perform_args(
394432
self,
@@ -397,44 +435,39 @@ def _chat_perform_args(
397435
tools: dict[str, Tool | ToolBuiltIn],
398436
data_model: Optional[type[BaseModel]] = None,
399437
kwargs: Optional["SubmitInputArgs"] = None,
400-
) -> "SubmitInputArgs":
438+
) -> tuple["SubmitInputArgs", bool]:
439+
"""
440+
Build kwargs for the Anthropic messages API.
441+
442+
Returns
443+
-------
444+
tuple
445+
A tuple of (kwargs, use_beta) where use_beta indicates whether
446+
to use the beta client for structured outputs.
447+
"""
401448
tool_schemas = [self._anthropic_tool_schema(tool) for tool in tools.values()]
402449

403-
# If data extraction is requested, add a "mock" tool with parameters inferred from the data model
450+
use_beta = False
404451
data_model_tool: Tool | None = None
405-
if data_model is not None:
406-
407-
def _structured_tool_call(**kwargs: Any):
408-
"""Extract structured data"""
409-
pass
410-
411-
data_model_tool = Tool.from_func(_structured_tool_call)
412-
413-
data_model_schema = basemodel_to_param_schema(data_model)
414-
415-
# Extract $defs from the nested schema and place at top level
416-
# JSON Schema $ref pointers like "#/$defs/..." need $defs at the root
417-
defs = data_model_schema.pop("$defs", None)
418-
419-
params: dict[str, Any] = {
420-
"type": "object",
421-
"properties": {
422-
"data": data_model_schema,
423-
},
424-
}
425-
if defs:
426-
params["$defs"] = defs
427-
428-
data_model_tool.schema["function"]["parameters"] = params
429-
430-
tool_schemas.append(self._anthropic_tool_schema(data_model_tool))
431452

432-
if stream:
433-
stream = False
434-
warnings.warn(
435-
"Anthropic does not support structured data extraction in streaming mode.",
436-
stacklevel=2,
437-
)
453+
if data_model is not None:
454+
# Check if model supports the new structured outputs API
455+
if _supports_structured_outputs(self.model):
456+
# Use the new output_format API (supports streaming!)
457+
use_beta = True
458+
else:
459+
# Fall back to the old tool-based approach for older models
460+
data_model_tool = self._create_data_model_tool(data_model)
461+
tool_schemas.append(self._anthropic_tool_schema(data_model_tool))
462+
463+
if stream:
464+
stream = False
465+
warnings.warn(
466+
"Anthropic does not support structured data extraction in "
467+
"streaming mode for older models. Consider using a newer model "
468+
f"like {', '.join(sorted(STRUCTURED_OUTPUT_MODELS))}.",
469+
stacklevel=4,
470+
)
438471

439472
kwargs_full: "SubmitInputArgs" = {
440473
"stream": stream,
@@ -445,7 +478,17 @@ def _structured_tool_call(**kwargs: Any):
445478
**(kwargs or {}),
446479
}
447480

481+
if use_beta and data_model is not None:
482+
# Use the new output_format parameter for structured outputs
483+
from anthropic import transform_schema
484+
485+
kwargs_full["output_format"] = {
486+
"type": "json_schema",
487+
"schema": transform_schema(data_model),
488+
}
489+
448490
if data_model_tool:
491+
# Old approach: force tool use
449492
kwargs_full["tool_choice"] = {
450493
"type": "tool",
451494
"name": data_model_tool.name,
@@ -461,7 +504,35 @@ def _structured_tool_call(**kwargs: Any):
461504
sys_param["cache_control"] = self._cache_control()
462505
kwargs_full["system"] = [sys_param]
463506

464-
return kwargs_full
507+
return kwargs_full, use_beta
508+
509+
def _create_data_model_tool(self, data_model: type[BaseModel]) -> Tool:
510+
"""Create a fake tool for structured data extraction (old approach)."""
511+
512+
def _structured_tool_call(**kwargs: Any):
513+
"""Extract structured data"""
514+
pass
515+
516+
data_model_tool = Tool.from_func(_structured_tool_call)
517+
518+
data_model_schema = basemodel_to_param_schema(data_model)
519+
520+
# Extract $defs from the nested schema and place at top level
521+
# JSON Schema $ref pointers like "#/$defs/..." need $defs at the root
522+
defs = data_model_schema.pop("$defs", None)
523+
524+
params: dict[str, Any] = {
525+
"type": "object",
526+
"properties": {
527+
"data": data_model_schema,
528+
},
529+
}
530+
if defs:
531+
params["$defs"] = defs
532+
533+
data_model_tool.schema["function"]["parameters"] = params
534+
535+
return data_model_tool
465536

466537
def stream_text(self, chunk) -> Optional[str]:
467538
if chunk.type == "content_block_delta":
@@ -576,7 +647,7 @@ def _token_count_args(
576647
) -> dict[str, Any]:
577648
turn = user_turn(*args)
578649

579-
kwargs = self._chat_perform_args(
650+
api_kwargs, _ = self._chat_perform_args(
580651
stream=False,
581652
turns=[turn],
582653
tools=tools,
@@ -591,7 +662,7 @@ def _token_count_args(
591662
"tool_choice",
592663
]
593664

594-
return {arg: kwargs[arg] for arg in args_to_keep if arg in kwargs}
665+
return {arg: api_kwargs[arg] for arg in args_to_keep if arg in api_kwargs}
595666

596667
def translate_model_params(self, params: StandardModelParams) -> "SubmitInputArgs":
597668
res: "SubmitInputArgs" = {}
@@ -753,11 +824,26 @@ def _anthropic_tool_schema(tool: "Tool | ToolBuiltIn") -> "ToolUnionParam":
753824

754825
def _as_turn(self, completion: Message, has_data_model=False) -> AssistantTurn:
755826
contents = []
827+
828+
# Detect which structured output approach was used:
829+
# - Old approach: has a _structured_tool_call tool_use block
830+
# - New approach: has_data_model=True but no _structured_tool_call (JSON in text)
831+
uses_old_tool_approach = has_data_model and any(
832+
c.type == "tool_use" and c.name == "_structured_tool_call"
833+
for c in completion.content
834+
)
835+
uses_new_output_format = has_data_model and not uses_old_tool_approach
836+
756837
for content in completion.content:
757838
if content.type == "text":
758-
contents.append(ContentText(text=content.text))
839+
if uses_new_output_format:
840+
# New API: JSON response is in text content
841+
contents.append(ContentJson(value=orjson.loads(content.text)))
842+
else:
843+
contents.append(ContentText(text=content.text))
759844
elif content.type == "tool_use":
760-
if has_data_model and content.name == "_structured_tool_call":
845+
if uses_old_tool_approach and content.name == "_structured_tool_call":
846+
# Old API: extract from tool input
761847
if not isinstance(content.input, dict):
762848
raise ValueError(
763849
"Expected data extraction tool to return a dictionary."
@@ -874,26 +960,30 @@ def batch_submit(
874960
requests: list["BatchRequest"] = []
875961

876962
for i, turns in enumerate(conversations):
877-
kwargs = self._chat_perform_args(
963+
api_kwargs, use_beta = self._chat_perform_args(
878964
stream=False,
879965
turns=turns,
880966
tools={},
881967
data_model=data_model,
882968
)
883969

884970
params: "MessageCreateParamsNonStreaming" = {
885-
"messages": kwargs.get("messages", {}),
971+
"messages": api_kwargs.get("messages", {}),
886972
"model": self.model,
887-
"max_tokens": kwargs.get("max_tokens", 4096),
973+
"max_tokens": api_kwargs.get("max_tokens", 4096),
888974
}
889975

890-
# If data_model, tools/tool_choice should be present
891-
tools = kwargs.get("tools")
892-
tool_choice = kwargs.get("tool_choice")
976+
# If data_model, tools/tool_choice should be present (old API)
977+
# or output_format (new API)
978+
tools = api_kwargs.get("tools")
979+
tool_choice = api_kwargs.get("tool_choice")
980+
output_format = api_kwargs.get("output_format")
893981
if tools and not isinstance(tools, NotGiven):
894982
params["tools"] = tools
895983
if tool_choice and not isinstance(tool_choice, NotGiven):
896984
params["tool_choice"] = tool_choice
985+
if output_format and not isinstance(output_format, NotGiven):
986+
params["output_format"] = output_format # type: ignore[typeddict-unknown-key]
897987

898988
requests.append({"custom_id": f"request-{i}", "params": params})
899989

chatlas/types/anthropic/_submit.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# ---------------------------------------------------------
44

55

6-
from typing import Iterable, Literal, Mapping, Optional, Sequence, TypedDict, Union
6+
from typing import Any, Iterable, Literal, Mapping, Optional, Sequence, TypedDict, Union
77

88
import anthropic
99
import anthropic.types.message_param
@@ -84,6 +84,9 @@ class SubmitInputArgs(TypedDict, total=False):
8484
]
8585
top_k: int | anthropic.Omit
8686
top_p: float | anthropic.Omit
87+
# Beta feature: structured outputs (output_format parameter)
88+
# https://platform.claude.com/docs/en/build-with-claude/structured-outputs
89+
output_format: Any
8790
extra_headers: Optional[Mapping[str, Union[str, anthropic.Omit]]]
8891
extra_query: Optional[Mapping[str, object]]
8992
extra_body: object | None

0 commit comments

Comments
 (0)