Skip to content

Commit 3e6d5b7

Browse files
committed
structured outputs with tools, anthropic tidyup
1 parent f0c948f commit 3e6d5b7

12 files changed

Lines changed: 661 additions & 143 deletions

File tree

docs-internal/anthropic-structured.md

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,43 @@
1-
# Anthropic structured outputs (beta-only)
1+
# Anthropic structured outputs
22

33
## Summary
4-
Fast-agent now uses Anthropic **beta** APIs exclusively for structured output support.
5-
This enables:
4+
Anthropic's current public docs describe structured outputs as generally available on
5+
the Claude API for supported models. In fast-agent, Anthropic structured output support
6+
still has two execution modes:
67

7-
- **JSON outputs** via `output_format` (default for structured calls when supported)
8-
- **Strict tool use** via `strict: true` (optional override)
9-
- Beta-native streaming events and richer content blocks
8+
- **JSON outputs** via `output_config.format` (default on supported models)
9+
- **Legacy `tool_use` fallback** via a synthetic strict tool (used for older models or
10+
explicit `structured=tool_use` overrides)
1011

11-
The Anthropic provider no longer uses non-beta `client.messages.*` calls.
12+
Only JSON mode can coexist cleanly with normal tool calling and Anthropic reasoning.
13+
The legacy `tool_use` path is kept as a compatibility fallback and changes request
14+
semantics by replacing normal tools with a synthetic structured-output tool.
1215

13-
## API surface (Anthropic SDK 0.76.0)
14-
We rely on:
16+
## Current SDK / transport notes
17+
The repo is currently pinned to `anthropic[vertex]==0.97.0`.
18+
19+
Fast-agent's provider implementation still uses the SDK beta namespace and beta stream
20+
types internally:
1521

1622
- `AsyncAnthropic(...).beta.messages.create(...)`
1723
- `AsyncAnthropic(...).beta.messages.stream(...)`
1824

19-
The beta API exposes:
20-
21-
- `output_format` (JSON schema response format)
22-
- `betas` (beta feature flags)
23-
- `BetaToolParam.strict`
24-
- Beta content blocks / streaming events (`BetaTextBlock`, `BetaToolUseBlock`, etc.)
25+
This is now slightly behind Anthropic's public documentation terminology, which documents
26+
JSON structured output using `output_config.format` and strict tool use as first-class
27+
structured-output features.
2528

2629
## Structured output modes
2730
Structured calls (`agent.chat.structured(...)`) can operate in two modes:
2831

2932
| Mode | Description | Anthropic API usage |
3033
| --- | --- | --- |
31-
| `json` (default) | JSON outputs with schema validation | `output_format={"type": "json_schema", "schema": ...}` |
32-
| `tool_use` | Strict tool-use, schema enforced by tool input | `tools=[{..., strict: true}]` + `tool_choice` |
34+
| `json` (default) | JSON outputs with schema validation | `output_config.format={"type": "json_schema", "schema": ...}` |
35+
| `tool_use` | Legacy fallback using a synthetic strict tool | `tools=[{..., strict: true}]` + `tool_choice` |
3336

3437
### Default selection
35-
The Anthropic provider selects **JSON outputs** by default when the model supports
36-
Anthropic structured outputs. If the model is unsupported, we automatically fall back
37-
to `tool_use`.
38+
The Anthropic provider selects **JSON outputs** by default when the model database
39+
marks the model with `json_mode="schema"`. Older / legacy Anthropic entries retain
40+
`json_mode=None`, which causes automatic fallback to `tool_use`.
3841

3942
### Override (`structured=` query parameter)
4043
You can override the mode in a model string query parameter:
@@ -46,31 +49,37 @@ This is parsed in `ModelFactory.parse_model_string(...)` and passed to the provi
4649
in the same style as `reasoning=`.
4750

4851
## Model database updates
49-
Anthropic models are annotated for structured output support via `json_mode`.
50-
For models that support JSON outputs, `json_mode="schema"` is retained. For
51-
unsupported models, `json_mode=None`.
52+
Anthropic models are annotated for structured output support via `json_mode`:
5253

53-
Supported in current Anthropic beta docs:
54+
- `json_mode="schema"`: use Anthropic JSON structured output by default
55+
- `json_mode=None`: use legacy `tool_use` fallback by default
56+
57+
Current fast-agent Anthropic entries that default to JSON mode:
5458

55-
- `claude-sonnet-4-5`
56-
- `claude-sonnet-4-5-20250929`
5759
- `claude-opus-4-1`
5860
- `claude-opus-4-5`
61+
- `claude-opus-4-6`
62+
- `claude-opus-4-7`
63+
- `claude-sonnet-4-5`
64+
- `claude-sonnet-4-5-20250929`
65+
- `claude-sonnet-4-6`
5966
- `claude-haiku-4-5`
6067
- `claude-haiku-4-5-20251001`
6168

