From f914c093eb16c36f3c8da8f81157145f398d337a Mon Sep 17 00:00:00 2001 From: Andrey Buzin Date: Fri, 20 Feb 2026 16:32:03 -0800 Subject: [PATCH 1/4] Rename the mcp.py example to fix eager importing, update the CLAUDE.md --- CLAUDE.md | 71 +---------------------- examples/samples/{mcp.py => mcp_tools.py} | 0 2 files changed, 1 insertion(+), 70 deletions(-) rename examples/samples/{mcp.py => mcp_tools.py} (100%) diff --git a/CLAUDE.md b/CLAUDE.md index cd679ae7..d2809be3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,70 +1 @@ -## Current task - -Implement `agent` module. - -Filesystem tools (local and sandbox) - -1. read -2. write / search-replace -3. ls / glob -4. grep -5. bash - -Durability - -1. state tracking -2. durable execution - -Agent features - -1. skills -2. web tools / browser -3. memory management -4. sub-agents - -## References - -See `.reference` - -### agent-sdk — Vercel Agent SDK (TypeScript, primary inspiration) -Opinionated sandboxed coding agent framework. LLM loop + sandbox + tools + session persistence. Biased towards Next.js/Vercel, but the tool design and sandbox interface are the reference spec. -- 7 built-in tools: Read, Write, Edit, List, Grep, Bash, JavaScript (meta-tool that orchestrates other tools via code) -- Sandbox abstraction with two bindings (local dev, Vercel VM) — exec, writeFiles, lifecycle (start/stop/snapshot) -- Process manager — persistent CWD per session, background process support -- Skills — SKILL.md discovery with YAML frontmatter, progressive disclosure -- Durable sessions with send/stream/interrupt -- Storage backends (local filesystem, Vercel Postgres, custom HTTP) -- Prompt caching (Anthropic/OpenAI automatic cache breakpoints) -- No web/search tools, no sub-agents, no memory, no context management - -### deepagents — LangChain Deep Agents (Python, patterns to draw from) -Ready-to-run agent harness built on LangGraph. Middleware stack architecture where each capability is a composable layer. Most feature-complete of the three. -- 7 filesystem tools: ls, read_file, write_file, edit_file, glob, grep, execute -- Backend protocol ABC with pluggable impls (in-memory state, local filesystem, local shell, LangGraph store, composite routing) -- Sub-agent spawning via `task` tool with isolated context windows -- Auto-summarization when context hits 85% of window, evicts history to file -- Large result eviction — results >20k tokens written to file, replaced with preview -- Memory — AGENTS.md files injected into system prompt, self-modifiable by agent -- Skills — SKILL.md progressive disclosure (same pattern as Vercel) -- Patch dangling tool calls from interrupted sessions -- Web tools (CLI only): web_search (Tavily), fetch_url (HTML to markdown), http_request - -### pi — Python Intelligence (terminal coding agent) -Terminal agent (Rust PTY + Python). Uses pydantic-ai. Local filesystem tools are pure pathlib, trivially portable. Shell tools depend on Pi's Rust binary (not portable). Key portable code: -- list_files, read_file, read_chunk — pure pathlib -- search_replace — exact match + rapidfuzz fuzzy fallback (threshold 80%) -- rewrite — write + mkdir + difflib diff -- exec (raw) — `asyncio.create_subprocess_exec` -- `@suppress_errors` decorator -- edit_lock (`asyncio.Lock` for concurrent write safety) - -### riff — Code Generation Agent (web app) -Same tool patterns as Pi but targeting remote Daytona sandboxes. Uses pydantic-ai. Key portable code beyond what Pi has: -- grep — builds ripgrep command with flags -- tree — directory structure with exclude patterns -- lint() after edit — ruff for Python, biome for TS -- TodoList/Todo/TodoStatus pydantic models -- add_todos, mark_todos, todo_status tools -- `@suppress_errors` with recursive timeout detection (prefer over Pi's) -- repair_stray_tool_calls — patches dangling tool calls - +1. treat `stream_step` and `stream_loop` as user code. they are convenience functions that could be reimplemented by the user, they *must* stay clean. diff --git a/examples/samples/mcp.py b/examples/samples/mcp_tools.py similarity index 100% rename from examples/samples/mcp.py rename to examples/samples/mcp_tools.py From 22f374c04904d98150ae3da4f8303cd8c6768198 Mon Sep 17 00:00:00 2001 From: Andrey Buzin Date: Fri, 20 Feb 2026 16:38:02 -0800 Subject: [PATCH 2/4] Implement structured output support delegated to providers --- examples/samples/structured_output.py | 48 +++++++++++++++++ src/vercel_ai_sdk/__init__.py | 2 + src/vercel_ai_sdk/anthropic/__init__.py | 43 +++++++++++++-- src/vercel_ai_sdk/core/llm.py | 6 ++- src/vercel_ai_sdk/core/messages.py | 56 +++++++++++++++++++- src/vercel_ai_sdk/core/runtime.py | 10 +++- src/vercel_ai_sdk/core/streams.py | 7 +++ src/vercel_ai_sdk/openai/__init__.py | 34 +++++++++++- tests/conftest.py | 18 +++++++ tests/core/test_llm.py | 41 ++++++++++++++- tests/core/test_messages.py | 70 ++++++++++++++++++++++++- tests/core/test_streams.py | 28 ++++++++++ 12 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 examples/samples/structured_output.py diff --git a/examples/samples/structured_output.py b/examples/samples/structured_output.py new file mode 100644 index 00000000..3cf18fb6 --- /dev/null +++ b/examples/samples/structured_output.py @@ -0,0 +1,48 @@ +import asyncio +import os + +import pydantic + +import vercel_ai_sdk as ai + + +class WeatherForecast(pydantic.BaseModel): + city: str + temperature: float + conditions: str + humidity: int + wind_speed: float + + +async def main() -> None: + llm = ai.openai.OpenAIModel( + model="anthropic/claude-sonnet-4", + base_url="https://ai-gateway.vercel.sh/v1", + api_key=os.environ.get("AI_GATEWAY_API_KEY"), + ) + + messages = ai.make_messages( + system="You are a weather assistant. Respond with realistic weather data.", + user="What's the weather like in San Francisco right now?", + ) + + # Streaming: watch the JSON arrive incrementally, get validated output at the end + print("--- Streaming ---") + async for msg in llm.stream(messages, output_type=WeatherForecast): + if msg.text_delta: + print(msg.text_delta, end="", flush=True) + if msg.output: + print(f"\n\nParsed: {msg.output}") + + # Non-streaming: get the validated output directly + print("\n--- Buffer ---") + msg = await llm.buffer(messages, output_type=WeatherForecast) + print(f"City: {msg.output.city}") + print(f"Temperature: {msg.output.temperature}") + print(f"Conditions: {msg.output.conditions}") + print(f"Humidity: {msg.output.humidity}%") + print(f"Wind: {msg.output.wind_speed} mph") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/vercel_ai_sdk/__init__.py b/src/vercel_ai_sdk/__init__.py index 6d6cbe3a..314ce348 100644 --- a/src/vercel_ai_sdk/__init__.py +++ b/src/vercel_ai_sdk/__init__.py @@ -10,6 +10,7 @@ Part, PartState, ReasoningPart, + StructuredOutputPart, TextPart, ToolDelta, ToolPart, @@ -47,6 +48,7 @@ "StreamResult", "Hook", "HookPart", + "StructuredOutputPart", "Checkpoint", # Functions "tool", diff --git a/src/vercel_ai_sdk/anthropic/__init__.py b/src/vercel_ai_sdk/anthropic/__init__.py index 27ec7c77..b89348de 100644 --- a/src/vercel_ai_sdk/anthropic/__init__.py +++ b/src/vercel_ai_sdk/anthropic/__init__.py @@ -6,6 +6,7 @@ from typing import Any, override import anthropic +import pydantic from .. import core @@ -119,6 +120,7 @@ async def stream_events( self, messages: list[core.messages.Message], tools: Sequence[core.tools.ToolLike] | None = None, + output_type: type[pydantic.BaseModel] | None = None, ) -> AsyncGenerator[core.llm.StreamEvent]: """Yield raw stream events from Anthropic API.""" system_prompt, anthropic_messages = _messages_to_anthropic(messages) @@ -140,12 +142,30 @@ async def stream_events( "budget_tokens": self._budget_tokens, } + # Structured output: use beta API with output_format + use_beta = False + if output_type is not None: + from anthropic.lib._parse._transform import transform_schema + + kwargs["output_format"] = { + "type": "json_schema", + "schema": transform_schema(output_type), + } + kwargs["betas"] = ["structured-outputs-2025-11-13"] + use_beta = True + # Track block types by index to know what End event to emit block_types: dict[int, str] = {} # index -> "text" | "thinking" | "tool_use" tool_ids: dict[int, str] = {} # index -> tool_call_id signature_buffer: dict[int, str] = {} # index -> accumulated signature - async with self._client.messages.stream(**kwargs) as stream: + stream_cm: Any # BetaAsyncMessageStreamManager | AsyncMessageStreamManager + if use_beta: + stream_cm = self._client.beta.messages.stream(**kwargs) + else: + stream_cm = self._client.messages.stream(**kwargs) + + async with stream_cm as stream: async for event in stream: if event.type == "content_block_start": block = event.content_block @@ -208,8 +228,23 @@ async def stream( self, messages: list[core.messages.Message], tools: Sequence[core.tools.ToolLike] | None = None, + output_type: type[pydantic.BaseModel] | None = None, ) -> AsyncGenerator[core.messages.Message]: - """Stream Messages (uses StreamProcessor internally).""" + """Stream Messages (uses StreamHandler internally).""" handler = core.llm.StreamHandler() - async for event in self.stream_events(messages, tools): - yield handler.handle_event(event) + msg: core.messages.Message | None = None + async for event in self.stream_events(messages, tools, output_type=output_type): + msg = handler.handle_event(event) + yield msg + + # After stream completes, validate and attach structured output part + if output_type is not None and msg is not None and msg.text: + data = json.loads(msg.text) + output_type.model_validate(data) # fail fast on bad data + part = core.messages.StructuredOutputPart( + data=data, + output_type_name=f"{output_type.__module__}.{output_type.__qualname__}", + ) + msg = msg.model_copy() + msg.parts = [*msg.parts, part] + yield msg diff --git a/src/vercel_ai_sdk/core/llm.py b/src/vercel_ai_sdk/core/llm.py index c1f1f916..2c262d3e 100644 --- a/src/vercel_ai_sdk/core/llm.py +++ b/src/vercel_ai_sdk/core/llm.py @@ -4,6 +4,8 @@ import dataclasses from collections.abc import AsyncGenerator, Sequence +import pydantic + from . import messages as messages_ from . import tools as tools_ @@ -216,6 +218,7 @@ async def stream( self, messages: list[messages_.Message], tools: Sequence[tools_.ToolLike] | None = None, + output_type: type[pydantic.BaseModel] | None = None, ) -> AsyncGenerator[messages_.Message]: raise NotImplementedError yield @@ -224,10 +227,11 @@ async def buffer( self, messages: list[messages_.Message], tools: Sequence[tools_.ToolLike] | None = None, + output_type: type[pydantic.BaseModel] | None = None, ) -> messages_.Message: """Drain the stream and return the final message.""" final = None - async for msg in self.stream(messages, tools): + async for msg in self.stream(messages, tools, output_type=output_type): final = msg if final is None: raise ValueError("LLM produced no messages") diff --git a/src/vercel_ai_sdk/core/messages.py b/src/vercel_ai_sdk/core/messages.py index 10a03a73..d1600478 100644 --- a/src/vercel_ai_sdk/core/messages.py +++ b/src/vercel_ai_sdk/core/messages.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import uuid from typing import Annotated, Any, Literal @@ -63,8 +64,53 @@ class HookPart(pydantic.BaseModel): type: Literal["hook"] = "hook" +def _resolve_class(fully_qualified_name: str) -> type[pydantic.BaseModel]: + """Import and return a class from its fully qualified name. + + E.g. ``"myapp.models.WeatherForecast"`` → the ``WeatherForecast`` class. + """ + module_path, _, class_name = fully_qualified_name.rpartition(".") + if not module_path: + raise ImportError( + f"Cannot resolve '{fully_qualified_name}': " + "expected a fully qualified name like 'mypackage.module.ClassName'" + ) + module = importlib.import_module(module_path) + cls = getattr(module, class_name, None) + if cls is None: + raise ImportError(f"Module '{module_path}' has no attribute '{class_name}'") + if not (isinstance(cls, type) and issubclass(cls, pydantic.BaseModel)): + raise TypeError( + f"'{fully_qualified_name}' is not a pydantic.BaseModel subclass" + ) + return cls + + +class StructuredOutputPart(pydantic.BaseModel): + """Part containing a validated structured output from the LLM. + + ``data`` stores the parsed JSON dict (always serializable). + ``output_type_name`` stores the fully qualified class name so the typed + Pydantic model can be lazily rehydrated via the ``value`` property. + """ + + data: dict[str, Any] + output_type_name: str + type: Literal["structured_output"] = "structured_output" + + _hydrated: Any = pydantic.PrivateAttr(default=None) + + @property + def value(self) -> Any: + """Lazily resolve the output type class and validate ``data`` into it.""" + if self._hydrated is None: + cls = _resolve_class(self.output_type_name) + self._hydrated = cls.model_validate(self.data) + return self._hydrated + + Part = Annotated[ - TextPart | ToolPart | ReasoningPart | HookPart, + TextPart | ToolPart | ReasoningPart | HookPart | StructuredOutputPart, pydantic.Field(discriminator="type"), ] @@ -85,6 +131,14 @@ class Message(pydantic.BaseModel): id: str = pydantic.Field(default_factory=_gen_id) label: str | None = None + @property + def output(self) -> Any: + """Return the validated structured output, or None.""" + for part in self.parts: + if isinstance(part, StructuredOutputPart): + return part.value + return None + @property def is_done(self) -> bool: """Message is done when all parts are done (or have no streaming state).""" diff --git a/src/vercel_ai_sdk/core/runtime.py b/src/vercel_ai_sdk/core/runtime.py index 69ae5fdf..ed906608 100644 --- a/src/vercel_ai_sdk/core/runtime.py +++ b/src/vercel_ai_sdk/core/runtime.py @@ -193,9 +193,12 @@ async def stream_step( messages: list[messages_.Message], tools: Sequence[tools_.ToolLike] | None = None, label: str | None = None, + output_type: type[pydantic.BaseModel] | None = None, ) -> AsyncGenerator[messages_.Message]: """Single LLM call that streams to Runtime.""" - async for msg in llm.stream(messages=messages, tools=tools): + async for msg in llm.stream( + messages=messages, tools=tools, output_type=output_type + ): msg.label = label yield msg @@ -257,12 +260,15 @@ async def stream_loop( messages: list[messages_.Message], tools: Sequence[tools_.ToolLike], label: str | None = None, + output_type: type[pydantic.BaseModel] | None = None, ) -> streams_.StreamResult: """Agent loop: stream LLM, execute tools, repeat until done.""" local_messages = list(messages) while True: - result = await stream_step(llm, local_messages, tools, label=label) + result = await stream_step( + llm, local_messages, tools, label=label, output_type=output_type + ) if not result.tool_calls: return result diff --git a/src/vercel_ai_sdk/core/streams.py b/src/vercel_ai_sdk/core/streams.py index c108018d..0a1bcc64 100644 --- a/src/vercel_ai_sdk/core/streams.py +++ b/src/vercel_ai_sdk/core/streams.py @@ -30,6 +30,13 @@ def text(self) -> str: return self.last_message.text return "" + @property + def output(self) -> Any: + """Parsed structured output from the last message, if available.""" + if self.last_message: + return self.last_message.output + return None + Stream = Callable[[], AsyncGenerator[messages_.Message]] # maybe it should have a name and an id inferred from LLM outputs diff --git a/src/vercel_ai_sdk/openai/__init__.py b/src/vercel_ai_sdk/openai/__init__.py index 2e8d2124..ff7d3fe3 100644 --- a/src/vercel_ai_sdk/openai/__init__.py +++ b/src/vercel_ai_sdk/openai/__init__.py @@ -1,10 +1,12 @@ from __future__ import annotations +import json import os from collections.abc import AsyncGenerator, Sequence from typing import Any, override import openai +import pydantic from .. import core @@ -138,6 +140,7 @@ async def stream_events( self, messages: list[core.messages.Message], tools: Sequence[core.tools.ToolLike] | None = None, + output_type: type[pydantic.BaseModel] | None = None, ) -> AsyncGenerator[core.llm.StreamEvent]: """Yield raw stream events from OpenAI API.""" openai_messages = _messages_to_openai(messages) @@ -151,6 +154,18 @@ async def stream_events( if openai_tools: kwargs["tools"] = openai_tools + if output_type is not None: + from openai.lib._pydantic import to_strict_json_schema + + kwargs["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": output_type.__name__, + "schema": to_strict_json_schema(output_type), + "strict": True, + }, + } + # Enable reasoning/thinking via Vercel AI Gateway's unified format # See: https://vercel.com/docs/ai-gateway/openai-compat/advanced if self._thinking: @@ -249,8 +264,23 @@ async def stream( self, messages: list[core.messages.Message], tools: Sequence[core.tools.ToolLike] | None = None, + output_type: type[pydantic.BaseModel] | None = None, ) -> AsyncGenerator[core.messages.Message]: """Stream Messages (uses StreamHandler internally).""" handler = core.llm.StreamHandler() - async for event in self.stream_events(messages, tools): - yield handler.handle_event(event) + msg: core.messages.Message | None = None + async for event in self.stream_events(messages, tools, output_type=output_type): + msg = handler.handle_event(event) + yield msg + + # After stream completes, validate and attach structured output part + if output_type is not None and msg is not None and msg.text: + data = json.loads(msg.text) + output_type.model_validate(data) # fail fast on bad data + part = core.messages.StructuredOutputPart( + data=data, + output_type_name=f"{output_type.__module__}.{output_type.__qualname__}", + ) + msg = msg.model_copy() + msg.parts = [*msg.parts, part] + yield msg diff --git a/tests/conftest.py b/tests/conftest.py index 636136b0..d6a1409b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,13 @@ from __future__ import annotations +import json from collections.abc import AsyncGenerator, Sequence +import pydantic + import vercel_ai_sdk as ai from vercel_ai_sdk.core import messages +from vercel_ai_sdk.core.messages import StructuredOutputPart class MockLLM(ai.LanguageModel): @@ -18,15 +22,29 @@ async def stream( self, messages: list[messages.Message], tools: Sequence[ai.ToolLike] | None = None, + output_type: type[pydantic.BaseModel] | None = None, ) -> AsyncGenerator[messages.Message]: if self._call_index >= len(self._responses): raise RuntimeError("MockLLM: no more responses configured") self.call_count += 1 seq = self._responses[self._call_index] self._call_index += 1 + msg = None for msg in seq: yield msg + # Simulate structured output validation (matching real provider behavior) + if output_type is not None and msg is not None and msg.text: + data = json.loads(msg.text) + output_type.model_validate(data) # fail fast on bad data + part = StructuredOutputPart( + data=data, + output_type_name=f"{output_type.__module__}.{output_type.__qualname__}", + ) + msg = msg.model_copy() + msg.parts = [*msg.parts, part] + yield msg + def text_msg( text: str, *, id: str = "msg-1", state: str = "done", delta: str | None = None diff --git a/tests/core/test_llm.py b/tests/core/test_llm.py index 30baff5c..a20f0c25 100644 --- a/tests/core/test_llm.py +++ b/tests/core/test_llm.py @@ -1,5 +1,12 @@ -"""StreamHandler: event accumulation, state transitions, message building.""" +"""StreamHandler: event accumulation, state transitions, message building. +LanguageModel.buffer() with structured output.""" +import json + +import pydantic +import pytest + +import vercel_ai_sdk as ai from vercel_ai_sdk.core.llm import ( MessageDone, ReasoningDelta, @@ -15,6 +22,14 @@ ) from vercel_ai_sdk.core.messages import ReasoningPart, TextPart, ToolPart +from ..conftest import MockLLM, text_msg + + +class _Weather(pydantic.BaseModel): + city: str + temperature: float + + # -- Text streaming -------------------------------------------------------- @@ -182,3 +197,27 @@ def test_deltas_only_on_active_blocks() -> None: text_parts = [p for p in m.parts if isinstance(p, TextPart)] assert text_parts[0].delta is None # t1 is done assert text_parts[1].delta == "second" # t2 is active + + +# -- LanguageModel.buffer() with structured output ------------------------- + + +@pytest.mark.asyncio +async def test_buffer_structured_output() -> None: + """buffer() returns a message with a validated StructuredOutputPart.""" + json_text = '{"city":"Tokyo","temperature":28.5}' + llm = MockLLM([[text_msg(json_text)]]) + + msg = await llm.buffer(ai.make_messages(user="weather?"), output_type=_Weather) + + assert isinstance(msg.output, _Weather) + assert msg.output.city == "Tokyo" + + +@pytest.mark.asyncio +async def test_buffer_structured_output_invalid_json_raises() -> None: + """Bad LLM output with output_type should raise, not silently pass.""" + llm = MockLLM([[text_msg("not json")]]) + + with pytest.raises((json.JSONDecodeError, pydantic.ValidationError)): + await llm.buffer(ai.make_messages(user="weather?"), output_type=_Weather) diff --git a/tests/core/test_messages.py b/tests/core/test_messages.py index c2b9b339..36565e90 100644 --- a/tests/core/test_messages.py +++ b/tests/core/test_messages.py @@ -1,14 +1,28 @@ -"""Message model: properties, ToolPart.set_result/set_error, make_messages.""" +"""Message model: properties, ToolPart.set_result/set_error, make_messages, +StructuredOutputPart.""" + +import pydantic +import pytest from vercel_ai_sdk.core.messages import ( HookPart, Message, ReasoningPart, + StructuredOutputPart, TextPart, ToolPart, make_messages, ) + +class _Weather(pydantic.BaseModel): + city: str + temperature: float + + +_WEATHER_DATA = {"city": "SF", "temperature": 62.0} +_WEATHER_TYPE_NAME = f"{_Weather.__module__}.{_Weather.__qualname__}" + # -- is_done --------------------------------------------------------------- @@ -214,3 +228,57 @@ def test_make_messages_user_only() -> None: msgs = make_messages(user="Hi") assert len(msgs) == 1 assert msgs[0].role == "user" + + +# -- StructuredOutputPart -------------------------------------------------- + + +def test_structured_output_part_value() -> None: + """Lazy hydration: resolves class, validates data, caches result.""" + part = StructuredOutputPart(data=_WEATHER_DATA, output_type_name=_WEATHER_TYPE_NAME) + val = part.value + assert isinstance(val, _Weather) + assert val.city == "SF" + assert part.value is val # cached + + +def test_structured_output_part_bad_class_name() -> None: + """Unresolvable class name raises ImportError on access.""" + part = StructuredOutputPart( + data=_WEATHER_DATA, output_type_name="nonexistent.module.Cls" + ) + with pytest.raises(ImportError): + _ = part.value + + +def test_message_output_from_part() -> None: + """Message.output property delegates to StructuredOutputPart.value.""" + m = Message( + id="m1", + role="assistant", + parts=[ + TextPart(text="{}"), + StructuredOutputPart( + data=_WEATHER_DATA, output_type_name=_WEATHER_TYPE_NAME + ), + ], + ) + assert isinstance(m.output, _Weather) + assert m.output.city == "SF" + + +def test_structured_output_round_trip() -> None: + """StructuredOutputPart survives model_dump -> model_validate.""" + m = Message( + id="m1", + role="assistant", + parts=[ + TextPart(text="{}"), + StructuredOutputPart( + data=_WEATHER_DATA, output_type_name=_WEATHER_TYPE_NAME + ), + ], + ) + restored = Message.model_validate(m.model_dump()) + assert isinstance(restored.output, _Weather) + assert restored.output.city == "SF" diff --git a/tests/core/test_streams.py b/tests/core/test_streams.py index d4463a98..84df24b4 100644 --- a/tests/core/test_streams.py +++ b/tests/core/test_streams.py @@ -1,5 +1,6 @@ """@stream decorator: context requirement, replay, queue submission.""" +import pydantic import pytest import vercel_ai_sdk as ai @@ -8,6 +9,12 @@ from ..conftest import MockLLM, text_msg + +class _Weather(pydantic.BaseModel): + city: str + temperature: float + + # -- StreamResult properties ----------------------------------------------- @@ -79,3 +86,24 @@ async def graph(llm: ai.LanguageModel) -> ai.StreamResult: r2 = ai.run(graph, llm2, checkpoint=cp) [msg async for msg in r2] assert llm2.call_count == 0 + + +# -- StreamResult.output --------------------------------------------------- + + +def test_stream_result_output_from_last_message() -> None: + """StreamResult.output delegates to the last message's StructuredOutputPart.""" + m = messages.Message( + id="m1", + role="assistant", + parts=[ + messages.TextPart(text="{}", state="done"), + messages.StructuredOutputPart( + data={"city": "SF", "temperature": 62.0}, + output_type_name=f"{_Weather.__module__}.{_Weather.__qualname__}", + ), + ], + ) + r = StreamResult(messages=[text_msg("streaming..."), m]) + assert r.output is not None + assert r.output.city == "SF" From a763cde1d37efe1ca5429ab7b123477d6c6b649e Mon Sep 17 00:00:00 2001 From: Andrey Buzin Date: Mon, 23 Feb 2026 10:00:38 -0800 Subject: [PATCH 3/4] Fix lint in the examples --- examples/fastapi-vite/backend/agent.py | 1 - examples/fastapi-vite/backend/routes/chat.py | 3 +-- examples/multiagent-textual/client.py | 2 +- examples/multiagent-textual/server.py | 1 - examples/samples/custom_loop.py | 3 +-- examples/samples/mcp_tools.py | 3 +-- examples/samples/structured_output.py | 7 +++++++ examples/temporal-durable/activities.py | 1 - examples/temporal-durable/main.py | 3 +-- examples/temporal-durable/workflow.py | 8 +++++--- 10 files changed, 17 insertions(+), 15 deletions(-) diff --git a/examples/fastapi-vite/backend/agent.py b/examples/fastapi-vite/backend/agent.py index d5e98867..2c40df10 100644 --- a/examples/fastapi-vite/backend/agent.py +++ b/examples/fastapi-vite/backend/agent.py @@ -1,7 +1,6 @@ """Agent logic for the chat demo.""" import os - from typing import Any import vercel_ai_sdk as ai diff --git a/examples/fastapi-vite/backend/routes/chat.py b/examples/fastapi-vite/backend/routes/chat.py index 26326b20..3f27d03a 100644 --- a/examples/fastapi-vite/backend/routes/chat.py +++ b/examples/fastapi-vite/backend/routes/chat.py @@ -11,8 +11,7 @@ import vercel_ai_sdk as ai import vercel_ai_sdk.ai_sdk_ui -from .. import agent -from .. import storage +from .. import agent, storage router = fastapi.APIRouter() file_storage = storage.FileStorage() diff --git a/examples/multiagent-textual/client.py b/examples/multiagent-textual/client.py index 0c25c5b3..9d2e49c9 100644 --- a/examples/multiagent-textual/client.py +++ b/examples/multiagent-textual/client.py @@ -12,12 +12,12 @@ import asyncio import json +import rich.text import textual import textual.app import textual.containers import textual.widgets import textual.worker -import rich.text import websockets import vercel_ai_sdk as ai diff --git a/examples/multiagent-textual/server.py b/examples/multiagent-textual/server.py index e1d7d719..7e8957af 100644 --- a/examples/multiagent-textual/server.py +++ b/examples/multiagent-textual/server.py @@ -14,7 +14,6 @@ import json import os import warnings - from typing import Any import fastapi diff --git a/examples/samples/custom_loop.py b/examples/samples/custom_loop.py index c5165770..4e65d83a 100644 --- a/examples/samples/custom_loop.py +++ b/examples/samples/custom_loop.py @@ -3,7 +3,6 @@ import asyncio import os from collections.abc import AsyncGenerator - from typing import Any import vercel_ai_sdk as ai @@ -29,7 +28,7 @@ async def custom_stream_step( messages: list[ai.Message], tools: list[ai.Tool[..., Any]], label: str | None = None, -) -> AsyncGenerator[ai.Message, None]: +) -> AsyncGenerator[ai.Message]: """Wraps llm.stream to inject a label on every message.""" async for msg in llm.stream(messages=messages, tools=tools): msg.label = label diff --git a/examples/samples/mcp_tools.py b/examples/samples/mcp_tools.py index 9d6971fa..cc83a0b0 100644 --- a/examples/samples/mcp_tools.py +++ b/examples/samples/mcp_tools.py @@ -2,11 +2,10 @@ import asyncio import os +from typing import Any import rich -from typing import Any - import vercel_ai_sdk as ai diff --git a/examples/samples/structured_output.py b/examples/samples/structured_output.py index 3cf18fb6..a8b76080 100644 --- a/examples/samples/structured_output.py +++ b/examples/samples/structured_output.py @@ -15,12 +15,19 @@ class WeatherForecast(pydantic.BaseModel): async def main() -> None: + # OpenAI-compatible provider (works with any model via Vercel AI Gateway) llm = ai.openai.OpenAIModel( model="anthropic/claude-sonnet-4", base_url="https://ai-gateway.vercel.sh/v1", api_key=os.environ.get("AI_GATEWAY_API_KEY"), ) + # # Anthropic provider (native structured output via beta API) + # llm = ai.anthropic.AnthropicModel( + # model="claude-sonnet-4-20250514", + # api_key=os.environ.get("ANTHROPIC_API_KEY"), + # ) + messages = ai.make_messages( system="You are a weather assistant. Respond with realistic weather data.", user="What's the weather like in San Francisco right now?", diff --git a/examples/temporal-durable/activities.py b/examples/temporal-durable/activities.py index 6993750d..a7dd1922 100644 --- a/examples/temporal-durable/activities.py +++ b/examples/temporal-durable/activities.py @@ -16,7 +16,6 @@ import vercel_ai_sdk as ai import vercel_ai_sdk.anthropic - # ── Tool activities (one per tool, plain functions) ─────────────── diff --git a/examples/temporal-durable/main.py b/examples/temporal-durable/main.py index 37363293..c8af4266 100644 --- a/examples/temporal-durable/main.py +++ b/examples/temporal-durable/main.py @@ -15,10 +15,9 @@ import sys import uuid +import activities import temporalio.client import temporalio.worker - -import activities import workflow TASK_QUEUE = "agent-durable" diff --git a/examples/temporal-durable/workflow.py b/examples/temporal-durable/workflow.py index 5baf999b..c09f1eec 100644 --- a/examples/temporal-durable/workflow.py +++ b/examples/temporal-durable/workflow.py @@ -6,14 +6,15 @@ from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence from typing import override +import pydantic import temporalio.common import temporalio.workflow with temporalio.workflow.unsafe.imports_passed_through(): - import vercel_ai_sdk as ai - import activities + import vercel_ai_sdk as ai + class DurableModel(ai.LanguageModel): def __init__( @@ -29,7 +30,8 @@ async def stream( self, messages: list[ai.Message], tools: Sequence[ai.ToolLike] | None = None, - ) -> AsyncGenerator[ai.Message, None]: + output_type: type[pydantic.BaseModel] | None = None, + ) -> AsyncGenerator[ai.Message]: result = await self.call_fn( activities.LLMCallParams( messages=[m.model_dump() for m in messages], From 91181b466e74b339d63894480b6b8cc562a9a291 Mon Sep 17 00:00:00 2001 From: Andrey Buzin Date: Mon, 23 Feb 2026 11:12:56 -0800 Subject: [PATCH 4/4] Bump Anthropic SDK to ensure structured outputs work with direct API --- examples/samples/structured_output.py | 22 +++++++++++----------- pyproject.toml | 2 +- src/vercel_ai_sdk/anthropic/__init__.py | 18 +++--------------- uv.lock | 8 ++++---- 4 files changed, 19 insertions(+), 31 deletions(-) diff --git a/examples/samples/structured_output.py b/examples/samples/structured_output.py index a8b76080..3392eb6d 100644 --- a/examples/samples/structured_output.py +++ b/examples/samples/structured_output.py @@ -15,19 +15,19 @@ class WeatherForecast(pydantic.BaseModel): async def main() -> None: - # OpenAI-compatible provider (works with any model via Vercel AI Gateway) - llm = ai.openai.OpenAIModel( - model="anthropic/claude-sonnet-4", - base_url="https://ai-gateway.vercel.sh/v1", - api_key=os.environ.get("AI_GATEWAY_API_KEY"), - ) - - # # Anthropic provider (native structured output via beta API) - # llm = ai.anthropic.AnthropicModel( - # model="claude-sonnet-4-20250514", - # api_key=os.environ.get("ANTHROPIC_API_KEY"), + # OpenAI-compatible provider + # llm = ai.openai.OpenAIModel( + # model="anthropic/claude-opus-4.6", + # base_url="https://ai-gateway.vercel.sh/v1", + # api_key=os.environ.get("AI_GATEWAY_API_KEY"), # ) + # Anthropic provider + llm = ai.anthropic.AnthropicModel( + model="claude-opus-4-6", + api_key=os.environ.get("ANTHROPIC_API_KEY"), + ) + messages = ai.make_messages( system="You are a weather assistant. Respond with realistic weather data.", user="What's the weather like in San Francisco right now?", diff --git a/pyproject.toml b/pyproject.toml index eafba3f7..f1d4db78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] requires-python = ">=3.12" dependencies = [ - "anthropic>=0.40.0", + "anthropic>=0.83.0", "httpx>=0.28.1", "mcp>=1.18.0", "openai>=2.14.0", diff --git a/src/vercel_ai_sdk/anthropic/__init__.py b/src/vercel_ai_sdk/anthropic/__init__.py index b89348de..fbb2f440 100644 --- a/src/vercel_ai_sdk/anthropic/__init__.py +++ b/src/vercel_ai_sdk/anthropic/__init__.py @@ -142,28 +142,16 @@ async def stream_events( "budget_tokens": self._budget_tokens, } - # Structured output: use beta API with output_format - use_beta = False + # Structured output: SDK handles schema transformation internally if output_type is not None: - from anthropic.lib._parse._transform import transform_schema - - kwargs["output_format"] = { - "type": "json_schema", - "schema": transform_schema(output_type), - } - kwargs["betas"] = ["structured-outputs-2025-11-13"] - use_beta = True + kwargs["output_format"] = output_type # Track block types by index to know what End event to emit block_types: dict[int, str] = {} # index -> "text" | "thinking" | "tool_use" tool_ids: dict[int, str] = {} # index -> tool_call_id signature_buffer: dict[int, str] = {} # index -> accumulated signature - stream_cm: Any # BetaAsyncMessageStreamManager | AsyncMessageStreamManager - if use_beta: - stream_cm = self._client.beta.messages.stream(**kwargs) - else: - stream_cm = self._client.messages.stream(**kwargs) + stream_cm = self._client.messages.stream(**kwargs) async with stream_cm as stream: async for event in stream: diff --git a/uv.lock b/uv.lock index 35dd8533..545c9d50 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.75.0" +version = "0.83.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -29,9 +29,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565, upload-time = "2025-11-24T20:41:45.28Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/e5/02cd2919ec327b24234abb73082e6ab84c451182cc3cc60681af700f4c63/anthropic-0.83.0.tar.gz", hash = "sha256:a8732c68b41869266c3034541a31a29d8be0f8cd0a714f9edce3128b351eceb4", size = 534058, upload-time = "2026-02-19T19:26:38.904Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164, upload-time = "2025-11-24T20:41:43.587Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/b9d58e4e2a4b1fc3e75ffbab978f999baf8b7c4ba9f96e60edb918ba386b/anthropic-0.83.0-py3-none-any.whl", hash = "sha256:f069ef508c73b8f9152e8850830d92bd5ef185645dbacf234bb213344a274810", size = 456991, upload-time = "2026-02-19T19:26:40.114Z" }, ] [[package]] @@ -1020,7 +1020,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "anthropic", specifier = ">=0.40.0" }, + { name = "anthropic", specifier = ">=0.83.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.18.0" }, { name = "openai", specifier = ">=2.14.0" },