Skip to content

feat(translation): preserve raw stream events - #192

Open
bbednarski9 wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
bbednarski9:feat/translation-preserve-raw-stream-events
Open

feat(translation): preserve raw stream events#192
bbednarski9 wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
bbednarski9:feat/translation-preserve-raw-stream-events

Conversation

@bbednarski9

@bbednarski9 bbednarski9 commented Jul 29, 2026

Copy link
Copy Markdown

What

This PR adds a provider-event envelope to Switchyard's streaming protocol so a host can send a provider response through libsy::Algorithm::run_stream without discarding provider-specific JSON fields.

The implementation:

  • adds LlmResponseChunk::ProviderEvent { source, raw, normalized } to switchyard-protocol;
  • exposes preserving decode and encode operations through TranslationEngine and the built-in stream helpers;
  • replays raw when the source and target formats match;
  • encodes normalized children when the target format differs;
  • teaches LlmResponse::into_agg and ResponseAccumulator to recursively fold normalized children while preserving typed decode and upstream-stream errors; and
  • covers OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages with same-format and cross-format contract tests.

This is intentionally limited to the streaming translation contract.

Lifecycle

sequenceDiagram
    participant Provider
    participant Host as Host / Relay
    participant Libsy as libsy run_stream
    participant Caller

    Provider-->>Host: Raw JSON stream event<br/>text delta + provider extension
    Host->>Host: Decode to ProviderEvent<br/>{ source, raw, normalized }
    Host-->>Libsy: Respond to CallLlm with stream
    Note over Libsy: Algorithms can aggregate or inspect<br/>the normalized meaning
    Libsy-->>Host: ReturnToAgent(stream)

    alt Caller format matches provider format
        Host-->>Caller: Replay the preserved raw JSON event
    else Caller format differs
        Host->>Host: Encode normalized children<br/>into caller format
        Host-->>Caller: Cross-format stream event
    end
Loading

Why

run_stream deliberately gives the embedding host control of provider dispatch. The host fulfills each CallLlm, but the resulting stream still passes back through libsy so an algorithm can inspect it, aggregate it, or select it as the final response.

Before this change, decoding a provider event produced only provider-neutral chunks. That is sufficient for cross-format translation, but it cannot faithfully replay a same-format stream after the libsy round trip.

For example, an OpenAI-compatible provider may emit:

{
  "id": "chatcmpl-1",
  "object": "chat.completion.chunk",
  "system_fingerprint": "fp_abc",
  "choices": [
    {
      "index": 0,
      "delta": {"content": "Hello"},
      "finish_reason": null
    }
  ]
}

Without the envelope, Switchyard normalizes this to approximately:

LlmResponseChunk::TextDelta {
    index: 0,
    text: "Hello".into(),
}

When that chunk returns from libsy, the encoder can reconstruct the text delta but has no representation for system_fingerprint; the field is silently lost. With ProviderEvent, the normalized TextDelta remains available to algorithms while the complete parsed JSON value remains available for same-format replay.

This enables library-first integrations such as NeMo Relay to:

  1. run a Switchyard algorithm in process;
  2. perform the actual provider call in the host;
  3. return the real response stream through CallLlmRequest::respond;
  4. let libsy continue through ReturnToAgent; and
  5. preserve provider extensions when the final wire format is unchanged.

For cross-format routes, only fields represented by the neutral IR are translated. In the example above, OpenAI system_fingerprint is intentionally absent from an Anthropic Messages output because Anthropic has no corresponding field.

Boundaries and compatibility

  • Preservation is exact for the parsed serde_json::Value, not for the original SSE bytes. Comments, SSE id fields, whitespace, and JSON key ordering are not retained by this envelope.
  • Provider-only fields are replayed only for same-format output. They are intentionally not injected into a different provider protocol.
  • Adding a public enum variant requires downstream exhaustive LlmResponseChunk matches to handle ProviderEvent.
  • Algorithms using aggregate conversion continue to see the normalized content because aggregation recursively folds normalized children.

Closes: N/A — this is an integration-discovered library contract gap.

How tested

  • uv run ruff check . clean — not applicable; no Python changed
  • uv run mypy switchyard clean — not applicable; no Python changed
  • uv run pytest tests/ green — not applicable; no Python changed
  • Manual smoke (described below)

Rust validation:

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test -p switchyard-translation
  • cargo test --workspace --exclude switchyard-py

The non-PyO3 workspace suite passes. A local cargo test --workspace attempt reached switchyard-py but could not link the macOS test artifact because the local linker did not resolve Python symbols; no Rust test failed before that link step.

