Skip to content

fix(grpc): router-authoritative string stop sequences for SGLang skip_tokenizer_init workers#1877

Draft
gongwei-130 wants to merge 1 commit into
mainfrom
fix/sglang-string-stops
Draft

fix(grpc): router-authoritative string stop sequences for SGLang skip_tokenizer_init workers#1877
gongwei-130 wants to merge 1 commit into
mainfrom
fix/sglang-string-stops

Conversation

@gongwei-130

@gongwei-130 gongwei-130 commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

SGLang gRPC workers run with skip_tokenizer_init=True (the router owns the tokenizer). The router forwards string stop sequences as-is into the gRPC SamplingParams, and SGLang's SamplingParams.verify() rejects them in that mode:

stop=['.'] is unavailable when skip_tokenizer_init=True
(requires tokenizer to decode tokens to text for matching).

So any request carrying the OpenAI-compatible stop parameter against an SGLang gRPC worker gets a 400. This PR makes the router authoritative for string stops end to end: the 400 is gone, output text is trimmed correctly, finish_reason / matched_stop / Anthropic stop_reason+stop_sequence match what vLLM reports for the same request, and streaming requests abort the worker when the router matches a stop — so multi-token stops no longer over-generate to max_tokens (no inflated usage.completion_tokens, no wasted GPU time).

SGLang-specific: the vLLM servicer forces detokenize=bool(stop), TRT-LLM tokenizes stop words server-side, MLX has no string-stop field.

Design

The router already decodes worker output through a StopSequenceDecoder per request. This PR completes that into full ownership of string stops:

1. Drain string stops from SGLang requests (resolve_sglang_string_stops in common/stages/helpers.rs), called from the chat / completions / messages request-building stages — exactly the paths whose streaming and non-streaming handlers both run a trimming decoder. As an optimization, a stop string that encodes to a single token is forwarded as a stop_token_ids entry so the worker halts immediately (skipped when ignore_eos=true, where SGLang skips all stop_token_ids matching — see Req._check_token_based_finish).

2. Record what matchedStopSequenceDecoder now tracks MatchedStop::Sequence(String) | TokenId(u32) when it stops.

3. Refine response metadata (utils::refine_stop_metadata, applied at all six response sites: chat/completions/messages × streaming/non-streaming):

  • decoder matched a string stop → finish_reason="stop", matched_stop = the original stop string (was: "length" + null);
  • worker halted on a converted single-token stop and reported a numeric token id → mapped back to the user's stop string;
  • EOS and user-provided stop_token_ids pass through unchanged.
    Also gates the streaming Messages stop_reason on an actual string match (a numeric matched_stop is end_turn, not stop_sequence with a null sequence), and removes the completions endpoint's stream-vs-non-stream finish_reason divergence.

4. Streaming early termination — when the router decoder matches a string stop on an SGLang stream (n=1), the loop records usage from the chunk's cumulative counters, emits the final chunks, and breaks without mark_completed(); dropping the stream sends the Abort RPC (AbortOnDropStream). Gated to SGLang streams and actual Sequence matches — vLLM/TRT-LLM paths are untouched.

Deliberately not drained (documented NOTEs at the call sites)

  • Harmony (gpt-oss): no router-side decoder exists there, and injecting a user stop token can truncate before <|return|>/<|call|>, corrupting Harmony parsing. Keeps the pre-existing 400.
  • Native /generate: its streaming path has no decoder, so a cleared stop would stream untrimmed text. Keeps the pre-existing 400.

Known remaining limitations (in the helper docstring)

  • Non-streaming multi-token stops: finish_reason/matched_stop are correct, but the worker still generates to EOS/max_tokens before the response is assembled. Needs early abort in response_collection — follow-up.
  • SentencePiece leading-space tokenization can keep the single-token optimization from firing mid-sentence; correctness holds via the decoder.

Tests

  • resolve_sglang_string_stops: 12 unit tests (single/multi-token, mixed, dedup, empty/unknown, whitespace single-token via a wrapper tokenizer, two distinct single tokens, ignore_eos, missing sampling_params, no tokenizer, non-SGLang untouched)
  • refine_stop_metadata: 5 unit tests
  • StopSequenceDecoder::matched_stop: 2 unit tests
  • Full smg lib suite green; clippy/fmt clean

End-to-end verification (B200, real SGLang gRPC worker)

Same host, same engine image and weights, only the router build swapped:

  • stop=["."] (non-stream) → 200, finish_reason="stop", matched_stop="." (the string, not a token id), text trimmed at the stop
  • multi-token stop (non-stream) → finish_reason="stop", matched_stop=<stop string>, text trimmed exactly before the stop
  • multi-token stop (streaming) → stream terminates at the match with usage.completion_tokens=3 vs 488 for the identical request non-streamed (worker log shows Receive abort request: ...), confirming the Abort path works

This is the same change merged in our production deployment after review and e2e validation.

@github-actions github-actions Bot added grpc gRPC client and router changes model-gateway Model gateway crate changes labels Jul 4, 2026
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new helper resolve_sglang_string_stops for SGLang gRPC requests that clears string-based stop sequences and converts single-token stops into stop_token_ids using a tokenizer. This helper is invoked in chat, completion, generate, and messages request-building stages before dispatch, plus new unit tests.

Changes

SGLang Stop Sequence Handling

Layer / File(s) Summary
Stop resolution helper and tests
model_gateway/src/routers/grpc/common/stages/helpers.rs
Adds Tokenizer import and resolve_sglang_string_stops, which for SGLang requests clears string stop values and converts single-token stops into deduplicated stop_token_ids, with a comprehensive test suite using a mock tokenizer covering single/multi-token, mixed, dedup, missing tokenizer, no-op, and non-SGLang cases.
Request-building stage wiring
model_gateway/src/routers/grpc/regular/stages/chat/request_building.rs, .../completion/request_building.rs, .../generate/request_building.rs, .../messages/request_building.rs
Each stage calls helpers::resolve_sglang_string_stops with the request's tokenizer before optional PD metadata injection or storing the proto request.

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

Sequence Diagram(s)

sequenceDiagram
  participant RequestBuildingStage
  participant Helpers as resolve_sglang_string_stops
  participant Tokenizer

  RequestBuildingStage->>Helpers: proto_request, tokenizer
  Helpers->>Helpers: check request is Sglang and stop non-empty
  alt tokenizer available
    Helpers->>Tokenizer: encode each stop string
    Tokenizer-->>Helpers: token id(s)
    Helpers->>Helpers: single-token stops appended to stop_token_ids (deduped)
  else no tokenizer
    Helpers->>Helpers: log warning
  end
  Helpers->>Helpers: clear sampling_params.stop
  Helpers-->>RequestBuildingStage: mutated proto_request
Loading

Possibly related PRs

  • lightseekorg/smg#744: Introduces the same MessageRequestBuildingStage/messages request-building pipeline that this PR wires the new stop-resolution call into.

Suggested labels: tests

Suggested reviewers: key4ng, slin1237, XinyueZhang369

Poem

A rabbit tokenized a stop,
single tokens go straight to the top!
Multi-token strings hop away,
the decoder handles them their own way.
Hooray for clean SGLang requests today! 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the core change: router-side handling of string stop sequences for SGLang workers without tokenizer initialization.
✨ 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 fix/sglang-string-stops

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.

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

👋 The PR description doesn't fully follow
PULL_REQUEST_TEMPLATE.md:

  • Missing header: ## Description
  • Missing header: ### Problem
  • Missing header: ### Solution
  • Missing header: ## Changes
  • Missing header: ## Test Plan

Please update the PR description so reviewers have the context they need.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request resolves issue #227, where SGLang gRPC workers reject string stop sequences when running with skip_tokenizer_init=True. It introduces a helper function resolve_sglang_string_stops that clears string stops from SGLang requests and converts single-token stops into stop_token_ids as an optimization, leaving multi-token stops to be handled by the router-side decoder. This helper is integrated into the chat, completion, generate, and message request building stages, and is accompanied by comprehensive unit tests. There are no review comments, so we have no feedback to provide.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@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: 1

🤖 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/regular/stages/chat/request_building.rs`:
- Around line 133-138: The repeated explanatory comment around
resolve_sglang_string_stops is duplicated across the request-building stages, so
trim the call site near proto_request handling to a short reference instead of
keeping the full 5-line block. Update the comment in the request_building stage
using the resolve_sglang_string_stops helper name as the anchor, and keep only a
brief note that points to its existing doc comment so the
chat/completion/generate/messages paths stay consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0bbf22c3-3106-4500-b6e7-a40082c9b3ab

📥 Commits

Reviewing files that changed from the base of the PR and between 8155b1f and f9cf09a.

📒 Files selected for processing (5)
  • model_gateway/src/routers/grpc/common/stages/helpers.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

Comment on lines +133 to +138
// issue #227: SGLang gRPC workers run with skip_tokenizer_init and
// reject string `stop` sequences. Resolve them router-side (drop the
// strings, convert single-token stops to stop_token_ids) before
// dispatch; the router-side StopSequenceDecoder handles text trimming.
helpers::resolve_sglang_string_stops(&mut proto_request, ctx.tokenizer_arc().as_ref());

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 | 🔵 Trivial | 💤 Low value

Correctly wired after sampling defaults, before PD metadata injection.

The repeated 5-line explanatory comment block is duplicated verbatim across all four request-building stages (chat/completion/generate/messages). Since resolve_sglang_string_stops already carries a thorough doc comment at its definition, consider trimming each call site to a one-line reference (e.g. // issue #227: see resolve_sglang_string_stops doc) to reduce comment-maintenance drift across 4 files.

🤖 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/stages/chat/request_building.rs`
around lines 133 - 138, The repeated explanatory comment around
resolve_sglang_string_stops is duplicated across the request-building stages, so
trim the call site near proto_request handling to a short reference instead of
keeping the full 5-line block. Update the comment in the request_building stage
using the resolve_sglang_string_stops helper name as the anchor, and keep only a
brief note that points to its existing doc comment so the
chat/completion/generate/messages paths stay consistent.

