Merge pull request #6080 from alfadb/fix/cc-stream-empty-tool-call-identity

fix(openai): 剔除流式 tool_call 后续 delta 中的空 id/name
This commit is contained in:
Wesley Liddick
2026-08-24 11:23:12 +08:00
committed by GitHub
4 changed files with 363 additions and 0 deletions
@@ -0,0 +1,103 @@
package service
import (
"bytes"
"strconv"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// stripEmptyChatToolCallIdentityFromSSELine 从 CC 流式 SSE 行中剔除
// choices[*].delta.tool_calls[*] 上的空 id / 空 function.name 字段。
//
// DashScope/DeepSeek 等 OpenAI 兼容上游会把同一个 tool_calls[index]
// 拆成多个 delta:首个 delta 带合法 id + function.name(arguments 可能
// 为空),后续参数 delta 带 id:"" 与 function.name:"" 只追加 arguments
// 碎片。dsh 等客户端按 `!== undefined` 合并字段,空串会被当成有效值
// 覆盖首包合法 id/name,最终得到 {"id":"","name":"",...} 导致
// ToolNotFoundError: unknown tool ""。这里无状态剔除空串字段(缺失
// 即不覆盖),不补写、不记忆首包 id/name,适用于所有走 raw CC 直转
// 路径的账号(不限定 DeepSeek)。
//
// 只处理流式 chunk 的 delta.tool_calls;非流式 message.tool_calls 不属于
// 本 helper 范围。
func stripEmptyChatToolCallIdentityFromSSELine(line string) string {
payload, ok := extractOpenAISSEDataLine(line)
if !ok {
return line
}
trimmed := strings.TrimSpace(payload)
if trimmed == "" || trimmed == "[DONE]" {
return line
}
rewritten, changed := stripEmptyChatToolCallIdentity([]byte(payload))
if !changed {
return line
}
prefixLen := len(line) - len(payload)
if prefixLen < 0 {
return line
}
return line[:prefixLen] + string(rewritten)
}
// stripEmptyChatToolCallIdentity 从单个 CC 流式 chunk payload 中删除
// choices[*].delta.tool_calls[*] 上存在但为空字符串的 id 与
// function.name 字段;arguments(即使是空串)、index、type 与其它字段
// 一律保留,非空 id/name 不动。多 choice、多 index 都会处理。
//
// 返回 (原始 payload, false) 当:payload 为空、不含 "tool_calls"、
// 非法 JSON、无 choices / delta / tool_calls 数组、或没有需要删除的
// 字段。sjson 删除失败时 fail-closed 返回原始 payload。
func stripEmptyChatToolCallIdentity(payload []byte) ([]byte, bool) {
if len(payload) == 0 {
return payload, false
}
// 热路径快速失败:绝大多数 chunk 没有 tool_calls。
if !bytes.Contains(payload, []byte("tool_calls")) {
return payload, false
}
if !gjson.ValidBytes(payload) {
return payload, false
}
choices := gjson.GetBytes(payload, "choices")
if !choices.Exists() || !choices.IsArray() {
return payload, false
}
updated := payload
changed := false
for ci, choice := range choices.Array() {
delta := choice.Get("delta")
if !delta.Exists() || !delta.IsObject() {
continue
}
toolCalls := delta.Get("tool_calls")
if !toolCalls.Exists() || !toolCalls.IsArray() {
continue
}
for ti, tc := range toolCalls.Array() {
if id := tc.Get("id"); id.Exists() && id.Type == gjson.String && id.Str == "" {
next, err := sjson.DeleteBytes(updated, "choices."+strconv.Itoa(ci)+".delta.tool_calls."+strconv.Itoa(ti)+".id")
if err != nil {
return payload, false
}
updated = next
changed = true
}
if name := tc.Get("function.name"); name.Exists() && name.Type == gjson.String && name.Str == "" {
next, err := sjson.DeleteBytes(updated, "choices."+strconv.Itoa(ci)+".delta.tool_calls."+strconv.Itoa(ti)+".function.name")
if err != nil {
return payload, false
}
updated = next
changed = true
}
}
}
if !changed {
return payload, false
}
return updated, true
}
@@ -0,0 +1,179 @@
//go:build unit
package service
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestStripEmptyChatToolCallIdentity_FirstChunkIdentityUntouched 首包带合法
// id/name 的 delta 必须原样保留(changed=false)。
func TestStripEmptyChatToolCallIdentity_FirstChunkIdentityUntouched(t *testing.T) {
payload := []byte(`{"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`)
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
require.False(t, changed)
require.Equal(t, string(payload), string(rewritten))
require.Equal(t, "call_example", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String())
require.Equal(t, "web_search", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String())
// 首包 arguments 为空串也不应被删除。
require.True(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").Exists())
require.Equal(t, "", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
}
// TestStripEmptyChatToolCallIdentity_FollowUpDelta 后续参数 delta 的
// `"id":""` 与 `"function":{"name":""}` 应被删除;arguments 碎片、
// index、type 保留。
func TestStripEmptyChatToolCallIdentity_FollowingDelta(t *testing.T) {
payload := []byte(`{"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`)
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
require.True(t, changed)
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists())
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists())
require.Equal(t, `{"query":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
require.Equal(t, int64(0), gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.index").Int())
require.Equal(t, "function", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.type").String())
// 空串字段不得再出现在 payload 里。
require.NotContains(t, string(rewritten), `"id":""`)
require.NotContains(t, string(rewritten), `"name":""`)
}
// TestStripEmptyChatToolCallIdentity_OnlyEmptyName / _OnlyEmptyID 只删
// 空的那一个,非空字段必须保留。
func TestStripEmptyChatToolCallIdentity_OnlyEmptyName(t *testing.T) {
payload := []byte(`{"id":"chatcmpl_tool","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"","arguments":"{}"}}]}}]}`)
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
require.True(t, changed)
require.Equal(t, "call_1", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String())
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists())
require.Equal(t, "{}", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
}
// TestStripEmptyChatToolCallIdentity_OnlyEmptyID 覆盖 `"id": ""` 带空格的
// JSON 形式,确认 gjson/sjson 均能识别。
func TestStripEmptyChatToolCallIdentity_OnlyEmptyID(t *testing.T) {
payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id" : "" , "type" : "function","function":{"name":"get_weather","arguments":"{}"}}]}}]}`)
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
require.True(t, changed)
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists())
require.Equal(t, "get_weather", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String())
require.Equal(t, "{}", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
}
// TestStripEmptyChatToolCallIdentity_EmptyArgumentsKept 空 arguments 不删,
// 只有 id/name 空串被剔除。
func TestStripEmptyChatToolCallIdentity_EmptyArgumentsKept(t *testing.T) {
payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}]}}]}`)
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
require.True(t, changed)
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists())
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists())
require.True(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").Exists())
require.Equal(t, "", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
}
// TestStripEmptyChatToolCallIdentity_TwoParallelToolCalls 两个并行 index 都要
// 处理:合法的 index 0 保留,后续参数 delta 的 index 1 剔除空 id/name。
func TestStripEmptyChatToolCallIdentity_TwoParallelToolCalls(t *testing.T) {
payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"tool_a","arguments":"{\"x\":"}},{"index":1,"id":"","type":"function","function":{"name":"","arguments":"{\"y\":"}}]}}]}`)
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
require.True(t, changed)
require.Equal(t, "call_a", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String())
require.Equal(t, "tool_a", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String())
require.Equal(t, `{"x":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.id").Exists())
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.function.name").Exists())
require.Equal(t, `{"y":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.function.arguments").String())
}
// TestStripEmptyChatToolCallIdentity_Passthrough 无 tool_calls、无 choices、
// 非数组 tool_calls、非法 JSON 一律原样返回。
func TestStripEmptyChatToolCallIdentity_Passthrough(t *testing.T) {
tests := []struct {
name string
payload string
}{
{"no tool_calls", `{"choices":[{"index":0,"delta":{"content":"hi"}}]}`},
{"no choices", `{"id":"chatcmpl_x"}`},
{"empty choices", `{"choices":[]}`},
{"tool_calls not array", `{"choices":[{"index":0,"delta":{"tool_calls":{"foo":1}}}]}`},
{"invalid JSON", `{"choices":[{`},
{"empty string", ``},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rewritten, changed := stripEmptyChatToolCallIdentity([]byte(tt.payload))
require.False(t, changed)
require.Equal(t, tt.payload, string(rewritten))
})
}
}
// TestStripEmptyChatToolCallIdentityFromSSELine_Passthrough SSE 行级:非
// data 行、[DONE]、空行原样;data 行保留 `data: ` 前缀。
func TestStripEmptyChatToolCallIdentityFromSSELine_Passthrough(t *testing.T) {
tests := []struct {
name string
line string
}{
{"done", "data: [DONE]"},
{"non-data line", ": keep-alive"},
{"empty line", ""},
{"comment line", ":"},
{"content chunk", `data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.line, stripEmptyChatToolCallIdentityFromSSELine(tt.line))
})
}
}
// TestStripEmptyChatToolCallIdentityFromSSELine_KeepsDataPrefix 改写后的
// SSE 行必须保留 `data: ` 前缀。
func TestStripEmptyChatToolCallIdentityFromSSELine_KeepsDataPrefix(t *testing.T) {
line := `data: {"id":"chatcmpl_tool","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{}"}}]}}]}`
got := stripEmptyChatToolCallIdentityFromSSELine(line)
require.True(t, strings.HasPrefix(got, "data: "))
payload, ok := extractOpenAISSEDataLine(got)
require.True(t, ok)
require.False(t, gjson.Get(payload, "choices.0.delta.tool_calls.0.id").Exists())
require.False(t, gjson.Get(payload, "choices.0.delta.tool_calls.0.function.name").Exists())
require.Equal(t, "{}", gjson.Get(payload, "choices.0.delta.tool_calls.0.function.arguments").String())
}
// TestStripEmptyChatToolCallIdentity_DshClientMerge 模拟 dsh rc.2 adapter 的
// 合并逻辑(字段存在——含空串——才覆盖):sanitize 后合并,最终 id/name 必须
// 仍是首包合法值,arguments 为各碎片拼接。
func TestStripEmptyChatToolCallIdentity_DshClientMerge(t *testing.T) {
lines := []string{
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`,
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`,
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"example\"}"}}]}}]}`,
}
var mergedID, mergedName, mergedArgs string
for _, line := range lines {
sanitized := stripEmptyChatToolCallIdentityFromSSELine(line)
payload, ok := extractOpenAISSEDataLine(sanitized)
require.True(t, ok)
for _, tc := range gjson.Get(payload, "choices.0.delta.tool_calls").Array() {
if v := tc.Get("id"); v.Exists() {
mergedID = v.String()
}
if v := tc.Get("function.name"); v.Exists() {
mergedName = v.String()
}
if v := tc.Get("function.arguments"); v.Exists() {
mergedArgs += v.String()
}
}
}
require.Equal(t, "call_example", mergedID)
require.Equal(t, "web_search", mergedName)
require.Equal(t, `{"query":"example"}`, mergedArgs)
}
@@ -336,6 +336,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions(
}
}
line = applyOllamaCloudRawChatCompletionsSSELine(account, line)
line = stripEmptyChatToolCallIdentityFromSSELine(line)
writeLine(line)
if line == "" {
@@ -607,6 +607,86 @@ func TestForwardAsRawChatCompletions_SilentRefusalNormalContentExempt(t *testing
require.Contains(t, rec.Body.String(), "data: [DONE]")
}
// TestForwardAsRawChatCompletions_StripsEmptyToolCallIdentity 端到端验证 raw
// CC 流式直转路径剔除 DashScope/DeepSeek 后续参数 delta 的空 id/name:
// 下游仍保留首包合法 id/name 与 arguments 碎片,但后续 delta 不再带
// `"id":""` / `"name":""`,避免 dsh 等客户端用 `!== undefined` 合并时把
// 首包合法值覆盖掉(ToolNotFoundError: unknown tool "")。
func TestForwardAsRawChatCompletions_StripsEmptyToolCallIdentity(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"weather"}],"stream":true}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstreamBody := strings.Join([]string{
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`,
"",
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`,
"",
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"example\"}"}}]}}]}`,
"",
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
"",
"data: [DONE]",
"",
}, "\n")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_tool_identity"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
require.NoError(t, err)
require.NotNil(t, result)
downstream := rec.Body.String()
require.Contains(t, downstream, `"id":"call_example"`)
require.Contains(t, downstream, `"name":"web_search"`)
require.Contains(t, downstream, `{\"query\":`)
require.Contains(t, downstream, `\"example\"}`)
require.Contains(t, downstream, "data: [DONE]")
require.NotContains(t, downstream, `"id":""`)
require.NotContains(t, downstream, `"name":""`)
// 逐条扫下游 data payload:后续参数 delta 的 tool_calls.0.id /
// function.name 必须已剔除(Exists() == false),首包合法值保留。
followUpSeen := false
for _, line := range strings.Split(downstream, "\n") {
payload, ok := extractOpenAISSEDataLine(line)
if !ok {
continue
}
trimmed := strings.TrimSpace(payload)
if trimmed == "" || trimmed == "[DONE]" {
continue
}
delta := gjson.Get(payload, "choices.0.delta")
if !delta.Exists() || !delta.Get("tool_calls").Exists() {
continue
}
id := delta.Get("tool_calls.0.id")
if id.String() == "call_example" {
require.Equal(t, "web_search", delta.Get("tool_calls.0.function.name").String())
continue
}
require.False(t, id.Exists(), "empty id must be stripped: %s", payload)
require.False(t, delta.Get("tool_calls.0.function.name").Exists(), "empty name must be stripped: %s", payload)
require.NotEmpty(t, delta.Get("tool_calls.0.function.arguments").String())
followUpSeen = true
}
require.True(t, followUpSeen)
}
func TestForwardAsRawChatCompletions_ClientDisconnectDrainsUsage(t *testing.T) {
gin.SetMode(gin.TestMode)