Skip to content

Commit a8ad30d

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 a8ad30d

1 file changed

Lines changed: 18 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 {

0 commit comments

Comments
 (0)