Skip to content

feat(zmq): forward EOS and resolve string stops for the direct backend (4/7) - #2057

Merged
slin1237 merged 1 commit into
mainfrom
zmq/04-eos-forwarding
Aug 6, 2026
Merged

feat(zmq): forward EOS and resolve string stops for the direct backend (4/7)#2057
slin1237 merged 1 commit into
mainfrom
zmq/04-eos-forwarding

Conversation

@slin1237

@slin1237 slin1237 commented Aug 5, 2026

Copy link
Copy Markdown
Member

Description

Problem

A direct-ZMQ engine receives token ids only and cannot match string stops itself,
and it is not told the model's EOS token ids. Without those, generation over ZMQ
can run to max_tokens (endless generation) and string stop sequences are
never honored.

Solution

Forward the model's EOS token ids to the engine at connect time, and resolve
string stop sequences on the gateway side for the ZMQ path: single-token stops
are encoded and sent as stop-token ids, while multi-token string stops are matched
locally by the stop decoder, whose match takes precedence over the engine's finish
reason.

Changes

  • crates/engine_zmq_client/.../zmq_client.rsEosTokenIds (primary + extra,
    from_model_dir), threaded through connect() and request/sampling translation.
  • model_gateway/src/worker/worker.rs — load EOS ids and pass them into
    connect_zmq_backend.
  • crates/tokenizer/src/stop.rsmatched_stop field + accessor (+ tests);
    crates/tokenizer/src/mock.rs — mock eos_token_ids.
  • model_gateway/.../common/stages/helpers.rsencode_single_token_stops /
    resolve_string_stops (+ tests).
  • model_gateway/.../regular/stages/{chat,generate,messages,completion}/request_building.rs
    — resolve string stops using the is_zmq backend flag.
  • model_gateway/.../regular/{processor,streaming}.rs — local stop-decoder match
    takes precedence over the engine finish reason.
  • model_gateway/src/routers/grpc/backend_client.rs — rename is_vllmis_zmq
    to name the actual capability gap (token-only backend).

Test Plan

  • cargo +nightly fmt -- --check and cargo clippy --all-features clean.
  • cargo test: stop.rs matched-stop accessor tests and helpers.rs
    single-token / string-stop resolution tests.
  • e2e (top of stack) covers endless-generation and string-stop behavior over ZMQ.
Checklist
  • cargo +nightly fmt passes
  • cargo clippy --all-targets --all-features -- -D warnings passes
  • (Optional) Documentation updated
  • (Optional) Please join us on Slack #sig-smg to discuss, review, and merge PRs

