Skip to content

Add JSON Patch to SDK#3

Open
kpwtxt wants to merge 2 commits into
mainfrom
kpwtxt/gtm-744-json-patch-sdk
Open

Add JSON Patch to SDK#3
kpwtxt wants to merge 2 commits into
mainfrom
kpwtxt/gtm-744-json-patch-sdk

Conversation

@kpwtxt
Copy link
Copy Markdown
Contributor

@kpwtxt kpwtxt commented May 22, 2026

No description provided.

@kpwtxt kpwtxt force-pushed the kpwtxt/gtm-744-json-patch-sdk branch from 9c8d18e to 602c697 Compare May 22, 2026 03:04
@kpwtxt kpwtxt force-pushed the kpwtxt/gtm-744-json-patch-sdk branch from 602c697 to ff64ec5 Compare May 22, 2026 03:18
@kpwtxt kpwtxt requested review from RobinPicard, aschwa and rlouf May 22, 2026 12:34
Comment thread src/dottxt/client.py
seed: int | None = None,
timeout: float = 60.0,
extra: dict[str, Any] | None = None,
) -> Any:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could type this as AsyncIterator[tuple[str, Any]]

Comment thread src/dottxt/streaming.py
return None
if path == "":
return None
field = path[1:] if path.startswith("/") else path
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we JSON Pointer-unescape the rest of the path after stripping the leading /? (RFC 6901: ~1 -> /, ~0 -> ~)?

Or is that handled server side?


try:
async for field, value in client.stream_fields(
model="openai/gpt-oss-20b",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got inconsistent behavior on this example which is likely model related

Copy link
Copy Markdown

@aschwa aschwa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slick examples! A few clarifying nits

Comment thread LICENSE
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's already a LICENSE file on main, why is this showing up?

Comment thread src/dottxt/streaming.py
return field, value


async def stream_fields(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would call it stream, and the alternative stream_tokens instead.

Comment thread src/dottxt/streaming.py
seed: int | None = None,
timeout: float = 60.0,
extra: dict[str, Any] | None = None,
) -> AsyncIterator[tuple[str, Any]]:
Copy link
Copy Markdown
Member

@rlouf rlouf May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not yield the patch (or probably better, the patch and reconstructed object so far)? JSON Patch is simple, very well documented and already has an ecosystem.

Comment thread src/dottxt/streaming.py
Comment on lines +93 to +113
async with client.stream("POST", url, json=body, headers=headers) as resp:
if resp.status_code != 200:
detail = (await resp.aread()).decode("utf-8", errors="replace")
raise PatchStreamError(status_code=resp.status_code, body=detail)
buf = ""
async for chunk in resp.aiter_text():
buf += chunk
while "\n" in buf:
line, buf = buf.split("\n", 1)
line = line.strip()
if not line:
continue
op = json.loads(line)
pair = _field_from_op(op)
if pair is not None:
yield pair
if buf.strip():
op = json.loads(buf)
pair = _field_from_op(op)
if pair is not None:
yield pair
Copy link
Copy Markdown
Member

@rlouf rlouf May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to stream the response here? All we care about is the final JSON Patch object, the user does not see anything else.

Comment thread src/dottxt/streaming.py
Comment on lines +71 to +85
if isinstance(input, str):
input = [{"role": "user", "content": input}]
body: dict[str, Any] = {
**(extra or {}),
"model": model,
"messages": input,
"response_format": build_response_format(response_format),
"stream": "patch",
}
if temperature is not None:
body["temperature"] = temperature
if max_tokens is not None:
body["max_tokens"] = max_tokens
if seed is not None:
body["seed"] = seed
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can some of this code be extracted in a common utility with the non-streaming generation?

Comment thread examples/stream_fanout.py
Comment on lines +46 to +56
async for field, value in client.stream_fields(
model="openai/gpt-oss-20b",
response_format=Plan,
input=(
"Plan three to five research steps to answer the question: "
"'What are the trade-offs between RAG and fine-tuning for "
"domain-specific assistants?' Each step should be a short "
"imperative sentence."
),
max_tokens=400,
):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very much a nit, but I'd separate stream = client.stream(...) and async for ... in stream

Comment thread examples/stream_fanout.py
time step 1 lands.

Usage:
DOTTXT_API_KEY=sk-... python examples/stream_fanout.py
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain what the user should pay attention to (total time < sum of individual times?) and why

Comment on lines +81 to +83
elapsed_ms = int((time.monotonic() - started) * 1000)
if verbose:
print(f"[t+{elapsed_ms:>5}ms] {field}: {value!r}")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd strip this off so there is less noise. My understanding is that we mostly want the user to see the succession of " -> did something" statements, instead of a monolithic one at the end?

Comment on lines +85 to +90
case "intent":
handler = HANDLERS.get(value)
if handler is not None:
# Fire-and-forget: routing kicks off while /reply is
# still streaming.
asyncio.create_task(handler(ticket_id))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use the explicit version without using a handler.

case "intent" if value == "billing":
case "intent" if value == "technical":

Comment thread docs/client.md
Comment on lines +264 to +265
The routing decision fires the moment `intent` arrives — typically tens of
milliseconds in — while `reply` continues to stream.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The routing decision fires the moment `intent` arrives — typically tens of
milliseconds in — while `reply` continues to stream.
The routing decision fires the moment `intent` arrives while `reply` continues to stream.

Comment thread docs/client.md
`AsyncDotTxt.stream_fields(...)` yields `(field, value)` tuples as the model
fills in a schema-constrained response, one leaf at a time. It is built on the
gateway's `stream: "patch"` mode, which emits RFC 6902 JSON Patch operations
in schema order — so downstream work can start the moment a field arrives,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
in schema order so downstream work can start the moment a field arrives,
in schema order so downstream work can start the moment a field arrives,

@@ -0,0 +1,93 @@
"""Mid-stream human approval — no checkpointer, no resume.
Copy link
Copy Markdown
Member

@rlouf rlouf May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really cool example

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants