diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index e6cc00e..ce6752d 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -38,6 +38,12 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { } return betaURL + "/completions", nil case constant.RelayModeChatCompletions: + // chat_via_responses 桥接:请求体已由 relaykit 转成 Responses 格式,URL 必须同步 + // 切到 /v1/responses——否则 Responses 体打到 chat 端点,上游按 chat 格式反序列化 + // 直接 400(tools[0] missing field `function`) + if info.UseResponsesAPI { + return baseURL + "/v1/responses", nil + } return baseURL + "/v1/chat/completions", nil case constant.RelayModeClaudeMessages: return baseURL + "/anthropic/v1/messages", nil diff --git a/relay/channel/deepseek/adaptor_test.go b/relay/channel/deepseek/adaptor_test.go index e72222a..8364fce 100644 --- a/relay/channel/deepseek/adaptor_test.go +++ b/relay/channel/deepseek/adaptor_test.go @@ -59,6 +59,38 @@ func TestGetRequestURL_ResponsesFlagRouting(t *testing.T) { } } +// TestGetRequestURL_ChatViaResponsesBridge chat 入站 + chat_via_responses 桥接: +// 请求体已被 relaykit 转成 Responses 格式,URL 必须同步切到 /v1/responses—— +// 回归 chn_debug_logs #356:URL 不切时 Responses 体打到 chat 端点, +// 上游 400 "tools[0]: missing field `function`"。 +func TestGetRequestURL_ChatViaResponsesBridge(t *testing.T) { + a := &Adaptor{} + info := deepseekResponsesInfo(false) + info.RelayMode = int(constant.RelayModeChatCompletions) + info.InboundFormat = constant.RelayFormatOpenAI + info.ClientFormat = constant.RelayFormatOpenAI + info.ChannelMeta.ChatViaResponses = true + info.UseResponsesAPI = true + + got, err := a.GetRequestURL(info) + if err != nil { + t.Fatalf("GetRequestURL error: %v", err) + } + if want := "https://api.deepseek.example.com/v1/responses"; got != want { + t.Errorf("chat via responses bridge: URL = %q, want %q", got, want) + } + + // 未开桥接的 chat 请求仍走 chat 端点 + info.UseResponsesAPI = false + got, err = a.GetRequestURL(info) + if err != nil { + t.Fatalf("GetRequestURL error: %v", err) + } + if want := "https://api.deepseek.example.com/v1/chat/completions"; got != want { + t.Errorf("plain chat: URL = %q, want %q", got, want) + } +} + const deepseekResponsesRequestBody = `{"model":"deepseek-v4-flash","instructions":"You are helpful.","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}],"stream":true}` // TestConvertRequest_ResponsesChatFallback_HardFail chat-only 上游的 Responses→chat diff --git a/relay/channel/openai/converter.go b/relay/channel/openai/converter.go index 4aa81e0..f10bcb2 100644 --- a/relay/channel/openai/converter.go +++ b/relay/channel/openai/converter.go @@ -1134,6 +1134,10 @@ func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info toolCallNameByID := make(map[string]string) toolCallArgsByID := make(map[string]string) toolCallNameSent := make(map[string]bool) + // item_id → call_id 映射:function_call 项的 item.id(OpenAI 为 fc_xxx、DeepSeek 为 UUID) + // 与 call_id 是两个不同的值,而 arguments 增量事件只带 item_id。不做映射会把同一 + // 工具调用拆成两个 index,客户端在无 name 的新 index 上报错 + callIDByItemID := make(map[string]string) sendChatChunk := func(chunk *dto.ChatCompletionStreamResponse) bool { if chunk == nil { @@ -1226,6 +1230,9 @@ func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info if callID == "" { return } + if itemID := strings.TrimSpace(streamResp.Item.ID); itemID != "" { + callIDByItemID[itemID] = callID + } name := strings.TrimSpace(streamResp.Item.Name) if name != "" { toolCallNameByID[callID] = name @@ -1251,10 +1258,14 @@ func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info case "response.function_call_arguments.delta": itemID := strings.TrimSpace(streamResp.ItemID) - callID := itemID - if callID == "" { + if itemID == "" { return } + // 增量事件只带 item_id,先映射回 call_id(无 added 事件先行的上游按原值兜底) + callID := callIDByItemID[itemID] + if callID == "" { + callID = itemID + } toolCallArgsByID[callID] += streamResp.Delta if !r2cSendToolCallChunk(responseID, createAt, model, callID, "", streamResp.Delta, toolCallIndexByID, toolCallNameByID, toolCallNameSent, writer, sendChatChunk) { sr.Stop(fmt.Errorf("send tool call args chunk failed")) diff --git a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream.go b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream.go index 61eb086..5f29b7e 100644 --- a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream.go +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream.go @@ -70,6 +70,10 @@ func (c *ResponsesToOpenAIStreamConverter) ConvertStreamResponse( toolCallNameByID := make(map[string]string) toolCallArgsByID := make(map[string]string) toolCallNameSent := make(map[string]bool) + // item_id → call_id 映射:function_call 项的 item.id(OpenAI 为 fc_xxx、DeepSeek 为 UUID) + // 与 call_id 是两个不同的值,而 arguments 增量事件只带 item_id。不做映射会把同一 + // 工具调用拆成两个 index,客户端在无 name 的新 index 上报错 + callIDByItemID := make(map[string]string) sendStartIfNeeded := func() error { if sentStart { @@ -172,6 +176,9 @@ func (c *ResponsesToOpenAIStreamConverter) ConvertStreamResponse( if callID == "" { continue } + if itemID := strings.TrimSpace(streamResp.Item.ID); itemID != "" { + callIDByItemID[itemID] = callID + } name := strings.TrimSpace(streamResp.Item.Name) if name != "" { toolCallNameByID[callID] = name @@ -196,10 +203,15 @@ func (c *ResponsesToOpenAIStreamConverter) ConvertStreamResponse( usageText.WriteString(argsDelta) case "response.function_call_arguments.delta": - callID := strings.TrimSpace(streamResp.ItemID) - if callID == "" { + itemID := strings.TrimSpace(streamResp.ItemID) + if itemID == "" { continue } + // 增量事件只带 item_id,先映射回 call_id(无 added 事件先行的上游按原值兜底) + callID := callIDByItemID[itemID] + if callID == "" { + callID = itemID + } toolCallArgsByID[callID] += streamResp.Delta if err := sendToolCallChunk(chunkWriter, responseID, createAt, model, callID, "", streamResp.Delta, toolCallIndexByID, toolCallNameByID, toolCallNameSent); err != nil { return fmt.Errorf("send tool call args chunk failed: %w", err) diff --git a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream_test.go b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream_test.go index 4a20de5..ac865e8 100644 --- a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream_test.go +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream_test.go @@ -141,6 +141,53 @@ func TestResponsesToOpenAIStream_ToolCalls(t *testing.T) { } } +// TestResponsesToOpenAIStream_ToolCallItemIDDiffersFromCallID 复现线上 bug(chn_debug_logs #357): +// function_call 项的 item.id(OpenAI 为 fc_xxx、DeepSeek 为 UUID)与 call_id 是两个不同的值, +// 而 arguments 增量事件只带 item_id。不做 item_id→call_id 映射会把同一工具调用拆成两个 +// index(续块无 name),ai-sdk 客户端报 "Expected 'function.name' to be a string"。 +func TestResponsesToOpenAIStream_ToolCallItemIDDiffersFromCallID(t *testing.T) { + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"9abf972b-39ee-431d-b452-1aedef3f6762","call_id":"call_00_abc","name":"Bash","arguments":""}}`, + ``, + `data: {"type":"response.function_call_arguments.delta","item_id":"9abf972b-39ee-431d-b452-1aedef3f6762","output_index":0,"delta":"{\"cmd\":"}`, + ``, + `data: {"type":"response.function_call_arguments.delta","item_id":"9abf972b-39ee-431d-b452-1aedef3f6762","output_index":0,"delta":"\"ls\"}"}`, + ``, + `data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"9abf972b-39ee-431d-b452-1aedef3f6762","call_id":"call_00_abc","name":"Bash","arguments":"{\"cmd\":\"ls\"}"}}`, + ``, + `data: {"type":"response.completed","response":{"id":"resp_up","object":"response","created_at":123,"status":"completed","usage":{"input_tokens":7,"output_tokens":3,"total_tokens":10}}}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + + chunks, err := runResponsesToChatStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + + var args strings.Builder + for _, c := range chunks { + if len(c.Choices) == 0 { + continue + } + for _, tc := range c.Choices[0].Delta.ToolCalls { + // 所有分片必须归并到同一个调用:index 恒 0、id 恒为 call_id + if tc.Index == nil || *tc.Index != 0 { + t.Errorf("tool call index = %v, want 0(同一调用被拆成多个 index)", tc.Index) + } + if tc.ID != "" && tc.ID != "call_00_abc" { + t.Errorf("tool call id = %q, want call_00_abc(item_id 泄漏成了调用 id)", tc.ID) + } + args.WriteString(tc.Function.Arguments) + } + } + // output_item.done 的累计 arguments 与已发增量做差,不得重复下发 + if args.String() != `{"cmd":"ls"}` { + t.Errorf("聚合 arguments = %q, want {\"cmd\":\"ls\"}", args.String()) + } +} + // TestResponsesToOpenAIStream_ReasoningDelta 推理摘要 delta 转 reasoning_content。 func TestResponsesToOpenAIStream_ReasoningDelta(t *testing.T) { sse := strings.Join([]string{