Skip to content

fix: normalize multi-round gateway streaming#136

Open
harivilasp wants to merge 6 commits into
vllm-project:mainfrom
harivilasp:fix/issue-119-gateway-stream-accumulator
Open

fix: normalize multi-round gateway streaming#136
harivilasp wants to merge 6 commits into
vllm-project:mainfrom
harivilasp:fix/issue-119-gateway-stream-accumulator

Conversation

@harivilasp

Copy link
Copy Markdown
Contributor

Summary

  • Normalize multi-round gateway streaming into one client-visible event sequence.
  • Preserve lossless upstream event frames while emitting gateway-executed built-in tool lifecycle events.
  • Supersedes fix: normalize multi-round gateway streaming #132, which GitHub closed automatically after its source branch was renamed.

Test Plan

  • cargo fmt -- --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test
  • pre-commit run --all-files

Signed-off-by: harivilasp <harivilasp@gmail.com>
Signed-off-by: harivilasp <harivilasp@gmail.com>
Signed-off-by: harivilasp <harivilasp@gmail.com>
Signed-off-by: harivilasp <harivilasp@gmail.com>
Signed-off-by: harivilasp <harivilasp@gmail.com>

@ashwing ashwing left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reworked cleanly — the accumulator is owned now (no Arc<Mutex> guard held across .await), error frames route through error_chunk so they stay numbered, the synthetic/deferred events share one EventFrame/process_event path, and the multi-round test covers single-lifecycle + monotonic sequence + cross-round output_index. One question on normalize_sse_line below; non-blocking.

.map_or(SSEEventType::Other, classify_event_type);

let sequence_number = json.get("sequence_number").and_then(Value::as_u64);
let event_type = SSEEventType::from(json.get("type")?.as_str()?);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The switch to SSEEventType::from(json.get("type")?.as_str()?) drops any data: frame that parses as JSON but has no string type — previously map_or(SSEEventType::Other, ...) kept it and forwarded it as Other. Given the "preserve lossless upstream frames" goal, is dropping intended here? Responses SSE always carries type in practice, so likely benign, but a typeless keepalive/data frame from a provider would now vanish silently instead of passing through. Either keeping the Other fallback for the type-missing case (distinct from malformed JSON, which should still drop) or a one-line comment noting the deliberate drop would make the intent clear — plus an assert!(normalize_sse_line("data: {\"foo\":1}").is_none()) (or .is_some()) to lock it in; the normalizer tests currently cover [DONE], malformed JSON, and unknown type, but not valid-JSON-without-type.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ae0f5b1. A missing or non-string type maps to Other; WireEvent preserves the absent wire field during serialization, so the frame relays losslessly. Added test_typeless_json_event_is_preserved.

@maralbahari maralbahari left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@harivilasp thanks for the changes. I added a few concerns inline comments.

let ch = exec_ctx.conv_handler.clone();
let rh = exec_ctx.resp_handler.clone();
if let Err(e) = persist_if_needed(payload, ctx, ch, rh).await {
if let Err(e) = persist_if_needed(payload.clone(), ctx, ch, rh).await {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why there is a need for payload.clone() ? the older version didnt require it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in ae0f5b1. The terminal SSE chunk is now prepared before persistence, then the payload moves into persist_if_needed. This keeps persistence before client-visible emission without cloning the payload.

.entry(item_id.clone())
.or_default()
.push(line.to_owned());
.push(frame.clone());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

the entire frame.clone() seems too expensive. see how can be it be avoided.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in ae0f5b1. ResponseAccumulator now processes the frame before stream handling, and deferred EventFrames are moved into the pending queue and moved out again on flush. No deep frame clone remains.

pub(crate) fn terminal_response_chunk(&mut self, payload: &ResponsePayload) -> ExecutorResult<Option<String>> {
let mut frame = terminal_response_frame(payload)?;
if !self.process_event(&mut frame, 0) {
return Ok(None);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A payload with final status in_progress maps to SSEEventType::ResponseInProgress (line 95), which the lifecycle dedup has usually already consumed from the relayed stream, so this returns Ok(None) and the client gets [DONE] with no terminal response snapshot. The terminal event is not the same thing as the early lifecycle announcement; the previous revision always emitted it, and no test covers this case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ae0f5b1. Terminal frames now bypass lifecycle dedup but still use the shared sequence stamper, so a final in_progress response is always emitted with the next sequence number. Added emits_in_progress_terminal_event_after_lifecycle_event.

yield terminal_event;
match stream_accumulator.terminal_response_chunk(&payload) {
Ok(Some(chunk)) => yield chunk,
Ok(None) => {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ok(None) seems to be regression in behavior compared to the older version. Silently dropping the terminal chunk means the stream ends with bare [DONE] and no final response state. If Ok(None) is really possible here, it needs a log and a test; otherwise make the terminal chunk unsuppressible and delete this arm.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ae0f5b1 alongside the terminal lifecycle fix. terminal_response_chunk now always returns a serialized terminal frame, so the bare DONE path and the Ok(None) arm are gone.

Signed-off-by: harivilasp <harivilasp@gmail.com>
@harivilasp

Copy link
Copy Markdown
Contributor Author

Thank you for the detailed review. All five points are addressed in ae0f5b1, with focused regressions plus the full local test and lint suite.

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.

3 participants