fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed - #760

Merged
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback
Jul 13, 2026
Merged

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed#760
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

AISIX-Cloud#1010: a customer running 0.3.1 with an aliyun output guardrail in monitor mode saw intermittent 422 rejections on Codex traffic — dashboard rows showing content_filter, 0 tokens, $0.0000, and 16–26 s latency. Monitor mode is documented to observe and never block.

Root cause: the streaming /v1/responses path (both the verbatim OpenAI forward and the cross-provider bridge) entered the whole-response hold-back branch whenever any output-hook guardrail was attached, ignoring the chain's resolved stream_output_policy:

  • a monitor-only chain resolves to EndOfStreamCheck (can never block), yet its stream was fully buffered — the client saw nothing until the generation finished;
  • past the buffer cap (DEFAULT_STREAM_OUTPUT_BUFFER_BYTES = 256 KiB, hit by exactly the long SSE streams Codex produces), the request was rejected 422 content_filterbefore any verdict ran — MonitorGuardrail never got the chance to downgrade.

chat.rs and messages.rs already gate hold-back on the policy (holds_back() / BufferFull-only); /v1/responses was the one deviating surface (family audit: completions/audio/passthrough/realtime have no streaming output-buffer path).

Fix

  • Hold-back (and its fail-closed overflow) now engages only when the resolved policy actually holds back (Window/BufferFull — any block-capable chain). Block-mode behavior is unchanged, including the fail-closed cap.
  • An EndOfStreamCheck chain (monitor-only) forwards the SSE live and runs the same two-phase scan (blob check + segment pass) at end-of-stream, so would_block/would_mask monitor hits still reach telemetry. The scan runs while the completion guard stays armed: SDK clients close the connection right after the terminal frame, and a disconnect mid-scan must fall back to the guard's Drop emit rather than lose the usage event for a fully-delivered stream. Scan input is bounded to the same 256 KiB on both paths so observation provider calls stay bounded.
  • A Block verdict on the live path (only reachable via the documented mandatory-unavailability composition) is logged and, on the bridge path, signalled with a trailing error frame — mirroring chat's EndOfStreamCheck behavior.

Behavior change

Monitor-mode-only output chains on streaming /v1/responses: clients now receive tokens live (no whole-response buffering latency) and oversized responses are no longer rejected. Blocking chains: no change.

LiteLLM baseline

LiteLLM never withholds or fails a stream for logging-only / on_flagged: monitor guardrails, and has no scan-buffer size cap at all — live-forward + observe-at-end matches the baseline. Keeping the fail-closed cap for blocking chains is our stricter, OOM-bounding divergence (pre-existing, unchanged).

Tests

  • Unit (responses.rs): oversized (300 KB) stream + monitor guardrail released with 200 on both paths (verbatim + cross-provider bridge) — both fail before the fix (mutation-verified); live-path would_block observation recorded on the usage event; disconnect-during-scan still emits the usage event (parks the scan on a delayed moderation backend, drops the body — mutation-verified against the take-before-await shape); existing block-mode oversized fail-closed tests still pass unchanged.
  • E2E (tests/e2e): self-gating block→monitor flip on a >256 KiB /v1/responses stream — block mode 422s (pins the secure default), monitor mode releases the full SSE live.

Fixes api7/AISIX-Cloud#1010

…fail streaming closed
The streaming /v1/responses path (both the verbatim OpenAI forward and
the cross-provider bridge) entered the whole-response hold-back branch
whenever ANY output-hook guardrail was attached, ignoring the chain's
resolved stream_output_policy. A monitor-only chain resolves to
EndOfStreamCheck — it can never block by definition — yet its stream was
fully buffered (no bytes until end of generation) and, past the 256 KiB
cap, rejected 422 content_filter. Monitor mode could therefore block
exactly the long generations Codex produces: 422 + 0 tokens + tens of
seconds latency, intermittently. chat.rs and messages.rs already gate
hold-back on the policy; /v1/responses was the one deviating surface.
Now hold-back engages only when the resolved policy holds back
(Window/BufferFull — any block-capable chain, unchanged fail-closed
secure default). An EndOfStreamCheck chain forwards the SSE live and
runs the same two-phase scan (blob check + segment pass) at
end-of-stream so would-block / would-mask monitor hits still reach
telemetry; a Block verdict there (only reachable via the documented
mandatory-unavailability composition) is signalled with a trailing
error frame on the bridge path, mirroring chat's EndOfStreamCheck
behavior.
LiteLLM baseline: logging-only / on_flagged=monitor guardrails never
withhold or fail a stream, and no scan-buffer size cap exists at all —
the live-forward + observe-at-end behavior matches; keeping the
fail-closed cap for blocking chains is our stricter (OOM-bounding)
divergence, unchanged here.
Fixesapi7/AISIX-Cloud#1010
@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

/v1/responses streaming now distinguishes monitor-only output guardrails from hold-back enforcement, performs clean end-of-stream scans, propagates monitor hits to telemetry, and tests both verbatim and cross-provider oversized streams.

Changes

Responses streaming guardrails

Layer / File(s)Summary
OpenAI streaming policy and telemetry
crates/aisix-proxy/src/responses.rs
Shared guardrail chains and output policies control buffering; clean stream completion scans captured output and includes output monitor hits in usage events.
Cross-provider bridge streaming policy
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Bridge buffering, overflow handling, scanning, and redaction now differ between hold-back and live-forward modes.
Monitor-mode streaming regression coverage
tests/e2e/src/cases/responses-streaming-monitor-guardrail-e2e.test.ts, crates/aisix-proxy/src/responses.rs
Tests verify oversized streams block in BLOCK mode but complete with forbidden content and monitor observations in MONITOR mode.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ResponsesHandler
participant StreamingPath
participant Guardrail
participant UsageEvent
Client->>ResponsesHandler: request streaming /v1/responses
ResponsesHandler->>StreamingPath: apply output policy
StreamingPath-->>Client: forward live SSE or hold frames
StreamingPath->>Guardrail: scan output at stream end
Guardrail-->>StreamingPath: monitor hits or redaction result
StreamingPath->>UsageEvent: emit usage and monitor hits
Loading

Possibly related PRs

  • api7/aisix#640: Defines the monitor-mode stream_output_policy behavior used to select end-of-stream checking.
  • api7/aisix#731: Adds the monitor-hit model and telemetry APIs consumed by this streaming completion flow.
  • api7/aisix#694: Overlaps with the bridged Responses stream redaction and held-frame handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 inconclusive)

Check nameStatusExplanationResolution
E2e Test Quality Review❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Security Check❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedThe changes address #1010 by keeping monitor-only guardrails from blocking oversized streaming responses while still recording monitor hits.
Out of Scope Changes check✅ PassedThe code and test changes stay focused on streaming output-guardrail behavior and related regression coverage.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly describes the main change: monitor-mode output guardrails should stream live without hold-back or fail-closed behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/responses-monitor-stream-holdback

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

…d bridge live-scan text
Audit findings on the live-forward path:
- H1: the explicit completion took the guard slot BEFORE awaiting the
end-of-stream observation. SDK clients close the connection right
after the terminal frame, dropping the generator at that await — a
fully-delivered 200 stream then emitted no UsageEvent at all
(billing/logs/TPM post-stream accounting lost) whenever the monitor
chain contained a remote provider. The scan now runs while the guard
stays armed (reading the captured text via a non-consuming clone),
so a mid-scan disconnect falls back to the guard's Drop emit, and
only then does the explicit completion take the slot. Regression
test parks the scan on a delayed moderation backend, drops the body,
and asserts the event still arrives (mutation-verified).
- M1: the bridge live path fed the unbounded assembled text to the
scan (the hold-back cap no longer applies there); it is now
truncated to DEFAULT_STREAM_OUTPUT_BUFFER_BYTES on a char boundary,
matching the verbatim path's EosOutputScan bound.
- L1: the masked-segment capture rebuild is gated on hold-back mode —
the live walk is read-only, so a masked outcome there must not
clobber the capture from the empty joined buffer.
- L4: correct the capture-cap comment (terminal text is bounded by the
SSE frame cap and re-truncated per consumer, not by the scan bound).
@jarvis9443
jarvis9443 merged commit e37f015 into mainJul 13, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/responses-monitor-stream-holdback branch July 13, 2026 12:32
Sign up for freeto 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

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed - #760

