From d7bd0cfb4ed5b2b4fce0b1527ab001330e84b8f6 Mon Sep 17 00:00:00 2001 From: chengqian Date: Tue, 22 Sep 2026 18:23:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(relay):=E4=BF=AE=E5=A4=8DChat=E2=86=92Claud?= =?UTF-8?q?e=20=E8=BD=AC=E6=8D=A2=E6=97=B6=E6=97=A0=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=BC=BA=20input=5Fschema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oai_chat/claude_to_openai_request.go | 5 +- .../oai_chat/claude_to_openai_request_test.go | 29 +++++++ .../oai_chat/openai_to_claude_request.go | 8 +- .../oai_chat/openai_to_claude_request_test.go | 75 +++++++++++++++++++ .../internal/shared/tool_mapper.go | 9 ++- .../internal/shared/tool_mapper_test.go | 59 +++++++++++++++ 6 files changed, 180 insertions(+), 5 deletions(-) diff --git a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request.go b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request.go index c6f44c8..e74a76e 100644 --- a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request.go +++ b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request.go @@ -97,7 +97,10 @@ func (c *ClaudeToOpenAIRequestConverter) ConvertRequest( openaiReq.WebSearchOptions = spec.ToOpenAIOptions() } - if claudeReq.ToolChoice != nil { + // tool_choice 依附于 tools:内置工具被过滤后 tools 可能为空(如客户端只带 web_search), + // 此时残留的 tool_choice 会形成「有 tool_choice 无 tools」的孤儿请求体,OpenAI 同样拒绝。 + // 与 o2c 方向对称处理。 + if claudeReq.ToolChoice != nil && len(openaiReq.Tools) > 0 { openaiReq.ToolChoice = c2oConvertToolChoice(claudeReq.ToolChoice) } diff --git a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request_test.go b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request_test.go index d5a361e..e48b107 100644 --- a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request_test.go +++ b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request_test.go @@ -430,3 +430,32 @@ func TestConvertClaudeAssistantMessage(t *testing.T) { t.Errorf("arguments = %q", tc.Function.Arguments) } } + +// Claude 侧只带服务端内置工具(web_search)时,函数工具列表为空, +// tool_choice 必须一并丢弃——OpenAI 同样拒绝「有 tool_choice 无 tools」的请求体。 +func TestClaudeToOpenAIRequestConverter_BuiltinOnlyToolsDropsToolChoice(t *testing.T) { + converter := &ClaudeToOpenAIRequestConverter{} + claudeReq := &dto.ClaudeRequest{ + Model: "claude-sonnet-4", + Tools: []dto.ClaudeTool{{Type: "web_search_20250305", Name: "web_search"}}, + ToolChoice: map[string]any{"type": "any"}, + Messages: []dto.ClaudeMessage{{Role: "user", Content: "搜一下"}}, + } + + result, err := converter.ConvertRequest(context.Background(), newClaudeInboundMeta(), claudeReq) + if err != nil { + t.Fatalf("ConvertRequest error: %v", err) + } + openaiReq := result.(*dto.GeneralOpenAIRequest) + + if len(openaiReq.Tools) != 0 { + t.Errorf("Tools = %+v, want empty", openaiReq.Tools) + } + if openaiReq.ToolChoice != nil { + t.Errorf("ToolChoice = %v, want nil without tools", openaiReq.ToolChoice) + } + // 搜索能力本身不能丢:走 web_search_options 承载 + if openaiReq.WebSearchOptions == nil { + t.Error("WebSearchOptions is nil, web search capability lost") + } +} diff --git a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request.go b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request.go index a24e623..3d6a920 100644 --- a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request.go +++ b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request.go @@ -153,9 +153,11 @@ func (c *OpenAIToClaudeRequestConverter) ConvertRequest( claudeReq.System = strings.Join(systemPrompts, "\n\n") } - // 转换 tools - if len(openaiReq.Tools) > 0 { - claudeReq.Tools = shared.MapOpenAIToolsToClaudeTools(openaiReq.Tools) + // 转换 tools。非 function 类型(如 OpenAI 的 custom 自由格式工具)会被过滤掉, + // 过滤后可能为空——此时 tool_choice 必须一并跳过:tools 空数组本身由 omitempty 省略, + // 但残留的 tool_choice 会形成「有 tool_choice 无 tools」的孤儿请求体,被 Claude 拒绝。 + if claudeTools := shared.MapOpenAIToolsToClaudeTools(openaiReq.Tools); len(claudeTools) > 0 { + claudeReq.Tools = claudeTools // 转换 tool_choice if openaiReq.ToolChoice != nil { switch tc := openaiReq.ToolChoice.(type) { diff --git a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request_test.go b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request_test.go index 1b0de7c..0d241ad 100644 --- a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request_test.go +++ b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request_test.go @@ -565,3 +565,78 @@ func TestOpenAIToClaudeRequestConverter_WebSearchOptions(t *testing.T) { t.Errorf("web_search_options 应还原为 claude 原生 web_search 工具,实际 tools: %+v", claudeReq.Tools) } } + +// 无参函数工具转换后必须携带 input_schema:Claude 的 custom tool 要求该字段必填。 +func TestOpenAIToClaudeRequestConverter_ToolWithoutParameters(t *testing.T) { + converter := &OpenAIToClaudeRequestConverter{} + maxTokens := 1024 + openaiReq := &dto.GeneralOpenAIRequest{ + Model: "gpt-4", + MaxTokens: &maxTokens, + Messages: []dto.Message{{Role: "user", Content: "现在几点"}}, + Tools: []dto.Tool{{ + Type: "function", + Function: dto.FunctionDef{Name: "get_current_time", Description: "无参数"}, + }}, + } + + result, err := converter.ConvertRequest(context.Background(), &mockMeta{upstreamModel: "claude-3-opus-20240229"}, openaiReq) + if err != nil { + t.Fatalf("ConvertRequest error: %v", err) + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var body struct { + Tools []map[string]any `json:"tools"` + } + if err := json.Unmarshal(data, &body); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if len(body.Tools) != 1 { + t.Fatalf("got %d tools, want 1: %s", len(body.Tools), data) + } + if _, ok := body.Tools[0]["input_schema"]; !ok { + t.Errorf("input_schema missing in %s", data) + } +} + +// 工具全部为非 function 类型时被过滤空,tools 与 tool_choice 都不得出现在请求体中: +// 只发 tool_choice 会形成孤儿字段,被 Claude 拒绝。 +func TestOpenAIToClaudeRequestConverter_AllToolsFilteredDropsToolChoice(t *testing.T) { + converter := &OpenAIToClaudeRequestConverter{} + maxTokens := 1024 + openaiReq := &dto.GeneralOpenAIRequest{ + Model: "gpt-4", + MaxTokens: &maxTokens, + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + Tools: []dto.Tool{{Type: "custom", Function: dto.FunctionDef{Name: "freeform"}}}, + ToolChoice: "required", + } + + result, err := converter.ConvertRequest(context.Background(), &mockMeta{upstreamModel: "claude-3-opus-20240229"}, openaiReq) + if err != nil { + t.Fatalf("ConvertRequest error: %v", err) + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if _, ok := body["tools"]; ok { + t.Errorf("tools should be absent, got %s", data) + } + if _, ok := body["tool_choice"]; ok { + t.Errorf("tool_choice should be absent without tools, got %s", data) + } +} diff --git a/relaykit/relayconvert/internal/shared/tool_mapper.go b/relaykit/relayconvert/internal/shared/tool_mapper.go index 1b09771..5c1b96e 100644 --- a/relaykit/relayconvert/internal/shared/tool_mapper.go +++ b/relaykit/relayconvert/internal/shared/tool_mapper.go @@ -20,9 +20,16 @@ func MapOpenAIToolsToClaudeTools(tools []dto.Tool) []dto.ClaudeTool { Description: tool.Function.Description, } - // 转换参数(JSON Schema) + // 转换参数(JSON Schema)。Claude 要求 custom tool 必须携带 input_schema, + // 而 OpenAI 允许无参函数省略 parameters——缺省时补一个空对象 schema, + // 否则 InputSchema 为 nil 被 omitempty 抹掉,上游直接 400。 if tool.Function.Parameters != nil { claudeTool.InputSchema = tool.Function.Parameters + } else { + claudeTool.InputSchema = map[string]any{ + "type": "object", + "properties": map[string]any{}, + } } claudeTools = append(claudeTools, claudeTool) diff --git a/relaykit/relayconvert/internal/shared/tool_mapper_test.go b/relaykit/relayconvert/internal/shared/tool_mapper_test.go index 30d9ce5..13c1f8a 100644 --- a/relaykit/relayconvert/internal/shared/tool_mapper_test.go +++ b/relaykit/relayconvert/internal/shared/tool_mapper_test.go @@ -149,3 +149,62 @@ func TestMapOpenAIToolCallsToClaude(t *testing.T) { t.Errorf("Expected location 'San Francisco', got %v", input["location"]) } } + +// 无参函数(OpenAI 允许省略 parameters)必须补出空对象 schema: +// Claude 的 custom tool 要求 input_schema 必填,缺失会被上游以 400 拒绝。 +func TestMapOpenAIToolsToClaudeTools_NoParameters(t *testing.T) { + tools := []dto.Tool{ + { + Type: "function", + Function: dto.FunctionDef{ + Name: "get_current_time", + Description: "No arguments needed", + }, + }, + } + + result := MapOpenAIToolsToClaudeTools(tools) + + if len(result) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(result)) + } + + if result[0].InputSchema == nil { + t.Fatal("InputSchema is nil, expected empty object schema") + } + + // 序列化后必须带 input_schema 键(omitempty 不能把它抹掉) + data, err := json.Marshal(result[0]) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + schema, ok := decoded["input_schema"].(map[string]any) + if !ok { + t.Fatalf("input_schema missing or wrong type in %s", data) + } + + if schema["type"] != "object" { + t.Errorf("input_schema.type = %v, want object", schema["type"]) + } + + if _, ok := schema["properties"].(map[string]any); !ok { + t.Errorf("input_schema.properties missing in %s", data) + } +} + +// 非 function 类型的工具被过滤后返回空 slice,调用方据此判空。 +func TestMapOpenAIToolsToClaudeTools_AllFiltered(t *testing.T) { + tools := []dto.Tool{ + {Type: "custom", Function: dto.FunctionDef{Name: "freeform"}}, + } + + if result := MapOpenAIToolsToClaudeTools(tools); len(result) != 0 { + t.Errorf("Expected 0 tools, got %d", len(result)) + } +}