6269
## Request construction
6370
### JSON outputs
6471
- Build schema from the Pydantic model.
6572
- Use `anthropic.transform_schema()` for unsupported JSON Schema constraints.
66-
- Pass through `output_format={"type": "json_schema", "schema": ...}`.
73+
- Pass through `output_config.format={"type": "json_schema", "schema": ...}`.
6774
- Include beta flag: `betas=["structured-outputs-2025-11-13"]`.
75+
- Preserve normal tools when they are supplied alongside structured output.
6876

69-
### Strict tool use
77+
### Legacy `tool_use` fallback
7078
- Define a synthetic tool named `return_structured_output`.
7179
- Set `strict: true` on the tool definition.
7280
- Apply `tool_choice` to force tool usage.
7381
- Include beta flag: `betas=["structured-outputs-2025-11-13"]`.
82+
- Suppress normal tools for that structured turn.
7483

7584
## Streaming and telemetry wiring
7685
The beta streaming events map directly to existing stream hooks:
@@ -95,11 +104,23 @@ declare `reasoning="anthropic_thinking"` in the model database.
95104
When structured outputs are enabled:
96105

97106
- JSON outputs: extended thinking is allowed; the grammar applies only to the final output.
98-
- Strict tool use: thinking is disabled because tool choice is forced.
107+
- Legacy `tool_use` fallback: thinking is disabled because tool choice is forced.
108+
109+
## Combined tools + structured outputs
110+
Anthropic's current docs allow JSON outputs and strict tool use features to be used
111+
together in a single request. Fast-agent partially reflects that today:
112+
113+
- JSON mode keeps normal tools enabled.
114+
- Legacy `tool_use` fallback does not; it swaps in the synthetic structured-output
115+
tool and suppresses normal tools.
116+
117+
The provider now emits an explicit warning when a structured request with tools has to
118+
fall back to legacy `tool_use` semantics.
99119

100120
## Testing notes
101121
- Update e2e structured tests to use models that support JSON outputs.
102-
- For strict tool-use coverage, set `?structured=tool_use` in the model string.
122+
- For legacy fallback coverage, set `?structured=tool_use` in the model string or use
123+
a legacy Anthropic model entry.
103124

