ai-sdk-stream-python 0.2.0a1__tar.gz → 0.2.0a3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ai-sdk-stream-python
3
- Version: 0.2.0a1
3
+ Version: 0.2.0a3
4
4
  Summary: A Python package for AI SDK streaming utilities
5
5
  Author: Shloimy Wiesel
6
6
  Requires-Dist: fastapi>=0.128.8
@@ -63,6 +63,7 @@ async def my_work(ctx):
63
63
  | **Typed events** | All 16 v6 protocol events as Pydantic models |
64
64
  | **Lifecycle auto-management** | `start`, `start-step`, `text-start` etc. are emitted automatically |
65
65
  | **Shared state** | `ctx.store.get/set()` — dot-path key-value store shared across modules |
66
+ | **Custom information** | `ctx.info` — typed, read-only Pydantic model for request-scoped metadata |
66
67
  | **Pass as parameter** | `ctx` flows through your services like a logger or DB session |
67
68
  | **Stream collection** | `collect=True` records all emitted content into `ctx.record` for DB persistence |
68
69
  | **Low-level escape hatch** | `ctx.write(event)` / `ctx.write_event_to_stream(ev)` for raw control |
@@ -123,6 +124,33 @@ plan = await ctx.store.get("user.plan", default="free") # with default
123
124
 
124
125
  The store uses an `asyncio.Lock` — safe to use across concurrent coroutines.
125
126
 
127
+ ### Custom information (`ctx.info`)
128
+
129
+ Pass a Pydantic model at construction time to carry static, read-only request-scoped data (e.g. `user_id`, `rate_limit`, `tenant_id`) through every service layer without threading extra arguments:
130
+
131
+ ```python
132
+ from pydantic import BaseModel
133
+ from ai_sdk_stream_python import StreamContext
134
+
135
+ class RequestInfo(BaseModel):
136
+ user_id: str
137
+ rate_limit: int
138
+
139
+ # Typed constructor — IDE infers ctx.info as RequestInfo
140
+ ctx: StreamContext[RequestInfo] = StreamContext(
141
+ custom_information=RequestInfo(user_id="u_42", rate_limit=100)
142
+ )
143
+
144
+ # In any service layer that receives ctx:
145
+ if ctx.info is not None:
146
+ print(ctx.info.user_id) # "u_42"
147
+ print(ctx.info.rate_limit) # 100
148
+ ```
149
+
150
+ - `ctx.info` is **read-only** (no setter). For mutable runtime state use `ctx.store`.
151
+ - Defaults to `None` when `custom_information` is not passed.
152
+ - `StreamContext` is generic — annotate as `StreamContext[YourModel]` for full IDE support.
153
+
126
154
  ### Writing to the stream
127
155
 
128
156
  ```python
@@ -226,6 +254,7 @@ ctx.message_id # the message ID in the start event
226
254
  ctx.current_text_id # ID of open text part, or None
227
255
  ctx.current_reasoning_id # ID of open reasoning part, or None
228
256
  ctx.is_finished # True after finish()/abort()
257
+ ctx.info # custom_information model, or None
229
258
  ctx.response_headers # dict with x-vercel-ai-ui-message-stream: v1
230
259
  ```
231
260
 
@@ -367,9 +396,9 @@ npm run dev
367
396
  uv run pytest tests/ -v
368
397
  ```
369
398
 
370
- 51 tests covering: basic lifecycle, reasoning ↔ text transitions, tool calls,
399
+ 56 tests covering: basic lifecycle, reasoning ↔ text transitions, tool calls,
371
400
  multi-step flows, source events, edge cases (double finish, abort, write after finish),
372
- StateStore integration, and stream collection (`collect=True`).
401
+ StateStore integration, stream collection (`collect=True`), and custom information (`ctx.info`).
373
402
 
374
403
  ---
375
404
 
@@ -53,6 +53,7 @@ async def my_work(ctx):
53
53
  | **Typed events** | All 16 v6 protocol events as Pydantic models |
54
54
  | **Lifecycle auto-management** | `start`, `start-step`, `text-start` etc. are emitted automatically |
55
55
  | **Shared state** | `ctx.store.get/set()` — dot-path key-value store shared across modules |
56
+ | **Custom information** | `ctx.info` — typed, read-only Pydantic model for request-scoped metadata |
56
57
  | **Pass as parameter** | `ctx` flows through your services like a logger or DB session |
57
58
  | **Stream collection** | `collect=True` records all emitted content into `ctx.record` for DB persistence |
58
59
  | **Low-level escape hatch** | `ctx.write(event)` / `ctx.write_event_to_stream(ev)` for raw control |
@@ -113,6 +114,33 @@ plan = await ctx.store.get("user.plan", default="free") # with default
113
114
 
114
115
  The store uses an `asyncio.Lock` — safe to use across concurrent coroutines.
115
116
 
117
+ ### Custom information (`ctx.info`)
118
+
119
+ Pass a Pydantic model at construction time to carry static, read-only request-scoped data (e.g. `user_id`, `rate_limit`, `tenant_id`) through every service layer without threading extra arguments:
120
+
121
+ ```python
122
+ from pydantic import BaseModel
123
+ from ai_sdk_stream_python import StreamContext
124
+
125
+ class RequestInfo(BaseModel):
126
+ user_id: str
127
+ rate_limit: int
128
+
129
+ # Typed constructor — IDE infers ctx.info as RequestInfo
130
+ ctx: StreamContext[RequestInfo] = StreamContext(
131
+ custom_information=RequestInfo(user_id="u_42", rate_limit=100)
132
+ )
133
+
134
+ # In any service layer that receives ctx:
135
+ if ctx.info is not None:
136
+ print(ctx.info.user_id) # "u_42"
137
+ print(ctx.info.rate_limit) # 100
138
+ ```
139
+
140
+ - `ctx.info` is **read-only** (no setter). For mutable runtime state use `ctx.store`.
141
+ - Defaults to `None` when `custom_information` is not passed.
142
+ - `StreamContext` is generic — annotate as `StreamContext[YourModel]` for full IDE support.
143
+
116
144
  ### Writing to the stream
117
145
 
118
146
  ```python
@@ -216,6 +244,7 @@ ctx.message_id # the message ID in the start event
216
244
  ctx.current_text_id # ID of open text part, or None
217
245
  ctx.current_reasoning_id # ID of open reasoning part, or None
218
246
  ctx.is_finished # True after finish()/abort()
247
+ ctx.info # custom_information model, or None
219
248
  ctx.response_headers # dict with x-vercel-ai-ui-message-stream: v1
220
249
  ```
221
250
 
@@ -357,9 +386,9 @@ npm run dev
357
386
  uv run pytest tests/ -v
358
387
  ```
359
388
 
360
- 51 tests covering: basic lifecycle, reasoning ↔ text transitions, tool calls,
389
+ 56 tests covering: basic lifecycle, reasoning ↔ text transitions, tool calls,
361
390
  multi-step flows, source events, edge cases (double finish, abort, write after finish),
362
- StateStore integration, and stream collection (`collect=True`).
391
+ StateStore integration, stream collection (`collect=True`), and custom information (`ctx.info`).
363
392
 
364
393
  ---
365
394
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ai-sdk-stream-python"
3
- version = "0.2.0-a.1"
3
+ version = "0.2.0-a.3"
4
4
  description = "A Python package for AI SDK streaming utilities"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -42,12 +42,18 @@ Quickstart::
42
42
  )