Merged
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback
Jul 13, 2026
Merged

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed#760
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

AISIX-Cloud#1010: a customer running 0.3.1 with an aliyun output guardrail in monitor mode saw intermittent 422 rejections on Codex traffic — dashboard rows showing content_filter, 0 tokens, $0.0000, and 16–26 s latency. Monitor mode is documented to observe and never block.

Root cause: the streaming /v1/responses path (both the verbatim OpenAI forward and the cross-provider bridge) entered the whole-response hold-back branch whenever any output-hook guardrail was attached, ignoring the chain's resolved stream_output_policy:

  • a monitor-only chain resolves to EndOfStreamCheck (can never block), yet its stream was fully buffered — the client saw nothing until the generation finished;
  • past the buffer cap (DEFAULT_STREAM_OUTPUT_BUFFER_BYTES = 256 KiB, hit by exactly the long SSE streams Codex produces), the request was rejected 422 content_filterbefore any verdict ran — MonitorGuardrail never got the chance to downgrade.

chat.rs and messages.rs already gate hold-back on the policy (holds_back() / BufferFull-only); /v1/responses was the one deviating surface (family audit: completions/audio/passthrough/realtime have no streaming output-buffer path).

Fix

  • Hold-back (and its fail-closed overflow) now engages only when the resolved policy actually holds back (Window/BufferFull — any block-capable chain). Block-mode behavior is unchanged, including the fail-closed cap.
  • An EndOfStreamCheck chain (monitor-only) forwards the SSE live and runs the same two-phase scan (blob check + segment pass) at end-of-stream, so would_block/would_mask monitor hits still reach telemetry. The scan runs while the completion guard stays armed: SDK clients close the connection right after the terminal frame, and a disconnect mid-scan must fall back to the guard's Drop emit rather than lose the usage event for a fully-delivered stream. Scan input is bounded to the same 256 KiB on both paths so observation provider calls stay bounded.
  • A Block verdict on the live path (only reachable via the documented mandatory-unavailability composition) is logged and, on the bridge path, signalled with a trailing error frame — mirroring chat's EndOfStreamCheck behavior.

Behavior change

Monitor-mode-only output chains on streaming /v1/responses: clients now receive tokens live (no whole-response buffering latency) and oversized responses are no longer rejected. Blocking chains: no change.

LiteLLM baseline

LiteLLM never withholds or fails a stream for logging-only / on_flagged: monitor guardrails, and has no scan-buffer size cap at all — live-forward + observe-at-end matches the baseline. Keeping the fail-closed cap for blocking chains is our stricter, OOM-bounding divergence (pre-existing, unchanged).

Tests

  • Unit (responses.rs): oversized (300 KB) stream + monitor guardrail released with 200 on both paths (verbatim + cross-provider bridge) — both fail before the fix (mutation-verified); live-path would_block observation recorded on the usage event; disconnect-during-scan still emits the usage event (parks the scan on a delayed moderation backend, drops the body — mutation-verified against the take-before-await shape); existing block-mode oversized fail-closed tests still pass unchanged.
  • E2E (tests/e2e): self-gating block→monitor flip on a >256 KiB /v1/responses stream — block mode 422s (pins the secure default), monitor mode releases the full SSE live.

Fixes api7/AISIX-Cloud#1010

…fail streaming closed
The streaming /v1/responses path (both the verbatim OpenAI forward and
the cross-provider bridge) entered the whole-response hold-back branch
whenever ANY output-hook guardrail was attached, ignoring the chain's
resolved stream_output_policy. A monitor-only chain resolves to
EndOfStreamCheck — it can never block by definition — yet its stream was
fully buffered (no bytes until end of generation) and, past the 256 KiB
cap, rejected 422 content_filter. Monitor mode could therefore block
exactly the long generations Codex produces: 422 + 0 tokens + tens of
seconds latency, intermittently. chat.rs and messages.rs already gate
hold-back on the policy; /v1/responses was the one deviating surface.
Now hold-back engages only when the resolved policy holds back
(Window/BufferFull — any block-capable chain, unchanged fail-closed
secure default). An EndOfStreamCheck chain forwards the SSE live and
runs the same two-phase scan (blob check + segment pass) at
end-of-stream so would-block / would-mask monitor hits still reach
telemetry; a Block verdict there (only reachable via the documented
mandatory-unavailability composition) is signalled with a trailing
error frame on the bridge path, mirroring chat's EndOfStreamCheck
behavior.
LiteLLM baseline: logging-only / on_flagged=monitor guardrails never
withhold or fail a stream, and no scan-buffer size cap exists at all —
the live-forward + observe-at-end behavior matches; keeping the
fail-closed cap for blocking chains is our stricter (OOM-bounding)
divergence, unchanged here.
Fixesapi7/AISIX-Cloud#1010
@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

/v1/responses streaming now distinguishes monitor-only output guardrails from hold-back enforcement, performs clean end-of-stream scans, propagates monitor hits to telemetry, and tests both verbatim and cross-provider oversized streams.

Changes

Responses streaming guardrails

Layer / File(s)Summary
OpenAI streaming policy and telemetry
crates/aisix-proxy/src/responses.rs
Shared guardrail chains and output policies control buffering; clean stream completion scans captured output and includes output monitor hits in usage events.
Cross-provider bridge streaming policy
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Bridge buffering, overflow handling, scanning, and redaction now differ between hold-back and live-forward modes.
Monitor-mode streaming regression coverage
tests/e2e/src/cases/responses-streaming-monitor-guardrail-e2e.test.ts, crates/aisix-proxy/src/responses.rs
Tests verify oversized streams block in BLOCK mode but complete with forbidden content and monitor observations in MONITOR mode.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ResponsesHandler
participant StreamingPath
participant Guardrail
participant UsageEvent
Client->>ResponsesHandler: request streaming /v1/responses
ResponsesHandler->>StreamingPath: apply output policy
StreamingPath-->>Client: forward live SSE or hold frames
StreamingPath->>Guardrail: scan output at stream end
Guardrail-->>StreamingPath: monitor hits or redaction result
StreamingPath->>UsageEvent: emit usage and monitor hits
Loading

Possibly related PRs

  • api7/aisix#640: Defines the monitor-mode stream_output_policy behavior used to select end-of-stream checking.
  • api7/aisix#731: Adds the monitor-hit model and telemetry APIs consumed by this streaming completion flow.
  • api7/aisix#694: Overlaps with the bridged Responses stream redaction and held-frame handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 inconclusive)

Check nameStatusExplanationResolution
E2e Test Quality Review❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Security Check❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedThe changes address #1010 by keeping monitor-only guardrails from blocking oversized streaming responses while still recording monitor hits.
Out of Scope Changes check✅ PassedThe code and test changes stay focused on streaming output-guardrail behavior and related regression coverage.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly describes the main change: monitor-mode output guardrails should stream live without hold-back or fail-closed behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/responses-monitor-stream-holdback

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

…d bridge live-scan text
Audit findings on the live-forward path:
- H1: the explicit completion took the guard slot BEFORE awaiting the
end-of-stream observation. SDK clients close the connection right
after the terminal frame, dropping the generator at that await — a
fully-delivered 200 stream then emitted no UsageEvent at all
(billing/logs/TPM post-stream accounting lost) whenever the monitor
chain contained a remote provider. The scan now runs while the guard
stays armed (reading the captured text via a non-consuming clone),
so a mid-scan disconnect falls back to the guard's Drop emit, and
only then does the explicit completion take the slot. Regression
test parks the scan on a delayed moderation backend, drops the body,
and asserts the event still arrives (mutation-verified).
- M1: the bridge live path fed the unbounded assembled text to the
scan (the hold-back cap no longer applies there); it is now
truncated to DEFAULT_STREAM_OUTPUT_BUFFER_BYTES on a char boundary,
matching the verbatim path's EosOutputScan bound.
- L1: the masked-segment capture rebuild is gated on hold-back mode —
the live walk is read-only, so a masked outcome there must not
clobber the capture from the empty joined buffer.
- L4: correct the capture-cap comment (terminal text is bounded by the
SSE frame cap and re-truncated per consumer, not by the scan bound).
@jarvis9443
jarvis9443 merged commit e37f015 into mainJul 13, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/responses-monitor-stream-holdback branch July 13, 2026 12:32
Sign up for freeto 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

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed - #760

Merged
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback
Jul 13, 2026
Merged

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed#760
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

AISIX-Cloud#1010: a customer running 0.3.1 with an aliyun output guardrail in monitor mode saw intermittent 422 rejections on Codex traffic — dashboard rows showing content_filter, 0 tokens, $0.0000, and 16–26 s latency. Monitor mode is documented to observe and never block.

Root cause: the streaming /v1/responses path (both the verbatim OpenAI forward and the cross-provider bridge) entered the whole-response hold-back branch whenever any output-hook guardrail was attached, ignoring the chain's resolved stream_output_policy:

  • a monitor-only chain resolves to EndOfStreamCheck (can never block), yet its stream was fully buffered — the client saw nothing until the generation finished;
  • past the buffer cap (DEFAULT_STREAM_OUTPUT_BUFFER_BYTES = 256 KiB, hit by exactly the long SSE streams Codex produces), the request was rejected 422 content_filterbefore any verdict ran — MonitorGuardrail never got the chance to downgrade.

chat.rs and messages.rs already gate hold-back on the policy (holds_back() / BufferFull-only); /v1/responses was the one deviating surface (family audit: completions/audio/passthrough/realtime have no streaming output-buffer path).

Fix

  • Hold-back (and its fail-closed overflow) now engages only when the resolved policy actually holds back (Window/BufferFull — any block-capable chain). Block-mode behavior is unchanged, including the fail-closed cap.
  • An EndOfStreamCheck chain (monitor-only) forwards the SSE live and runs the same two-phase scan (blob check + segment pass) at end-of-stream, so would_block/would_mask monitor hits still reach telemetry. The scan runs while the completion guard stays armed: SDK clients close the connection right after the terminal frame, and a disconnect mid-scan must fall back to the guard's Drop emit rather than lose the usage event for a fully-delivered stream. Scan input is bounded to the same 256 KiB on both paths so observation provider calls stay bounded.
  • A Block verdict on the live path (only reachable via the documented mandatory-unavailability composition) is logged and, on the bridge path, signalled with a trailing error frame — mirroring chat's EndOfStreamCheck behavior.

Behavior change

Monitor-mode-only output chains on streaming /v1/responses: clients now receive tokens live (no whole-response buffering latency) and oversized responses are no longer rejected. Blocking chains: no change.

LiteLLM baseline

LiteLLM never withholds or fails a stream for logging-only / on_flagged: monitor guardrails, and has no scan-buffer size cap at all — live-forward + observe-at-end matches the baseline. Keeping the fail-closed cap for blocking chains is our stricter, OOM-bounding divergence (pre-existing, unchanged).

Tests

  • Unit (responses.rs): oversized (300 KB) stream + monitor guardrail released with 200 on both paths (verbatim + cross-provider bridge) — both fail before the fix (mutation-verified); live-path would_block observation recorded on the usage event; disconnect-during-scan still emits the usage event (parks the scan on a delayed moderation backend, drops the body — mutation-verified against the take-before-await shape); existing block-mode oversized fail-closed tests still pass unchanged.
  • E2E (tests/e2e): self-gating block→monitor flip on a >256 KiB /v1/responses stream — block mode 422s (pins the secure default), monitor mode releases the full SSE live.

Fixes api7/AISIX-Cloud#1010

…fail streaming closed
The streaming /v1/responses path (both the verbatim OpenAI forward and
the cross-provider bridge) entered the whole-response hold-back branch
whenever ANY output-hook guardrail was attached, ignoring the chain's
resolved stream_output_policy. A monitor-only chain resolves to
EndOfStreamCheck — it can never block by definition — yet its stream was
fully buffered (no bytes until end of generation) and, past the 256 KiB
cap, rejected 422 content_filter. Monitor mode could therefore block
exactly the long generations Codex produces: 422 + 0 tokens + tens of
seconds latency, intermittently. chat.rs and messages.rs already gate
hold-back on the policy; /v1/responses was the one deviating surface.
Now hold-back engages only when the resolved policy holds back
(Window/BufferFull — any block-capable chain, unchanged fail-closed
secure default). An EndOfStreamCheck chain forwards the SSE live and
runs the same two-phase scan (blob check + segment pass) at
end-of-stream so would-block / would-mask monitor hits still reach
telemetry; a Block verdict there (only reachable via the documented
mandatory-unavailability composition) is signalled with a trailing
error frame on the bridge path, mirroring chat's EndOfStreamCheck
behavior.
LiteLLM baseline: logging-only / on_flagged=monitor guardrails never
withhold or fail a stream, and no scan-buffer size cap exists at all —
the live-forward + observe-at-end behavior matches; keeping the
fail-closed cap for blocking chains is our stricter (OOM-bounding)
divergence, unchanged here.
Fixesapi7/AISIX-Cloud#1010
@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

/v1/responses streaming now distinguishes monitor-only output guardrails from hold-back enforcement, performs clean end-of-stream scans, propagates monitor hits to telemetry, and tests both verbatim and cross-provider oversized streams.

Changes

Responses streaming guardrails

Layer / File(s)Summary
OpenAI streaming policy and telemetry
crates/aisix-proxy/src/responses.rs
Shared guardrail chains and output policies control buffering; clean stream completion scans captured output and includes output monitor hits in usage events.
Cross-provider bridge streaming policy
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Bridge buffering, overflow handling, scanning, and redaction now differ between hold-back and live-forward modes.
Monitor-mode streaming regression coverage
tests/e2e/src/cases/responses-streaming-monitor-guardrail-e2e.test.ts, crates/aisix-proxy/src/responses.rs
Tests verify oversized streams block in BLOCK mode but complete with forbidden content and monitor observations in MONITOR mode.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ResponsesHandler
participant StreamingPath
participant Guardrail
participant UsageEvent
Client->>ResponsesHandler: request streaming /v1/responses
ResponsesHandler->>StreamingPath: apply output policy
StreamingPath-->>Client: forward live SSE or hold frames
StreamingPath->>Guardrail: scan output at stream end
Guardrail-->>StreamingPath: monitor hits or redaction result
StreamingPath->>UsageEvent: emit usage and monitor hits
Loading

Possibly related PRs

  • api7/aisix#640: Defines the monitor-mode stream_output_policy behavior used to select end-of-stream checking.
  • api7/aisix#731: Adds the monitor-hit model and telemetry APIs consumed by this streaming completion flow.
  • api7/aisix#694: Overlaps with the bridged Responses stream redaction and held-frame handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 inconclusive)

Check nameStatusExplanationResolution
E2e Test Quality Review❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Security Check❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedThe changes address #1010 by keeping monitor-only guardrails from blocking oversized streaming responses while still recording monitor hits.
Out of Scope Changes check✅ PassedThe code and test changes stay focused on streaming output-guardrail behavior and related regression coverage.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly describes the main change: monitor-mode output guardrails should stream live without hold-back or fail-closed behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/responses-monitor-stream-holdback

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

…d bridge live-scan text
Audit findings on the live-forward path:
- H1: the explicit completion took the guard slot BEFORE awaiting the
end-of-stream observation. SDK clients close the connection right
after the terminal frame, dropping the generator at that await — a
fully-delivered 200 stream then emitted no UsageEvent at all
(billing/logs/TPM post-stream accounting lost) whenever the monitor
chain contained a remote provider. The scan now runs while the guard
stays armed (reading the captured text via a non-consuming clone),
so a mid-scan disconnect falls back to the guard's Drop emit, and
only then does the explicit completion take the slot. Regression
test parks the scan on a delayed moderation backend, drops the body,
and asserts the event still arrives (mutation-verified).
- M1: the bridge live path fed the unbounded assembled text to the
scan (the hold-back cap no longer applies there); it is now
truncated to DEFAULT_STREAM_OUTPUT_BUFFER_BYTES on a char boundary,
matching the verbatim path's EosOutputScan bound.
- L1: the masked-segment capture rebuild is gated on hold-back mode —
the live walk is read-only, so a masked outcome there must not
clobber the capture from the empty joined buffer.
- L4: correct the capture-cap comment (terminal text is bounded by the
SSE frame cap and re-truncated per consumer, not by the scan bound).
@jarvis9443
jarvis9443 merged commit e37f015 into mainJul 13, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/responses-monitor-stream-holdback branch July 13, 2026 12:32
Sign up for freeto 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

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed - #760

Merged
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback
Jul 13, 2026
Merged

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed#760
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

