fix(relay):修复response转chat移除问题(适配ZCode)

This commit is contained in:
chengqian committed 2026-09-18 17:18:45 +08:00
1 parent a42453e989
commit a1469e5b1b
5 files changed
+112 -4

No files matched your search

+6
View File
@@ -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
+32
View File
@@ -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
+13 -2
View File
@@ -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"))
@@ -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)
@@ -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{