From 6dacf6b25e778a471494754dfd58d7670223f849 Mon Sep 17 00:00:00 2001 From: xintjjliu Date: Wed, 3 Jun 2026 14:00:40 +0800 Subject: [PATCH] fix: handle nested function tool names --- internal/protocol/anthropic/adapter.go | 3 + internal/protocol/anthropic/adapter_test.go | 28 +++++++++ internal/protocol/openai/adapter.go | 31 +++++++++- internal/protocol/openai/adapter_test.go | 67 ++++++++++++++++++++- internal/protocol/openai/types.go | 17 ++++-- 5 files changed, 137 insertions(+), 9 deletions(-) diff --git a/internal/protocol/anthropic/adapter.go b/internal/protocol/anthropic/adapter.go index 5a09e767..5a3d3e7f 100644 --- a/internal/protocol/anthropic/adapter.go +++ b/internal/protocol/anthropic/adapter.go @@ -310,6 +310,9 @@ func (a *AnthropicProviderAdapter) FromCoreRequest(ctx context.Context, req *for if len(req.Tools) > 0 { anthropicReq.Tools = make([]Tool, 0, len(req.Tools)) for _, t := range req.Tools { + if strings.TrimSpace(t.Name) == "" { + continue + } schema := cleanSchema(t.InputSchema) if schema == nil { schema = map[string]any{"type": "object"} diff --git a/internal/protocol/anthropic/adapter_test.go b/internal/protocol/anthropic/adapter_test.go index ed057567..41bc65b2 100644 --- a/internal/protocol/anthropic/adapter_test.go +++ b/internal/protocol/anthropic/adapter_test.go @@ -177,6 +177,34 @@ func TestFromCoreRequest_Tools(t *testing.T) { t.Errorf("tool type = %q, want empty (Anthropic custom tools have no type field)", msgReq.Tools[0].Type) } } + +func TestFromCoreRequest_FiltersEmptyToolNames(t *testing.T) { + adapter := newTestAdapter() + + coreReq := &format.CoreRequest{ + Model: "claude-sonnet-4", + Messages: []format.CoreMessage{ + {Role: "user", Content: []format.CoreContentBlock{{Type: "text", Text: "call a tool"}}}, + }, + Tools: []format.CoreTool{ + {Name: "", Description: "invalid", InputSchema: map[string]any{"type": "object"}}, + {Name: "get_weather", Description: "Get the weather", InputSchema: map[string]any{"type": "object"}}, + }, + } + + result, err := adapter.FromCoreRequest(context.Background(), coreReq) + if err != nil { + t.Fatal(err) + } + msgReq := result.(*anthropic.MessageRequest) + if len(msgReq.Tools) != 1 { + t.Fatalf("got %d tools, want 1: %+v", len(msgReq.Tools), msgReq.Tools) + } + if msgReq.Tools[0].Name != "get_weather" { + t.Errorf("tool name = %q", msgReq.Tools[0].Name) + } +} + func TestFromCoreRequest_ImageMessage(t *testing.T) { adapter := newTestAdapter() diff --git a/internal/protocol/openai/adapter.go b/internal/protocol/openai/adapter.go index 32038518..dfc5cf02 100644 --- a/internal/protocol/openai/adapter.go +++ b/internal/protocol/openai/adapter.go @@ -34,14 +34,14 @@ type OpenAIAdapter struct { hooks format.CorePluginHooks disablePatchProxy func(string) bool - streamMu sync.Mutex - streamEvents []StreamEvent + streamMu sync.Mutex + streamEvents []StreamEvent } // NewOpenAIAdapter creates a new OpenAIAdapter with the given config and hooks. func NewOpenAIAdapter(hooks format.CorePluginHooks) *OpenAIAdapter { return &OpenAIAdapter{ - hooks: hooks.WithDefaults(), + hooks: hooks.WithDefaults(), disablePatchProxy: hooks.DisablePatchProxy, } } @@ -1524,6 +1524,7 @@ func buildToolOutputItemStreaming(block *format.CoreContentBlock, extensions map // Custom tools are expanded using codex package helpers. // Namespace tools are recursively flattened. func convertToolWithNamespace(tool Tool, namespace string, disablePatchProxy func(string) bool) []format.CoreTool { + tool = normalizeToolFunction(tool) name := namespacedToolName(namespace, tool.Name) ext := make(map[string]any) @@ -1639,6 +1640,9 @@ func flattenToolsWithNamespace(openaiTools []Tool, namespace string, disablePatc seen := make(map[string]bool, len(result)) deduped := make([]format.CoreTool, 0, len(result)) for _, t := range result { + if strings.TrimSpace(t.Name) == "" { + continue + } if seen[t.Name] { continue } @@ -1649,6 +1653,27 @@ func flattenToolsWithNamespace(openaiTools []Tool, namespace string, disablePatc return result } +// normalizeToolFunction accepts Chat Completions-style function tools inside +// Responses requests, where name/schema live under tool.function. +func normalizeToolFunction(tool Tool) Tool { + if tool.Function == nil { + return tool + } + if strings.TrimSpace(tool.Name) == "" { + tool.Name = tool.Function.Name + } + if strings.TrimSpace(tool.Description) == "" { + tool.Description = tool.Function.Description + } + if tool.Parameters == nil { + tool.Parameters = tool.Function.Parameters + } + if tool.Strict == nil { + tool.Strict = tool.Function.Strict + } + return tool +} + // namespacedToolName joins namespace and name. func namespacedToolName(namespace, name string) string { return codextool.NamespacedToolName(namespace, name) diff --git a/internal/protocol/openai/adapter_test.go b/internal/protocol/openai/adapter_test.go index 0bcca6d5..692af45b 100644 --- a/internal/protocol/openai/adapter_test.go +++ b/internal/protocol/openai/adapter_test.go @@ -89,6 +89,69 @@ func TestToCoreRequest_AppendsInjectedTools(t *testing.T) { } } +func TestToCoreRequest_ChatCompletionsFunctionTool(t *testing.T) { + adapter := openai.NewOpenAIAdapter(format.CorePluginHooks{}) + + strict := true + req := &openai.ResponsesRequest{ + Model: "gpt-4o", + Input: json.RawMessage(`"call a tool"`), + Tools: []openai.Tool{{ + Type: "function", + Function: &openai.ToolFunction{ + Name: "get_weather", + Description: "Get the weather", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "city": map[string]any{"type": "string"}, + }, + }, + Strict: &strict, + }, + }}, + } + + result, err := adapter.ToCoreRequest(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if len(result.Tools) != 1 { + t.Fatalf("got %d tools, want 1: %+v", len(result.Tools), result.Tools) + } + if result.Tools[0].Name != "get_weather" { + t.Fatalf("tool name = %q, want get_weather", result.Tools[0].Name) + } + if result.Tools[0].Description != "Get the weather" { + t.Fatalf("tool description = %q", result.Tools[0].Description) + } + if result.Tools[0].InputSchema["type"] != "object" { + t.Fatalf("tool schema = %+v", result.Tools[0].InputSchema) + } +} + +func TestToCoreRequest_FiltersEmptyToolNames(t *testing.T) { + adapter := openai.NewOpenAIAdapter(format.CorePluginHooks{}) + + req := &openai.ResponsesRequest{ + Model: "gpt-4o", + Input: json.RawMessage(`"call a tool"`), + Tools: []openai.Tool{{ + Type: "function", + Description: "missing name", + Parameters: map[string]any{"type": "object"}, + }}, + } + + result, err := adapter.ToCoreRequest(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if len(result.Tools) != 0 { + t.Fatalf("got tools %+v, want none", result.Tools) + } +} + func TestToCoreRequest_FunctionCallOutputImage(t *testing.T) { adapter := openai.NewOpenAIAdapter(format.CorePluginHooks{}) @@ -294,8 +357,8 @@ func TestToCoreRequest_BatchesCustomToolCallsAndOutputsIntoSingleRound(t *testin for i, want := range []struct { assistantTextIdx int msgIdx int - callID string - outcome string + callID string + outcome string }{ {0, 1, "call_a", "ok a"}, {3, 4, "call_b", "ok b"}, diff --git a/internal/protocol/openai/types.go b/internal/protocol/openai/types.go index 24ba63c7..7c506cce 100644 --- a/internal/protocol/openai/types.go +++ b/internal/protocol/openai/types.go @@ -47,6 +47,7 @@ type Tool struct { Description string `json:"description,omitempty"` Parameters map[string]any `json:"parameters,omitempty"` Strict *bool `json:"strict,omitempty"` + Function *ToolFunction `json:"function,omitempty"` Format map[string]any `json:"format,omitempty"` Tools []Tool `json:"tools,omitempty"` ExternalWebAccess *bool `json:"external_web_access,omitempty"` @@ -56,6 +57,14 @@ type Tool struct { DisplayHeight int `json:"display_height,omitempty"` } +// ToolFunction represents a Chat Completions-style nested function tool payload. +type ToolFunction struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` + Strict *bool `json:"strict,omitempty"` +} + // ============================================================================ // Response // ============================================================================ @@ -116,10 +125,10 @@ type ContentPart struct { // Usage represents token usage statistics. type Usage struct { - InputTokens int `json:"input_tokens,omitempty"` - OutputTokens int `json:"output_tokens,omitempty"` - TotalTokens int `json:"total_tokens"` - InputTokensDetails InputTokensDetails `json:"input_tokens_details,omitempty"` + InputTokens int `json:"input_tokens,omitempty"` + OutputTokens int `json:"output_tokens,omitempty"` + TotalTokens int `json:"total_tokens"` + InputTokensDetails InputTokensDetails `json:"input_tokens_details,omitempty"` OutputTokensDetails OutputTokensDetails `json:"output_tokens_details,omitempty"` }