AISIX-Cloud#1010: a customer running 0.3.1 with an aliyun output guardrail in monitor mode saw intermittent 422 rejections on Codex traffic — dashboard rows showing content_filter, 0 tokens, $0.0000, and 16–26 s latency. Monitor mode is documented to observe and never block.

Root cause: the streaming /v1/responses path (both the verbatim OpenAI forward and the cross-provider bridge) entered the whole-response hold-back branch whenever any output-hook guardrail was attached, ignoring the chain's resolved stream_output_policy:

  • a monitor-only chain resolves to EndOfStreamCheck (can never block), yet its stream was fully buffered — the client saw nothing until the generation finished;
  • past the buffer cap (DEFAULT_STREAM_OUTPUT_BUFFER_BYTES = 256 KiB, hit by exactly the long SSE streams Codex produces), the request was rejected 422 content_filterbefore any verdict ran — MonitorGuardrail never got the chance to downgrade.

chat.rs and messages.rs already gate hold-back on the policy (holds_back() / BufferFull-only); /v1/responses was the one deviating surface (family audit: completions/audio/passthrough/realtime have no streaming output-buffer path).

Fix

  • Hold-back (and its fail-closed overflow) now engages only when the resolved policy actually holds back (Window/BufferFull — any block-capable chain). Block-mode behavior is unchanged, including the fail-closed cap.
  • An EndOfStreamCheck chain (monitor-only) forwards the SSE live and runs the same two-phase scan (blob check + segment pass) at end-of-stream, so would_block/would_mask monitor hits still reach telemetry. The scan runs while the completion guard stays armed: SDK clients close the connection right after the terminal frame, and a disconnect mid-scan must fall back to the guard's Drop emit rather than lose the usage event for a fully-delivered stream. Scan input is bounded to the same 256 KiB on both paths so observation provider calls stay bounded.
  • A Block verdict on the live path (only reachable via the documented mandatory-unavailability composition) is logged and, on the bridge path, signalled with a trailing error frame — mirroring chat's EndOfStreamCheck behavior.

Behavior change

Monitor-mode-only output chains on streaming /v1/responses: clients now receive tokens live (no whole-response buffering latency) and oversized responses are no longer rejected. Blocking chains: no change.

LiteLLM baseline

LiteLLM never withholds or fails a stream for logging-only / on_flagged: monitor guardrails, and has no scan-buffer size cap at all — live-forward + observe-at-end matches the baseline. Keeping the fail-closed cap for blocking chains is our stricter, OOM-bounding divergence (pre-existing, unchanged).

Tests

  • Unit (responses.rs): oversized (300 KB) stream + monitor guardrail released with 200 on both paths (verbatim + cross-provider bridge) — both fail before the fix (mutation-verified); live-path would_block observation recorded on the usage event; disconnect-during-scan still emits the usage event (parks the scan on a delayed moderation backend, drops the body — mutation-verified against the take-before-await shape); existing block-mode oversized fail-closed tests still pass unchanged.
  • E2E (tests/e2e): self-gating block→monitor flip on a >256 KiB /v1/responses stream — block mode 422s (pins the secure default), monitor mode releases the full SSE live.

Fixes api7/AISIX-Cloud#1010

…fail streaming closed
The streaming /v1/responses path (both the verbatim OpenAI forward and
the cross-provider bridge) entered the whole-response hold-back branch
whenever ANY output-hook guardrail was attached, ignoring the chain's
resolved stream_output_policy. A monitor-only chain resolves to
EndOfStreamCheck — it can never block by definition — yet its stream was
fully buffered (no bytes until end of generation) and, past the 256 KiB
cap, rejected 422 content_filter. Monitor mode could therefore block
exactly the long generations Codex produces: 422 + 0 tokens + tens of
seconds latency, intermittently. chat.rs and messages.rs already gate
hold-back on the policy; /v1/responses was the one deviating surface.
Now hold-back engages only when the resolved policy holds back
(Window/BufferFull — any block-capable chain, unchanged fail-closed
secure default). An EndOfStreamCheck chain forwards the SSE live and
runs the same two-phase scan (blob check + segment pass) at
end-of-stream so would-block / would-mask monitor hits still reach
telemetry; a Block verdict there (only reachable via the documented
mandatory-unavailability composition) is signalled with a trailing
error frame on the bridge path, mirroring chat's EndOfStreamCheck
behavior.
LiteLLM baseline: logging-only / on_flagged=monitor guardrails never
withhold or fail a stream, and no scan-buffer size cap exists at all —
the live-forward + observe-at-end behavior matches; keeping the
fail-closed cap for blocking chains is our stricter (OOM-bounding)
divergence, unchanged here.
Fixesapi7/AISIX-Cloud#1010
@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

/v1/responses streaming now distinguishes monitor-only output guardrails from hold-back enforcement, performs clean end-of-stream scans, propagates monitor hits to telemetry, and tests both verbatim and cross-provider oversized streams.

Changes

Responses streaming guardrails

Layer / File(s)Summary
OpenAI streaming policy and telemetry
crates/aisix-proxy/src/responses.rs
Shared guardrail chains and output policies control buffering; clean stream completion scans captured output and includes output monitor hits in usage events.
Cross-provider bridge streaming policy
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Bridge buffering, overflow handling, scanning, and redaction now differ between hold-back and live-forward modes.
Monitor-mode streaming regression coverage
tests/e2e/src/cases/responses-streaming-monitor-guardrail-e2e.test.ts, crates/aisix-proxy/src/responses.rs
Tests verify oversized streams block in BLOCK mode but complete with forbidden content and monitor observations in MONITOR mode.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ResponsesHandler
participant StreamingPath
participant Guardrail
participant UsageEvent
Client->>ResponsesHandler: request streaming /v1/responses
ResponsesHandler->>StreamingPath: apply output policy
StreamingPath-->>Client: forward live SSE or hold frames
StreamingPath->>Guardrail: scan output at stream end
Guardrail-->>StreamingPath: monitor hits or redaction result
StreamingPath->>UsageEvent: emit usage and monitor hits
Loading

Possibly related PRs

  • api7/aisix#640: Defines the monitor-mode stream_output_policy behavior used to select end-of-stream checking.
  • api7/aisix#731: Adds the monitor-hit model and telemetry APIs consumed by this streaming completion flow.
  • api7/aisix#694: Overlaps with the bridged Responses stream redaction and held-frame handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 inconclusive)

Check nameStatusExplanationResolution
E2e Test Quality Review❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Security Check❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedThe changes address #1010 by keeping monitor-only guardrails from blocking oversized streaming responses while still recording monitor hits.
Out of Scope Changes check✅ PassedThe code and test changes stay focused on streaming output-guardrail behavior and related regression coverage.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly describes the main change: monitor-mode output guardrails should stream live without hold-back or fail-closed behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/responses-monitor-stream-holdback

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

…d bridge live-scan text
Audit findings on the live-forward path:
- H1: the explicit completion took the guard slot BEFORE awaiting the
end-of-stream observation. SDK clients close the connection right
after the terminal frame, dropping the generator at that await — a
fully-delivered 200 stream then emitted no UsageEvent at all
(billing/logs/TPM post-stream accounting lost) whenever the monitor
chain contained a remote provider. The scan now runs while the guard
stays armed (reading the captured text via a non-consuming clone),
so a mid-scan disconnect falls back to the guard's Drop emit, and
only then does the explicit completion take the slot. Regression
test parks the scan on a delayed moderation backend, drops the body,
and asserts the event still arrives (mutation-verified).
- M1: the bridge live path fed the unbounded assembled text to the
scan (the hold-back cap no longer applies there); it is now
truncated to DEFAULT_STREAM_OUTPUT_BUFFER_BYTES on a char boundary,
matching the verbatim path's EosOutputScan bound.
- L1: the masked-segment capture rebuild is gated on hold-back mode —
the live walk is read-only, so a masked outcome there must not
clobber the capture from the empty joined buffer.
- L4: correct the capture-cap comment (terminal text is bounded by the
SSE frame cap and re-truncated per consumer, not by the scan bound).
@jarvis9443
jarvis9443 merged commit e37f015 into mainJul 13, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/responses-monitor-stream-holdback branch July 13, 2026 12:32
Sign up for freeto 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

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed - #760

Merged
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback
Jul 13, 2026
Merged

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed#760
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

AISIX-Cloud#1010: a customer running 0.3.1 with an aliyun output guardrail in monitor mode saw intermittent 422 rejections on Codex traffic — dashboard rows showing content_filter, 0 tokens, $0.0000, and 16–26 s latency. Monitor mode is documented to observe and never block.

Root cause: the streaming /v1/responses path (both the verbatim OpenAI forward and the cross-provider bridge) entered the whole-response hold-back branch whenever any output-hook guardrail was attached, ignoring the chain's resolved stream_output_policy:

  • a monitor-only chain resolves to EndOfStreamCheck (can never block), yet its stream was fully buffered — the client saw nothing until the generation finished;
  • past the buffer cap (DEFAULT_STREAM_OUTPUT_BUFFER_BYTES = 256 KiB, hit by exactly the long SSE streams Codex produces), the request was rejected 422 content_filterbefore any verdict ran — MonitorGuardrail never got the chance to downgrade.

chat.rs and messages.rs already gate hold-back on the policy (holds_back() / BufferFull-only); /v1/responses was the one deviating surface (family audit: completions/audio/passthrough/realtime have no streaming output-buffer path).

Fix

  • Hold-back (and its fail-closed overflow) now engages only when the resolved policy actually holds back (Window/BufferFull — any block-capable chain). Block-mode behavior is unchanged, including the fail-closed cap.
  • An EndOfStreamCheck chain (monitor-only) forwards the SSE live and runs the same two-phase scan (blob check + segment pass) at end-of-stream, so would_block/would_mask monitor hits still reach telemetry. The scan runs while the completion guard stays armed: SDK clients close the connection right after the terminal frame, and a disconnect mid-scan must fall back to the guard's Drop emit rather than lose the usage event for a fully-delivered stream. Scan input is bounded to the same 256 KiB on both paths so observation provider calls stay bounded.
  • A Block verdict on the live path (only reachable via the documented mandatory-unavailability composition) is logged and, on the bridge path, signalled with a trailing error frame — mirroring chat's EndOfStreamCheck behavior.

Behavior change

Monitor-mode-only output chains on streaming /v1/responses: clients now receive tokens live (no whole-response buffering latency) and oversized responses are no longer rejected. Blocking chains: no change.

LiteLLM baseline

LiteLLM never withholds or fails a stream for logging-only / on_flagged: monitor guardrails, and has no scan-buffer size cap at all — live-forward + observe-at-end matches the baseline. Keeping the fail-closed cap for blocking chains is our stricter, OOM-bounding divergence (pre-existing, unchanged).

Tests

  • Unit (responses.rs): oversized (300 KB) stream + monitor guardrail released with 200 on both paths (verbatim + cross-provider bridge) — both fail before the fix (mutation-verified); live-path would_block observation recorded on the usage event; disconnect-during-scan still emits the usage event (parks the scan on a delayed moderation backend, drops the body — mutation-verified against the take-before-await shape); existing block-mode oversized fail-closed tests still pass unchanged.
  • E2E (tests/e2e): self-gating block→monitor flip on a >256 KiB /v1/responses stream — block mode 422s (pins the secure default), monitor mode releases the full SSE live.

Fixes api7/AISIX-Cloud#1010

…fail streaming closed
The streaming /v1/responses path (both the verbatim OpenAI forward and
the cross-provider bridge) entered the whole-response hold-back branch
whenever ANY output-hook guardrail was attached, ignoring the chain's
resolved stream_output_policy. A monitor-only chain resolves to
EndOfStreamCheck — it can never block by definition — yet its stream was
fully buffered (no bytes until end of generation) and, past the 256 KiB
cap, rejected 422 content_filter. Monitor mode could therefore block
exactly the long generations Codex produces: 422 + 0 tokens + tens of
seconds latency, intermittently. chat.rs and messages.rs already gate
hold-back on the policy; /v1/responses was the one deviating surface.
Now hold-back engages only when the resolved policy holds back
(Window/BufferFull — any block-capable chain, unchanged fail-closed
secure default). An EndOfStreamCheck chain forwards the SSE live and
runs the same two-phase scan (blob check + segment pass) at
end-of-stream so would-block / would-mask monitor hits still reach
telemetry; a Block verdict there (only reachable via the documented
mandatory-unavailability composition) is signalled with a trailing
error frame on the bridge path, mirroring chat's EndOfStreamCheck
behavior.
LiteLLM baseline: logging-only / on_flagged=monitor guardrails never
withhold or fail a stream, and no scan-buffer size cap exists at all —
the live-forward + observe-at-end behavior matches; keeping the
fail-closed cap for blocking chains is our stricter (OOM-bounding)
divergence, unchanged here.
Fixesapi7/AISIX-Cloud#1010
@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

/v1/responses streaming now distinguishes monitor-only output guardrails from hold-back enforcement, performs clean end-of-stream scans, propagates monitor hits to telemetry, and tests both verbatim and cross-provider oversized streams.

Changes

Responses streaming guardrails

Layer / File(s)Summary
OpenAI streaming policy and telemetry
crates/aisix-proxy/src/responses.rs
Shared guardrail chains and output policies control buffering; clean stream completion scans captured output and includes output monitor hits in usage events.
Cross-provider bridge streaming policy
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Bridge buffering, overflow handling, scanning, and redaction now differ between hold-back and live-forward modes.
Monitor-mode streaming regression coverage
tests/e2e/src/cases/responses-streaming-monitor-guardrail-e2e.test.ts, crates/aisix-proxy/src/responses.rs
Tests verify oversized streams block in BLOCK mode but complete with forbidden content and monitor observations in MONITOR mode.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ResponsesHandler
participant StreamingPath
participant Guardrail
participant UsageEvent
Client->>ResponsesHandler: request streaming /v1/responses
ResponsesHandler->>StreamingPath: apply output policy
StreamingPath-->>Client: forward live SSE or hold frames
StreamingPath->>Guardrail: scan output at stream end
Guardrail-->>StreamingPath: monitor hits or redaction result
StreamingPath->>UsageEvent: emit usage and monitor hits
Loading

Possibly related PRs

  • api7/aisix#640: Defines the monitor-mode stream_output_policy behavior used to select end-of-stream checking.
  • api7/aisix#731: Adds the monitor-hit model and telemetry APIs consumed by this streaming completion flow.
  • api7/aisix#694: Overlaps with the bridged Responses stream redaction and held-frame handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 inconclusive)

Check nameStatusExplanationResolution
E2e Test Quality Review❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Security Check❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedThe changes address #1010 by keeping monitor-only guardrails from blocking oversized streaming responses while still recording monitor hits.
Out of Scope Changes check✅ PassedThe code and test changes stay focused on streaming output-guardrail behavior and related regression coverage.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly describes the main change: monitor-mode output guardrails should stream live without hold-back or fail-closed behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/responses-monitor-stream-holdback

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

…d bridge live-scan text
Audit findings on the live-forward path:
- H1: the explicit completion took the guard slot BEFORE awaiting the
end-of-stream observation. SDK clients close the connection right
after the terminal frame, dropping the generator at that await — a
fully-delivered 200 stream then emitted no UsageEvent at all
(billing/logs/TPM post-stream accounting lost) whenever the monitor
chain contained a remote provider. The scan now runs while the guard
stays armed (reading the captured text via a non-consuming clone),
so a mid-scan disconnect falls back to the guard's Drop emit, and
only then does the explicit completion take the slot. Regression
test parks the scan on a delayed moderation backend, drops the body,
and asserts the event still arrives (mutation-verified).
- M1: the bridge live path fed the unbounded assembled text to the
scan (the hold-back cap no longer applies there); it is now
truncated to DEFAULT_STREAM_OUTPUT_BUFFER_BYTES on a char boundary,
matching the verbatim path's EosOutputScan bound.
- L1: the masked-segment capture rebuild is gated on hold-back mode —
the live walk is read-only, so a masked outcome there must not
clobber the capture from the empty joined buffer.
- L4: correct the capture-cap comment (terminal text is bounded by the
SSE frame cap and re-truncated per consumer, not by the scan bound).
@jarvis9443
jarvis9443 merged commit e37f015 into mainJul 13, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/responses-monitor-stream-holdback branch July 13, 2026 12:32
Sign up for freeto 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

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed - #760

Merged
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback
Jul 13, 2026
Merged

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed#760
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