Integration validation used NeMo Relay as the host-side dispatcher with no Switchyard service or switchyard-llm-client. Buffered and streaming routes were exercised across OpenAI Chat, OpenAI Responses, and Anthropic Messages caller APIs, using NVIDIA Inference Hub, Anthropic, and Ollama targets.

Checklist

  • One class per file; filename = snake_case of the primary class. — not applicable; Rust-only change
  • New public symbols exported from switchyard/__init__.py.__all__ if intended for downstream use. — not applicable; Rust crate API
  • Unit tests added for new components / bug fixes.
  • README / --help updated if customer-facing surface changed. — not required; no CLI/configuration change
  • Commits signed off (Signed-off-by: Your Name <email>) per the DCO.

Notes for reviewers

Please focus on the ownership boundary of raw versus normalized and on whether a public ProviderEvent variant is the preferred representation before Switchyard 0.2 stabilizes. The same-format path never reconstructs an event from the normalized IR; the cross-format path never copies unknown provider fields into the target protocol.

The direct built-in helper surface and TranslationEngine surface are both covered so callers do not get different preservation semantics depending on which public translation entry point they use.

Summary by CodeRabbit

  • New Features

    • Added streaming event preservation, allowing same-format events to be replayed with their original provider-specific data intact.
    • Added streaming decode and encode APIs for translating individual events between supported formats.
    • Improved cross-format streaming translation using normalized event content.
    • Added support for provider-specific streaming events and nested normalized chunks.
  • Bug Fixes

    • Improved handling and reporting of errors occurring during streamed provider events.

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@bbednarski9
bbednarski9 marked this pull request as ready for review July 30, 2026 00:24
@bbednarski9
bbednarski9 requested a review from a team as a code owner July 30, 2026 00:24
@bbednarski9

Copy link
Copy Markdown
Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Provider Event Streaming

