Skip to content

fix(proxy): omit terminal [DONE] when stream terminates abnormally - #198

Merged
moonming merged 3 commits into
mainfrom
fix/177-streaming-abnormal-termination
May 10, 2026
Merged

fix(proxy): omit terminal [DONE] when stream terminates abnormally#198
moonming merged 3 commits into
mainfrom
fix/177-streaming-abnormal-termination

Conversation

@moonming

@moonming moonming commented May 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #177.

Problem

Per gateway docs §5 (`docs/api-proxy.md`):

If the upstream stream terminates abnormally, aisix sends a final error chunk and closes the response without `[DONE]`. Client SDKs that interpret missing `[DONE]` as an error will surface the right error class.

The proxy's `build_sse_stream` (`crates/aisix-proxy/src/chat.rs`) correctly emitted an SSE `event: error` chunk on upstream errors — but then unconditionally appended `data: [DONE]\n\n` afterward. SDK consumers that key off `[DONE]` as the clean-completion signal got tricked into treating truncated streams as complete.

Fix

Track whether an error event has been yielded during the stream loop; only emit the terminal `[DONE]` on clean completion. The `on_complete` token-accounting callback still fires unconditionally so TPM/usage telemetry isn't lost on the abnormal-termination path.

```rust
let mut errored = false;
while let Some(item) = upstream.next().await {
let ev = match item {
Ok(chunk) => { ... }
Err(err) => {
errored = true;
Event::default().event("error").data(err.to_string())
}
};
yield Ok::<, Infallible>(ev);
}
on_complete(total_tokens);
if !errored {
yield Ok::<
, Infallible>(Event::default().data("[DONE]"));
}
```

Tests

E2E regression test (`tests/e2e/src/cases/streaming-edges-e2e.test.ts`): restored "upstream disconnects mid-stream" case from the held-back queue. Mock upstream emits 2 SSE chunks then closes the TCP connection (no `[DONE]`). Strict assertions per docs §5:

  • `collected.join("") === "partial "` — chunks 1 + 2 reach the caller (the bytes the upstream emitted before the close)
  • `sawFinish === false` — no synthetic `finish_reason: "stop"` injection (the chunk carrying the real stop never reached the gateway)
  • `surfacedError === true` — iterator throws on the SSE error event (the OpenAI Node SDK treats `event: error` as a fatal stream error)

