Skip to content

Commit ae06278

Browse files
authored
Merge pull request #55 from vercel-labs/fix-models
Rework models api
2 parents 82c1424 + cb32777 commit ae06278

41 files changed

Lines changed: 636 additions & 1716 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

examples/coding-agent/1_raw_stream.py

Lines changed: 0 additions & 35 deletions
This file was deleted.

examples/samples/explicit_client.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919

2020
async def main() -> None:
2121
try:
22-
async for event in ai.models.stream(model, messages):
23-
if isinstance(event, ai.TextDelta):
24-
print(event.chunk, end="", flush=True)
22+
async with ai.stream(model, messages) as s:
23+
async for event in s:
24+
if isinstance(event, ai.TextDelta):
25+
print(event.chunk, end="", flush=True)
2526
print()
2627
finally:
28+
# Explicit clients need explicit cleanup.
2729
await client.aclose()
2830

2931

examples/samples/inline_image.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Inline image generation — LLM that outputs images alongside text.
22
33
Models like Gemini 3 Pro Image can generate images as part of their
4-
language model response. The images arrive as FileParts on the final
5-
MessageEnd message.
4+
language model response. The images arrive as ``FileEvent`` events
5+
during the stream and end up as ``FilePart``s on the aggregated
6+
``Stream.message``.
67
"""
78

89
import asyncio
@@ -22,20 +23,18 @@
2223

2324

2425
async def main() -> None:
25-
last_msg: ai.Message | None = None
26-
27-
# Stream — text deltas arrive as events, images arrive on MessageEnd
28-
async for event in ai.stream(model, messages):
29-
if isinstance(event, ai.TextDelta):
30-
print(event.chunk, end="", flush=True)
31-
elif isinstance(event, ai.MessageEnd):
32-
last_msg = event.message
26+
# Stream — text deltas arrive as TextDelta events, generated images
27+
# arrive as FileEvent events and accumulate on s.message.
28+
async with ai.stream(model, messages) as s:
29+
async for event in s:
30+
if isinstance(event, ai.TextDelta):
31+
print(event.chunk, end="", flush=True)
3332

3433
print()
3534

36-
# Check for images in the final message
37-
if last_msg and last_msg.images:
38-
for i, img in enumerate(last_msg.images):
35+
# Check for images in the aggregated message.
36+
if s.message.images:
37+
for i, img in enumerate(s.message.images):
3938
filename = f"inline_{i}.png"
4039
data = (
4140
img.data if isinstance(img.data, bytes) else base64.b64decode(img.data)

examples/samples/multimodal_input.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020

2121

2222
async def main() -> None:
23-
async for event in ai.stream(model, messages):
24-
if isinstance(event, ai.TextDelta):
25-
print(event.chunk, end="", flush=True)
23+
async with ai.stream(model, messages) as s:
24+
async for event in s:
25+
if isinstance(event, ai.TextDelta):
26+
print(event.chunk, end="", flush=True)
2627
print()
2728

2829

examples/samples/stream.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313

1414

1515
async def main() -> None:
16-
async for event in ai.stream(model, messages):
17-
if isinstance(event, ai.TextDelta):
18-
print(event.chunk, end="", flush=True)
16+
async with ai.stream(model, messages) as s:
17+
async for event in s:
18+
if isinstance(event, ai.TextDelta):
19+
print(event.chunk, end="", flush=True)
1920
print()
2021

2122

examples/samples/structured_output.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,18 @@ class Recipe(pydantic.BaseModel):
2020

2121

2222
async def main() -> None:
23-
# Stream with structured output — watch JSON arrive, get validated at the end
24-
async for event in ai.stream(model, messages, output_type=Recipe):
25-
if isinstance(event, ai.TextDelta):
26-
print(event.chunk, end="", flush=True)
27-
elif isinstance(event, ai.MessageEnd) and event.message.output:
28-
recipe: Recipe = event.message.output
29-
print(f"\n\nParsed recipe: {recipe.name}")
30-
print(f" Ingredients: {', '.join(recipe.ingredients)}")
31-
print(f" Prep time: {recipe.prep_time_minutes} min")
23+
# Stream with structured output — watch JSON arrive, get validated at the end.
24+
async with ai.stream(model, messages, output_type=Recipe) as s:
25+
async for event in s:
26+
if isinstance(event, ai.TextDelta):
27+
print(event.chunk, end="", flush=True)
28+
29+
# After iteration, s.output is the validated pydantic model.
30+
recipe: Recipe | None = s.output
31+
if recipe is not None:
32+
print(f"\n\nParsed recipe: {recipe.name}")
33+
print(f" Ingredients: {', '.join(recipe.ingredients)}")
34+
print(f" Prep time: {recipe.prep_time_minutes} min")
3235

3336

3437
if __name__ == "__main__":

examples/samples/tools_schema.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,17 @@
2424

2525

2626
async def main() -> None:
27-
# Stream with tools — the model may emit tool calls
28-
async for event in ai.stream(model, messages, tools=[get_weather]):
29-
if isinstance(event, ai.TextDelta):
30-
print(event.chunk, end="", flush=True)
31-
elif isinstance(event, ai.MessageEnd):
32-
for tc in event.message.tool_calls:
33-
print(f"\nTool call: {tc.tool_name}({tc.tool_args})")
27+
# Stream with tools — the model may emit tool calls.
28+
async with ai.stream(model, messages, tools=[get_weather]) as s:
29+
async for event in s:
30+
if isinstance(event, ai.TextDelta):
31+
print(event.chunk, end="", flush=True)
3432
print()
3533

34+
# After iteration, s.tool_calls collects every tool call from the response.
35+
for tc in s.tool_calls:
36+
print(f"Tool call: {tc.tool_name}({tc.tool_args})")
37+
3638

3739
if __name__ == "__main__":
3840
asyncio.run(main())

src/ai/__init__.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@
1717
from .middleware import AgentRunContext, Middleware
1818
from .models import (
1919
Client,
20+
Executor,
21+
GenerateExecutor,
22+
GenerateRequest,
2023
ImageParams,
2124
Model,
2225
Provider,
23-
StreamResult,
26+
Stream,
27+
StreamExecutor,
28+
StreamRequest,
2429
VideoParams,
2530
ai_gateway,
2631
anthropic,
@@ -32,22 +37,20 @@
3237

3338
# Re-export core types
3439
from .types import (
35-
End,
3640
Event,
41+
FileEvent,
3742
FilePart,
3843
HookPart,
3944
HookResolution,
4045
HookSuspention,
4146
Message,
42-
MessageEnd,
43-
MessageStart,
4447
Part,
4548
ReasoningDelta,
4649
ReasoningEnd,
4750
ReasoningPart,
4851
ReasoningStart,
49-
Start,
50-
StreamResultLike,
52+
StreamEnd,
53+
StreamStart,
5154
StructuredOutputPart,
5255
TextDelta,
5356
TextEnd,
@@ -74,12 +77,10 @@
7477

7578
__all__ = [
7679
# Types (from types/)
77-
"Start",
78-
"End",
7980
"Event",
8081
"Message",
81-
"MessageStart",
82-
"MessageEnd",
82+
"StreamStart",
83+
"StreamEnd",
8384
"Part",
8485
"TextPart",
8586
"TextStart",
@@ -94,6 +95,7 @@
9495
"ReasoningStart",
9596
"ReasoningDelta",
9697
"ReasoningEnd",
98+
"FileEvent",
9799
"FilePart",
98100
"HookPart",
99101
"HookSuspention",
@@ -116,8 +118,12 @@
116118
"ImageParams",
117119
"VideoParams",
118120
"Client",
119-
"StreamResult",
120-
"StreamResultLike",
121+
"Stream",
122+
"StreamRequest",
123+
"GenerateRequest",
124+
"Executor",
125+
"StreamExecutor",
126+
"GenerateExecutor",
121127
"check_connection",
122128
"stream",
123129
"generate",

src/ai/agents/agent.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .. import middleware as middleware_
1414
from .. import models, types
1515
from ..types import builders
16+
from . import events as events_
1617
from . import runtime
1718

1819

@@ -199,21 +200,23 @@ def resolve(self, tool_parts: list[types.ToolCallPart]) -> list[ToolCall]:
199200
]
200201

201202

202-
StreamItem = types.Event | types.Message
203+
StreamItem = events_.AgentEvent | types.Message
203204

204205

205206
class LoopFn(Protocol):
206207
def __call__(self, context: Context) -> AsyncGenerator[StreamItem]: ...
207208

208209

209-
async def _message_events(message: types.Message) -> AsyncGenerator[types.Event]:
210-
yield types.MessageStart(message=message)
211-
yield types.MessageEnd(message=message)
210+
async def _message_events(
211+
message: types.Message,
212+
) -> AsyncGenerator[events_.AgentEvent]:
213+
yield events_.MessageStart(message=message)
214+
yield events_.MessageEnd(message=message)
212215

213216

214217
async def _coerce_events(
215218
source: AsyncIterable[StreamItem],
216-
) -> AsyncGenerator[types.Event]:
219+
) -> AsyncGenerator[events_.AgentEvent]:
217220
async for item in source:
218221
if isinstance(item, types.Message):
219222
async for event in _message_events(item):
@@ -222,15 +225,23 @@ async def _coerce_events(
222225
yield item
223226

224227

225-
async def _default_loop(context: Context) -> AsyncGenerator[types.Event]:
228+
async def _default_loop(context: Context) -> AsyncGenerator[events_.AgentEvent]:
226229
while True:
227230
stream = models.stream(
228231
context.model,
229232
context.messages,
230233
tools=context.tools,
231234
)
232-
async for event in stream:
233-
yield event
235+
async for stream_event in stream:
236+
yield stream_event
237+
238+
# Bridge: emit MessageStart/MessageEnd around the assistant message
239+
# the model stream just produced, so _collect_messages and downstream
240+
# consumers (AI-SDK outbound, label stamping) see the same boundary
241+
# events they did under the previous adapter contract.
242+
if stream.message is not None and stream.message.parts:
243+
async for boundary in _message_events(stream.message):
244+
yield boundary
234245

235246
tool_calls = context.resolve(stream.tool_calls)
236247
if not tool_calls:
@@ -244,14 +255,14 @@ async def _default_loop(context: Context) -> AsyncGenerator[types.Event]:
244255
# Left un-stamped: the tool result is the input of the *next* turn,
245256
# so the next stream() call will stamp it with that turn's id.
246257
tool_msg = builders.tool_message(*(t.result() for t in tasks))
247-
async for event in _message_events(tool_msg):
248-
yield event
258+
async for boundary in _message_events(tool_msg):
259+
yield boundary
249260

250261

251262
async def _collect_messages(
252263
source: AsyncIterable[StreamItem],
253264
messages: list[types.Message],
254-
) -> AsyncGenerator[types.Event]:
265+
) -> AsyncGenerator[events_.AgentEvent]:
255266
"""Intercept yielded events and collect MessageEnd messages into *messages*.
256267
257268
This runs on the **producer** side (same coroutine as the loop function),
@@ -260,7 +271,7 @@ async def _collect_messages(
260271
happened on the consumer side of the runtime queue.
261272
"""
262273
async for event in _coerce_events(source):
263-
if isinstance(event, types.MessageEnd):
274+
if isinstance(event, events_.MessageEnd):
264275
message = event.message
265276
for i, existing in enumerate(messages):
266277
if existing.id == message.id:
@@ -292,7 +303,7 @@ async def yield_from(source: AsyncIterable[StreamItem]) -> str:
292303
last: types.Message | None = None
293304
async for item in _coerce_events(source):
294305
await rt.put_event(item)
295-
if isinstance(item, types.MessageEnd):
306+
if isinstance(item, events_.MessageEnd):
296307
last = item.message
297308
return last.text if last else ""
298309

@@ -325,7 +336,7 @@ async def run(
325336
*,
326337
label: str | None = None,
327338
middleware: list[middleware_.Middleware] | None = None,
328-
) -> AsyncGenerator[types.Event]:
339+
) -> AsyncGenerator[events_.AgentEvent]:
329340
"""Run the agent loop, yielding events to the consumer.
330341
331342
Args:
@@ -349,7 +360,7 @@ async def run(
349360

350361
async def _real(
351362
call: middleware_.AgentRunContext,
352-
) -> AsyncGenerator[types.Event]:
363+
) -> AsyncGenerator[events_.AgentEvent]:
353364
context = Context(
354365
model=call.model,
355366
messages=list(call.messages),
@@ -359,8 +370,8 @@ async def _real(
359370
async for event in runtime.run(source):
360371
if call.label is not None:
361372
event_message: types.Message | None = None
362-
if isinstance(event, types.MessageEnd) or (
363-
isinstance(event, types.MessageStart)
373+
if isinstance(event, events_.MessageEnd) or (
374+
isinstance(event, events_.MessageStart)
364375
and event.message is not None
365376
):
366377
event_message = event.message

0 commit comments

Comments
 (0)