Layer / File(s) Summary
Provider event aggregation
crates/protocol/src/stream.rs
Adds provider-event chunks with raw and normalized data, recursively aggregates normalized children, translates nested stream errors, and tests both behaviors.
Preserving stream translation APIs
crates/switchyard-translation/src/codecs/stream.rs, crates/switchyard-translation/src/engine.rs
Adds streaming decode and encode APIs that preserve same-format events and recursively translate normalized chunks across formats.
Provider codec handling and tests
crates/switchyard-translation/src/codecs/*/stream.rs, crates/switchyard-translation/tests/stream_translation.rs
Adds provider-specific passthrough or normalized encoding for Anthropic, OpenAI Chat, and OpenAI Responses, with round-trip and cross-format coverage.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Poem

I’m a rabbit with events in my ears,
Keeping raw streams safe through the years.
Normalized hops cross each codec’s lane,
Errors get names, then vanish like rain.
Same-source crumbs replay bright and clear!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: preserving raw stream events in translation.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/switchyard-translation/tests/stream_translation.rs (1)

16-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add concise comments documenting the preservation contracts under test.

  • crates/switchyard-translation/tests/stream_translation.rs#L16-L69: note that equality is for the parsed JSON value replayed to the same provider format.
  • crates/switchyard-translation/tests/stream_translation.rs#L71-L97: note that cross-format encoding must discard provider-specific fields and use normalized events.

As per coding guidelines, Rust changes require concise comments for important test behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-translation/tests/stream_translation.rs` around lines 16 -
69, Add concise comments documenting the two preservation contracts in
stream_translation.rs: at lines 16-69, clarify that same-format replay compares
the parsed JSON value emitted back to the same provider format; at lines 71-97,
clarify that cross-format encoding uses normalized events and discards
provider-specific fields.

Source: Coding guidelines

crates/protocol/src/stream.rs (1)

78-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the raw-replay versus normalized-routing invariant.

  • crates/protocol/src/stream.rs#L78-L101: explain recursive error propagation through normalized children.
  • crates/switchyard-translation/src/engine.rs#L298-L316: document same-format raw replay versus cross-format normalized encoding.
  • crates/switchyard-translation/src/codecs/anthropic/stream.rs#L129-L137: annotate the ProviderEvent routing branch.
  • crates/switchyard-translation/src/codecs/openai_chat/stream.rs#L157-L165: annotate the ProviderEvent routing branch.
  • crates/switchyard-translation/src/codecs/responses/stream.rs#L157-L165: annotate the ProviderEvent routing branch.

As per coding guidelines, Rust changes require concise comments for non-obvious private helpers and complex routing logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/protocol/src/stream.rs` around lines 78 - 101, Document the raw-replay
versus normalized-routing invariant with concise comments: in
crates/protocol/src/stream.rs lines 78-101, explain that push_checked_chunk
recursively processes normalized ProviderEvent children and propagates their
errors; in crates/switchyard-translation/src/engine.rs lines 298-316, describe
raw replay for same-format responses versus normalized encoding across formats;
and annotate the ProviderEvent routing branches in
crates/switchyard-translation/src/codecs/anthropic/stream.rs lines 129-137,
crates/switchyard-translation/src/codecs/openai_chat/stream.rs lines 157-165,
and crates/switchyard-translation/src/codecs/responses/stream.rs lines 157-165.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/protocol/src/stream.rs`:
- Around line 103-108: The documentation overstates preservation as exact or
lossless replay; describe it as retaining and replaying parsed JSON values only.
Update the documentation in crates/protocol/src/stream.rs lines 103-108,
crates/switchyard-translation/src/codecs/stream.rs line 217, and
crates/switchyard-translation/src/engine.rs lines 247 and 264-268 to use
parsed-event, parsed-value, and structural JSON-value replay wording, while
preserving the existing API behavior.

---

Nitpick comments:
In `@crates/protocol/src/stream.rs`:
- Around line 78-101: Document the raw-replay versus normalized-routing
invariant with concise comments: in crates/protocol/src/stream.rs lines 78-101,
explain that push_checked_chunk recursively processes normalized ProviderEvent
children and propagates their errors; in
crates/switchyard-translation/src/engine.rs lines 298-316, describe raw replay
for same-format responses versus normalized encoding across formats; and
annotate the ProviderEvent routing branches in
crates/switchyard-translation/src/codecs/anthropic/stream.rs lines 129-137,
crates/switchyard-translation/src/codecs/openai_chat/stream.rs lines 157-165,
and crates/switchyard-translation/src/codecs/responses/stream.rs lines 157-165.

In `@crates/switchyard-translation/tests/stream_translation.rs`:
- Around line 16-69: Add concise comments documenting the two preservation
contracts in stream_translation.rs: at lines 16-69, clarify that same-format
replay compares the parsed JSON value emitted back to the same provider format;
at lines 71-97, clarify that cross-format encoding uses normalized events and
discards provider-specific fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: f57ca300-512e-41f4-92bf-4be3ad0a3101

📥 Commits

Reviewing files that changed from the base of the PR and between 27a9af0 and 773a451.

📒 Files selected for processing (7)
  • crates/protocol/src/stream.rs
  • crates/switchyard-translation/src/codecs/anthropic/stream.rs
  • crates/switchyard-translation/src/codecs/openai_chat/stream.rs
  • crates/switchyard-translation/src/codecs/responses/stream.rs
  • crates/switchyard-translation/src/codecs/stream.rs
  • crates/switchyard-translation/src/engine.rs
  • crates/switchyard-translation/tests/stream_translation.rs

Comment on lines +103 to +108
/// One streaming event carried between a host and an algorithm.
///
/// Normalized variants expose provider-neutral meaning. [`ProviderEvent`](Self::ProviderEvent)
/// additionally retains exact source JSON for a lossless same-format round trip while keeping
/// normalized children available to algorithms and cross-format encoders.
/// `switchyard-translation` re-exports this type as `ConversationStreamEvent`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Describe preservation as parsed-JSON-value replay, not lossless replay.

The PR contract excludes original SSE bytes, comments, IDs, whitespace, and key ordering, so “exact” and “lossless” can mislead API consumers.

  • crates/protocol/src/stream.rs#L103-L108: state that raw retains the parsed JSON value.
  • crates/switchyard-translation/src/codecs/stream.rs#L217-L217: replace “exact source JSON” with parsed-event wording.
  • crates/switchyard-translation/src/engine.rs#L247-L247: qualify retained JSON as a parsed value.
  • crates/switchyard-translation/src/engine.rs#L264-L268: qualify replay as structural JSON-value replay.

Based on PR objectives, preservation is limited to parsed JSON values rather than original SSE representation.

📍 Affects 3 files
  • crates/protocol/src/stream.rs#L103-L108 (this comment)
  • crates/switchyard-translation/src/codecs/stream.rs#L217-L217
  • crates/switchyard-translation/src/engine.rs#L247-L247
  • crates/switchyard-translation/src/engine.rs#L264-L268
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/protocol/src/stream.rs` around lines 103 - 108, The documentation
overstates preservation as exact or lossless replay; describe it as retaining
and replaying parsed JSON values only. Update the documentation in
crates/protocol/src/stream.rs lines 103-108,
crates/switchyard-translation/src/codecs/stream.rs line 217, and
crates/switchyard-translation/src/engine.rs lines 247 and 264-268 to use
parsed-event, parsed-value, and structural JSON-value replay wording, while
preserving the existing API behavior.

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.

1 participant