`eventDelayMs: 50` between mock writes ensures both chunks flush to the gateway BEFORE `res.destroy()` aborts the connection. Without the delay, write buffering on the mock side races the destroy and the gateway sometimes sees zero chunks (which would surface as a request-time 502 instead of an iterator error, masking the regression test's intent).

Verification

  • `cargo test -p aisix-proxy --lib`: 139/139 passing (incl. new raw-wire unit test)
  • `cargo clippy --workspace -- -D warnings`: clean
  • `streaming-edges-e2e.test.ts`: 2/2 passing (client abort + upstream disconnect)

Audit follow-ups (commits 98dba67, 8ac2f67)

Round 1 (commit 98dba67) — three findings addressed

  • MEDIUM-1 → fixed: SSE `event: error` frames now carry OpenAI-envelope JSON (`{"error": {"message", "type"}}`) instead of a plain string. The OpenAI Node SDK calls `JSON.parse(sse.data)` BEFORE checking `sse.event`, so the previous plain-string payload would surface as a `SyntaxError` instead of the typed `APIError` callers expect. Error-type taxonomy is sourced from the existing `BridgeError::error_type()` mapping for upstream errors and `internal_error` for fatal-stream-encode errors. See `error_frame_payload` helper in `crates/aisix-proxy/src/chat.rs`.

  • HIGH-2 → fixed: added Rust unit test `streaming_response_omits_done_when_upstream_returns_invalid_json_mid_stream` (`crates/aisix-proxy/src/lib.rs`) that drains raw response bytes through the full proxy stack and asserts at the byte level:

    • `!wire.contains("data: [DONE]")` — terminal sentinel absent
    • `wire.contains("event: error")` — error event emitted
    • error-frame data line parses as JSON with `error` key — envelope shape valid

    This pins the docs §5 contract independent of any SDK parser behavior, complementing the e2e test that exercises the same path through the OpenAI Node SDK.

  • MEDIUM-2 → fixed: bumped `eventDelayMs: 50 → 200` in the disconnect-case mock to match the peer client-abort case for consistency.

Round 2 (commit 8ac2f67) — e2e tightening + scope justification

  • MEDIUM-2 (round 2) → fixed: the e2e `surfacedError === true` assertion was satisfied by EITHER the new typed `APIError` path OR the prior plain-string path (`SyntaxError` from `JSON.parse(sse.data)` BEFORE the event-type check). A regression that re-introduced `err.to_string()` raw would still pass. Tightened to assert `errCtor !== "SyntaxError"` and `!errMessage.includes("Could not parse message into JSON")` — pins the user-visible contract that callers see a typed `APIError`, not a parser failure.

  • HIGH-1, HIGH-2 (round 2) → out-of-scope, filed as enhancement: optional strict-mode error envelope sanitization (OpenAI-conformant taxonomy) #199: the audit flagged that `error.message` carries raw `err.to_string()` (which can include upstream provider error text and parser-detail bleed-through) and that `error.type` emits aisix-internal taxonomy (`upstream_decode_error`, `transport_error`, `stream_aborted`, `config_error`) that don't appear in OpenAI's enumerated error.type values. Both leaks pre-exist on the non-streaming path (`crates/aisix-proxy/src/error.rs:127` calls `ProxyError::Bridge(err).to_string()` which transparently forwards to BridgeError's Display, and `error.rs:112` uses `b.error_type()` directly). Fixing only the streaming path here would create divergence; fixing both is out of scope for a streaming-termination PR. Filed as enhancement: optional strict-mode error envelope sanitization (OpenAI-conformant taxonomy) #199 with proposed fix shape (sanitize message + remap type at the proxy boundary, uniformly across both paths).

  • MEDIUM-3 / LOW-1 / LOW-3 → reviewed, deferred: MEDIUM-3 (use SseDecoder for unit-test parsing) is stylistic — the byte-level `event: error` assertion is the better contract pin since it asserts the actual wire format. LOW-1 (unreachable fallback in `error_frame_payload`) is defensive style; CLAUDE.md "never silently swallow errors" supports keeping the fallback. LOW-3 (PR description correction `ProxyError::error_type` → `BridgeError::error_type`) corrected above.

Out of scope

  • Pre-stream errors (upstream RSTs before sending response headers) still surface as request-time 502. Per docs §5 wording — "stream terminates abnormally" — that path isn't a streaming-shape failure and request-time 5xx is the appropriate signal.
  • Anthropic provider's streaming bridge has its own SSE encoder; this fix is scoped to the proxy's wrapping layer (`build_sse_stream`) which handles ALL providers' upstream streams uniformly. Anthropic's wire-shape conversion is a separate concern.

Note on full e2e suite

The full e2e suite has an unrelated failure in `weighted-routing-distribution-e2e` — filed as #197 (pre-existing bug on `main` where 100/0 split lands instead of declared 70/30). This PR does not touch routing; the same test fails on pristine main. `streaming-edges-e2e` (the only test this PR cares about) passes cleanly.

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling for streaming responses: when upstream failures occur during streaming, error events are properly emitted and the completion marker is appropriately skipped.
  • Tests

    • Enhanced test coverage for streaming edge cases, including scenarios where upstream disconnects mid-stream with partial content delivery.

Review Change Stack

Copilot AI review requested due to automatic review settings May 10, 2026 05:15
@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 22 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 62ccf8ef-16c9-4ce5-8766-86e66c7b449a

📥 Commits

Reviewing files that changed from the base of the PR and between 98dba67 and c2e9189.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/lib.rs
  • tests/e2e/src/cases/streaming-edges-e2e.test.ts
📝 Walkthrough

Walkthrough

When an upstream streaming connection errors or disconnects mid-stream, the SSE builder sets an errored flag, emits an event: error SSE frame containing an OpenAI-style JSON envelope, and omits the terminal data: [DONE]; tests and docs cover the behavior.

Changes

Streaming Error Handling

Layer / File(s) Summary
SSE Stream Error Tracking
crates/aisix-proxy/src/chat.rs
build_sse_stream introduces an errored flag that detects chunk serialization failures and upstream stream errors, and controls whether data: [DONE] is emitted.
Per-upstream-item Error Handling
crates/aisix-proxy/src/chat.rs
Chunk render/serialization failures and upstream item errors set errored = true and emit event: error SSE frames with JSON-formatted payloads.
Error Payload Construction
crates/aisix-proxy/src/chat.rs
Adds error_frame_payload(...) to build an OpenAI-style {"error":{...}} JSON envelope with a fallback for serialization failures.
Conditional Completion Emission
crates/aisix-proxy/src/chat.rs
on_complete(total_tokens) is still invoked at stream end, but data: [DONE] is yielded only when no error was observed (errored == false).
Regression Test: malformed mid-stream
crates/aisix-proxy/src/lib.rs
Adds a tokio test that injects malformed JSON mid-stream, asserts an event: error with OpenAI-envelope JSON is emitted, and that no data: [DONE] sentinel is sent.
E2E doc & test: upstream disconnect mid-stream
tests/e2e/src/cases/streaming-edges-e2e.test.ts
Updates streaming-edge comment and adds an E2E test verifying partial chunks reach the caller, no finish_reason is observed, and stream iteration surfaces an error without emitting [DONE].

🎯 3 (Moderate) | ⏱️ ~20 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes the proxy’s SSE streaming termination semantics to match docs/api-proxy.md §5: when an upstream stream errors mid-flight, the proxy must emit an error chunk and close without the terminal data: [DONE], so client SDKs don’t misinterpret truncated streams as clean completions.

Changes:

  • Track whether an SSE event: error was emitted during streaming; only append terminal [DONE] on clean completion.
  • Add an E2E regression test covering “upstream disconnects mid-stream” to assert partial chunk forwarding + iterator-time error + no [DONE] / no synthetic finish_reason:"stop".

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
crates/aisix-proxy/src/chat.rs Conditionally omit terminal [DONE] when a streaming error was yielded.
tests/e2e/src/cases/streaming-edges-e2e.test.ts Adds an E2E case for upstream mid-stream disconnects asserting the documented streaming contract.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 184 to +188
});