AISIX-Cloud#1010: a customer running 0.3.1 with an aliyun output guardrail in monitor mode saw intermittent 422 rejections on Codex traffic — dashboard rows showing content_filter, 0 tokens, $0.0000, and 16–26 s latency. Monitor mode is documented to observe and never block.

Root cause: the streaming /v1/responses path (both the verbatim OpenAI forward and the cross-provider bridge) entered the whole-response hold-back branch whenever any output-hook guardrail was attached, ignoring the chain's resolved stream_output_policy:

  • a monitor-only chain resolves to EndOfStreamCheck (can never block), yet its stream was fully buffered — the client saw nothing until the generation finished;
  • past the buffer cap (DEFAULT_STREAM_OUTPUT_BUFFER_BYTES = 256 KiB, hit by exactly the long SSE streams Codex produces), the request was rejected 422 content_filterbefore any verdict ran — MonitorGuardrail never got the chance to downgrade.

chat.rs and messages.rs already gate hold-back on the policy (holds_back() / BufferFull-only); /v1/responses was the one deviating surface (family audit: completions/audio/passthrough/realtime have no streaming output-buffer path).

Fix

  • Hold-back (and its fail-closed overflow) now engages only when the resolved policy actually holds back (Window/BufferFull — any block-capable chain). Block-mode behavior is unchanged, including the fail-closed cap.
  • An EndOfStreamCheck chain (monitor-only) forwards the SSE live and runs the same two-phase scan (blob check + segment pass) at end-of-stream, so would_block/would_mask monitor hits still reach telemetry. The scan runs while the completion guard stays armed: SDK clients close the connection right after the terminal frame, and a disconnect mid-scan must fall back to the guard's Drop emit rather than lose the usage event for a fully-delivered stream. Scan input is bounded to the same 256 KiB on both paths so observation provider calls stay bounded.
  • A Block verdict on the live path (only reachable via the documented mandatory-unavailability composition) is logged and, on the bridge path, signalled with a trailing error frame — mirroring chat's EndOfStreamCheck behavior.

Behavior change

Monitor-mode-only output chains on streaming /v1/responses: clients now receive tokens live (no whole-response buffering latency) and oversized responses are no longer rejected. Blocking chains: no change.

LiteLLM baseline

LiteLLM never withholds or fails a stream for logging-only / on_flagged: monitor guardrails, and has no scan-buffer size cap at all — live-forward + observe-at-end matches the baseline. Keeping the fail-closed cap for blocking chains is our stricter, OOM-bounding divergence (pre-existing, unchanged).

Tests

  • Unit (responses.rs): oversized (300 KB) stream + monitor guardrail released with 200 on both paths (verbatim + cross-provider bridge) — both fail before the fix (mutation-verified); live-path would_block observation recorded on the usage event; disconnect-during-scan still emits the usage event (parks the scan on a delayed moderation backend, drops the body — mutation-verified against the take-before-await shape); existing block-mode oversized fail-closed tests still pass unchanged.
  • E2E (tests/e2e): self-gating block→monitor flip on a >256 KiB /v1/responses stream — block mode 422s (pins the secure default), monitor mode releases the full SSE live.

Fixes api7/AISIX-Cloud#1010

…fail streaming closed
The streaming /v1/responses path (both the verbatim OpenAI forward and
the cross-provider bridge) entered the whole-response hold-back branch
whenever ANY output-hook guardrail was attached, ignoring the chain's
resolved stream_output_policy. A monitor-only chain resolves to
EndOfStreamCheck — it can never block by definition — yet its stream was
fully buffered (no bytes until end of generation) and, past the 256 KiB
cap, rejected 422 content_filter. Monitor mode could therefore block
exactly the long generations Codex produces: 422 + 0 tokens + tens of
seconds latency, intermittently. chat.rs and messages.rs already gate
hold-back on the policy; /v1/responses was the one deviating surface.
Now hold-back engages only when the resolved policy holds back
(Window/BufferFull — any block-capable chain, unchanged fail-closed
secure default). An EndOfStreamCheck chain forwards the SSE live and
runs the same two-phase scan (blob check + segment pass) at
end-of-stream so would-block / would-mask monitor hits still reach
telemetry; a Block verdict there (only reachable via the documented
mandatory-unavailability composition) is signalled with a trailing
error frame on the bridge path, mirroring chat's EndOfStreamCheck
behavior.
LiteLLM baseline: logging-only / on_flagged=monitor guardrails never
withhold or fail a stream, and no scan-buffer size cap exists at all —
the live-forward + observe-at-end behavior matches; keeping the
fail-closed cap for blocking chains is our stricter (OOM-bounding)
divergence, unchanged here.
Fixesapi7/AISIX-Cloud#1010
@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

/v1/responses streaming now distinguishes monitor-only output guardrails from hold-back enforcement, performs clean end-of-stream scans, propagates monitor hits to telemetry, and tests both verbatim and cross-provider oversized streams.

Changes

Responses streaming guardrails

Layer / File(s)Summary
OpenAI streaming policy and telemetry
crates/aisix-proxy/src/responses.rs
Shared guardrail chains and output policies control buffering; clean stream completion scans captured output and includes output monitor hits in usage events.
Cross-provider bridge streaming policy
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Bridge buffering, overflow handling, scanning, and redaction now differ between hold-back and live-forward modes.
Monitor-mode streaming regression coverage
tests/e2e/src/cases/responses-streaming-monitor-guardrail-e2e.test.ts, crates/aisix-proxy/src/responses.rs
Tests verify oversized streams block in BLOCK mode but complete with forbidden content and monitor observations in MONITOR mode.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ResponsesHandler
participant StreamingPath
participant Guardrail
participant UsageEvent
Client->>ResponsesHandler: request streaming /v1/responses
ResponsesHandler->>StreamingPath: apply output policy
StreamingPath-->>Client: forward live SSE or hold frames
StreamingPath->>Guardrail: scan output at stream end
Guardrail-->>StreamingPath: monitor hits or redaction result
StreamingPath->>UsageEvent: emit usage and monitor hits
Loading

Possibly related PRs

  • api7/aisix#640: Defines the monitor-mode stream_output_policy behavior used to select end-of-stream checking.
  • api7/aisix#731: Adds the monitor-hit model and telemetry APIs consumed by this streaming completion flow.
  • api7/aisix#694: Overlaps with the bridged Responses stream redaction and held-frame handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 inconclusive)

Check nameStatusExplanationResolution
E2e Test Quality Review❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Security Check❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedThe changes address #1010 by keeping monitor-only guardrails from blocking oversized streaming responses while still recording monitor hits.
Out of Scope Changes check✅ PassedThe code and test changes stay focused on streaming output-guardrail behavior and related regression coverage.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly describes the main change: monitor-mode output guardrails should stream live without hold-back or fail-closed behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/responses-monitor-stream-holdback

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

…d bridge live-scan text
Audit findings on the live-forward path:
- H1: the explicit completion took the guard slot BEFORE awaiting the
end-of-stream observation. SDK clients close the connection right
after the terminal frame, dropping the generator at that await — a
fully-delivered 200 stream then emitted no UsageEvent at all
(billing/logs/TPM post-stream accounting lost) whenever the monitor
chain contained a remote provider. The scan now runs while the guard
stays armed (reading the captured text via a non-consuming clone),
so a mid-scan disconnect falls back to the guard's Drop emit, and
only then does the explicit completion take the slot. Regression
test parks the scan on a delayed moderation backend, drops the body,
and asserts the event still arrives (mutation-verified).
- M1: the bridge live path fed the unbounded assembled text to the
scan (the hold-back cap no longer applies there); it is now
truncated to DEFAULT_STREAM_OUTPUT_BUFFER_BYTES on a char boundary,
matching the verbatim path's EosOutputScan bound.
- L1: the masked-segment capture rebuild is gated on hold-back mode —
the live walk is read-only, so a masked outcome there must not
clobber the capture from the empty joined buffer.
- L4: correct the capture-cap comment (terminal text is bounded by the
SSE frame cap and re-truncated per consumer, not by the scan bound).
@jarvis9443
jarvis9443 merged commit e37f015 into mainJul 13, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/responses-monitor-stream-holdback branch July 13, 2026 12:32
Sign up for freeto 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

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed - #760

Merged
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback
Jul 13, 2026
Merged

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed#760
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

AISIX-Cloud#1010: a customer running 0.3.1 with an aliyun output guardrail in monitor mode saw intermittent 422 rejections on Codex traffic — dashboard rows showing content_filter, 0 tokens, $0.0000, and 16–26 s latency. Monitor mode is documented to observe and never block.

