fix(relay):修复response转chat移除问题(适配ZCode)
This commit is contained in:
1 parent
a42453e989
commit
a1469e5b1b
5 files changed
+112
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in new issue
Block a user