fix(relay):修复Chat→Claude 转换时无参数工具缺 input_schema
This commit is contained in:
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user