Stack (split of the oversized #2041, merge bottom-up): 01-core-dispatch →
02-structured-outputs → 03-multimodal → 04-eos-forwarding (this PR)
05-harmony-stop-matcher → 06-e2e-infra → 07-e2e-tests. Review/merge after its
parent zmq/03-multimodal.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 80b5eebd-86cd-4b73-b355-9d64e314c18e

📥 Commits

Reviewing files that changed from the base of the PR and between 01b793b and 252ee46.

📒 Files selected for processing (12)
  • crates/tokenizer/src/mock.rs
  • crates/tokenizer/src/stop.rs
  • model_gateway/src/routers/grpc/backend_client.rs
  • model_gateway/src/routers/grpc/common/stages/helpers.rs
  • model_gateway/src/routers/grpc/regular/processor.rs
  • model_gateway/src/routers/grpc/regular/stages/chat/request_building.rs
  • model_gateway/src/routers/grpc/regular/stages/completion/request_building.rs
  • model_gateway/src/routers/grpc/regular/stages/generate/request_building.rs
  • model_gateway/src/routers/grpc/regular/stages/messages/request_building.rs
  • model_gateway/src/routers/grpc/regular/streaming.rs
  • model_gateway/src/routers/grpc/zmq_client.rs
  • model_gateway/src/worker/worker.rs
🚧 Files skipped from review as they are similar to previous changes (12)
  • crates/tokenizer/src/mock.rs
  • model_gateway/src/routers/grpc/regular/stages/generate/request_building.rs
  • model_gateway/src/routers/grpc/backend_client.rs
  • model_gateway/src/routers/grpc/regular/stages/completion/request_building.rs
  • model_gateway/src/routers/grpc/regular/stages/chat/request_building.rs
  • model_gateway/src/worker/worker.rs
  • crates/tokenizer/src/stop.rs
  • model_gateway/src/routers/grpc/regular/processor.rs
  • model_gateway/src/routers/grpc/regular/streaming.rs
  • model_gateway/src/routers/grpc/common/stages/helpers.rs
  • model_gateway/src/routers/grpc/regular/stages/messages/request_building.rs
  • model_gateway/src/routers/grpc/zmq_client.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Improved stop-sequence handling across chat, messages, completions, and streaming generation.
    • String stop sequences are recognized consistently, including locally matched stops and matched-stop reporting.
    • Added support for model-specific end-of-sequence tokens in compatible generation backends.
    • Streaming responses now stop promptly and report the correct stop reason and matched sequence.
  • Bug Fixes

    • Prevented text generated after a locally matched stop sequence from appearing in responses.
    • Improved handling of single-token, multi-token, empty, and unsupported stop sequences.

Walkthrough

The PR adds matched stop reporting to the tokenizer, resolves string stops for applicable requests, propagates model EOS IDs into ZMQ vLLM sampling parameters, and preserves local stop results across regular and streaming responses.

Changes

Stop and EOS processing

Layer / File(s) Summary
Tokenizer stop match reporting
crates/tokenizer/src/stop.rs, crates/tokenizer/src/mock.rs
StopSequenceDecoder exposes matched string stops and clears them on reset. MockTokenizer returns EOS ID 999.
ZMQ EOS configuration and request translation
model_gateway/src/routers/grpc/zmq_client.rs, model_gateway/src/worker/worker.rs, model_gateway/src/routers/grpc/backend_client.rs
ZMQ connections load EOS IDs from local model configuration and pass primary, extra, and aggregate stop-token IDs to vLLM requests.
Request stop normalization
model_gateway/src/routers/grpc/common/stages/helpers.rs, model_gateway/src/routers/grpc/regular/stages/*/request_building.rs
Single-token string stops become deduplicated token IDs. Multi-token and unsupported stops remain for router-side decoding.
Local stop response handling
model_gateway/src/routers/grpc/regular/processor.rs, model_gateway/src/routers/grpc/regular/streaming.rs
Local stop matches set the finish reason, suppress later chunks, and take precedence over backend stop metadata.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant RequestBuilder
  participant ZmqEngineClient
  participant EngineCore
  participant StopSequenceDecoder
  Client->>RequestBuilder: submit generation request
  RequestBuilder->>ZmqEngineClient: send normalized stops
  ZmqEngineClient->>EngineCore: send EOS and stop-token parameters
  EngineCore-->>StopSequenceDecoder: stream generated chunks
  StopSequenceDecoder-->>Client: emit text and local stop metadata
Loading

Possibly related PRs

Suggested labels: tests

Suggested reviewers: key4ng, catherinesue

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: forwarding EOS IDs and resolving string stops for the direct ZMQ backend.
Description check ✅ Passed The description directly explains the problem, solution, affected components, and test plan for the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch zmq/04-eos-forwarding

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions github-actions Bot added tokenizer Tokenizer related changes grpc gRPC client and router changes model-gateway Model gateway crate changes labels Aug 5, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clean, well-structured PR. The stop-sequence resolution and EOS forwarding logic is correct across all paths (streaming, non-streaming, multi-choice). Key observations:

  • StopSequenceDecoder.matched_stop: Correctly tracks the matched string and resets on reset(). Tests cover both string and token-level stops.
  • resolve_string_stops: Properly drains string stops from the proto while forwarding single-token ones as stop_token_ids. SGLang always resolves; vLLM only over ZMQ. The is_vllm()→is_zmq() rename on BackendClient correctly narrows the scope (gRPC-vLLM handles stops server-side).
  • Dual EOS injection: Tokenizer EOS ids (in resolve_string_stops) and model-dir EOS ids (in translate_sampling) are complementary sources with dedup — defensive and correct.
  • Streaming finish-reason pinning: stopped_indices / stopped flags correctly prevent the engine's Complete message from overwriting a local stop-decoder match.
  • Test coverage: Comprehensive — covers single/multi-token stops, dedup, ignore_eos, no-tokenizer fallback, model-dir resolution, and matched-stop reporting.

No issues found. 0 important · 0 nit · 0 pre-existing

@slin1237
slin1237 force-pushed the zmq/03-multimodal branch from fc7ed51 to da17295 Compare August 5, 2026 20:20
@slin1237
slin1237 force-pushed the zmq/04-eos-forwarding branch from 1bddbe3 to ad35b4b Compare August 5, 2026 20:20
Comment on lines 118 to 124
@@ -71,6 +124,9 @@ pub struct ZmqEngineClient {
/// Model id advertised for metadata (the engine does not report it on the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: eos_ids_from_file silently swallows JSON parse failures — if config.json exists but contains corrupt JSON, this returns empty EOS ids with no log. Since this runs once at connect time (not per-request), a warn! on parse failure would help diagnose "generation runs to max_tokens" issues without adding noise.

The tokenizer-based EOS injection in resolve_string_stops serves as a backstop, but if both paths fail silently, diagnosing the root cause becomes harder.

Suggested change
fn eos_ids_from_file(path: &Path) -> Vec<u32> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(_) => return Vec::new(),
};
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(config) => eos_ids_from_value(config.get("eos_token_id")),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "failed to parse model config for EOS ids");
Vec::new()
}
}
}

@slin1237
slin1237 force-pushed the zmq/03-multimodal branch 3 times, most recently from 447543f to 20a273d Compare August 6, 2026 00:30
Base automatically changed from zmq/03-multimodal to main August 6, 2026 02:14
@slin1237
slin1237 force-pushed the zmq/04-eos-forwarding branch from ad35b4b to ca6a4b1 Compare August 6, 2026 02:17

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

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

Inline comments:
In `@model_gateway/src/routers/grpc/common/stages/helpers.rs`:
- Around line 372-403: Extend the request match handling to cover
ProtoGenerateRequest::TokenSpeed when is_zmq is true, applying the same stop
extraction, single-token encoding, and deduplication used for the Vllm ZMQ
branch before translate_request_tokenspeed runs. Add a TokenSpeed ZMQ test that
verifies a one-token string stop is serialized on the wire in stop_token_ids.

In `@model_gateway/src/routers/grpc/regular/streaming.rs`:
- Around line 418-437: Add regression tests covering local string-stop handling
in the chat and Messages streaming paths, including PD decode streams. Verify
pre-stop text is emitted, subsequent chunks are ignored, final responses report
finish reason "stop" with the matched sequence, and chat requests with more than
one choice apply the behavior independently to each choice.

In `@model_gateway/src/routers/grpc/zmq_client.rs`:
- Around line 101-107: Update eos_ids_from_file to distinguish a missing
optional file from a present file that fails reading or JSON parsing: preserve
the empty result for absent files, but return or propagate an error for read or
parse failures so connect_zmq_backend cannot connect with invalid EOS
configuration. Adjust callers accordingly and add a test covering malformed
JSON.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a4253928-13e9-414d-853c-ffeaf098e7ed

📥 Commits

Reviewing files that changed from the base of the PR and between b385926 and ca6a4b1.

📒 Files selected for processing (12)
  • crates/tokenizer/src/mock.rs
  • crates/tokenizer/src/stop.rs
  • model_gateway/src/routers/grpc/backend_client.rs
  • model_gateway/src/routers/grpc/common/stages/helpers.rs
  • model_gateway/src/routers/grpc/regular/processor.rs
  • model_gateway/src/routers/grpc/regular/stages/chat/request_building.rs
  • model_gateway/src/routers/grpc/regular/stages/completion/request_building.rs
  • model_gateway/src/routers/grpc/regular/stages/generate/request_building.rs
  • model_gateway/src/routers/grpc/regular/stages/messages/request_building.rs
  • model_gateway/src/routers/grpc/regular/streaming.rs
  • model_gateway/src/routers/grpc/zmq_client.rs
  • model_gateway/src/worker/worker.rs

Comment thread model_gateway/src/routers/grpc/common/stages/helpers.rs Outdated
Comment on lines +418 to +437
let (chunk_text, should_stop) =
Self::process_chunk_tokens(stop_decoder, chunk.token_ids());

if should_stop {
// Stop-decoder match takes precedence: pin "stop" even if
// the backend's eventual Complete carries "length" (the
// local stop sequence fired first). Any pre-stop text in
// `chunk_text` is still emitted below before the finish
// reason is flushed in Phase 4.
finish_reasons
.entry(index)
.or_insert_with(|| "stop".to_string());
matched_stops.entry(index).or_insert_with(|| {
stop_decoder
.matched_stop()
.map(|s| Value::String(s.to_string()))
});
stopped_indices.insert(index);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate existing Rust tests for local stop handling and streaming metadata.
rg -n -C 3 --type rust \
  'matched_stop|StopSequence|stopped_indices|process_streaming_chunks|process_messages_streaming_chunks' \
  model_gateway crates

Repository: smg-project/smg

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant test file sizes and focused search"
wc -l model_gateway/tests/messages_streaming_test.rs model_gateway/tests/chat_streaming_test.rs 2>/dev/null || true
rg -n -C 4 --type rust \
  'local.*stop|string stop|stop string|matched_stop_json|local-stop|length|MaxTokens|stop_sequence|n>1' \
  model_gateway/tests crates/protocols/tests 2>/dev/null || true

echo
echo "Streaming source relevant functions outline"
ast-grep outline model_gateway/src/routers/grpc/regular/streaming.rs \
  --view expanded --match process_streaming_chunks --match process_messages_streaming_chunks \
  2>/dev/null || true

echo
echo "Read streaming source relevant ranges"
sed -n '360,470p' model_gateway/src/routers/grpc/regular/streaming.rs
echo
sed -n '560,625p' model_gateway/src/routers/grpc/regular/streaming.rs

Repository: smg-project/smg

Length of output: 43703


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "chat streaming tests focused search"
if [ -f model_gateway/tests/chat_streaming_test.rs ]; then
  wc -l model_gateway/tests/chat_streaming_test.rs
  rg -n -C 4 --type rust \
    'matched_stop|stop.*length|length.*stop|n>1|multi.*choice|stop_sequence|local.*stop|string.*stop|stop_decoder|process_chunk_tokens|process_messages_streaming_chunks|process_streaming_chunks' \
    model_gateway/tests/chat_streaming_test.rs
else
  echo "missing model_gateway/tests/chat_streaming_test.rs"
fi

echo
echo "Messages streaming tests focused search"
wc -l model_gateway/tests/messages_streaming_test.rs
rg -n -C 4 --type rust \
  'matched_stop|stop.*length|length.*stop|n>1|multi.*choice|stop_sequence|local.*stop|string.*stop|stop_decoder|process_messages_streaming_chunks' \
  model_gateway/tests/messages_streaming_test.rs

echo
echo "streaming functions call sites/routes"
rg -n -C 3 \
  'process_streaming_chunks|process_messages_streaming_chunks|process_decode_streaming_chunk|DecodeStream|Decode' \
  model_gateway/src/routers/grpc \
  crates/tokenizer/src \
  | head -n 240

Repository: smg-project/smg

Length of output: 27098


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Find test files containing process_streaming_chunks or stream function names"
rg -n --type rust \
  'fn (.*stream|.*chat.*stream|.*regular.*stream|test.*stream)|process_streaming_chunks|process_messages_streaming_chunks|LocalStop|matched_stop|stopped_indices' \
  model_gateway/tests model_gateway/src/routers/grpc crates/protocols/src/builders/chat 2>/dev/null | head -n 240

echo
echo "Locate exact function definitions"
rg -n --type rust \
  'pub\(crate\)? fn process_streaming_chunks|pub\(crate\)? fn process_messages_streaming_chunks|fn process_streaming_chunks|fn process_messages_streaming_chunks' \
  model_gateway/src/routers/grpc crates/protocols/src/builders/chat 2>/dev/null | head -n 80

echo
echo "Inspect surrounding implementation if locations known"
if true; then
  for f in \
    model_gateway/src/routers/grpc/regular/streaming.rs \
    model_gateway/src/routers/grpc/pd/streaming.rs \
    model_gateway/src/routers/grpc/messages/streaming.rs \
    crates/protocols/src/builders/chat/stream_response.rs
  do
    if [ -f "$f" ]; then
      echo "--- $f line count ---"
      wc -l "$f"
    fi
  done
fi

Repository: smg-project/smg

Length of output: 18534


🟡 Add local-stop streaming regression coverage.

Add tests where a local string stop fires before a backend "length" completion for chat and Messages streams. Check that pre-stop text is emitted, later chunks are ignored, the final response uses "stop", the matched sequence is present, n > 1 chat choices are handled, and PD decode streams use the same path.

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

In `@model_gateway/src/routers/grpc/regular/streaming.rs` around lines 418 - 437,
Add regression tests covering local string-stop handling in the chat and
Messages streaming paths, including PD decode streams. Verify pre-stop text is
emitted, subsequent chunks are ignored, final responses report finish reason
"stop" with the matched sequence, and chat requests with more than one choice
apply the behavior independently to each choice.

Source: Coding guidelines

Comment on lines +101 to +107
/// Read a config file's `eos_token_id`, which is a single id or a list.
fn eos_ids_from_file(path: &Path) -> Vec<u32> {
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.map(|config| eos_ids_from_value(config.get("eos_token_id")))
.unwrap_or_default()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🔴 Important Fail when a present EOS configuration cannot be read or parsed.

These calls convert an unreadable or malformed local configuration into an empty EOS set. connect_zmq_backend then connects successfully, and translate_sampling can send no EOS ID. If no request tokenizer is available for the later fallback, generation stops only at explicit stops or max_tokens.

Keep missing optional files supported if required. Return an error for a present file that cannot be read or parsed. Add a malformed-JSON test.

As per coding guidelines, “Do not silently fall back to None or a default when configuration validation should fail loudly.”

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

In `@model_gateway/src/routers/grpc/zmq_client.rs` around lines 101 - 107, Update
eos_ids_from_file to distinguish a missing optional file from a present file
that fails reading or JSON parsing: preserve the empty result for absent files,
but return or propagate an error for read or parse failures so
connect_zmq_backend cannot connect with invalid EOS configuration. Adjust
callers accordingly and add a test covering malformed JSON.

Source: Coding guidelines

A direct-ZMQ engine has no tokenizer or model config, so it can neither
stop at EOS on its own nor match string stop sequences — the router must
do both.

- Resolve EOS ids from the worker's local model dir at connect time and
  attach them to every vLLM request: the primary rides _eos_token_id,
  extra ids merge into stop_token_ids, and the union feeds
  _all_stop_token_ids (min_tokens masking). ignore_eos drops the wire
  stops but keeps the bookkeeping set, mirroring the reference frontend.
- Add resolve_string_stops, the single point shared by SGLang gRPC
  (skip_tokenizer_init) and every ZMQ backend: drop the string stop list,
  forward single-token stops as stop_token_ids, and leave multi-token
  stops to the router-side StopSequenceDecoder. Wire it into the chat,
  generate, messages, and completion request-building stages via a new
  BackendClient::is_zmq (renamed from is_vllm).
- Teach StopSequenceDecoder to report the matched stop string; the
  regular processor and streaming paths let a local decoder match take
  precedence over the engine's finish reason (which is "length" when
  stops are enforced gateway-side) and surface the matched sequence.

Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
@slin1237
slin1237 force-pushed the zmq/04-eos-forwarding branch from ca6a4b1 to 252ee46 Compare August 6, 2026 04:02
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@slin1237
slin1237 merged commit 05e720b into main Aug 6, 2026
40 of 45 checks passed
@slin1237
slin1237 deleted the zmq/04-eos-forwarding branch August 6, 2026 04:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

grpc gRPC client and router changes model-gateway Model gateway crate changes tokenizer Tokenizer related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant