Skip to content

Commit 3eafab5

Browse files
committed
fix(streaming): skip chat deltas for role-init elements to prevent first token duplication
When TASK_RESPONSE_TYPE_OAI_CHAT is used, the first streaming token produces a JSON array with two elements: a role-init chunk and the actual content chunk. The grpc-server loop called attach_chat_deltas for both elements with the same raw_result pointer, stamping the first token's ChatDelta.Content on both replies. The Go side accumulated both, emitting the first content token twice to SSE clients. Fix: in the array iteration loops in PredictStream, detect role-init elements (delta has "role" key) and skip attach_chat_deltas for them. Only content/reasoning elements get chat deltas attached. Reasoning models are unaffected because their first token goes into reasoning_content, not content.
1 parent 706cf5d commit 3eafab5

3 files changed

Lines changed: 106 additions & 3 deletions

File tree

backend/cpp/llama-cpp/grpc-server.cpp

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1716,12 +1716,23 @@ class BackendServiceImpl final : public backend::Backend::Service {
17161716
}
17171717
};
17181718

1719-
// Process first result
1719+
// Process first result.
1720+
// When TASK_RESPONSE_TYPE_OAI_CHAT is used, the first token may
1721+
// produce a JSON array with a role-init element followed by the
1722+
// actual content element. We must only attach chat deltas to the
1723+
// content element — attaching to both would duplicate the first
1724+
// token since oaicompat_msg_diffs is the same for both.
17201725
json first_res_json = first_result->to_json();
17211726
if (first_res_json.is_array()) {
17221727
for (const auto & res : first_res_json) {
17231728
auto reply = build_reply_from_json(res, first_result.get());
1724-
attach_chat_deltas(reply, first_result.get());
1729+
// Skip chat deltas for role-init elements (have "role" in
1730+
// delta but no content/reasoning diffs of their own).
1731+
bool is_role_init = res.contains("choices") && !res["choices"].empty() &&
1732+
res["choices"][0].value("delta", json::object()).contains("role");
1733+
if (!is_role_init) {
1734+
attach_chat_deltas(reply, first_result.get());
1735+
}
17251736
writer->Write(reply);
17261737
}
17271738
} else {
@@ -1745,7 +1756,11 @@ class BackendServiceImpl final : public backend::Backend::Service {
17451756
if (res_json.is_array()) {
17461757
for (const auto & res : res_json) {
17471758
auto reply = build_reply_from_json(res, result.get());
1748-
attach_chat_deltas(reply, result.get());
1759+
bool is_role_init = res.contains("choices") && !res["choices"].empty() &&
1760+
res["choices"][0].value("delta", json::object()).contains("role");
1761+
if (!is_role_init) {
1762+
attach_chat_deltas(reply, result.get());
1763+
}
17491764
writer->Write(reply);
17501765
}
17511766
} else {

tests/e2e/mock-backend/main.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,33 @@ func (m *MockBackend) PredictStream(in *pb.PredictOptions, stream pb.Backend_Pre
219219
return nil
220220
}
221221

222+
// Simulate the first-token streaming for non-reasoning models.
223+
// The C++ backend with TASK_RESPONSE_TYPE_OAI_CHAT sends a role-init
224+
// reply (no ChatDeltas) followed by content replies (with ChatDeltas).
225+
// Previously, attach_chat_deltas was called for both, duplicating the
226+
// first token. After the fix, role-init replies have no ChatDeltas.
227+
if strings.Contains(in.Prompt, "FIRST_TOKEN_DUP") {
228+
content := "Hello! How can I help you today?"
229+
// Reply 1: role-init (no ChatDeltas — fix applied)
230+
if err := stream.Send(&pb.Reply{
231+
Message: []byte{},
232+
}); err != nil {
233+
return err
234+
}
235+
// Reply 2+: content tokens with ChatDeltas
236+
for _, word := range strings.Split(content, "") {
237+
if err := stream.Send(&pb.Reply{
238+
Message: []byte(word),
239+
ChatDeltas: []*pb.ChatDelta{
240+
{Content: word},
241+
},
242+
}); err != nil {
243+
return err
244+
}
245+
}
246+
return nil
247+
}
248+
222249
var toStream string
223250
toolName := mockToolNameFromRequest(in)
224251
if toolName != "" && !promptHasToolResults(in.Prompt) {

tests/e2e/mock_backend_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,4 +509,65 @@ var _ = Describe("Mock Backend E2E Tests", Label("MockBackend"), func() {
509509
})
510510
})
511511
})
512+
513+
// Regression test: first content token duplicated in streaming for
514+
// non-reasoning models. The C++ backend's PredictStream with
515+
// TASK_RESPONSE_TYPE_OAI_CHAT returns a two-element JSON array for the
516+
// first token (role-init + content), and attach_chat_deltas stamps
517+
// both with the same ChatDelta.Content, causing the Go side to emit
518+
// the first token twice.
519+
Describe("First Token Duplication", Label("FirstTokenDup"), func() {
520+
It("should not duplicate the first content token in streaming", func() {
521+
body := `{
522+
"model": "mock-model-autoparser",
523+
"messages": [{"role": "user", "content": "FIRST_TOKEN_DUP"}],
524+
"stream": true
525+
}`
526+
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
527+
Expect(err).ToNot(HaveOccurred())
528+
req.Header.Set("Content-Type", "application/json")
529+
530+
httpClient := &http.Client{Timeout: 60 * time.Second}
531+
resp, err := httpClient.Do(req)
532+
Expect(err).ToNot(HaveOccurred())
533+
defer resp.Body.Close()
534+
Expect(resp.StatusCode).To(Equal(200))
535+
536+
data, err := io.ReadAll(resp.Body)
537+
Expect(err).ToNot(HaveOccurred())
538+
539+
// Collect all content deltas
540+
var contentParts []string
541+
for _, line := range strings.Split(string(data), "\n") {
542+
line = strings.TrimSpace(line)
543+
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
544+
continue
545+
}
546+
var chunk map[string]any
547+
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &chunk); err != nil {
548+
continue
549+
}
550+
choices, _ := chunk["choices"].([]any)
551+
if len(choices) == 0 {
552+
continue
553+
}
554+
delta, _ := choices[0].(map[string]any)["delta"].(map[string]any)
555+
if delta == nil {
556+
continue
557+
}
558+
if content, ok := delta["content"].(string); ok && content != "" {
559+
contentParts = append(contentParts, content)
560+
}
561+
}
562+
563+
fullContent := strings.Join(contentParts, "")
564+
565+
// The first token "Hello" should appear exactly once at the start
566+
Expect(strings.Count(fullContent, "Hello")).To(Equal(1),
567+
"First token 'Hello' was duplicated. Full streamed content: %q "+
568+
"(content parts: %v)", fullContent, contentParts)
569+
Expect(fullContent).To(HavePrefix("Hello"),
570+
"Content should start with 'Hello', got: %q", fullContent)
571+
})
572+
})
512573
})

0 commit comments

Comments
 (0)