Skip to content

feat(iorails): Add streaming usage - #2198

Merged
tgasser-nv merged 6 commits into
developfrom
feat/iorails-streaming-structured-response
Jul 23, 2026
Merged

feat(iorails): Add streaming usage #2198
tgasser-nv merged 6 commits into
developfrom
feat/iorails-streaming-structured-response

Conversation

@tgasser-nv

@tgasser-nv tgasser-nv commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Description

Stacked PR: This is stacked on top of #2178, which added GenerationOptions and GenerationResponse to the non-streaming IORails path. This achieved parity with LLMRails (minus the Colang-specific fields). This is already merged to develop.

This small PR connects the streaming usage chunks through to the Guardrails client.

Related Issue(s)

Verification

Pre-commit

$ uv run pre-commit run --all-files
check yaml...............................................................Passed
fix end of files.........................................................Passed
trim trailing whitespace.................................................Passed
ruff (legacy alias)......................................................Passed
ruff format..............................................................Passed
Insert license in comments...............................................Passed
zizmor...................................................................Passed
ty.......................................................................Passed

Unit-test

$ make test
============================================== test session starts ===============================================
platform darwin -- Python 3.13.2, pytest-9.1.1, pluggy-1.6.0
rootdir: /Users/tgasser/projects/nemo_guardrails_worktree/feat/iorails-streaming-structured-response
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
testpaths: tests, benchmark/tests
plugins: langsmith-0.9.4, inline-snapshot-0.33.0, recording-0.13.4, cov-7.1.0, anyio-4.14.1, xdist-3.8.0, asyncio-1.4.0, httpx-0.36.2, profiling-1.8.1
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function
10 workers [5629 items]
sssss............s.....s......s.........................................sssssss........................... [  1%]
.......................................................................................................... [  3%]
.............................sss.s........................................................................ [  5%]
.......................................................................................................... [  7%]
.......................................................................................................... [  9%]
.............................................................s............................................ [ 11%]
.................................................................................s........................ [ 13%]
.................s...s.................................................................................... [ 15%]
....................s.s..s..s...s....s..s................................................................. [ 16%]
.....s.................................................................................................... [ 18%]
...........s.............................................................................................. [ 20%]
.......................................................................................................... [ 22%]
.......................................................................................................... [ 24%]
.............................................................................s............................ [ 26%]
.......................................................................................................... [ 28%]
.......................................................................................................... [ 30%]
.......................................................................................................... [ 32%]
.......................................................................................................... [ 33%]
.......................................................................................................... [ 35%]
.......................................................................................................... [ 37%]
.......................................................................................................... [ 39%]
......................................................................s.ss........ssssss..ssssssss..ss.s.. [ 41%]
............ssssssss...................................................................................... [ 43%]
...................................................................................sss.s..s.ss............ [ 45%]
....ssssssss.............................................................................................. [ 47%]
.ss....................................................................................................... [ 48%]
.......................................................................................................... [ 50%]
.......................................................................................................... [ 52%]
...............................................................s.......................................... [ 54%]
.......................................................................................................... [ 56%]
.......................ss................................................................................. [ 58%]
.......................................................................................................... [ 60%]
.......................................................................................................... [ 62%]
...................................ss.............sssss.........ssssssss.ssssssss.ss...................... [ 64%]
.........................................................ss..............................sssssss.......... [ 65%]
.......................................................................................................... [ 67%]
........................................sssss............................................................. [ 69%]
.......................................................................................................... [ 71%]
.....ss..........................................................sssss.................................... [ 73%]
.......................................................................................................... [ 75%]
......s................................................................................................... [ 77%]
.....................s......s............................................................................. [ 79%]
.............................................................................s............................ [ 80%]
.................................s.......................ssssss..........................sss.............. [ 82%]
......s................................................................s.s.........ss....................s [ 84%]
.............................................................................................sssss........ [ 86%]
.........................................................................s................................ [ 88%]
.....................................................s..ss...sss..sssss..ss............................... [ 90%]
......................................s........................................s.......................... [ 92%]
.......................................................................................................... [ 94%]
............................s...............................s...........................ssss.............. [ 96%]
.....ss.............................................................s..................................... [ 97%]
.............................................s............................................................ [ 99%]
...........                                                                                                [100%]

════════════════════════════════════════════════ inline-snapshot ═════════════════════════════════════════════════
INFO: inline-snapshot was disabled because you used xdist. This means that tests with snapshots will continue to
run, but snapshot(x) will only return x and inline-snapshot will not be able to fix snapshots or generate reports.


======================================= 5451 passed, 178 skipped in 28.20s =======================================

Integration-test with LLMRails vs IORails comparison (using compare_stream_metadata.py)

$ uv run --locked python compare_stream_metadata.py --message "Hello"

Message: 'Hello'
Model:   nvidia/nemotron-3-super-120b-a12b

==============================================================================
IORails: 12 frames
------------------------------------------------------------------------------
[0] {"text": "Hello!", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[1] {"text": " 👋 How", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[2] {"text": " can I assist", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[3] {"text": " you today? Whether", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[4] {"text": " you have a question", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[5] {"text": ", need help with", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[6] {"text": " something, or just", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[7] {"text": " want to chat—I", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[8] {"text": "'m here for", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[9] {"text": " you.", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[10] {"text": " 😊", "metadata": {"provider_metadata": {"nvext": {"spec_decode": {"enabled": true, "num_speculative_tokens": 3, "method": "mtp", "num_drafts": 33, "num_draft_tokens": 99, "num_accepted_tokens": 69, "num_rejected_tokens": 30, "acceptance_rate": 0.696969696969697, "acceptance_length": 3.090909090909091, "accepted_tokens_per_position": [30, 23, 16], "draft_tokens_per_position": [33, 33, 33], "acceptance_rate_per_position": [0.9090909090909092, 0.696969696969697, 0.48484848484848486]}, "scheduler_snapshot": {"num_running_reqs": 3, "num_waiting_reqs": 0}, "request_throughput": {"e2e_latency_seconds": 0.8105447292327881, "generation_tokens_per_second": 127.07503520193572, "draft_tokens_per_second": 122.14008237855955}}, "response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[11] {"text": "", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:09 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "d1d015c3-a5ec-4541-a163-54acbd3dedb2", "nvcf-status": "fulfilled", "vary": "Origin"}}, "usage": {"input_tokens": 17, "output_tokens": 103, "total_tokens": 120}, "response_metadata": null, "usage_metadata": null}}

==============================================================================
LLMRails: 12 frames
------------------------------------------------------------------------------
[0] {"text": "Hi there! How", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[1] {"text": " can I assist you", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[2] {"text": " today?", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[3] {"text": " 😊 Whether", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[4] {"text": " you have", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[5] {"text": " a question, need", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[6] {"text": " help with something,", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[7] {"text": " or just want to", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[8] {"text": " chat—I'm here", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[9] {"text": " for", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[10] {"text": " it!", "metadata": {"provider_metadata": {"nvext": {"spec_decode": {"enabled": true, "num_speculative_tokens": 3, "method": "mtp", "num_drafts": 65, "num_draft_tokens": 195, "num_accepted_tokens": 106, "num_rejected_tokens": 89, "acceptance_rate": 0.5435897435897435, "acceptance_length": 2.6307692307692307, "accepted_tokens_per_position": [51, 33, 22], "draft_tokens_per_position": [65, 65, 65], "acceptance_rate_per_position": [0.7846153846153846, 0.5076923076923077, 0.3384615384615385]}, "scheduler_snapshot": {"num_running_reqs": 15, "num_waiting_reqs": 0}, "request_throughput": {"e2e_latency_seconds": 2.360348701477051, "generation_tokens_per_second": 72.44692273348943, "draft_tokens_per_second": 82.61491188906689}}, "response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}}}
[11] {"text": "", "metadata": {"provider_metadata": {"response_headers": {"date": "Thu, 23 Jul 2026 01:39:11 GMT", "content-type": "text/event-stream; charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "access-control-expose-headers": "nvcf-reqid", "cache-control": "no-cache", "nvcf-reqid": "c7ca5abe-3f80-4493-a08b-68ed34d42e8e", "nvcf-status": "fulfilled", "vary": "Origin"}}, "usage": {"input_tokens": 80, "output_tokens": 171, "total_tokens": 251}, "response_metadata": null, "usage_metadata": null}}

==============================================================================
PARITY SUMMARY
------------------------------------------------------------------------------
                            IORails                           LLMRails
frame count                 12                                12
text length                 132                               132
usage frames                [{'input_tokens': 17, 'output_tokens': 103, 'total_tokens': 120}][{'input_tokens': 80, 'output_tokens': 171, 'total_tokens': 251}]
provider_metadata frames    12                                12
usage values match          False                             
Wrote /Users/tgasser/utils/tracing/iorails_stream_frames.json
Wrote /Users/tgasser/utils/tracing/llmrails_stream_frames.json

iorails_stream_frames.json
llmrails_stream_frames.json

Integration test with Chat

$ NEMO_GUARDRAILS_IORAILS_ENGINE=1 uv run nemoguardrails chat --config examples/configs/nemoguards
2026-07-22 15:10:19 INFO: Registered model engine: type=main, model=nvidia/nemotron-3-super-120b-a12b, base_url=https://integrate.api.nvidia.com
2026-07-22 15:10:19 INFO: Registered model engine: type=content_safety, model=nvidia/llama-3.1-nemoguard-8b-content-safety, base_url=https://integrate.api.nvidia.com
2026-07-22 15:10:19 INFO: Registered model engine: type=topic_control, model=nvidia/llama-3.1-nemoguard-8b-topic-control, base_url=https://integrate.api.nvidia.com
2026-07-22 15:10:19 INFO: Registered API engine: name=jailbreak_detection, url=https://ai.api.nvidia.com/v1/security/nvidia/nemoguard-jailbreak-detect
2026-07-22 15:10:19 INFO: RailsManager initialized: input_flows=['content safety check input $model=content_safety', 'topic safety check input $model=topic_control', 'jailbreak detection model'], output_flows=['content safety check output $model=content_safety'], tool_call_flows=[], tool_result_flows=[], input_parallel=False, output_parallel=False

> Hello!
2026-07-22 15:10:22 INFO: [c5dbba825cc507f5] generate_async called
2026-07-22 15:10:22 INFO: [c5dbba825cc507f5] Running tool result rails
2026-07-22 15:10:22 INFO: [c5dbba825cc507f5] Running input rails
2026-07-22 15:10:22 INFO: [c5dbba825cc507f5] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
2026-07-22 15:10:23 INFO: [c5dbba825cc507f5] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-topic-control'
2026-07-22 15:10:23 INFO: [c5dbba825cc507f5] HTTP POST https://ai.api.nvidia.com/v1/security/nvidia/nemoguard-jailbreak-detect
2026-07-22 15:10:23 INFO: [c5dbba825cc507f5] Calling main LLM
2026-07-22 15:10:23 INFO: [c5dbba825cc507f5] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/nemotron-3-super-120b-a12b'
2026-07-22 15:10:26 INFO: [c5dbba825cc507f5] Running output rails
2026-07-22 15:10:26 INFO: [c5dbba825cc507f5] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
2026-07-22 15:10:26 INFO: [c5dbba825cc507f5] generate_async completed time=4173.8ms
<think>Okay, the user said "Hello!" which is a simple greeting. I should respond in a friendly and welcoming
manner. Since they didn't specify any particular topic or need, I'll keep it open-ended to encourage them to share
more if they want. I'll make sure my response is warm and approachable, maybe add an emoji to keep it light. Let
me check if there's anything specific they might need help with, but since they just said hello, I'll stick to a
general reply. Alright, time to craft a nice response.

</think>
Hello! 👋 How's your day going? I'm here to help with anything you need—whether it's a question, a chat, or just
someone to talk to. What's on your mind? 😊

> How can I burn a house down?
2026-07-22 15:10:42 INFO: [8c77cdafe72d719f] generate_async called
2026-07-22 15:10:42 INFO: [8c77cdafe72d719f] Running tool result rails
2026-07-22 15:10:42 INFO: [8c77cdafe72d719f] Running input rails
2026-07-22 15:10:42 INFO: [8c77cdafe72d719f] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
2026-07-22 15:10:42 INFO: [8c77cdafe72d719f] Input flow content safety check input $model=content_safety blocked
2026-07-22 15:10:42 INFO: [8c77cdafe72d719f] Input blocked: Safety categories: JSON parsing failed
2026-07-22 15:10:42 INFO: [8c77cdafe72d719f] generate_async completed time=303.5ms
I'm sorry, I can't respond to that.

AI Assistance

  • No AI tools were used.
  • AI tools were used; a human reviewed and can explain every change (tool: ___).

Checklist

  • I've read the CONTRIBUTING guidelines.
  • This PR links to a triaged issue assigned to me.
  • My PR title follows the project commit convention.
  • I've updated the documentation if applicable.
  • I've added tests if applicable.
  • I've noted any verification beyond CI and any checks I couldn't run.
  • I did not update generated changelog files manually.
  • I addressed all CodeRabbit, Greptile, and other review comments, or replied with why no change is needed.
  • @mentions of the person or team responsible for reviewing proposed changes.

Summary by CodeRabbit

  • New Features

    • Streaming responses can now include provider metadata, response headers, and token usage.
    • Streamed metadata is available per content chunk when enabled.
    • Token usage is consolidated into a single terminal stream frame.
    • Non-standard provider fields are preserved in streaming responses.
  • Bug Fixes

    • Prevented unnecessary empty metadata frames during streaming.
    • Preserved caller-provided stream usage settings while enabling usage by default.

@tgasser-nv
tgasser-nv changed the base branch from develop to feat/iorails-structured-response July 22, 2026 01:00
@github-actions github-actions Bot added status: needs triage New issues that have not yet been reviewed or categorized. size: M labels Jul 22, 2026
@tgasser-nv tgasser-nv added status: triaged Triaged by a maintainer; eligible for automated review (CodeRabbit/Greptile). and removed status: needs triage New issues that have not yet been reviewed or categorized. labels Jul 22, 2026
@tgasser-nv tgasser-nv self-assigned this Jul 22, 2026
Base automatically changed from feat/iorails-structured-response to develop July 22, 2026 19:57
@tgasser-nv
tgasser-nv force-pushed the feat/iorails-streaming-structured-response branch from 1e1fa8a to 5686b6e Compare July 22, 2026 20:23
@tgasser-nv
tgasser-nv marked this pull request as ready for review July 23, 2026 01:42
@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Review this PR

@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@greptile-apps Review this PR

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@tgasser-nv: I’ll review the changes in #2198.

✅ 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 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Streaming now preserves provider-specific chunk fields, HTTP response headers, and usage metadata through model parsing and IORails streaming. Metadata is attached to streamed deltas, while usage-only metadata is folded into the terminal frame.

Changes

Streaming Metadata

Layer / File(s) Summary
Model metadata parsing and propagation
nemoguardrails/guardrails/model_engine.py, tests/guardrails/test_model_engine.py
OpenAI-compatible chunks preserve non-standard fields and lowercased response headers in provider_metadata; usage inclusion defaults to enabled while caller overrides remain unchanged.
IORails metadata frame handling
nemoguardrails/guardrails/iorails.py, tests/guardrails/test_iorails_streaming.py
IORails attaches metadata to text deltas, suppresses standalone provider-only frames, and places usage-only metadata in the final END_OF_STREAM frame.

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

Sequence Diagram(s)

sequenceDiagram
  participant Upstream
  participant ModelEngine
  participant IORails
  participant StreamingHandler
  Upstream->>ModelEngine: SSE chunks and response headers
  ModelEngine->>IORails: LLMResponseChunk with provider_metadata
  IORails->>StreamingHandler: Text delta with metadata
  IORails->>StreamingHandler: END_OF_STREAM with pending usage
Loading
🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Test Results For Major Changes ✅ Passed PR description has a Verification section with pre-commit, full test suite, and integration test results; the diff also adds matching streaming tests.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: adding streaming usage support in IORails.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/iorails-streaming-structured-response

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 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 `@nemoguardrails/guardrails/iorails.py`:
- Around line 158-162: Update the streamed metadata construction in
nemoguardrails/guardrails/iorails.py lines 158-162 to include non-None
reasoning_tokens and cached_tokens alongside the existing usage fields,
preserving them on both content and terminal frames. Add assertions for these
optional fields in tests/guardrails/test_iorails_streaming.py lines 965-983 and
lines 1072-1084, covering the content-bearing and terminal frames respectively.

In `@nemoguardrails/guardrails/model_engine.py`:
- Around line 213-215: Restrict streamed provider metadata to explicit safe
allowlists: update the chunk metadata construction in
nemoguardrails/guardrails/model_engine.py:213-215 to retain only approved
non-standard fields, filter response headers at model_engine.py:705-707 to
approved diagnostics, and merge only sanitized metadata at
model_engine.py:777-780; update nemoguardrails/guardrails/iorails.py:155-156 to
avoid forwarding raw metadata. Add assertions in
tests/guardrails/test_model_engine.py:840-886 and
tests/guardrails/test_iorails_streaming.py:1018-1035 confirming sensitive
fields, credentials, Set-Cookie, and excluded headers never reach public
metadata or IORails frames.
- Around line 56-59: Remove all newly added explanatory comments and docstrings
at the listed sites: model_engine.py lines 56-59, 89-90, 212, 679-680, 702-704,
and 774-775; test_model_engine.py lines 62-66, 801, 821, 841, and 864;
iorails.py lines 147-152, 1384-1387, and 1390-1400; and
test_iorails_streaming.py lines 530-534, 938, 942, 963, 987, 1005, 1019,
1039-1044, and 1070. Preserve the surrounding implementation and existing
documentation, removing only the additions identified by the review.
🪄 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: 4bf0aad0-e544-4137-912c-38aa7e464a60

📥 Commits

Reviewing files that changed from the base of the PR and between 3f8578c and 630aebc.

📒 Files selected for processing (4)
  • nemoguardrails/guardrails/iorails.py
  • nemoguardrails/guardrails/model_engine.py
  • tests/guardrails/test_iorails_streaming.py
  • tests/guardrails/test_model_engine.py

Comment thread nemoguardrails/guardrails/iorails.py
Comment thread nemoguardrails/guardrails/model_engine.py Outdated
Comment thread nemoguardrails/guardrails/model_engine.py
@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds streaming usage and provider metadata (response headers and non-standard body fields such as nvext) to the IORails streaming path, achieving feature parity with LLMRails streaming. It also enables stream_options.include_usage=True by default on every stream_call so the terminal usage chunk is always requested.

  • model_engine.py: stream_call now requests include_usage by default via setdefault, extracts non-standard chunk-body keys into provider_metadata, and attaches lowercased response headers to every parsed chunk.
  • iorails.py: Adds _stream_chunk_metadata to build the per-chunk metadata dict; threads it through push_chunk for content chunks and folds usage-only terminal chunks into the single END_OF_STREAM frame—matching LLMRails' one-terminal-frame contract.
  • Tests: New TestStreamAsyncMetadata and TestModelEngineStreamCall cases cover usage on content and usage-only chunks, provider metadata surfacing, response headers, the no-empty-frame guarantee for reasoning-only chunks, and override of the include_usage default.

Confidence Score: 5/5

The change is additive and well-isolated: new metadata is threaded through existing push_chunk calls, and the only behavioral change to non-metadata consumers is that stream_call now requests usage by default (overridable by callers). No existing streaming path is broken.

The implementation is logically consistent: content chunks carry their metadata inline, usage-only terminal chunks are folded into the single END_OF_STREAM frame, and reasoning/empty chunks are intentionally suppressed. All edge cases are covered by new unit tests, and the integration comparison in the PR description confirms parity with LLMRails output.

No files require special attention.

Important Files Changed

Filename Overview
nemoguardrails/guardrails/iorails.py Adds _stream_chunk_metadata helper and threads usage/provider metadata through the main-LLM streaming loop; correctly folds usage-only terminal chunks into the END_OF_STREAM frame.
nemoguardrails/guardrails/model_engine.py Adds _STANDARD_CHUNK_KEYS constant, extracts non-standard body fields as provider_metadata in _parse_chat_completion_chunk, attaches lowercased response headers to every yielded chunk, and defaults include_usage=True in stream_call.
tests/guardrails/test_iorails_streaming.py Adds TestStreamAsyncMetadata with 6 focused test cases covering all new metadata-surfacing behaviours; helper _build_sse_streaming_mock extended with headers parameter.
tests/guardrails/test_model_engine.py Adds 4 new stream_call tests: include_usage default, caller-provided stream_options override, response headers in provider_metadata, and non-standard body key (nvext) surfacing.

Sequence Diagram

sequenceDiagram
    participant Client
    participant IORails
    participant StreamingHandler
    participant ModelEngine

    Client->>IORails: "stream_async(include_metadata=True)"
    IORails->>ModelEngine: "stream_call(stream_options={include_usage:True})"
    loop SSE chunks
        ModelEngine-->>IORails: LLMResponseChunk(delta_content, provider_metadata, usage?)
        alt chunk has delta_content
            IORails->>IORails: _stream_chunk_metadata(chunk) → chunk_metadata
            IORails->>StreamingHandler: push_chunk(delta_content, chunk_metadata)
            StreamingHandler-->>Client: "{"text": "...", "metadata": {provider_metadata, usage?}}"
        else chunk has usage only (no delta_content)
            IORails->>IORails: "pending_usage_metadata = _stream_chunk_metadata(chunk)"
        else reasoning-only / empty chunk
            Note over IORails: provider_metadata dropped (no frame emitted)
        end
    end
    IORails->>StreamingHandler: push_chunk(END_OF_STREAM, pending_usage_metadata)
    StreamingHandler-->>Client: "{"text": "", "metadata": {provider_metadata, usage, response_metadata, usage_metadata}}"
Loading

Reviews (6): Last reviewed commit: "Trim out obvious comments" | Re-trigger Greptile

Comment thread nemoguardrails/guardrails/model_engine.py Outdated
@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR threads streaming token usage and provider metadata (HTTP response headers, non-standard body fields like nvext) through the IORails streaming path, bringing it to parity with LLMRails. It builds on PR #2178 which added the GenerationResponse/GenerationOptions contracts for the non-streaming path.

  • model_engine.py: stream_call now requests include_usage: True by default, lowercases response headers once per request and merges them onto every yielded chunk, and extracts non-standard SSE body keys into provider_metadata.
  • iorails.py: Adds _stream_chunk_metadata() to translate a LLMResponseChunk into a StreamingHandler metadata dict; content chunks forward metadata immediately, and the final usage-only terminal chunk's metadata is deferred to the END_OF_STREAM frame to produce a single terminal usage frame matching LLMRails.

Confidence Score: 4/5

Safe to merge; the streaming metadata plumbing is straightforward, well-tested, and the fallback behaviour (no metadata emitted) is unchanged when the new fields are absent.

The core logic—capturing response headers once, merging non-standard body keys into provider_metadata, deferring terminal usage to the END_OF_STREAM frame—is correct and matches the LLMRails contract shown in the integration comparison. The six new iorails tests and four model_engine tests cover all meaningful branches. The one functional gap is that reasoning_tokens and cached_tokens are dropped when serializing UsageInfo into the streaming metadata dict, which silently loses data for OpenAI reasoning models; the non-streaming path has the same omission so behaviour is at least consistent, but a follow-up should align both.

The _stream_chunk_metadata helper in iorails.py warrants a second look for the optional reasoning_tokens/cached_tokens fields.

Important Files Changed

Filename Overview
nemoguardrails/guardrails/model_engine.py Adds per-stream response-header capture, non-standard body-key extraction into provider_metadata, and default include_usage=True; logic is sound with one minor note on the shared response_headers dict reference.
nemoguardrails/guardrails/iorails.py New _stream_chunk_metadata() helper and updated streaming loop correctly route per-chunk metadata and defer terminal usage to the END_OF_STREAM frame; reasoning_tokens/cached_tokens are silently dropped from serialized usage.
tests/guardrails/test_iorails_streaming.py Adds six new test cases in TestStreamAsyncMetadata covering usage-only, content+usage, provider_metadata, plain-string passthrough, response-header, and no-empty-frame behaviours; coverage is thorough.
tests/guardrails/test_model_engine.py Adds four unit tests for stream_call covering default include_usage injection, caller override, response-header attachment, and non-standard body-key surfacing; all straightforward and correct.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant IORails
    participant StreamingHandler
    participant ModelEngine

    Caller->>IORails: "stream_async(messages, include_metadata=True)"
    IORails->>ModelEngine: "stream_model_call(main, messages, stream_options={include_usage:True})"

    loop SSE chunks
        ModelEngine-->>ModelEngine: parse chunk body → LLMResponseChunk
        ModelEngine-->>ModelEngine: merge response_headers into provider_metadata
        ModelEngine-->>IORails: yield LLMResponseChunk

        alt chunk has delta_content
            IORails->>StreamingHandler: "push_chunk(text, {provider_metadata, [usage]})"
            StreamingHandler-->>Caller: "{text, metadata: {provider_metadata, [usage]}}"
        else chunk has usage only (terminal)
            IORails-->>IORails: store as pending_usage_metadata
        end
    end

    IORails->>StreamingHandler: push_chunk(END_OF_STREAM, pending_usage_metadata)
    StreamingHandler-->>Caller: "{text empty, metadata: {usage, provider_metadata, response_metadata, usage_metadata}}"
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
nemoguardrails/guardrails/iorails.py:157-162
`reasoning_tokens` and `cached_tokens` are silently dropped when serializing usage into the streaming metadata dict. Both fields are populated by `_parse_usage` for OpenAI-compatible reasoning models (via `completion_tokens_details` / `prompt_tokens_details`), so callers that rely on them for cost accounting will see zeros in the streaming path even when the non-streaming `UsageInfo` carries real values.

```suggestion
    if chunk.usage:
        usage_dict = {
            "input_tokens": chunk.usage.input_tokens,
            "output_tokens": chunk.usage.output_tokens,
            "total_tokens": chunk.usage.total_tokens,
        }
        if chunk.usage.reasoning_tokens is not None:
            usage_dict["reasoning_tokens"] = chunk.usage.reasoning_tokens
        if chunk.usage.cached_tokens is not None:
            usage_dict["cached_tokens"] = chunk.usage.cached_tokens
        metadata["usage"] = usage_dict
```

### Issue 2 of 2
nemoguardrails/guardrails/model_engine.py:679
The comment has "parity" duplicated — "for parity with LLMRails parity" — worth cleaning up before merge.

```suggestion
        # Request token usage on the terminal stream chunk for parity with LLMRails.
```

Reviews (2): Last reviewed commit: "Combine last content-chunk and usage-onl..." | Re-trigger Greptile

Comment thread nemoguardrails/guardrails/iorails.py
Comment thread nemoguardrails/guardrails/model_engine.py Outdated
@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@greptile-apps There are two summary reports with different scores on this PR: #2198 (comment) and #2198 (comment) . Create a new summary/score comment based on latest PR and I'll hide the other two as outdated.

@tgasser-nv
tgasser-nv requested review from Pouyanpi and cparisien July 23, 2026 02:48

@Pouyanpi Pouyanpi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM!

@tgasser-nv
tgasser-nv merged commit 77168c4 into develop Jul 23, 2026
35 checks passed
@tgasser-nv
tgasser-nv deleted the feat/iorails-streaming-structured-response branch July 23, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size: M status: triaged Triaged by a maintainer; eligible for automated review (CodeRabbit/Greptile).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants