Skip to content

feat: streaming support for structured output - #142

Open
Meguazy wants to merge 7 commits into
datapizza-labs:mainfrom
Meguazy:main
Open

feat: streaming support for structured output#142
Meguazy wants to merge 7 commits into
datapizza-labs:mainfrom
Meguazy:main

Conversation

@Meguazy

@Meguazy Meguazy commented Jul 17, 2026

Copy link
Copy Markdown

Summary

Adds streaming support for structured output across the framework: the base Client, the Agent runner, and the provider clients (Google, OpenAI, OpenAI-like, Anthropic, Mistral).

Core (datapizza-ai-core)

  • New stream_structured_response / a_stream_structured_response on the base Client, with non-abstract provider hooks defaulting to NotImplementedError (existing subclasses keep working unchanged).
  • Chunk contract: intermediate chunks carry the raw JSON fragments in chunk.delta (content=[]); the final chunk carries the validated Pydantic instance as a StructuredBlock plus usage and stop_reason. Invalid JSON at end of stream raises pydantic.ValidationError.
  • Progressive consumption built in: every chunk exposes chunk.snapshot (JSON text accumulated so far) and chunk.partial — a lazy, best-effort parsed dict (via pydantic_core.from_json(allow_partial=...)), so apps can render fields as they arrive.
  • Agents with stream=True + output_cls now stream the JSON deltas before the final StepResult; clients without streaming-structured support fall back transparently to the blocking structured call.

Providers

Provider Mechanism
Google generate_content_stream + response_mime_type=application/json + response_schema
OpenAI Responses API responses.stream(text_format=...)
OpenAI-like chat.completions.create(stream=True) + json_schema response_format (no beta helpers, works with vLLM/Ollama/any compatible server)
Anthropic messages.create(stream=True) + output_config json_schema, reusing the existing raw-event state machine
Mistral chat.stream / chat.stream_async + response_format_from_pydantic_model (mistralai floor bumped to >=1.5.0, already required de facto by the existing chat.parse usage)

Bedrock and WatsonX are intentionally untouched: they inherit the base default and raise NotImplementedError, with the Agent fallback keeping behavior identical to before.

Testing

  • ~90 new mocked unit tests across core and provider packages (sync + async, invalid-JSON, request-params assertions, json_object sentinel), following the existing test conventions. All suites green, ruff check clean.
  • Verified end-to-end against real APIs: Google (Gemini), OpenAI (gpt-4o-mini, Responses API), Anthropic (claude-sonnet-4-5), Mistral (mistral-small-latest), and OpenAI-like against a local Ollama server — streamed deltas, progressive partial dicts, and validated final objects on both sync and async paths.

Add stream_structured_response / a_stream_structured_response to the base
Client with non-abstract provider hooks (default NotImplementedError).
Intermediate chunks carry raw JSON fragments in delta, plus the accumulated
snapshot and a lazily parsed partial dict; the final chunk carries the
validated StructuredBlock.

Agents with stream=True and output_cls now stream structured output and
fall back transparently to the blocking structured call when the client
does not implement the new hooks.
Stream via generate_content_stream with response_mime_type=application/json
and response_schema, accumulate the JSON text deltas and validate the final
buffer into the output model. Verified end-to-end against the Gemini API.
Stream via chat.completions.create(stream=True) with a json_schema
response_format built through type_to_response_format_param (same
mechanism as beta.chat.completions.parse), reusing the existing stream
delta accumulation and strict-tools handling. Accumulate the JSON text
deltas and validate the final buffer into the output model.

Verified end-to-end against a local Ollama server (qwen3.5:9b).
Stream via chat.stream / chat.stream_async with a json_schema
response_format built through response_format_from_pydantic_model,
accumulate the JSON text deltas and validate the final buffer into the
output model.

Bump mistralai floor to >=1.5.0: mistralai.extra and chat.parse (already
used by the existing structured path) do not exist in earlier releases.

Verified end-to-end against the Mistral API (mistral-small-latest).
Stream via messages.create(stream=True) with output_config json_schema
(transform_schema, the same transform used by messages.parse), reusing the
existing raw-event stream state machine. Text deltas carry the JSON
fragments; the final chunk validates the accumulated buffer into the
output model.

Verified end-to-end against the Anthropic API (claude-sonnet-4-5).
Stream via the Responses API responses.stream(text_format=...) context
manager; delta events carry the JSON fragments and the completed event
flows through _response_to_client_response, which already extracts the
parsed StructuredBlock. The json_object sentinel is handled by parsing
output_text directly.

Verified end-to-end against the OpenAI API (gpt-4o-mini).
## Summary

Adds streaming support for structured output across the framework: the base `Client`, the Agent runner, and the provider clients (Google, OpenAI, OpenAI-like, Anthropic, Mistral).

### Core (`datapizza-ai-core`)

- New `stream_structured_response` / `a_stream_structured_response` on the base `Client`, with non-abstract provider hooks defaulting to `NotImplementedError` (existing subclasses keep working unchanged).
- Chunk contract: intermediate chunks carry the raw JSON fragments in `chunk.delta` (`content=[]`); the final chunk carries the validated Pydantic instance as a `StructuredBlock` plus `usage` and `stop_reason`. Invalid JSON at end of stream raises `pydantic.ValidationError`.
- Progressive consumption built in: every chunk exposes `chunk.snapshot` (JSON text accumulated so far) and `chunk.partial` — a lazy, best-effort parsed dict (via `pydantic_core.from_json(allow_partial=...)`), so apps can render fields as they arrive.
- Agents with `stream=True` + `output_cls` now stream the JSON deltas before the final `StepResult`; clients without streaming-structured support fall back transparently to the blocking structured call.

### Providers

| Provider | Mechanism |
|---|---|
| Google | `generate_content_stream` + `response_mime_type=application/json` + `response_schema` |
| OpenAI | Responses API `responses.stream(text_format=...)` |
| OpenAI-like | `chat.completions.create(stream=True)` + json_schema `response_format` (no beta helpers, works with vLLM/Ollama/any compatible server) |
| Anthropic | `messages.create(stream=True)` + `output_config` json_schema, reusing the existing raw-event state machine |
| Mistral | `chat.stream` / `chat.stream_async` + `response_format_from_pydantic_model` (mistralai floor bumped to `>=1.5.0`, already required de facto by the existing `chat.parse` usage) |

Bedrock and WatsonX are intentionally untouched: they inherit the base default and raise `NotImplementedError`, with the Agent fallback keeping behavior identical to before.

## Testing

- ~90 new mocked unit tests across core and provider packages (sync + async, invalid-JSON, request-params assertions, `json_object` sentinel), following the existing test conventions. All suites green, `ruff check` clean.
- Verified end-to-end against real APIs: Google (Gemini), OpenAI (`gpt-4o-mini`, Responses API), Anthropic (`claude-sonnet-4-5`), Mistral (`mistral-small-latest`), and OpenAI-like against a local Ollama server — streamed deltas, progressive `partial` dicts, and validated final objects on both sync and async paths.
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.

2 participants