104125
## Related files
105126
- `src/fast_agent/llm/provider/anthropic/llm_anthropic.py`

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ dependencies = [
2222
"pyyaml==6.0.3",
2323
"rich==14.3.3",
2424
"typer==0.24.1",
25-
"anthropic[vertex]==0.96.0",
26-
"openai[aiohttp]==2.31.0",
25+
"anthropic[vertex]==0.97.0",
26+
"openai[aiohttp]==2.32.0",
2727
"prompt-toolkit==3.0.52",
2828
"aiohttp==3.13.5",
2929
"opentelemetry-distro==0.60b1",
@@ -32,7 +32,7 @@ dependencies = [
3232
"opentelemetry-instrumentation-anthropic==0.52.1; python_version >= '3.10' and python_version < '4.0'",
3333
"opentelemetry-instrumentation-mcp==0.52.1; python_version >= '3.10' and python_version < '4.0'",
3434
"opentelemetry-instrumentation-google-genai==0.6b0",
35-
"google-genai==1.66.0",
35+
"google-genai==1.73.1",
3636
"deprecated==1.3.1",
3737
"a2a-sdk==0.3.26",
3838
"email-validator==2.2.0",
@@ -46,7 +46,7 @@ dependencies = [
4646
"uvloop==0.22.1; platform_system != 'Windows'",
4747
"multilspy==0.0.15",
4848
"ruamel.yaml==0.19.1",
49-
"huggingface_hub==1.11.0",
49+
"huggingface_hub==1.12.0",
5050
"mslex==1.3.0",
5151
]
5252

src/fast_agent/llm/fastagent_llm.py

Lines changed: 37 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -685,18 +685,24 @@ async def generate(
685685
await self._save_history(filename, messages)
686686
return Prompt.assistant(f"History saved to {filename}")
687687

688-
# Store MCP metadata in context variable
689688
final_request_params = self.get_request_params(request_params)
690-
if final_request_params.mcp_metadata:
691-
_mcp_metadata_var.set(final_request_params.mcp_metadata)
689+
prepared_messages, prepared_request_params = self._prepare_structured_request(
690+
messages,
691+
final_request_params,
692+
tools,
693+
)
694+
695+
# Store MCP metadata in context variable
696+
if prepared_request_params.mcp_metadata:
697+
_mcp_metadata_var.set(prepared_request_params.mcp_metadata)
692698

693699
# The caller supplies the full conversation to send
694-
full_history = messages
700+
full_history = prepared_messages
695701

696702
timing_capture, cleanup_timing_capture = self._start_request_timing_capture()
697703
try:
698704
assistant_response = await self._execute_with_retry(
699-
self._apply_prompt_provider_specific, full_history, request_params, tools
705+
self._apply_prompt_provider_specific, full_history, prepared_request_params, tools
700706
)
701707
finally:
702708
cleanup_timing_capture()
@@ -796,44 +802,20 @@ async def structured(
796802
Returns:
797803
Tuple of (parsed model instance or None, assistant response message)
798804
"""
799-
800-
# Store MCP metadata in context variable
801-
final_request_params = self.get_request_params(request_params)
802-
803-
# TODO -- this doesn't need to go here anymore.
804-
if final_request_params.mcp_metadata:
805-
_mcp_metadata_var.set(final_request_params.mcp_metadata)
806-
807-
full_history = messages
808-
809-
timing_capture, cleanup_timing_capture = self._start_request_timing_capture()
810-
try:
811-
result_or_response = await self._execute_with_retry(
812-
self._apply_prompt_provider_specific_structured,
813-
full_history,
814-
model,
815-
request_params,
816-
on_final_error=self._handle_retry_failure,
817-
)
818-
finally:
819-
cleanup_timing_capture()
820-
if isinstance(result_or_response, PromptMessageExtended):
821-
result, assistant_response = self._structured_from_multipart(result_or_response, model)
822-
else:
823-
result, assistant_response = result_or_response
824-
end_time = time.perf_counter()
825-
self._add_timing_channel(
826-
assistant_response,
827-
timing_capture.start_time,
828-
end_time,
829-
ttft_ms=timing_capture.ttft_ms,
830-
time_to_response_ms=timing_capture.time_to_response_ms,
805+
schema = validate_json_schema_definition(model.model_json_schema())
806+
parsed_json, assistant_response = await self.structured_schema(
807+
messages,
808+
schema,
809+
request_params,
831810
)
832-
833-
self.usage_accumulator.count_tools(len(assistant_response.tool_calls or {}))
834-
self._append_usage_channel(assistant_response)
835-
836-
return result, assistant_response
811+
if parsed_json is None:
812+
return None, assistant_response
813+
try:
814+
return model.model_validate(parsed_json), assistant_response
815+
except Exception as e:
816+
logger = get_logger(__name__)
817+
logger.warning(f"Failed to validate structured response: {str(e)}")
818+
return None, assistant_response
837819

838820
async def structured_schema(
839821
self,
@@ -858,43 +840,12 @@ async def structured_schema(
858840
update={"structured_schema": normalized_schema}
859841
)
860842

861-
if final_request_params.mcp_metadata:
862-
_mcp_metadata_var.set(final_request_params.mcp_metadata)
863-
864-
timing_capture, cleanup_timing_capture = self._start_request_timing_capture()
865-
try:
866-
result_or_response = await self._execute_with_retry(
867-
self._apply_prompt_provider_specific_structured_schema,
868-
messages,
869-
normalized_schema,
870-
final_request_params,
871-
on_final_error=self._handle_retry_failure,
872-
)
873-
finally:
874-
cleanup_timing_capture()
875-
876-
if isinstance(result_or_response, PromptMessageExtended):
877-
result, assistant_response = self._structured_schema_from_multipart(
878-
result_or_response,
879-
normalized_schema,
880-
)
881-
else:
882-
result, assistant_response = result_or_response
883-
884-
end_time = time.perf_counter()
885-
self._add_timing_channel(
843+
assistant_response = await self.generate(messages, final_request_params)
844+
return self._structured_schema_from_multipart(
886845
assistant_response,
887-
timing_capture.start_time,
888-
end_time,
889-
ttft_ms=timing_capture.ttft_ms,
890-
time_to_response_ms=timing_capture.time_to_response_ms,
846+
normalized_schema,
891847
)
892848

893-
self.usage_accumulator.count_tools(len(assistant_response.tool_calls or {}))
894-
self._append_usage_channel(assistant_response)
895-
896-
return result, assistant_response
897-
898849
@staticmethod
899850
def model_to_response_format(
900851
model: Type[Any],
@@ -1032,6 +983,16 @@ def _prepare_structured_text(self, text: str) -> str:
1032983
"""Hook for subclasses to adjust structured output text before parsing."""
1033984
return text
1034985

986+
def _prepare_structured_request(
987+
self,
988+
messages: list[PromptMessageExtended],
989+
request_params: RequestParams,
990+
tools: list[Tool] | None = None,
991+
) -> tuple[list[PromptMessageExtended], RequestParams]:
992+
"""Hook for providers to adapt structured-output intent before generation."""
993+
del tools
994+
return messages, request_params
995+
1035996
def record_templates(self, templates: list[PromptMessageExtended]) -> None:
1036997
"""Hook for providers that need template visibility (e.g., caching)."""
1037998
return

0 commit comments

Comments
 (0)