fix(relay):修复Chat→Claude 转换时无参数工具缺 input_schema

This commit is contained in:
chengqian committed 2026-09-22 18:23:29 +08:00
1 parent 22bdb23c8d
commit d7bd0cfb4e
6 files changed
+180 -5

No files matched your search

@@ -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)
}
@@ -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")
}
}
@@ -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) {
@@ -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)
}
}
@@ -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)
@@ -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))
}
}