Root cause: the streaming /v1/responses path (both the verbatim OpenAI forward and the cross-provider bridge) entered the whole-response hold-back branch whenever any output-hook guardrail was attached, ignoring the chain's resolved stream_output_policy:

  • a monitor-only chain resolves to EndOfStreamCheck (can never block), yet its stream was fully buffered — the client saw nothing until the generation finished;
  • past the buffer cap (DEFAULT_STREAM_OUTPUT_BUFFER_BYTES = 256 KiB, hit by exactly the long SSE streams Codex produces), the request was rejected 422 content_filterbefore any verdict ran — MonitorGuardrail never got the chance to downgrade.

chat.rs and messages.rs already gate hold-back on the policy (holds_back() / BufferFull-only); /v1/responses was the one deviating surface (family audit: completions/audio/passthrough/realtime have no streaming output-buffer path).

Fix

  • Hold-back (and its fail-closed overflow) now engages only when the resolved policy actually holds back (Window/BufferFull — any block-capable chain). Block-mode behavior is unchanged, including the fail-closed cap.
  • An EndOfStreamCheck chain (monitor-only) forwards the SSE live and runs the same two-phase scan (blob check + segment pass) at end-of-stream, so would_block/would_mask monitor hits still reach telemetry. The scan runs while the completion guard stays armed: SDK clients close the connection right after the terminal frame, and a disconnect mid-scan must fall back to the guard's Drop emit rather than lose the usage event for a fully-delivered stream. Scan input is bounded to the same 256 KiB on both paths so observation provider calls stay bounded.
  • A Block verdict on the live path (only reachable via the documented mandatory-unavailability composition) is logged and, on the bridge path, signalled with a trailing error frame — mirroring chat's EndOfStreamCheck behavior.

Behavior change

Monitor-mode-only output chains on streaming /v1/responses: clients now receive tokens live (no whole-response buffering latency) and oversized responses are no longer rejected. Blocking chains: no change.

LiteLLM baseline

LiteLLM never withholds or fails a stream for logging-only / on_flagged: monitor guardrails, and has no scan-buffer size cap at all — live-forward + observe-at-end matches the baseline. Keeping the fail-closed cap for blocking chains is our stricter, OOM-bounding divergence (pre-existing, unchanged).

Tests

  • Unit (responses.rs): oversized (300 KB) stream + monitor guardrail released with 200 on both paths (verbatim + cross-provider bridge) — both fail before the fix (mutation-verified); live-path would_block observation recorded on the usage event; disconnect-during-scan still emits the usage event (parks the scan on a delayed moderation backend, drops the body — mutation-verified against the take-before-await shape); existing block-mode oversized fail-closed tests still pass unchanged.
  • E2E (tests/e2e): self-gating block→monitor flip on a >256 KiB /v1/responses stream — block mode 422s (pins the secure default), monitor mode releases the full SSE live.

Fixes api7/AISIX-Cloud#1010

…fail streaming closed
The streaming /v1/responses path (both the verbatim OpenAI forward and
the cross-provider bridge) entered the whole-response hold-back branch
whenever ANY output-hook guardrail was attached, ignoring the chain's
resolved stream_output_policy. A monitor-only chain resolves to
EndOfStreamCheck — it can never block by definition — yet its stream was
fully buffered (no bytes until end of generation) and, past the 256 KiB
cap, rejected 422 content_filter. Monitor mode could therefore block
exactly the long generations Codex produces: 422 + 0 tokens + tens of
seconds latency, intermittently. chat.rs and messages.rs already gate
hold-back on the policy; /v1/responses was the one deviating surface.
Now hold-back engages only when the resolved policy holds back
(Window/BufferFull — any block-capable chain, unchanged fail-closed
secure default). An EndOfStreamCheck chain forwards the SSE live and
runs the same two-phase scan (blob check + segment pass) at
end-of-stream so would-block / would-mask monitor hits still reach
telemetry; a Block verdict there (only reachable via the documented
mandatory-unavailability composition) is signalled with a trailing
error frame on the bridge path, mirroring chat's EndOfStreamCheck
behavior.
LiteLLM baseline: logging-only / on_flagged=monitor guardrails never
withhold or fail a stream, and no scan-buffer size cap exists at all —
the live-forward + observe-at-end behavior matches; keeping the
fail-closed cap for blocking chains is our stricter (OOM-bounding)
divergence, unchanged here.
Fixesapi7/AISIX-Cloud#1010
@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

/v1/responses streaming now distinguishes monitor-only output guardrails from hold-back enforcement, performs clean end-of-stream scans, propagates monitor hits to telemetry, and tests both verbatim and cross-provider oversized streams.

Changes

Responses streaming guardrails

Layer / File(s)Summary
OpenAI streaming policy and telemetry
crates/aisix-proxy/src/responses.rs
Shared guardrail chains and output policies control buffering; clean stream completion scans captured output and includes output monitor hits in usage events.
Cross-provider bridge streaming policy
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Bridge buffering, overflow handling, scanning, and redaction now differ between hold-back and live-forward modes.
Monitor-mode streaming regression coverage
tests/e2e/src/cases/responses-streaming-monitor-guardrail-e2e.test.ts, crates/aisix-proxy/src/responses.rs
Tests verify oversized streams block in BLOCK mode but complete with forbidden content and monitor observations in MONITOR mode.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ResponsesHandler
participant StreamingPath
participant Guardrail
participant UsageEvent
Client->>ResponsesHandler: request streaming /v1/responses
ResponsesHandler->>StreamingPath: apply output policy
StreamingPath-->>Client: forward live SSE or hold frames
StreamingPath->>Guardrail: scan output at stream end
Guardrail-->>StreamingPath: monitor hits or redaction result
StreamingPath->>UsageEvent: emit usage and monitor hits
Loading

Possibly related PRs

  • api7/aisix#640: Defines the monitor-mode stream_output_policy behavior used to select end-of-stream checking.
  • api7/aisix#731: Adds the monitor-hit model and telemetry APIs consumed by this streaming completion flow.
  • api7/aisix#694: Overlaps with the bridged Responses stream redaction and held-frame handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 inconclusive)

Check nameStatusExplanationResolution
E2e Test Quality Review❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Security Check❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedThe changes address #1010 by keeping monitor-only guardrails from blocking oversized streaming responses while still recording monitor hits.
Out of Scope Changes check✅ PassedThe code and test changes stay focused on streaming output-guardrail behavior and related regression coverage.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly describes the main change: monitor-mode output guardrails should stream live without hold-back or fail-closed behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/responses-monitor-stream-holdback

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

…d bridge live-scan text
Audit findings on the live-forward path:
- H1: the explicit completion took the guard slot BEFORE awaiting the
end-of-stream observation. SDK clients close the connection right
after the terminal frame, dropping the generator at that await — a
fully-delivered 200 stream then emitted no UsageEvent at all
(billing/logs/TPM post-stream accounting lost) whenever the monitor
chain contained a remote provider. The scan now runs while the guard
stays armed (reading the captured text via a non-consuming clone),
so a mid-scan disconnect falls back to the guard's Drop emit, and
only then does the explicit completion take the slot. Regression
test parks the scan on a delayed moderation backend, drops the body,
and asserts the event still arrives (mutation-verified).
- M1: the bridge live path fed the unbounded assembled text to the
scan (the hold-back cap no longer applies there); it is now
truncated to DEFAULT_STREAM_OUTPUT_BUFFER_BYTES on a char boundary,
matching the verbatim path's EosOutputScan bound.
- L1: the masked-segment capture rebuild is gated on hold-back mode —
the live walk is read-only, so a masked outcome there must not
clobber the capture from the empty joined buffer.
- L4: correct the capture-cap comment (terminal text is bounded by the
SSE frame cap and re-truncated per consumer, not by the scan bound).
@jarvis9443
jarvis9443 merged commit e37f015 into mainJul 13, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/responses-monitor-stream-holdback branch July 13, 2026 12:32
Sign up for freeto 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

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed - #760

Merged
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback
Jul 13, 2026
Merged

fix(responses): monitor-mode output guardrails must not hold back or fail streaming closed#760
jarvis9443 merged 2 commits into
mainfrom
fix/responses-monitor-stream-holdback

Conversation

@jarvis9443

