From cc894ef57871dd173a3e4006f9d9d1fd6eaa5446 Mon Sep 17 00:00:00 2001 From: alfadb Date: Sat, 22 Aug 2026 15:06:04 +0800 Subject: [PATCH] fix(openai): strip empty streamed tool-call id/name DashScope/DeepSeek later tool_call deltas send empty id and function.name. Clients that merge with !== undefined overwrite the first delta's identity and dispatch unknown tool "". Drop those empty fields on the raw Chat Completions SSE path. --- .../openai_gateway_cc_tool_call_identity.go | 103 ++++++++++ ...enai_gateway_cc_tool_call_identity_test.go | 179 ++++++++++++++++++ .../openai_gateway_chat_completions_raw.go | 1 + ...penai_gateway_chat_completions_raw_test.go | 80 ++++++++ 4 files changed, 363 insertions(+) create mode 100644 backend/internal/service/openai_gateway_cc_tool_call_identity.go create mode 100644 backend/internal/service/openai_gateway_cc_tool_call_identity_test.go diff --git a/backend/internal/service/openai_gateway_cc_tool_call_identity.go b/backend/internal/service/openai_gateway_cc_tool_call_identity.go new file mode 100644 index 000000000..6b0249c9c --- /dev/null +++ b/backend/internal/service/openai_gateway_cc_tool_call_identity.go @@ -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 +} diff --git a/backend/internal/service/openai_gateway_cc_tool_call_identity_test.go b/backend/internal/service/openai_gateway_cc_tool_call_identity_test.go new file mode 100644 index 000000000..aa27c8063 --- /dev/null +++ b/backend/internal/service/openai_gateway_cc_tool_call_identity_test.go @@ -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) +} diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index a3eeecda2..f3506d045 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -336,6 +336,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( } } line = applyOllamaCloudRawChatCompletionsSSELine(account, line) + line = stripEmptyChatToolCallIdentityFromSSELine(line) writeLine(line) if line == "" { diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index a80234913..8a83bbc7e 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -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)