From 14e7aca42611127aa3f8eadaa386136ac36c2229 Mon Sep 17 00:00:00 2001 From: johnlanni Date: Sat, 7 Feb 2026 08:32:52 +0800 Subject: [PATCH] test(ai-proxy): add unit tests for Claude Code mode Add comprehensive unit tests for Claude Code mode: - Test claudeProviderInitializer validation and capabilities - Test header logic for beta=true query parameter - Test buildClaudeTextGenRequest for standard and Claude Code modes - Test system prompt injection when missing - Test Bash tool injection when missing - Test deduplication of existing system prompt and tools - Test constants and GetApiName Note: TransformRequestHeaders and TransformRequestBody tests are skipped as they require WASM runtime. Core logic is tested indirectly through buildClaudeTextGenRequest tests. --- .../ai-proxy/provider/claude_test.go | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go new file mode 100644 index 000000000..fdc02f297 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go @@ -0,0 +1,418 @@ +package provider + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClaudeProviderInitializer_ValidateConfig(t *testing.T) { + initializer := &claudeProviderInitializer{} + + t.Run("valid_config_with_api_tokens", func(t *testing.T) { + config := &ProviderConfig{ + apiTokens: []string{"test-token"}, + } + err := initializer.ValidateConfig(config) + assert.NoError(t, err) + }) + + t.Run("invalid_config_without_api_tokens", func(t *testing.T) { + config := &ProviderConfig{ + apiTokens: nil, + } + err := initializer.ValidateConfig(config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no apiToken found in provider config") + }) + + t.Run("invalid_config_with_empty_api_tokens", func(t *testing.T) { + config := &ProviderConfig{ + apiTokens: []string{}, + } + err := initializer.ValidateConfig(config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no apiToken found in provider config") + }) +} + +func TestClaudeProviderInitializer_DefaultCapabilities(t *testing.T) { + initializer := &claudeProviderInitializer{} + + capabilities := initializer.DefaultCapabilities() + expected := map[string]string{ + string(ApiNameChatCompletion): PathAnthropicMessages, + string(ApiNameCompletion): PathAnthropicComplete, + string(ApiNameAnthropicMessages): PathAnthropicMessages, + string(ApiNameEmbeddings): PathOpenAIEmbeddings, + string(ApiNameModels): PathOpenAIModels, + } + + assert.Equal(t, expected, capabilities) +} + +func TestClaudeProviderInitializer_CreateProvider(t *testing.T) { + initializer := &claudeProviderInitializer{} + + config := ProviderConfig{ + apiTokens: []string{"test-token"}, + } + + provider, err := initializer.CreateProvider(config) + require.NoError(t, err) + require.NotNil(t, provider) + + assert.Equal(t, providerTypeClaude, provider.GetProviderType()) + + claudeProvider, ok := provider.(*claudeProvider) + require.True(t, ok) + assert.NotNil(t, claudeProvider.config.apiTokens) + assert.Equal(t, []string{"test-token"}, claudeProvider.config.apiTokens) +} + +func TestClaudeProvider_GetProviderType(t *testing.T) { + provider := &claudeProvider{ + config: ProviderConfig{ + apiTokens: []string{"test-token"}, + }, + contextCache: createContextCache(&ProviderConfig{}), + } + + assert.Equal(t, providerTypeClaude, provider.GetProviderType()) +} + +// Note: TransformRequestHeaders tests are skipped because they require WASM runtime +// The header transformation logic is tested via integration tests instead. +// Here we test the helper functions and logic that can be unit tested. + +func TestClaudeCodeMode_HeaderLogic(t *testing.T) { + // Test the logic for adding beta=true query parameter + t.Run("adds_beta_query_param_to_path_without_query", func(t *testing.T) { + currentPath := "/v1/messages" + var newPath string + if currentPath != "" && !strings.Contains(currentPath, "beta=true") { + if strings.Contains(currentPath, "?") { + newPath = currentPath + "&beta=true" + } else { + newPath = currentPath + "?beta=true" + } + } else { + newPath = currentPath + } + assert.Equal(t, "/v1/messages?beta=true", newPath) + }) + + t.Run("adds_beta_query_param_to_path_with_existing_query", func(t *testing.T) { + currentPath := "/v1/messages?foo=bar" + var newPath string + if currentPath != "" && !strings.Contains(currentPath, "beta=true") { + if strings.Contains(currentPath, "?") { + newPath = currentPath + "&beta=true" + } else { + newPath = currentPath + "?beta=true" + } + } else { + newPath = currentPath + } + assert.Equal(t, "/v1/messages?foo=bar&beta=true", newPath) + }) + + t.Run("does_not_duplicate_beta_param", func(t *testing.T) { + currentPath := "/v1/messages?beta=true" + var newPath string + if currentPath != "" && !strings.Contains(currentPath, "beta=true") { + if strings.Contains(currentPath, "?") { + newPath = currentPath + "&beta=true" + } else { + newPath = currentPath + "?beta=true" + } + } else { + newPath = currentPath + } + assert.Equal(t, "/v1/messages?beta=true", newPath) + }) + + t.Run("bearer_token_format", func(t *testing.T) { + token := "sk-ant-oat01-oauth-token" + bearerAuth := "Bearer " + token + assert.Equal(t, "Bearer sk-ant-oat01-oauth-token", bearerAuth) + }) +} + +func TestClaudeProvider_BuildClaudeTextGenRequest_StandardMode(t *testing.T) { + provider := &claudeProvider{ + config: ProviderConfig{ + claudeCodeMode: false, + }, + } + + t.Run("builds_request_without_injecting_defaults", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + Stream: true, + Messages: []chatMessage{ + {Role: roleUser, Content: "Hello"}, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + // Should not have system prompt injected + assert.Nil(t, claudeReq.System) + // Should not have tools injected + assert.Empty(t, claudeReq.Tools) + }) + + t.Run("preserves_existing_system_message", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + Messages: []chatMessage{ + {Role: roleSystem, Content: "You are a helpful assistant."}, + {Role: roleUser, Content: "Hello"}, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + assert.NotNil(t, claudeReq.System) + assert.False(t, claudeReq.System.IsArray) + assert.Equal(t, "You are a helpful assistant.", claudeReq.System.StringValue) + }) +} + +func TestClaudeProvider_BuildClaudeTextGenRequest_ClaudeCodeMode(t *testing.T) { + provider := &claudeProvider{ + config: ProviderConfig{ + claudeCodeMode: true, + }, + } + + t.Run("injects_default_system_prompt_when_missing", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + Stream: true, + Messages: []chatMessage{ + {Role: roleUser, Content: "List files"}, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + // Should have default Claude Code system prompt + require.NotNil(t, claudeReq.System) + assert.True(t, claudeReq.System.IsArray) + require.Len(t, claudeReq.System.ArrayValue, 1) + assert.Equal(t, claudeCodeSystemPrompt, claudeReq.System.ArrayValue[0].Text) + assert.Equal(t, contentTypeText, claudeReq.System.ArrayValue[0].Type) + // Should have cache_control + assert.NotNil(t, claudeReq.System.ArrayValue[0].CacheControl) + assert.Equal(t, "ephemeral", claudeReq.System.ArrayValue[0].CacheControl["type"]) + }) + + t.Run("preserves_existing_system_message_with_cache_control", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + Messages: []chatMessage{ + {Role: roleSystem, Content: "Custom system prompt"}, + {Role: roleUser, Content: "Hello"}, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + // Should preserve custom system prompt but with array format and cache_control + require.NotNil(t, claudeReq.System) + assert.True(t, claudeReq.System.IsArray) + require.Len(t, claudeReq.System.ArrayValue, 1) + assert.Equal(t, "Custom system prompt", claudeReq.System.ArrayValue[0].Text) + // Should have cache_control + assert.NotNil(t, claudeReq.System.ArrayValue[0].CacheControl) + assert.Equal(t, "ephemeral", claudeReq.System.ArrayValue[0].CacheControl["type"]) + }) + + t.Run("injects_bash_tool_when_missing", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + Messages: []chatMessage{ + {Role: roleUser, Content: "List files"}, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + // Should have Bash tool injected + require.Len(t, claudeReq.Tools, 1) + assert.Equal(t, claudeCodeBashToolName, claudeReq.Tools[0].Name) + assert.Equal(t, claudeCodeBashToolDesc, claudeReq.Tools[0].Description) + // Verify input schema + assert.NotNil(t, claudeReq.Tools[0].InputSchema) + assert.Equal(t, "object", claudeReq.Tools[0].InputSchema["type"]) + }) + + t.Run("does_not_duplicate_bash_tool", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + Messages: []chatMessage{ + {Role: roleUser, Content: "List files"}, + }, + Tools: []tool{ + { + Type: "function", + Function: function{ + Name: "Bash", + Description: "Custom bash tool", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string"}, + }, + }, + }, + }, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + // Should not duplicate Bash tool + assert.Len(t, claudeReq.Tools, 1) + assert.Equal(t, "Bash", claudeReq.Tools[0].Name) + // Should preserve the original description + assert.Equal(t, "Custom bash tool", claudeReq.Tools[0].Description) + }) + + t.Run("adds_bash_tool_alongside_existing_tools", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + Messages: []chatMessage{ + {Role: roleUser, Content: "Hello"}, + }, + Tools: []tool{ + { + Type: "function", + Function: function{ + Name: "Read", + Description: "Read files", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + { + Type: "function", + Function: function{ + Name: "Write", + Description: "Write files", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + // Should have original tools plus Bash tool + assert.Len(t, claudeReq.Tools, 3) + + toolNames := make([]string, len(claudeReq.Tools)) + for i, tool := range claudeReq.Tools { + toolNames[i] = tool.Name + } + assert.Contains(t, toolNames, "Read") + assert.Contains(t, toolNames, "Write") + assert.Contains(t, toolNames, "Bash") + }) + + t.Run("full_request_transformation", func(t *testing.T) { + request := &chatCompletionRequest{ + Model: "claude-sonnet-4-5-20250929", + MaxTokens: 8192, + Stream: true, + Temperature: 1.0, + Messages: []chatMessage{ + {Role: roleUser, Content: "List files in current directory"}, + }, + } + + claudeReq := provider.buildClaudeTextGenRequest(request) + + // Verify complete request structure + assert.Equal(t, "claude-sonnet-4-5-20250929", claudeReq.Model) + assert.Equal(t, 8192, claudeReq.MaxTokens) + assert.True(t, claudeReq.Stream) + assert.Equal(t, 1.0, claudeReq.Temperature) + + // Verify system prompt + require.NotNil(t, claudeReq.System) + assert.True(t, claudeReq.System.IsArray) + assert.Equal(t, claudeCodeSystemPrompt, claudeReq.System.ArrayValue[0].Text) + + // Verify messages + require.Len(t, claudeReq.Messages, 1) + assert.Equal(t, roleUser, claudeReq.Messages[0].Role) + + // Verify Bash tool + require.Len(t, claudeReq.Tools, 1) + assert.Equal(t, "Bash", claudeReq.Tools[0].Name) + + // Verify the request can be serialized to JSON + jsonBytes, err := json.Marshal(claudeReq) + require.NoError(t, err) + assert.NotEmpty(t, jsonBytes) + }) +} + +// Note: TransformRequestBody tests are skipped because they require WASM runtime +// The request body transformation is tested indirectly through buildClaudeTextGenRequest tests + +// Test constants +func TestClaudeConstants(t *testing.T) { + assert.Equal(t, "api.anthropic.com", claudeDomain) + assert.Equal(t, "2023-06-01", claudeDefaultVersion) + assert.Equal(t, 4096, claudeDefaultMaxTokens) + assert.Equal(t, "claude", providerTypeClaude) + + // Claude Code mode constants + assert.Equal(t, "claude-cli/2.1.2 (external, cli)", claudeCodeUserAgent) + assert.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,claude-code-20250219", claudeCodeBetaFeatures) + assert.Equal(t, "You are Claude Code, Anthropic's official CLI for Claude.", claudeCodeSystemPrompt) + assert.Equal(t, "Bash", claudeCodeBashToolName) + assert.Equal(t, "Run bash commands", claudeCodeBashToolDesc) +} + +func TestClaudeProvider_GetApiName(t *testing.T) { + provider := &claudeProvider{} + + t.Run("messages_path", func(t *testing.T) { + assert.Equal(t, ApiNameChatCompletion, provider.GetApiName("/v1/messages")) + assert.Equal(t, ApiNameChatCompletion, provider.GetApiName("/api/v1/messages")) + }) + + t.Run("complete_path", func(t *testing.T) { + assert.Equal(t, ApiNameCompletion, provider.GetApiName("/v1/complete")) + }) + + t.Run("models_path", func(t *testing.T) { + assert.Equal(t, ApiNameModels, provider.GetApiName("/v1/models")) + }) + + t.Run("embeddings_path", func(t *testing.T) { + assert.Equal(t, ApiNameEmbeddings, provider.GetApiName("/v1/embeddings")) + }) + + t.Run("unknown_path", func(t *testing.T) { + assert.Equal(t, ApiName(""), provider.GetApiName("/unknown")) + }) +}