/// Only SGLang is affected: the vLLM servicer forces `detokenize=bool(stop)`,
/// TRT-LLM tokenizes stop words server-side, and the MLX proto has no
/// string-`stop` field. Non-SGLang requests are left untouched.
pub(crate) fn resolve_sglang_string_stops(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Important: HarmonyRequestBuildingStage also builds SGLang requests via build_generate_request_from_chatbuild_grpc_sampling_params_from_chat, which sets stop: stop_sequences (string stops extracted from the Chat request). But resolve_sglang_string_stops is never called on the Harmony path — so Harmony Chat requests carrying string stop sequences against an SGLang worker will still get the 400 rejection from SamplingParams.verify().

The Harmony stage (harmony/stages/request_building.rs) builds the proto at line 148, injects Harmony stop token IDs at line 334, then jumps to PD metadata — there's no call to this function in between. The Responses path is safe (it hardcodes stop: vec![] at sglang_scheduler.rs:445), but the Chat path is exposed.

Suggested fix: add a resolve_sglang_string_stops(&mut proto_request, ctx.tokenizer_arc().as_ref()) call in HarmonyRequestBuildingStage::execute after the proto is built (before or after the Harmony stop token injection block).

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9cf09a1e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Always drop the string stops from the SGLang request: the worker cannot
// handle them under skip_tokenizer_init and the router-side decoder is the
// source of truth for string-stop matching/trimming.
let stop_strings = std::mem::take(&mut params.stop);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve stop metadata when falling back to router trimming

When a SGLang request has a multi-token stop sequence, this removes it from the backend request and no replacement stop_token_id is added. I checked the regular response processors: they derive finish_reason/stop_reason from complete.finish_reason() and matched_stop_json(), not from StopSequenceDecoder::is_stopped, so these requests can be truncated by the router but still report length/end_turn and omit stop_sequence after the worker runs to max tokens or EOS. Please propagate the router-side stop match into the response metadata, or avoid clearing stops without an equivalent signal.

Useful? React with 👍 / 👎.

Comment on lines +324 to +326
let id = ids[0];
if !params.stop_token_ids.contains(&id) {
params.stop_token_ids.push(id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Translate converted stop tokens back to message stop sequences

For Messages API stop_sequences that encode to one token, this appends only a numeric stop_token_id. The SGLang servicer returns that as a numeric matched token, but the non-streaming Messages processor only treats matched_stop_json().as_str() as a stop sequence, so a request stopped by a user sequence like "." is returned as end_turn with stop_sequence: null; streaming similarly cannot fill stop_sequence. Keep a mapping from the inserted token id to the original string, or skip this optimization for Messages.

Useful? React with 👍 / 👎.

@slin1237 slin1237 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.

it doesn't look like the fix should be here?
And even if we wanna add a code like this for SGLang, the code shouldn't be in helper
Also. Please combine all the if statement to single if.
And follow pull request template.

@gongwei-130 gongwei-130 marked this pull request as draft July 6, 2026 18:04
@slin1237 slin1237 force-pushed the fix/sglang-string-stops branch from 1935463 to 3608abf Compare July 6, 2026 20:42
…r_init workers

SGLang gRPC workers run with skip_tokenizer_init=True (the router owns the
tokenizer). The router forwarded string `stop` sequences as-is into the gRPC
SamplingParams, and upstream SGLang's SamplingParams.verify() rejects them in
that mode ("stop=[...] is unavailable when skip_tokenizer_init=True"),
returning a 400 for any request carrying the OpenAI-compatible `stop`
parameter. Make the router authoritative for string stops end to end:

1. resolve_sglang_string_stops (common/stages/helpers.rs) drains string stops
   from SGLang requests in the chat/completions/messages request-building
   stages — exactly the paths whose streaming AND non-streaming handlers both
   run a trimming StopSequenceDecoder. Stop strings that encode to a single
   token are forwarded as stop_token_ids so the worker halts early (skipped
   under ignore_eos=true, where SGLang ignores stop_token_ids entirely).
   Harmony (gpt-oss) and native /generate are deliberately NOT drained
   (documented NOTEs): they have no trimming decoder, and a converted stop
   token could truncate Harmony's <|return|>/<|call|> terminals.

2. StopSequenceDecoder records WHICH stop matched (MatchedStop::Sequence |
   TokenId) so responses can report it.

3. utils::refine_stop_metadata applies the decoder's verdict at all six
   response sites (chat/completions/messages x streaming/non-streaming):
   decoder-matched string stop -> finish_reason="stop" + matched_stop is the
   original stop string; a numeric matched_stop from a converted single-token
   stop is mapped back to the user's string; EOS and user stop_token_ids pass
   through unchanged. Also gates the streaming Messages stop_reason on an
   actual string match (numeric matched_stop is end_turn, not
   stop_sequence:null), and removes the completions endpoint's
   stream-vs-non-stream finish_reason divergence.

4. Streaming early termination: when the router decoder matches a string stop
   on an SGLang stream (n=1), record usage from the chunk's cumulative
   counters, emit the final chunks, and break WITHOUT mark_completed() — the
   stream drop sends the Abort RPC (AbortOnDropStream), so the worker no
   longer generates to EOS/max_tokens (fixes usage.completion_tokens
   overcount and latency for multi-token stops). Gated to SGLang streams and
   Sequence matches; other backends halt on string stops server-side.

Tests: 12 unit tests for resolve_sglang_string_stops, 5 for
refine_stop_metadata, 2 for MatchedStop recording.

Verified end-to-end on a B200 against a real SGLang gRPC worker: stop=["."]
returns 200 with matched_stop="." (string), a multi-token stop returns
finish_reason="stop" + the stop string with the text trimmed, and the
streaming path aborts the worker (usage.completion_tokens 3 vs 488 for the
same request non-streamed; worker log shows "Receive abort request").

Signed-off-by: gongwei-130 <56567052+gongwei-130@users.noreply.github.com>
@gongwei-130 gongwei-130 changed the title fix(grpc): resolve string stop sequences for SGLang skip_tokenizer_init workers fix(grpc): router-authoritative string stop sequences for SGLang skip_tokenizer_init workers Jul 7, 2026
@gongwei-130 gongwei-130 force-pushed the fix/sglang-string-stops branch from 3608abf to 88f4402 Compare July 7, 2026 16:18
@github-actions github-actions Bot added the tokenizer Tokenizer related changes label Jul 7, 2026
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 priority:high High priority tokenizer Tokenizer related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants