Skip to content

Commit 96d1b7c

Browse files
committed
Add JSON Patch streaming support
1 parent 1842b0f commit 96d1b7c

15 files changed

Lines changed: 1577 additions & 33 deletions

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,62 @@ For direct `chat.completions.create(...)`, pass the wrapped OpenAI-style
163163

164164
Use `DotTxt.models.list()` and `AsyncDotTxt.models.list()` for model listing.
165165

166+
## Streaming Fields
167+
168+
`AsyncDotTxt.stream(...)` yields `PatchEvent` objects as the model fills in a
169+
schema-constrained response. The wire format is the gateway's `stream: "patch"`
170+
mode (RFC 6902 JSON Patch over NDJSON).
171+
172+
Each event carries the raw op (`event.op`) and an independent deep copy of the
173+
document so far (`event.snapshot`). For the common case of reacting to one
174+
field at a time, use the demux properties: `event.is_leaf` skips structural
175+
ops (root seed, empty-container init), `event.field` is the JSON Pointer with
176+
the leading `/` stripped (`"intent"`, `"steps/0"`, `"address/city"`), and
177+
`event.value` is the op's value.
178+
179+
```python
180+
import asyncio
181+
from typing import Literal
182+
183+
from pydantic import BaseModel
184+
185+
from dottxt import AsyncDotTxt
186+
187+
188+
class SupportTicket(BaseModel):
189+
# Field order = arrival order. Put what unblocks downstream work first.
190+
intent: Literal["billing", "technical", "account"]
191+
urgency: Literal["low", "medium", "high", "critical"]
192+
reply: str
193+
194+
195+
async def main() -> None:
196+
client = AsyncDotTxt()
197+
stream = client.stream(
198+
model="openai/gpt-oss-20b",
199+
response_format=SupportTicket,
200+
input="I was charged twice this month, please refund the duplicate.",
201+
)
202+
async for event in stream:
203+
if not event.is_leaf:
204+
continue
205+
match event.field:
206+
case "intent":
207+
print(f"dispatching to {event.value} queue")
208+
case "urgency" if event.value == "critical":
209+
print("paging oncall")
210+
case "reply":
211+
print(f"reply: {event.value}")
212+
213+
214+
asyncio.run(main())
215+
```
216+
217+
The routing decision can fire tens of milliseconds into generation while
218+
`reply` continues to stream. See
219+
[docs/client.md](docs/client.md#streaming-fields-patch-stream) for the full
220+
reference.
221+
166222
## OpenAI-Compatible Usage
167223

168224
Use `DotTxt` when you want an OpenAI-style client surface with
@@ -224,3 +280,8 @@ The compatibility surface expects the wrapped OpenAI-style
224280
- [Use a Genson schema builder to generate](examples/generate_genson.py)
225281
- [List available models](examples/list_models.py)
226282
- [OpenAI-Compatible chat completions](examples/openai_chat_completions.py)
283+
- [Stream fields as they arrive](examples/stream_field_printer.py)
284+
- [Route on /intent before /reply finishes](examples/stream_early_routing.py)
285+
- [Mid-stream human approval](examples/stream_hitl_approval.py)
286+
- [Fan out research on each /steps/N](examples/stream_fanout.py)
287+
- [Reconstruct the document from raw patch ops](examples/stream_reconstruct.py)

docs/cli.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Output rules:
7474
targeted error with guidance to run `dottxt models` and set `DOTTXT_MODEL`
7575
or pass `--model`
7676

77+
<<<<<<< HEAD
7778
### `dottxt schema check`
7879

7980
Validate a schema file as JSON Schema.
@@ -82,3 +83,16 @@ Validate a schema file as JSON Schema.
8283
- `<schema-file>`: JSON file path to validate
8384
- `--json`: emits structured payload including `status` and `schema_file`
8485
- Errors follow the shared `--json` error envelope when enabled
86+
=======
87+
### `dottxt stream`
88+
89+
Stream one generation as it is produced using JSON Patch RFC 6902.
90+
91+
- `-m, --model TEXT`: model id (required unless `DOTTXT_MODEL` is set)
92+
- `-s, --schema FILE`: schema file path (required)
93+
- `[PROMPT]`: literal prompt text (falls back to stdin, same rules as `generate`)
94+
95+
Output rules:
96+
97+
- stdout: one RFC 6902 `add` op per line (NDJSON), in arrival order.
98+
>>>>>>> d5cc04a (Add JSON Patch streaming support)

docs/client.md

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Requires Python 3.10+.
3030
The client takes two arguments. Each is read from the constructor first, then
3131
from the environment.
3232

33-
- `api_key` (`str | None`): falls back to `DOTTXT_API_KEY`. Required the
33+
- `api_key` (`str | None`): falls back to `DOTTXT_API_KEY`. Required, the
3434
constructor raises `ValueError` if neither is set.
3535
- `base_url` (`str | None`): falls back to `DOTTXT_BASE_URL`, then to
3636
`https://api.dottxt.ai/v1`.
@@ -209,11 +209,81 @@ result = client.generate(
209209
print(result) # {'severity': 'high', 'team': 'checkout'}
210210
```
211211

212+
## Streaming Fields (Patch Stream)
213+
214+
`AsyncDotTxt.stream(...)` yields `PatchEvent` objects as the model fills in a
215+
schema-constrained response. It is built on the gateway's `stream: "patch"`
216+
mode, which emits RFC 6902 JSON Patch operations in schema order, so
217+
downstream work can start the moment a field arrives, without waiting for
218+
the closing brace.
219+
220+
Parameters mirror `generate(...)`:
221+
222+
- `model` (`str`)
223+
- `input` (`str | list[dict]`)
224+
- `response_format` (`Any`) — any schema input accepted by `generate(...)`
225+
- `temperature`, `max_tokens`, `seed`, `timeout` — optional
226+
- `extra` (`dict | None`) — extra chat-completions body fields
227+
228+
Each `PatchEvent` carries:
229+
230+
- `event.op` — the raw RFC 6902 operation (`{"op": "add", "path": ..., "value": ...}`)
231+
- `event.snapshot` — an independent deep copy of the JSON object built up to
232+
and including this op
233+
- `event.is_leaf` / `event.field` / `event.value` convenience demux for
234+
the common case of reacting to one field at a time. `is_leaf` is `True`
235+
for non-structural adds (skipping the root seed and empty-container init
236+
ops); `field` is the JSON Pointer with the leading `/` stripped
237+
(`"intent"`, `"steps/0"`, `"address/city"`).
238+
239+
```python
240+
import asyncio
241+
from typing import Literal
242+
from pydantic import BaseModel
243+
from dottxt import AsyncDotTxt
244+
245+
class SupportTicket(BaseModel):
246+
# Field order = arrival order. Put what unblocks downstream work first.
247+
intent: Literal["billing", "technical", "account"]
248+
urgency: Literal["low", "medium", "high", "critical"]
249+
reply: str
250+
251+
async def main():
252+
client = AsyncDotTxt()
253+
stream = client.stream(
254+
model="openai/gpt-oss-20b",
255+
response_format=SupportTicket,
256+
input="I was charged twice this month, please refund the duplicate.",
257+
)
258+
async for event in stream:
259+
if not event.is_leaf:
260+
continue
261+
match event.field:
262+
case "intent":
263+
asyncio.create_task(dispatch_to_queue(event.value))
264+
case "urgency" if event.value == "critical":
265+
asyncio.create_task(page_oncall())
266+
case "reply":
267+
await send(event.value)
268+
269+
asyncio.run(main())
270+
```
271+
272+
The routing decision fires the moment `intent` arrives, typically tens of
273+
milliseconds in while `reply` continues to stream. If you need the full
274+
object so far (e.g. to log progress or hand a partial object to another
275+
service), use `event.snapshot`.
276+
277+
Errors:
278+
279+
- `dottxt.PatchStreamError`: raised when the gateway returns a non-200
280+
status. Exposes `status_code` and `body`.
281+
212282
## OpenAI-Compatible Text Generation
213283

214284
If you prefer the standard OpenAI SDK surface, you can call
215285
`chat.completions.create(...)` directly. The client passes the call through
216-
unchanged and returns the raw chat completion object parsing and
286+
unchanged and returns the raw chat completion object, parsing and
217287
validation are up to the caller.
218288

219289
For structured output, pass the wrapped OpenAI-style `response_format`
@@ -268,3 +338,11 @@ Runnable examples live in the [`examples/`](../examples) directory:
268338
- [`list_models.py`](../examples/list_models.py): list available models
269339
- [`openai_chat_completions.py`](../examples/openai_chat_completions.py): use
270340
the OpenAI-compatible `chat.completions.create` surface
341+
- [`stream_field_printer.py`](../examples/stream_field_printer.py): minimal
342+
`stream` demo — print each leaf field and value as it lands
343+
- [`stream_early_routing.py`](../examples/stream_early_routing.py): route on
344+
`/intent` while `/reply` is still streaming
345+
- [`stream_hitl_approval.py`](../examples/stream_hitl_approval.py): approve a
346+
proposed action mid-stream and discard the reply if the operator declines
347+
- [`stream_fanout.py`](../examples/stream_fanout.py): fan research tasks out
348+
on each `/steps/N` as the planner emits them

examples/stream_early_routing.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Route on /intent before /reply finishes.
2+
3+
The schema is ordered ``intent`` → ``urgency`` → ``reply``. Because dottxt
4+
streams fields in schema order, the routing decision fires the moment
5+
``intent`` arrives, typically tens of milliseconds in, while the model
6+
continues generating the (much longer) ``reply``.
7+
8+
What to watch in the output: the ``-> dispatched ...`` and
9+
``-> paged oncall ...`` lines arrive well before the final reply line.
10+
The elapsed-time prefix on the reply line is the punchline, how much later
11+
the full message lands compared to when routing was already settled.
12+
13+
Usage:
14+
DOTTXT_API_KEY=sk-... python examples/stream_early_routing.py
15+
"""
16+
17+
import asyncio
18+
import time
19+
from typing import Literal
20+
21+
from pydantic import BaseModel, Field
22+
23+
from dottxt import AsyncDotTxt
24+
25+
26+
class SupportTicket(BaseModel):
27+
"""A triaged support reply.
28+
29+
Field order is significant: earlier fields arrive first and unblock
30+
downstream work that does not depend on later fields.
31+
"""
32+
33+
intent: Literal["billing", "technical", "account", "feedback"]
34+
urgency: Literal["low", "medium", "high", "critical"]
35+
reply: str = Field(max_length=400)
36+
37+
38+
async def route_to_billing(ticket_id: str) -> None:
39+
"""Dispatch the ticket to the billing queue (stub)."""
40+
print(f" -> dispatched {ticket_id} to billing queue")
41+
42+
43+
async def route_to_technical(ticket_id: str) -> None:
44+
"""Dispatch the ticket to the technical queue (stub)."""
45+
print(f" -> dispatched {ticket_id} to technical queue")
46+
47+
48+
async def page_oncall(ticket_id: str) -> None:
49+
"""Page the on-call engineer (stub)."""
50+
print(f" -> paged oncall for {ticket_id}")
51+
52+
53+
async def main() -> None:
54+
"""Run the example."""
55+
ticket_id = "TKT-8821"
56+
user_message = (
57+
"I was charged twice for my subscription this month and the second "
58+
"charge doesn't appear in my invoice list. Please refund the duplicate."
59+
)
60+
61+
client = AsyncDotTxt()
62+
started = time.monotonic()
63+
try:
64+
stream = client.stream(
65+
model="openai/gpt-oss-20b",
66+
response_format=SupportTicket,
67+
input=[
68+
{
69+
"role": "system",
70+
"content": "Triage support tickets and draft a reply.",
71+
},
72+
{"role": "user", "content": user_message},
73+
],
74+
max_tokens=400,
75+
)
76+
async for event in stream:
77+
match event.field:
78+
# Fire-and-forget: routing kicks off while /reply is still
79+
# streaming.
80+
case "intent" if event.value == "billing":
81+
asyncio.create_task(route_to_billing(ticket_id))
82+
case "intent" if event.value == "technical":
83+
asyncio.create_task(route_to_technical(ticket_id))
84+
case "urgency" if event.value == "critical":
85+
asyncio.create_task(page_oncall(ticket_id))
86+
case "reply":
87+
elapsed_ms = int((time.monotonic() - started) * 1000)
88+
print(f"reply ({elapsed_ms}ms): {event.value}")
89+
finally:
90+
await client.close()
91+
92+
93+
if __name__ == "__main__":
94+
asyncio.run(main())

0 commit comments

Comments
 (0)