@jarvis9443jarvis9443 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

AISIX-Cloud#1010: a customer running 0.3.1 with an aliyun output guardrail in monitor mode saw intermittent 422 rejections on Codex traffic — dashboard rows showing content_filter, 0 tokens, $0.0000, and 16–26 s latency. Monitor mode is documented to observe and never block.

Root cause: the streaming /v1/responses path (both the verbatim OpenAI forward and the cross-provider bridge) entered the whole-response hold-back branch whenever any output-hook guardrail was attached, ignoring the chain's resolved stream_output_policy:

  • a monitor-only chain resolves to EndOfStreamCheck (can never block), yet its stream was fully buffered — the client saw nothing until the generation finished;
  • past the buffer cap (DEFAULT_STREAM_OUTPUT_BUFFER_BYTES = 256 KiB, hit by exactly the long SSE streams Codex produces), the request was rejected 422 content_filterbefore any verdict ran — MonitorGuardrail never got the chance to downgrade.

chat.rs and messages.rs already gate hold-back on the policy (holds_back() / BufferFull-only); /v1/responses was the one deviating surface (family audit: completions/audio/passthrough/realtime have no streaming output-buffer path).

Fix

  • Hold-back (and its fail-closed overflow) now engages only when the resolved policy actually holds back (Window/BufferFull — any block-capable chain). Block-mode behavior is unchanged, including the fail-closed cap.
  • An EndOfStreamCheck chain (monitor-only) forwards the SSE live and runs the same two-phase scan (blob check + segment pass) at end-of-stream, so would_block/would_mask monitor hits still reach telemetry. The scan runs while the completion guard stays armed: SDK clients close the connection right after the terminal frame, and a disconnect mid-scan must fall back to the guard's Drop emit rather than lose the usage event for a fully-delivered stream. Scan input is bounded to the same 256 KiB on both paths so observation provider calls stay bounded.
  • A Block verdict on the live path (only reachable via the documented mandatory-unavailability composition) is logged and, on the bridge path, signalled with a trailing error frame — mirroring chat's EndOfStreamCheck behavior.

Behavior change

Monitor-mode-only output chains on streaming /v1/responses: clients now receive tokens live (no whole-response buffering latency) and oversized responses are no longer rejected. Blocking chains: no change.

LiteLLM baseline

LiteLLM never withholds or fails a stream for logging-only / on_flagged: monitor guardrails, and has no scan-buffer size cap at all — live-forward + observe-at-end matches the baseline. Keeping the fail-closed cap for blocking chains is our stricter, OOM-bounding divergence (pre-existing, unchanged).

Tests

  • Unit (responses.rs): oversized (300 KB) stream + monitor guardrail released with 200 on both paths (verbatim + cross-provider bridge) — both fail before the fix (mutation-verified); live-path would_block observation recorded on the usage event; disconnect-during-scan still emits the usage event (parks the scan on a delayed moderation backend, drops the body — mutation-verified against the take-before-await shape); existing block-mode oversized fail-closed tests still pass unchanged.
  • E2E (tests/e2e): self-gating block→monitor flip on a >256 KiB /v1/responses stream — block mode 422s (pins the secure default), monitor mode releases the full SSE live.

Fixes api7/AISIX-Cloud#1010

…fail streaming closed
The streaming /v1/responses path (both the verbatim OpenAI forward and
the cross-provider bridge) entered the whole-response hold-back branch
whenever ANY output-hook guardrail was attached, ignoring the chain's
resolved stream_output_policy. A monitor-only chain resolves to
EndOfStreamCheck — it can never block by definition — yet its stream was
fully buffered (no bytes until end of generation) and, past the 256 KiB
cap, rejected 422 content_filter. Monitor mode could therefore block
exactly the long generations Codex produces: 422 + 0 tokens + tens of
seconds latency, intermittently. chat.rs and messages.rs already gate
hold-back on the policy; /v1/responses was the one deviating surface.
Now hold-back engages only when the resolved policy holds back
(Window/BufferFull — any block-capable chain, unchanged fail-closed
secure default). An EndOfStreamCheck chain forwards the SSE live and
runs the same two-phase scan (blob check + segment pass) at
end-of-stream so would-block / would-mask monitor hits still reach
telemetry; a Block verdict there (only reachable via the documented
mandatory-unavailability composition) is signalled with a trailing
error frame on the bridge path, mirroring chat's EndOfStreamCheck
behavior.
LiteLLM baseline: logging-only / on_flagged=monitor guardrails never
withhold or fail a stream, and no scan-buffer size cap exists at all —
the live-forward + observe-at-end behavior matches; keeping the
fail-closed cap for blocking chains is our stricter (OOM-bounding)
divergence, unchanged here.
Fixesapi7/AISIX-Cloud#1010
@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

/v1/responses streaming now distinguishes monitor-only output guardrails from hold-back enforcement, performs clean end-of-stream scans, propagates monitor hits to telemetry, and tests both verbatim and cross-provider oversized streams.

Changes

Responses streaming guardrails

Layer / File(s)Summary
OpenAI streaming policy and telemetry
crates/aisix-proxy/src/responses.rs
Shared guardrail chains and output policies control buffering; clean stream completion scans captured output and includes output monitor hits in usage events.
Cross-provider bridge streaming policy
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Bridge buffering, overflow handling, scanning, and redaction now differ between hold-back and live-forward modes.
Monitor-mode streaming regression coverage
tests/e2e/src/cases/responses-streaming-monitor-guardrail-e2e.test.ts, crates/aisix-proxy/src/responses.rs
Tests verify oversized streams block in BLOCK mode but complete with forbidden content and monitor observations in MONITOR mode.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ResponsesHandler
participant StreamingPath
participant Guardrail
participant UsageEvent
Client->>ResponsesHandler: request streaming /v1/responses
ResponsesHandler->>StreamingPath: apply output policy
StreamingPath-->>Client: forward live SSE or hold frames
StreamingPath->>Guardrail: scan output at stream end
Guardrail-->>StreamingPath: monitor hits or redaction result
StreamingPath->>UsageEvent: emit usage and monitor hits
Loading

Possibly related PRs

  • api7/aisix#640: Defines the monitor-mode stream_output_policy behavior used to select end-of-stream checking.
  • api7/aisix#731: Adds the monitor-hit model and telemetry APIs consumed by this streaming completion flow.
  • api7/aisix#694: Overlaps with the bridged Responses stream redaction and held-frame handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 inconclusive)

Check nameStatusExplanationResolution
E2e Test Quality Review❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Security Check❓ InconclusiveRepository clone failed, so this custom check could not run with code access.Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedThe changes address #1010 by keeping monitor-only guardrails from blocking oversized streaming responses while still recording monitor hits.
Out of Scope Changes check✅ PassedThe code and test changes stay focused on streaming output-guardrail behavior and related regression coverage.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly describes the main change: monitor-mode output guardrails should stream live without hold-back or fail-closed behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/responses-monitor-stream-holdback

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

…d bridge live-scan text
Audit findings on the live-forward path:
- H1: the explicit completion took the guard slot BEFORE awaiting the
end-of-stream observation. SDK clients close the connection right
after the terminal frame, dropping the generator at that await — a
fully-delivered 200 stream then emitted no UsageEvent at all
(billing/logs/TPM post-stream accounting lost) whenever the monitor
chain contained a remote provider. The scan now runs while the guard
stays armed (reading the captured text via a non-consuming clone),
so a mid-scan disconnect falls back to the guard's Drop emit, and
only then does the explicit completion take the slot. Regression
test parks the scan on a delayed moderation backend, drops the body,
and asserts the event still arrives (mutation-verified).
- M1: the bridge live path fed the unbounded assembled text to the
scan (the hold-back cap no longer applies there); it is now
truncated to DEFAULT_STREAM_OUTPUT_BUFFER_BYTES on a char boundary,
matching the verbatim path's EosOutputScan bound.
- L1: the masked-segment capture rebuild is gated on hold-back mode —
the live walk is read-only, so a masked outcome there must not
clobber the capture from the empty joined buffer.
- L4: correct the capture-cap comment (terminal text is bounded by the
SSE frame cap and re-truncated per consumer, not by the scan bound).
@jarvis9443
jarvis9443 merged commit e37f015 into mainJul 13, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/responses-monitor-stream-holdback branch July 13, 2026 12:32
Sign up for freeto 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

@jarvis9443