43
43
  """
44
44
 
45
+ from .collect import DataPartRecord as DataPartRecord
46
+ from .collect import FileRecord as FileRecord
45
47
  from .collect import SourceRecord as SourceRecord
46
48
  from .collect import StreamRecord as StreamRecord
47
49
  from .collect import ToolCallRecord as ToolCallRecord
48
50
  from .context import StreamContext, ToolCallHandle
49
51
  from .events import (
52
+ AbortEvent,
50
53
  BaseEvent,
54
+ DataEvent,
55
+ ErrorEvent,
56
+ FileEvent,
51
57
  FinishEvent,
52
58
  FinishStepEvent,
53
59
  ReasoningDeltaEvent,
@@ -77,6 +83,8 @@ __all__ = [
77
83
  "StreamRecord",
78
84
  "ToolCallRecord",
79
85
  "SourceRecord",
86
+ "FileRecord",
87
+ "DataPartRecord",
80
88
  # Base / union
81
89
  "BaseEvent",
82
90
  "UIMessageStreamEvent",
@@ -101,4 +109,11 @@ __all__ = [
101
109
  "ToolOutputErrorEvent",
102
110
  # Sources
103
111
  "SourceUrlEvent",
112
+ # Files
113
+ "FileEvent",
114
+ # Custom data parts
115
+ "DataEvent",
116
+ # Error / Abort
117
+ "ErrorEvent",
118
+ "AbortEvent",
104
119
  ]
@@ -32,6 +32,23 @@ class SourceRecord:
32
32
  title: str | None = None
33
33
 
34
34
 
35
+ @dataclass
36
+ class FileRecord:
37
+ """A file/image emitted via ``write_file``."""
38
+
39
+ url: str
40
+ media_type: str
41
+
42
+
43
+ @dataclass
44
+ class DataPartRecord:
45
+ """A non-transient custom data part emitted via ``write_data``."""
46
+
47
+ name: str
48
+ data: dict[str, Any]
49
+ id: str | None = None
50
+
51
+
35
52
  @dataclass
36
53
  class StreamRecord:
37
54
  """
@@ -66,6 +83,8 @@ class StreamRecord:
66
83
  reasoning: str = ""
67
84
  tool_calls: list[ToolCallRecord] = field(default_factory=list)
68
85
  sources: list[SourceRecord] = field(default_factory=list)
86
+ files: list[FileRecord] = field(default_factory=list)
87
+ data_parts: list[DataPartRecord] = field(default_factory=list)
69
88
  finish_reason: str | None = None
70
89
  step_count: int = 0
71
90
 
@@ -93,9 +112,30 @@ class StreamRecord:
93
112
  }
94
113
  for s in self.sources
95
114
  ],
115
+ "files": [
116
+ {
117
+ "url": f.url,
118
+ "media_type": f.media_type,
119
+ }
120
+ for f in self.files
121
+ ],
122
+ "data_parts": [
123
+ {
124
+ "name": dp.name,
125
+ "data": dp.data,
126
+ "id": dp.id,
127
+ }
128
+ for dp in self.data_parts
129
+ ],
96
130
  "finish_reason": self.finish_reason,
97
131
  "step_count": self.step_count,
98
132
  }
99
133
 
100
134
 
101
- __all__ = ["SourceRecord", "StreamRecord", "ToolCallRecord"]
135
+ __all__ = [
136
+ "DataPartRecord",
137
+ "FileRecord",
138
+ "SourceRecord",
139
+ "StreamRecord",
140
+ "ToolCallRecord",
141
+ ]
@@ -56,11 +56,23 @@ import asyncio
56
56
  import uuid
57
57
  from collections.abc import AsyncGenerator
58
58
  from dataclasses import dataclass
59
- from typing import Any, ClassVar
59
+ from typing import Any, ClassVar, Generic, TypeVar
60
60
 
61
- from .collect import SourceRecord, StreamRecord, ToolCallRecord
61
+ from pydantic import BaseModel
62
+
63
+ from .collect import (
64
+ DataPartRecord,
65
+ FileRecord,
66
+ SourceRecord,
67
+ StreamRecord,
68
+ ToolCallRecord,
69
+ )
62
70
  from .events import (
71
+ AbortEvent,
63
72
  BaseEvent,
73
+ DataEvent,
74
+ ErrorEvent,
75
+ FileEvent,
64
76
  FinishEvent,
65
77
  FinishStepEvent,
66
78
  ReasoningDeltaEvent,
@@ -73,12 +85,15 @@ from .events import (
73
85
  TextEndEvent,
74
86
  TextStartEvent,
75
87
  ToolInputAvailableEvent,
88
+ ToolInputDeltaEvent,
76
89
  ToolInputStartEvent,
77
90
  ToolOutputAvailableEvent,
78
91
  ToolOutputErrorEvent,
79
92
  )
80
93
  from .state import StateStore
81
94
 
95
+ _InfoT = TypeVar("_InfoT", bound=BaseModel)
96
+
82
97
 
83
98
  @dataclass
84
99
  class ToolCallHandle:
@@ -88,7 +103,7 @@ class ToolCallHandle:
88
103
  toolName: str
89
104
 
90
105
 
91
- class StreamContext:
106
+ class StreamContext(Generic[_InfoT]):
92
107
  """
93
108
  A stateful context for producing a Vercel AI SDK v6 UIMessageStream.
94
109
 
@@ -100,6 +115,11 @@ class StreamContext:
100
115
  ----------
101
116
  store : StateStore
102
117
  Async key-value store shared across the background task and any caller.
118
+ info : _InfoT | None
119
+ Static, read-only metadata supplied at construction time (e.g. user_id,
120
+ rate_limit). Pass any Pydantic ``BaseModel`` instance as
121
+ ``custom_information=`` — it is available unchanged for the entire
122
+ lifetime of the context. Defaults to ``None`` when not provided.
103
123
  """
104
124
 
105
125
  response_headers: ClassVar[dict[str, str]] = {
@@ -114,10 +134,12 @@ class StreamContext:
114
134
  message_id: str | None = None,
115
135
  *,
116
136
  collect: bool = False,
137
+ custom_information: _InfoT | None = None,
117
138
  ) -> None:
118
139
  self._message_id: str = message_id or str(uuid.uuid4())
119
140
  self._queue: asyncio.Queue[BaseEvent | None] = asyncio.Queue()
120
141
  self.store: StateStore = StateStore()
142
+ self._info: _InfoT | None = custom_information
121
143
 
122
144
  # Lifecycle state
123
145
  self._started: bool = False
@@ -151,6 +173,18 @@ class StreamContext:
151
173
  def is_finished(self) -> bool:
152
174
  return self._finished
153
175
 
176
+ @property
177
+ def info(self) -> _InfoT | None:
178
+ """
179
+ Static, read-only metadata supplied at construction time.
180
+
181
+ Returns the ``custom_information`` value passed to ``__init__``, or
182
+ ``None`` if none was provided. Useful for carrying request-scoped
183
+ data (e.g. ``user_id``, ``rate_limit``) through service layers without
184
+ threading extra function arguments.
185
+ """
186
+ return self._info
187
+
154
188
  @property
155
189
  def record(self) -> StreamRecord | None:
156
190
  """
@@ -284,6 +318,63 @@ class StreamContext:
284
318
  )
285
319
  return ToolCallHandle(toolCallId=tcid, toolName=tool_name)
286
320
 
321
+ async def start_tool_input(
322
+ self,
323
+ tool_name: str,
324
+ *,
325
+ tool_call_id: str | None = None,
326
+ ) -> ToolCallHandle:
327
+ """
328
+ Emit ``tool-input-start`` and return a handle for streaming deltas.
329
+
330
+ Use this when tool arguments arrive incrementally (e.g. from an LLM
331
+ stream). Follow with :meth:`stream_tool_input_delta` calls and
332
+ finish with :meth:`finish_tool_input`.
333
+ """
334
+ await self._ensure_step_open()
335
+ await self._ensure_text_closed()
336
+ await self._ensure_reasoning_closed()
337
+ tcid = tool_call_id or str(uuid.uuid4())
338
+ self._queue.put_nowait(ToolInputStartEvent(toolCallId=tcid, toolName=tool_name))
339
+ if self._record is not None:
340
+ self._record.tool_calls.append(
341
+ ToolCallRecord(tool_call_id=tcid, tool_name=tool_name, input={})
342
+ )
343
+ return ToolCallHandle(toolCallId=tcid, toolName=tool_name)
344
+
345
+ async def stream_tool_input_delta(
346
+ self, tool_call_id: str, input_text_delta: str
347
+ ) -> None:
348
+ """Emit a ``tool-input-delta`` for an in-progress tool call."""
349
+ self.write_event_to_stream(
350
+ ToolInputDeltaEvent(
351
+ toolCallId=tool_call_id, inputTextDelta=input_text_delta
352
+ )
353
+ )
354
+
355
+ async def finish_tool_input(
356
+ self,
357
+ tool_call_id: str,
358
+ tool_name: str,
359
+ input: dict[str, Any],
360
+ ) -> None:
361
+ """
362
+ Emit ``tool-input-available`` to close a streaming tool call.
363
+
364
+ Updates the collected :class:`~collect.ToolCallRecord` with the
365
+ final *input* if collection is enabled.
366
+ """
367
+ if self._record is not None:
368
+ for tc in self._record.tool_calls:
369
+ if tc.tool_call_id == tool_call_id:
370
+ tc.input = input
371
+ break
372
+ self.write_event_to_stream(
373
+ ToolInputAvailableEvent(
374
+ toolCallId=tool_call_id, toolName=tool_name, input=input
375
+ )
376
+ )
377
+
287
378
  async def complete_tool_call(self, tool_call_id: str, output: Any) -> None:
288
379
  """Emit ``tool-output-available`` with the tool result."""
289
380
  if self._record is not None:
@@ -306,6 +397,51 @@ class StreamContext:
306
397
  ToolOutputErrorEvent(toolCallId=tool_call_id, error=error)
307
398
  )
308
399
 
400
+ async def write_data(
401
+ self,
402
+ name: str,
403
+ data: dict[str, Any],
404
+ *,
405
+ id: str | None = None,
406
+ transient: bool = False,
407
+ ) -> None:
408
+ """
409
+ Emit a custom data part (``data-{name}`` type).
410
+
411
+ *name* is validated: it must be non-empty and contain only
412
+ alphanumeric characters, hyphens, or underscores.
413
+ Non-transient parts are collected in ``ctx.record.data_parts``.
414
+ Transient parts are only available through the ``onData`` callback
415
+ on the frontend; they are not stored in message history.
416
+ """
417
+ if not name or not all(c.isalnum() or c in "-_" for c in name):
418
+ raise ValueError(
419
+ f"Invalid data part name {name!r}. "
420
+ "Use only alphanumeric characters, hyphens, or underscores."
421
+ )
422
+ await self._ensure_started()
423
+ event = DataEvent(
424
+ type=f"data-{name}",
425
+ data=data,
426
+ id=id,
427
+ transient=transient or None,
428
+ )
429
+ if self._record is not None and not transient:
430
+ self._record.data_parts.append(DataPartRecord(name=name, data=data, id=id))
431
+ self.write_event_to_stream(event)
432
+
433
+ async def write_file(self, url: str, media_type: str) -> None:
434
+ """
435
+ Emit a ``file`` event (image, PDF, or other file content).
436
+
437
+ Auto-emits ``start`` and ``start-step`` if not yet open.
438
+ On the frontend this produces a ``FileUIPart`` in ``message.parts``.
439
+ """
440
+ await self._ensure_step_open()
441
+ if self._record is not None:
442
+ self._record.files.append(FileRecord(url=url, media_type=media_type))
443
+ self.write_event_to_stream(FileEvent(url=url, mediaType=media_type))
444
+
309
445
  async def write_source(
310
446
  self,
311
447
  source_id: str,
@@ -354,16 +490,33 @@ class StreamContext:
354
490
  )
355
491
  self._queue.put_nowait(None) # sentinel → stream() yields [DONE]
356
492
 
357
- async def abort(self) -> None:
493
+ async def abort(self, reason: str | None = None) -> None:
358
494
  """
359
- Terminate the stream immediately without a proper finish event.
495
+ Emit an ``abort`` event and terminate the stream.
360
496
 
361
497
  Use in error-handling paths where the normal ``finish()`` flow
362
- cannot be reached.
498
+ cannot be reached. The optional *reason* string is forwarded to
499
+ the frontend so ``useChat`` can surface why the stream stopped.
500
+ Safe to call multiple times; subsequent calls are no-ops.
501
+ """
502
+ if self._finished:
503
+ return
504
+ self._finished = True
505
+ self._queue.put_nowait(AbortEvent(reason=reason))
506
+ self._queue.put_nowait(None)
507
+
508
+ async def error(self, error_text: str) -> None:
509
+ """
510
+ Emit an ``error`` event and terminate the stream.
511
+
512
+ The AI SDK v6 ``useChat`` hook surfaces this via its ``error`` object.
513
+ Safe to call multiple times; subsequent calls are no-ops.
363
514
  """
364
515
  if self._finished:
365
516
  return
517
+ await self._ensure_started()
366
518
  self._finished = True
519
+ self._queue.put_nowait(ErrorEvent(errorText=error_text))
367
520
  self._queue.put_nowait(None)
368
521
 
369
522
  # ── SSE async generator ───────────────────────────────────────────────────
@@ -129,6 +129,47 @@ class SourceUrlEvent(BaseEvent):
129
129
  title: str | None = None
130
130
 
131
131
 
132
+ # ── Files ──────────────────────────────────────────────────────────────────────
133
+
134
+
135
+ class FileEvent(BaseEvent):
136
+ type: Literal["file"] = "file"
137
+ url: str
138
+ mediaType: str
139
+
140
+
141
+ # ── Error ──────────────────────────────────────────────────────────────────────
142
+
143
+
144
+ class ErrorEvent(BaseEvent):
145
+ type: Literal["error"] = "error"
146
+ errorText: str
147
+
148
+
149
+ # ── Custom data parts ─────────────────────────────────────────────────────────
150
+
151
+
152
+ class DataEvent(BaseEvent):
153
+ """Custom data part with a dynamic ``data-{name}`` type.
154
+
155
+ Not included in the ``UIMessageStreamEvent`` discriminated union because
156
+ the ``type`` field is dynamic. Use ``ctx.write_data()`` to emit these.
157
+ """
158
+
159
+ type: str # "data-{name}", validated by ctx.write_data()
160
+ data: dict[str, Any]
161
+ id: str | None = None
162
+ transient: bool | None = None
163
+
164
+
165
+ # ── Abort ──────────────────────────────────────────────────────────────────────
166
+
167
+
168
+ class AbortEvent(BaseEvent):
169
+ type: Literal["abort"] = "abort"
170
+ reason: str | None = None
171
+
172
+
132
173
  # ── Discriminated union ────────────────────────────────────────────────────────
133
174
 
134
175
  UIMessageStreamEvent = Annotated[
@@ -146,6 +187,9 @@ UIMessageStreamEvent = Annotated[
146
187
  | ToolOutputAvailableEvent
147
188
  | ToolOutputErrorEvent
148
189
  | SourceUrlEvent
190
+ | FileEvent
191
+ | ErrorEvent
192
+ | AbortEvent
149
193
  | FinishStepEvent
150
194
  | FinishEvent,
151
195
  Field(discriminator="type"),
@@ -170,4 +214,8 @@ __all__ = [
170
214
  "ToolOutputAvailableEvent",
171
215
  "ToolOutputErrorEvent",
172
216
  "SourceUrlEvent",
217
+ "FileEvent",
218
+ "DataEvent",
219
+ "ErrorEvent",
220
+ "AbortEvent",
173
221
  ]