fix(relay):修复chat协议调用claude协议工具调用问题(适配ZCode)

This commit is contained in:
chengqian committed 2026-09-18 16:06:20 +08:00
1 parent 62a23325c7
commit e6add44afb
25 files changed
+78 -103

No files matched your search

+2 -1
View File
@@ -480,10 +480,11 @@ func appendToolCall(toolCalls []dto.ToolCall, idx *int, part dto.GeminiPart) []d
return toolCalls
}
argsJSON, _ := json.Marshal(part.FunctionCall.Arguments)
i := *idx
tc := dto.ToolCall{
ID: fmt.Sprintf("call_%d", *idx),
Type: "function",
Index: *idx,
Index: &i,
Function: dto.FunctionCall{
Name: part.FunctionCall.FunctionName,
Arguments: string(argsJSON),
+2 -1
View File
@@ -470,8 +470,9 @@ func legacyOllamaStreamToolCalls(toolCalls []OllamaToolCall, nextIndex *int) []d
argsBytes = b
}
}
idx := *nextIndex
out = append(out, dto.ToolCall{
Index: *nextIndex,
Index: &idx,
ID: id,
Type: "function",
Function: dto.FunctionCall{
+1 -1
View File
@@ -1360,7 +1360,7 @@ func r2cSendToolCallChunk(responseID string, createAt int64, model string, callI
if nameByID[callID] != "" {
name = nameByID[callID]
}
tool := dto.ToolCall{ID: callID, Type: "function", Index: idx, Function: dto.FunctionCall{Arguments: argsDelta}}
tool := dto.ToolCall{ID: callID, Type: "function", Index: &idx, Function: dto.FunctionCall{Arguments: argsDelta}}
if name != "" && !nameSent[callID] {
tool.Function.Name = name
nameSent[callID] = true
+10 -6
View File
@@ -49,7 +49,7 @@ type ImageConfigDTO struct {
// Message OpenAI 消息格式
type Message struct {
Role string `json:"role"`
Role string `json:"role,omitempty"` // 请求/非流式恒有值;流式仅首帧带 role,续帧空值不序列化
Content any `json:"content"` // string 或 []ContentPart
Refusal *string `json:"refusal,omitempty"` // 结构化输出拒绝原因
ReasoningContent *string `json:"reasoning_content,omitempty"` // 推理模型(DeepSeek/o1)的思考内容
@@ -103,15 +103,19 @@ type FunctionDef struct {
// ToolCall 工具调用
type ToolCall struct {
Index int `json:"index,omitempty"`
ID string `json:"id"`
Type string `json:"type"`
// Index 用指针:流式增量中 index 是客户端归并分片的唯一依据,首个调用的 0 也必须
// 序列化(int+omitempty 会把 0 吞掉,客户端把续块当成 name 为空的新调用);
// 请求侧与非流式响应不含该字段,留 nil 不序列化。
Index *int `json:"index,omitempty"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function FunctionCall `json:"function"`
}
// FunctionCall 函数调用
// FunctionCall 函数调用(流式续块只带 arguments,id/type/name 空值不得序列化为 "";
// arguments 恒带键:codex 等严格 serde 客户端要求首块 "arguments":"" 存在)
type FunctionCall struct {
Name string `json:"name"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments"`
}
@@ -146,7 +146,7 @@ func (c *ClaudeToOpenAIStreamConverter) ConvertStreamResponse(
case "tool_use":
// tool_use 块开始
toolCall := dto.ToolCall{
Index: toolCallIdx,
Index: intPtr(toolCallIdx),
ID: event.ContentBlock.ID,
Type: "function",
Function: dto.FunctionCall{
@@ -188,7 +188,7 @@ func (c *ClaudeToOpenAIStreamConverter) ConvertStreamResponse(
if event.Delta.PartialJSON != nil && *event.Delta.PartialJSON != "" {
if err := chunkWriter(newChunk(dto.Message{
ToolCalls: []dto.ToolCall{{
Index: toolCallIdx - 1,
Index: intPtr(toolCallIdx - 1),
Function: dto.FunctionCall{
Arguments: *event.Delta.PartialJSON,
},
@@ -22,7 +22,7 @@
"expected_response": {
"id": "msg_001",
"object": "chat.completion",
"created": 1785291101,
"created": 1789717794,
"model": "claude-3-5-sonnet-20241022",
"choices": [
{
@@ -212,10 +212,14 @@ func (c *GeminiToOpenAIStreamConverter) ConvertStreamResponse(
// 函数调用
if part.FunctionCall != nil {
argsJSON, _ := json.Marshal(part.FunctionCall.Arguments)
// Gemini 每个函数调用整体到达,但 OpenAI 流式契约要求每个
// tool_call 增量都带 index(0 也要发),客户端按 index 归并分片
idx := toolCallIdx
if err := chunkWriter(newChunk(dto.Message{
ToolCalls: []dto.ToolCall{{
ID: fmt.Sprintf("call_%s_%d", responseID, toolCallIdx),
Type: "function",
Index: &idx,
ID: fmt.Sprintf("call_%s_%d", responseID, toolCallIdx),
Type: "function",
Function: dto.FunctionCall{
Name: part.FunctionCall.FunctionName,
Arguments: string(argsJSON),
@@ -30,7 +30,6 @@
{
"index": 0,
"delta": {
"role": "",
"content": "Hello!"
},
"finish_reason": null
@@ -47,7 +46,6 @@
{
"index": 0,
"delta": {
"role": "",
"content": " How are you?"
},
"finish_reason": null
@@ -64,7 +62,6 @@
{
"index": 0,
"delta": {
"role": "",
"content": null
},
"finish_reason": "stop"
@@ -276,6 +276,11 @@ func (c *OpenAIToResponsesStreamConverter) ConvertStreamResponse(
// 工具调用
for _, tc := range choice.Delta.ToolCalls {
callID := tc.ID
// index 缺失时按 0 处理(单工具调用流的兼容兜底)
tcIdx := 0
if tc.Index != nil {
tcIdx = *tc.Index
}
// 新 tool call:有 ID 和 name
if callID != "" && tc.Function.Name != "" {
@@ -284,7 +289,7 @@ func (c *OpenAIToResponsesStreamConverter) ConvertStreamResponse(
return err
}
// 记录 index → callID 映射,用于后续参数 chunk 的查找
toolCallIDByIndex[tc.Index] = callID
toolCallIDByIndex[tcIdx] = callID
// 先关闭文本 content part
if !sentTextDone {
@@ -317,7 +322,7 @@ func (c *OpenAIToResponsesStreamConverter) ConvertStreamResponse(
// 参数 chunk:ID 可能为空,通过 index 查找对应的 callID
if callID == "" {
callID = toolCallIDByIndex[tc.Index]
callID = toolCallIDByIndex[tcIdx]
}
if callID == "" {
continue
@@ -306,7 +306,7 @@ func sendToolCallChunk(chunkWriter func(chunk any) error, responseID string, cre
if nameByID[callID] != "" {
name = nameByID[callID]
}
tool := dto.ToolCall{ID: callID, Type: "function", Index: idx, Function: dto.FunctionCall{Arguments: argsDelta}}
tool := dto.ToolCall{ID: callID, Type: "function", Index: &idx, Function: dto.FunctionCall{Arguments: argsDelta}}
if name != "" && !nameSent[callID] {
tool.Function.Name = name
nameSent[callID] = true
@@ -120,7 +120,7 @@ func TestResponsesToOpenAIStream_ToolCalls(t *testing.T) {
t.Fatalf("tool chunks = %d, want 3", len(toolChunks))
}
first := toolChunks[0].Choices[0].Delta.ToolCalls[0]
if first.ID != "call_1" || first.Type != "function" || first.Function.Name != "get_weather" || first.Index != 0 {
if first.ID != "call_1" || first.Type != "function" || first.Function.Name != "get_weather" || first.Index == nil || *first.Index != 0 {
t.Errorf("first tool chunk = %+v", first)
}
// name 只在首个 chunk 下发一次
@@ -424,7 +424,7 @@ func TestOllamaToOpenAIStreamConverter_ToolCalls(t *testing.T) {
t.Fatalf("tool delta = %+v", toolDelta.ToolCalls)
}
tc := toolDelta.ToolCalls[0]
if tc.Index != 0 || tc.ID != "call_1" || tc.Type != "function" {
if tc.Index == nil || *tc.Index != 0 || tc.ID != "call_1" || tc.Type != "function" {
t.Errorf("tool call = %+v", tc)
}
if tc.Function.Name != "get_weather" || tc.Function.Arguments != `{"city":"Paris"}` {
@@ -178,8 +178,9 @@ func ollamaStreamToolCalls(toolCalls []dto.OllamaToolCall, nextIndex *int) []dto
argsBytes = b
}
}
idx := *nextIndex
out = append(out, dto.ToolCall{
Index: *nextIndex,
Index: &idx,
ID: id,
Type: "function",
Function: dto.FunctionCall{
@@ -23,8 +23,7 @@
"choices": [
{
"delta": {
"content": "Hello",
"role": ""
"content": "Hello"
},
"finish_reason": null,
"index": 0
@@ -42,8 +41,7 @@
"choices": [
{
"delta": {
"content": ", world!",
"role": ""
"content": ", world!"
},
"finish_reason": null,
"index": 0
@@ -61,8 +59,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "stop",
"index": 0
@@ -24,8 +24,7 @@
{
"delta": {
"content": null,
"reasoning_content": "Let me think...",
"role": ""
"reasoning_content": "Let me think..."
},
"finish_reason": null,
"index": 0
@@ -43,8 +42,7 @@
"choices": [
{
"delta": {
"content": "The answer is 42.",
"role": ""
"content": "The answer is 42."
},
"finish_reason": null,
"index": 0
@@ -62,8 +60,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "stop",
"index": 0
@@ -23,8 +23,7 @@
"choices": [
{
"delta": {
"content": "Let me check the weather.",
"role": ""
"content": "Let me check the weather."
},
"finish_reason": null,
"index": 0
@@ -43,7 +42,6 @@
{
"delta": {
"content": null,
"role": "",
"tool_calls": [
{
"function": {
@@ -51,6 +49,7 @@
"name": "get_weather"
},
"id": "\u003cscrubbed\u003e",
"index": 0,
"type": "function"
}
]
@@ -72,15 +71,12 @@
{
"delta": {
"content": null,
"role": "",
"tool_calls": [
{
"function": {
"arguments": "{\"city\":",
"name": ""
"arguments": "{\"city\":"
},
"id": "\u003cscrubbed\u003e",
"type": ""
"index": 0
}
]
},
@@ -101,15 +97,12 @@
{
"delta": {
"content": null,
"role": "",
"tool_calls": [
{
"function": {
"arguments": "\"Shanghai\"}",
"name": ""
"arguments": "\"Shanghai\"}"
},
"id": "\u003cscrubbed\u003e",
"type": ""
"index": 0
}
]
},
@@ -129,8 +122,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "tool_calls",
"index": 0
@@ -23,8 +23,7 @@
"choices": [
{
"delta": {
"content": "Hello",
"role": ""
"content": "Hello"
},
"finish_reason": null,
"index": 0
@@ -42,8 +41,7 @@
"choices": [
{
"delta": {
"content": ", world!",
"role": ""
"content": ", world!"
},
"finish_reason": null,
"index": 0
@@ -61,8 +59,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "stop",
"index": 0
@@ -24,8 +24,7 @@
{
"delta": {
"content": null,
"reasoning_content": "Let me think...",
"role": ""
"reasoning_content": "Let me think..."
},
"finish_reason": null,
"index": 0
@@ -43,8 +42,7 @@
"choices": [
{
"delta": {
"content": "The answer is 42.",
"role": ""
"content": "The answer is 42."
},
"finish_reason": null,
"index": 0
@@ -62,8 +60,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "stop",
"index": 0
@@ -23,8 +23,7 @@
"choices": [
{
"delta": {
"content": "Let me check the weather.",
"role": ""
"content": "Let me check the weather."
},
"finish_reason": null,
"index": 0
@@ -43,7 +42,6 @@
{
"delta": {
"content": null,
"role": "",
"tool_calls": [
{
"function": {
@@ -51,6 +49,7 @@
"name": "get_weather"
},
"id": "\u003cscrubbed\u003e",
"index": 0,
"type": "function"
}
]
@@ -71,8 +70,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "stop",
"index": 0
@@ -23,8 +23,7 @@
"choices": [
{
"delta": {
"content": "Hello",
"role": ""
"content": "Hello"
},
"finish_reason": null,
"index": 0
@@ -42,8 +41,7 @@
"choices": [
{
"delta": {
"content": ", world!",
"role": ""
"content": ", world!"
},
"finish_reason": null,
"index": 0
@@ -61,8 +59,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "stop",
"index": 0
@@ -24,8 +24,7 @@
{
"delta": {
"content": null,
"reasoning_content": "Let me think...",
"role": ""
"reasoning_content": "Let me think..."
},
"finish_reason": null,
"index": 0
@@ -43,8 +42,7 @@
"choices": [
{
"delta": {
"content": "The answer is 42.",
"role": ""
"content": "The answer is 42."
},
"finish_reason": null,
"index": 0
@@ -62,8 +60,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "stop",
"index": 0
@@ -5,7 +5,6 @@
{
"delta": {
"content": null,
"role": "",
"tool_calls": [
{
"function": {
@@ -13,6 +12,7 @@
"name": "get_weather"
},
"id": "\u003cscrubbed\u003e",
"index": 0,
"type": "function"
}
]
@@ -34,14 +34,13 @@
{
"delta": {
"content": null,
"role": "",
"tool_calls": [
{
"function": {
"arguments": "{\"city\":",
"name": ""
"arguments": "{\"city\":"
},
"id": "\u003cscrubbed\u003e",
"index": 0,
"type": "function"
}
]
@@ -63,14 +62,13 @@
{
"delta": {
"content": null,
"role": "",
"tool_calls": [
{
"function": {
"arguments": "\"Shanghai\"}",
"name": ""
"arguments": "\"Shanghai\"}"
},
"id": "\u003cscrubbed\u003e",
"index": 0,
"type": "function"
}
]
@@ -110,8 +108,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "tool_calls",
"index": 0
@@ -23,8 +23,7 @@
"choices": [
{
"delta": {
"content": "Hello",
"role": ""
"content": "Hello"
},
"finish_reason": null,
"index": 0
@@ -42,8 +41,7 @@
"choices": [
{
"delta": {
"content": ", world!",
"role": ""
"content": ", world!"
},
"finish_reason": null,
"index": 0
@@ -61,8 +59,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "stop",
"index": 0
@@ -24,8 +24,7 @@
{
"delta": {
"content": "",
"reasoning_content": "Let me think...",
"role": ""
"reasoning_content": "Let me think..."
},
"finish_reason": null,
"index": 0
@@ -43,8 +42,7 @@
"choices": [
{
"delta": {
"content": "The answer is 42.",
"role": ""
"content": "The answer is 42."
},
"finish_reason": null,
"index": 0
@@ -62,8 +60,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "stop",
"index": 0
@@ -24,7 +24,6 @@
{
"delta": {
"content": "",
"role": "",
"tool_calls": [
{
"function": {
@@ -32,6 +31,7 @@
"name": "get_weather"
},
"id": "\u003cscrubbed\u003e",
"index": 0,
"type": "function"
}
]
@@ -52,8 +52,7 @@
"choices": [
{
"delta": {
"content": null,
"role": ""
"content": null
},
"finish_reason": "tool_calls",
"index": 0