diff --git a/llm/pipeline/empty_response_test.go b/llm/pipeline/empty_response_test.go index 6f3a55eb1..9d41de4a3 100644 --- a/llm/pipeline/empty_response_test.go +++ b/llm/pipeline/empty_response_test.go @@ -238,8 +238,8 @@ func TestPipeline_Process_StreamEmptyResponseDetection(t *testing.T) { if streamCalls == 1 { return streams.SliceStream([]*llm.Response{ { - RequestType: llm.RequestTypeSpeech, - APIFormat: llm.APIFormatOpenAISpeech, + RequestType: llm.RequestTypeSpeech, + APIFormat: llm.APIFormatOpenAISpeech, SpeechStreamEvent: &llm.SpeechStreamEvent{Type: "speech.audio.done"}, }, llm.DoneResponse, diff --git a/llm/pipeline/streaming_integration_test.go b/llm/pipeline/streaming_integration_test.go index de9cda28f..85216e152 100644 --- a/llm/pipeline/streaming_integration_test.go +++ b/llm/pipeline/streaming_integration_test.go @@ -669,7 +669,6 @@ func TestPipeline_NonStreaming_AutoAggregateUpgradedStream_EmptyAggregatedBody(t require.ErrorContains(t, err, "empty aggregated body") } - func TestPipeline_NonStreaming_AutoAggregateUpgradedStream_EmptyJSONObjectAggregatedBodyAllowed(t *testing.T) { ctx := context.Background() diff --git a/llm/transformer/anthropic/outbound_convert.go b/llm/transformer/anthropic/outbound_convert.go index 13e7dae10..71bdb07ef 100644 --- a/llm/transformer/anthropic/outbound_convert.go +++ b/llm/transformer/anthropic/outbound_convert.go @@ -1,6 +1,8 @@ package anthropic import ( + "encoding/json" + "github.com/samber/lo" "github.com/looplj/axonhub/llm" @@ -225,10 +227,14 @@ func convertToolsAnthropic(tools []llm.Tool, config *Config) []Tool { for _, tool := range tools { switch tool.Type { case llm.ToolTypeFunction: + inputSchema := tool.Function.Parameters + if len(inputSchema) == 0 { + inputSchema = json.RawMessage(`{"type":"object","properties":{}}`) + } anthropicTools = append(anthropicTools, Tool{ Name: tool.Function.Name, Description: tool.Function.Description, - InputSchema: tool.Function.Parameters, + InputSchema: inputSchema, CacheControl: convertToAnthropicCacheControl(tool.CacheControl), }) case llm.ToolTypeWebSearch: diff --git a/llm/transformer/anthropic/outbound_convert_test.go b/llm/transformer/anthropic/outbound_convert_test.go index ef44137e6..add7d0fb8 100644 --- a/llm/transformer/anthropic/outbound_convert_test.go +++ b/llm/transformer/anthropic/outbound_convert_test.go @@ -1115,3 +1115,62 @@ func TestConvertToAnthropicRequest(t *testing.T) { }) } } + +func TestConvertToolsAnthropic_NilParameters(t *testing.T) { + t.Run("nil parameters defaults to empty object schema", func(t *testing.T) { + tools := []llm.Tool{ + { + Type: llm.ToolTypeFunction, + Function: llm.Function{ + Name: "web_search", + Description: "Search the web", + Parameters: nil, + }, + }, + } + result := convertToolsAnthropic(tools, nil) + require.Len(t, result, 1) + require.Equal(t, "web_search", result[0].Name) + require.NotNil(t, result[0].InputSchema) + require.True(t, json.Valid(result[0].InputSchema)) + var schema map[string]any + require.NoError(t, json.Unmarshal(result[0].InputSchema, &schema)) + require.Equal(t, "object", schema["type"]) + require.NotNil(t, schema["properties"]) + }) + + t.Run("empty parameters defaults to empty object schema", func(t *testing.T) { + tools := []llm.Tool{ + { + Type: llm.ToolTypeFunction, + Function: llm.Function{ + Name: "no_params_func", + Description: "A function with no parameters", + Parameters: json.RawMessage{}, + }, + }, + } + result := convertToolsAnthropic(tools, nil) + require.Len(t, result, 1) + require.NotNil(t, result[0].InputSchema) + var schema map[string]any + require.NoError(t, json.Unmarshal(result[0].InputSchema, &schema)) + require.Equal(t, "object", schema["type"]) + }) + + t.Run("explicit parameters are preserved unchanged", func(t *testing.T) { + params := json.RawMessage(`{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}`) + tools := []llm.Tool{ + { + Type: llm.ToolTypeFunction, + Function: llm.Function{ + Name: "search", + Parameters: params, + }, + }, + } + result := convertToolsAnthropic(tools, nil) + require.Len(t, result, 1) + require.Equal(t, params, result[0].InputSchema) + }) +} diff --git a/llm/transformer/anthropic/outbound_stream_server_tool_use_test.go b/llm/transformer/anthropic/outbound_stream_server_tool_use_test.go index 1840fe861..94acfb9f9 100644 --- a/llm/transformer/anthropic/outbound_stream_server_tool_use_test.go +++ b/llm/transformer/anthropic/outbound_stream_server_tool_use_test.go @@ -30,10 +30,10 @@ func TestOutboundTransformer_ServerToolUse_NoPanic(t *testing.T) { sseEvent(t, "message_start", map[string]any{ "type": "message_start", "message": map[string]any{ - "id": "msg_01AEsGpin3gJumakZWMTyQp3", - "type": "message", - "role": "assistant", - "model": "claude-opus-4-7", + "id": "msg_01AEsGpin3gJumakZWMTyQp3", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-7", "content": []any{}, "usage": map[string]any{ "input_tokens": 6,