Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/protocol/anthropic/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
28 changes: 28 additions & 0 deletions internal/protocol/anthropic/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
31 changes: 28 additions & 3 deletions internal/protocol/openai/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down
67 changes: 65 additions & 2 deletions internal/protocol/openai/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})

Expand Down Expand Up @@ -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"},
Expand Down
17 changes: 13 additions & 4 deletions internal/protocol/openai/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
// ============================================================================
Expand Down Expand Up @@ -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"`
}

Expand Down