test("upstream disconnects mid-stream: partial chunks reach caller, iterator throws, no [DONE]", async (ctx) => {
if (!etcdReachable || !app || !admin) {
ctx.skip();
moonming added a commit that referenced this pull request May 10, 2026
…unit test

Audit follow-ups on PR #198:

- MEDIUM-1: SSE `event: error` frames previously carried a plain
  string `data:` payload. The OpenAI Node SDK calls
  `JSON.parse(sse.data)` BEFORE checking `sse.event === "error"`,
  so a plain-string payload yields a SyntaxError instead of the
  typed APIError callers expect. Emit the OpenAI error-envelope
  shape `{"error": {"message": "...", "type": "..."}}` per
  https://platform.openai.com/docs/guides/error-codes/api-errors.
  The error_type field is sourced from the existing
  ProxyError::error_type() taxonomy for upstream errors and
  defaults to "internal_error" for fatal-stream-encode errors.

- HIGH-2: add Rust unit test
  streaming_response_omits_done_when_upstream_returns_invalid_json_mid_stream
  that drains raw response bytes and asserts:
    * !contains("data: [DONE]") — no terminal sentinel after error
    * contains("event: error") — error event emitted
    * data line parses as JSON with `error` key — envelope shape
  This pins the contract at the byte level, independent of any SDK
  parser behavior.

- MEDIUM-2: bump streaming-edges-e2e disconnect case `eventDelayMs`
  50→200 to match the peer client-abort case.

Verification:
- cargo test -p aisix-proxy --lib: 139/139 passing (incl. new test)
- cargo clippy -p aisix-proxy --tests -- -D warnings: clean
- cargo fmt --check: clean
moonming added a commit that referenced this pull request May 10, 2026
…d APIError from SyntaxError

Audit MEDIUM-2 follow-up on PR #198:

The prior `surfacedError === true` assertion was satisfied by EITHER:

- The new typed `APIError` path — OpenAI Node SDK throws `APIError` after parsing the SSE error frame's JSON envelope
- The OLD plain-string path — SDK calls `JSON.parse(sse.data)` BEFORE checking `sse.event === "error"`, so a plain-string payload yields `SyntaxError("Could not parse message into JSON: ...")`

A regression that re-introduced `err.to_string()` raw on the SSE frame would
still pass the prior assertion. Tighten by capturing the thrown error's class
and message and asserting:

- `errCtor !== "SyntaxError"` — must not be the SDK parser-error path
- `!errMessage.includes("Could not parse message into JSON")` — must not be the SDK parser-error message

This pins the user-visible contract that callers see a typed `APIError`, not a
spurious parser failure, on abnormal stream termination.
Copilot AI review requested due to automatic review settings May 10, 2026 05:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

moonming added 3 commits May 10, 2026 13:54
…loses #177)

Issue: per docs/api-proxy.md §5 ("Streaming protocol details"):
> "If the upstream stream terminates abnormally, aisix sends a
> final error chunk and closes the response without `[DONE]`."

The proxy's `build_sse_stream` (crates/aisix-proxy/src/chat.rs)
correctly emitted an SSE `event: error` chunk on upstream errors
but then unconditionally appended `data: [DONE]\n\n` afterward —
making the truncated stream look complete to SDK consumers that
treat `[DONE]` as the clean-completion signal. Per the same docs
§5: "Client SDKs that interpret missing `[DONE]` as an error will
surface the right error class" — that signal was being silently
overwritten.

Fix: track whether an error event has been yielded during the
stream loop; only emit the terminal `[DONE]` on a clean upstream
completion. The `on_complete` token-accounting callback still
fires unconditionally so TPM/usage telemetry isn't lost on the
abnormal-termination path.

E2E regression test (tests/e2e/src/cases/streaming-edges-e2e.test.ts):
restored "upstream disconnects mid-stream" case from the held-back
queue. Mock upstream emits 2 SSE chunks then `res.destroy()`s the
TCP connection (no `[DONE]`). Strict assertions per docs §5:

  - collected.join("") === "partial " (chunks 1 + 2 reach caller)
  - sawFinish === false (no synthetic `finish_reason: "stop"`
    injection — chunk 4 with the real stop never reached gateway)
  - surfacedError === true (iterator throws on the SSE error
    event, since OpenAI Node SDK treats `event: error` as a
    fatal stream error)

`eventDelayMs: 50` between mock writes ensures both chunks flush
to the gateway BEFORE `res.destroy()` aborts the connection —
without the delay, write buffering on the mock side races the
destroy and the gateway sometimes sees zero chunks (which would
surface as a request-time 502 instead of an iterator error,
masking the regression test's intent).

Verified locally:
  - cargo test -p aisix-proxy --lib: 138/138
  - cargo clippy --workspace -- -D warnings: clean
  - streaming-edges-e2e.test.ts: 2/2 (client abort + upstream
    disconnect)

Note: full e2e suite has an unrelated failure in
weighted-routing-distribution-e2e (filed as #197 — pre-existing
bug on main where 100/0 split lands instead of 70/30). This PR
does not touch routing; verified that the same test fails on
pristine main.
…unit test

Audit follow-ups on PR #198:

- MEDIUM-1: SSE `event: error` frames previously carried a plain
  string `data:` payload. The OpenAI Node SDK calls
  `JSON.parse(sse.data)` BEFORE checking `sse.event === "error"`,
  so a plain-string payload yields a SyntaxError instead of the
  typed APIError callers expect. Emit the OpenAI error-envelope
  shape `{"error": {"message": "...", "type": "..."}}` per
  https://platform.openai.com/docs/guides/error-codes/api-errors.
  The error_type field is sourced from the existing
  ProxyError::error_type() taxonomy for upstream errors and
  defaults to "internal_error" for fatal-stream-encode errors.

- HIGH-2: add Rust unit test
  streaming_response_omits_done_when_upstream_returns_invalid_json_mid_stream
  that drains raw response bytes and asserts:
    * !contains("data: [DONE]") — no terminal sentinel after error
    * contains("event: error") — error event emitted
    * data line parses as JSON with `error` key — envelope shape
  This pins the contract at the byte level, independent of any SDK
  parser behavior.

- MEDIUM-2: bump streaming-edges-e2e disconnect case `eventDelayMs`
  50→200 to match the peer client-abort case.

Verification:
- cargo test -p aisix-proxy --lib: 139/139 passing (incl. new test)
- cargo clippy -p aisix-proxy --tests -- -D warnings: clean
- cargo fmt --check: clean
…d APIError from SyntaxError

Audit MEDIUM-2 follow-up on PR #198:

The prior `surfacedError === true` assertion was satisfied by EITHER:

- The new typed `APIError` path — OpenAI Node SDK throws `APIError` after parsing the SSE error frame's JSON envelope
- The OLD plain-string path — SDK calls `JSON.parse(sse.data)` BEFORE checking `sse.event === "error"`, so a plain-string payload yields `SyntaxError("Could not parse message into JSON: ...")`

A regression that re-introduced `err.to_string()` raw on the SSE frame would
still pass the prior assertion. Tighten by capturing the thrown error's class
and message and asserting:

- `errCtor !== "SyntaxError"` — must not be the SDK parser-error path
- `!errMessage.includes("Could not parse message into JSON")` — must not be the SDK parser-error message

This pins the user-visible contract that callers see a typed `APIError`, not a
spurious parser failure, on abnormal stream termination.
@moonming
moonming force-pushed the fix/177-streaming-abnormal-termination branch from 8ac2f67 to c2e9189 Compare May 10, 2026 05:55
@moonming
moonming merged commit 977e68d into main May 10, 2026
7 checks passed
@moonming
moonming deleted the fix/177-streaming-abnormal-termination branch May 10, 2026 06:03
moonming added a commit that referenced this pull request May 10, 2026
* fix(proxy): run output guardrails on streaming responses (closes #204)

## Problem

Pre-fix the streaming path (`build_sse_stream` in
`crates/aisix-proxy/src/chat.rs`) skipped output guardrails entirely.
A `kind: "keyword"` deny-list configured on `hook_point: "output"`
fired correctly on non-streaming responses but was trivially
bypassable by setting `stream: true` — the gateway forwarded SSE
chunks verbatim and never accumulated the assistant text for
guardrail evaluation. So a forbidden literal in a streamed
completion reached the caller untouched.

A guardrail that only fires when someone forgets to enable a flag
isn't a security control. The reference implementation in this
category treats streaming output guardrails as a baseline
requirement (every streaming chat-completion request goes through
the guardrail dispatcher).

## Fix

Buffer-then-check at end-of-stream:

1. `build_sse_stream` now takes an optional `StreamGuardrailContext`
   (a typed wrapper around `Arc<dyn Guardrail>` + the model name for
   tracing). When set, the stream loop accumulates `chunk.delta.content`
   into a `String` buffer.
2. After the loop completes cleanly (no upstream error), if a
   guardrail context is present, synthesize a `ChatResponse` from the
   accumulated content + the StreamCompletion's tracked usage stats
   and call `chain.check_output(&resp).await`.
3. On `Block { reason }`:
   - Mirror #153's wire-level redaction: emit an SSE `event: error`
     frame with the OpenAI envelope shape (`error.type:
     "content_filter"`, generic `"response blocked by content policy"`
     message — no matched-literal leak).
   - Set `errored = true` so the terminal `[DONE]` is suppressed
     (per docs §5 abnormal-termination contract).
   - Emit `tracing::warn!` with the rich verdict reason (operator-
     side log carries the matched detail; wire-side does not).
   - Set `comp.guardrail_blocked = true` so the post-stream
     telemetry callback records the block on `usage_events`.

The call site in `dispatch_streaming` always passes `Some(ctx)` —
the chain itself short-circuits when no policies are configured, so
the per-chunk overhead for guardrail-free deployments is just a
single `Option::is_some()` check + an unused `String` allocation.

## Trade-off (documented)

Per-chunk evaluation is wrong for blocking guardrails: by the time
chunk N matches the forbidden literal, chunks 1..N-1 have already
been emitted — the secret leaks regardless. Buffer-then-check
trades latency-to-first-completion for the security guarantee.
Streaming masking-style guardrails (where partial leakage is
repaired by rewriting tokens) could use a different cadence; that's
a v2 concern.

## Tests

**Rust unit (`crates/aisix-proxy/src/lib.rs`)** — new
`streaming_output_guardrail_blocks_with_sse_error_event_and_no_done`
test that drains raw response bytes through the full proxy stack
and asserts:
- `!wire.contains("data: [DONE]")` — terminal sentinel suppressed
- `wire.contains("event: error")` — error frame emitted
- error-frame data parses as JSON with `error.type === "content_filter"`
- error message equals the redacted static string
- error message does NOT contain the matched literal

**E2E (`tests/e2e/src/cases/guardrail-output-e2e.test.ts`)** — added
streaming case to the existing output-guardrail describe block.
Sets up a separate streaming upstream + Model + caller key sharing
the existing env-wide guardrail policy. Sends `stream: true` request
via raw `fetch`, asserts the wire-level shape (no `[DONE]`,
`event: error` present, OpenAI envelope, redacted message, no
forbidden literal in the error envelope, upstream IS hit per
buffer-then-check semantics).

## Verification

- `cargo test -p aisix-proxy --lib`: **158/158 passing** (incl. new test)
- `cargo clippy --workspace --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean

## References

- Issue: #204 (`bug` + `vulnerability`)
- `docs/api-proxy.md` §5 (abnormal-termination contract — same SSE
  shape this fix uses for guardrail blocks)
- #153 (non-streaming guardrail redaction — same wire-level
  contract mirrored to streaming)
- #198 (`error_frame_payload` helper — reused here)

* fix(streaming-guardrail): audit follow-ups — Bypass telemetry, fast-path skip, Allow e2e

Audit fixes on PR #222:

- HIGH-1: Output `Bypass` verdict at end-of-stream was silently
  dropped from telemetry. Pre-fix the streaming arm only matched
  `Block`; `Bypass { reason }` fell through to `[DONE]` with no
  side effect, while the non-streaming path captures Bypass into
  `usage_events.bypass_reason` so operators can audit which policy
  fail-opened. Added `bypass_reason: String` field to
  `StreamCompletion`, populated on the Bypass arm with first-bypass-
  wins semantics. The on_complete closure merges with the input-
  side `bypass_reason_for_telem` snapshot before emitting telemetry.

- MEDIUM-2: No fast-path skip when the guardrail chain is empty —
  `String` allocated per chunk on every streaming request even for
  guardrail-free deployments. Added `is_empty(&self) -> bool`
  default-method to the `Guardrail` trait (returns `false`,
  preserving safe behavior for custom impls); `GuardrailChain`
  overrides to return `self.guardrails.is_empty()`. The streaming
  call site in `chat.rs::dispatch_streaming` now passes
  `Some(StreamGuardrailContext)` only when the chain is non-empty;
  guardrail-free deployments skip both the per-chunk content
  accumulation and the post-loop synthesized-ChatResponse build.

- MEDIUM-3: E2E did not cover the streaming-Allow path — a
  regression that ALWAYS blocks streaming would have passed the
  existing block-case test. Added a companion case in
  `guardrail-output-e2e.test.ts` with a separate clean upstream +
  Model + caller that emits content NOT containing
  `FORBIDDEN_WORD`. Asserts the wire shape: terminal `[DONE]` IS
  present, no `event: error`, full assistant content reaches the
  caller.

- MEDIUM-1 (client-disconnect bypasses guardrail check) →
  filed as #223. The post-loop check sits after `while let
  Some(item) = upstream.next().await`; client disconnect drops
  the generator at the last yield suspension, so the post-loop
  code never executes and `comp.guardrail_blocked` stays
  `false` even when the buffered content would have blocked.
  Telemetry under-counts disconnects-as-blocks. Out of scope
  for this PR (already a step forward vs pre-fix "every
  streaming request bypasses the guardrail"); recommended fix
  shape is to spawn a detached task from the `CompleteOnDrop`
  Drop impl.

- LOW-1, LOW-2 (cosmetic — model_name field, message-content clone):
  reviewed, deferred. Both are micro-optimizations on a path
  already gated behind the is_empty() fast-path, so they don't
  affect the dominant guardrail-free deployment.

Verification:
- cargo test -p aisix-proxy --lib: 158/158 passing
- cargo test -p aisix-guardrails --lib: 42/42 passing
- cargo clippy --workspace --lib --tests -- -D warnings: clean
- cargo fmt --check: clean
- pnpm tsc --noEmit (e2e): clean
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: upstream mid-stream disconnect produces request-time 5xx instead of partial chunks + final error chunk per docs §5

2 participants