diff --git a/plugins/wasm-go/extensions/ai-proxy/main_test.go b/plugins/wasm-go/extensions/ai-proxy/main_test.go index 83b3182a6..3155ce219 100644 --- a/plugins/wasm-go/extensions/ai-proxy/main_test.go +++ b/plugins/wasm-go/extensions/ai-proxy/main_test.go @@ -150,3 +150,9 @@ func TestBedrock(t *testing.T) { test.RunBedrockOnHttpResponseHeadersTests(t) test.RunBedrockOnHttpResponseBodyTests(t) } + +func TestClaude(t *testing.T) { + test.RunClaudeParseConfigTests(t) + test.RunClaudeOnHttpRequestHeadersTests(t) + test.RunClaudeOnHttpRequestBodyTests(t) +} diff --git a/plugins/wasm-go/extensions/ai-proxy/test/claude.go b/plugins/wasm-go/extensions/ai-proxy/test/claude.go new file mode 100644 index 000000000..582cbf3fc --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/test/claude.go @@ -0,0 +1,463 @@ +package test + +import ( + "encoding/json" + "testing" + + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/higress-group/wasm-go/pkg/test" + "github.com/stretchr/testify/require" +) + +// Claude standard mode config +var claudeStandardConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "claude", + "apiTokens": []string{"sk-ant-api-key-123"}, + }, + }) + return data +}() + +// Claude Code mode config +var claudeCodeModeConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "claude", + "apiTokens": []string{"sk-ant-oat01-oauth-token-456"}, + "claudeCodeMode": true, + }, + }) + return data +}() + +// Claude Code mode config with custom apiVersion +var claudeCodeModeWithVersionConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "claude", + "apiTokens": []string{"sk-ant-oat01-oauth-token-789"}, + "claudeCodeMode": true, + "claudeVersion": "2024-01-01", + }, + }) + return data +}() + +// Claude config without token (should fail validation) +var claudeNoTokenConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "claude", + }, + }) + return data +}() + +func RunClaudeParseConfigTests(t *testing.T) { + test.RunGoTest(t, func(t *testing.T) { + t.Run("claude standard config", func(t *testing.T) { + host, status := test.NewTestHost(claudeStandardConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + config, err := host.GetMatchConfig() + require.NoError(t, err) + require.NotNil(t, config) + }) + + t.Run("claude code mode config", func(t *testing.T) { + host, status := test.NewTestHost(claudeCodeModeConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + config, err := host.GetMatchConfig() + require.NoError(t, err) + require.NotNil(t, config) + }) + + t.Run("claude config without token fails", func(t *testing.T) { + host, status := test.NewTestHost(claudeNoTokenConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusFailed, status) + }) + }) +} + +func RunClaudeOnHttpRequestHeadersTests(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + t.Run("claude standard mode uses x-api-key", func(t *testing.T) { + host, status := test.NewTestHost(claudeStandardConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "api.anthropic.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + require.True(t, test.HasHeaderWithValue(requestHeaders, "x-api-key", "sk-ant-api-key-123")) + require.True(t, test.HasHeaderWithValue(requestHeaders, "anthropic-version", "2023-06-01")) + + // Should NOT have Claude Code specific headers + _, hasAuth := test.GetHeaderValue(requestHeaders, "authorization") + require.False(t, hasAuth, "standard mode should not have authorization header") + + _, hasXApp := test.GetHeaderValue(requestHeaders, "x-app") + require.False(t, hasXApp, "standard mode should not have x-app header") + }) + + t.Run("claude code mode uses bearer authorization", func(t *testing.T) { + host, status := test.NewTestHost(claudeCodeModeConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "api.anthropic.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + + // Claude Code mode should use Bearer authorization + require.True(t, test.HasHeaderWithValue(requestHeaders, "authorization", "Bearer sk-ant-oat01-oauth-token-456")) + + // Should NOT have x-api-key in Claude Code mode + _, hasXApiKey := test.GetHeaderValue(requestHeaders, "x-api-key") + require.False(t, hasXApiKey, "claude code mode should not have x-api-key header") + + // Should have Claude Code specific headers + require.True(t, test.HasHeaderWithValue(requestHeaders, "x-app", "cli")) + require.True(t, test.HasHeaderWithValue(requestHeaders, "user-agent", "claude-cli/2.1.2 (external, cli)")) + require.True(t, test.HasHeaderWithValue(requestHeaders, "anthropic-beta", "oauth-2025-04-20,interleaved-thinking-2025-05-14,claude-code-20250219")) + require.True(t, test.HasHeaderWithValue(requestHeaders, "anthropic-version", "2023-06-01")) + }) + + t.Run("claude code mode adds beta query param", func(t *testing.T) { + host, status := test.NewTestHost(claudeCodeModeConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "api.anthropic.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + path, found := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, found) + require.Contains(t, path, "beta=true", "claude code mode should add beta=true query param") + }) + + t.Run("claude code mode with custom version", func(t *testing.T) { + host, status := test.NewTestHost(claudeCodeModeWithVersionConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "api.anthropic.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + require.True(t, test.HasHeaderWithValue(requestHeaders, "anthropic-version", "2024-01-01")) + }) + }) +} + +func RunClaudeOnHttpRequestBodyTests(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + t.Run("claude standard mode does not inject defaults", func(t *testing.T) { + host, status := test.NewTestHost(claudeStandardConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "api.anthropic.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + body := `{ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 8192, + "stream": true, + "messages": [ + {"role": "user", "content": "Hello"} + ] + }` + action := host.CallOnHttpRequestBody([]byte(body)) + require.Equal(t, types.ActionContinue, action) + + processedBody := host.GetRequestBody() + var request map[string]interface{} + err := json.Unmarshal(processedBody, &request) + require.NoError(t, err) + + // Standard mode should NOT inject system prompt or tools + _, hasSystem := request["system"] + require.False(t, hasSystem, "standard mode should not inject system prompt") + + tools, hasTools := request["tools"] + if hasTools { + toolsArr, ok := tools.([]interface{}) + require.True(t, ok) + require.Empty(t, toolsArr, "standard mode should not inject tools") + } + }) + + t.Run("claude code mode injects default system prompt", func(t *testing.T) { + host, status := test.NewTestHost(claudeCodeModeConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "api.anthropic.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + body := `{ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 8192, + "stream": true, + "messages": [ + {"role": "user", "content": "List files"} + ] + }` + action := host.CallOnHttpRequestBody([]byte(body)) + require.Equal(t, types.ActionContinue, action) + + processedBody := host.GetRequestBody() + var request map[string]interface{} + err := json.Unmarshal(processedBody, &request) + require.NoError(t, err) + + // Claude Code mode should inject system prompt + system, hasSystem := request["system"] + require.True(t, hasSystem, "claude code mode should inject system prompt") + + systemArr, ok := system.([]interface{}) + require.True(t, ok, "system should be an array in claude code mode") + require.Len(t, systemArr, 1) + + systemBlock, ok := systemArr[0].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "text", systemBlock["type"]) + require.Equal(t, "You are Claude Code, Anthropic's official CLI for Claude.", systemBlock["text"]) + + // Should have cache_control + cacheControl, hasCacheControl := systemBlock["cache_control"] + require.True(t, hasCacheControl, "system prompt should have cache_control") + cacheControlMap, ok := cacheControl.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "ephemeral", cacheControlMap["type"]) + }) + + t.Run("claude code mode injects bash tool", func(t *testing.T) { + host, status := test.NewTestHost(claudeCodeModeConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "api.anthropic.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + body := `{ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 8192, + "messages": [ + {"role": "user", "content": "List files"} + ] + }` + action := host.CallOnHttpRequestBody([]byte(body)) + require.Equal(t, types.ActionContinue, action) + + processedBody := host.GetRequestBody() + var request map[string]interface{} + err := json.Unmarshal(processedBody, &request) + require.NoError(t, err) + + // Claude Code mode should inject Bash tool + tools, hasTools := request["tools"] + require.True(t, hasTools, "claude code mode should inject tools") + + toolsArr, ok := tools.([]interface{}) + require.True(t, ok) + require.Len(t, toolsArr, 1) + + bashTool, ok := toolsArr[0].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "Bash", bashTool["name"]) + require.Equal(t, "Run bash commands", bashTool["description"]) + }) + + t.Run("claude code mode preserves existing system prompt", func(t *testing.T) { + host, status := test.NewTestHost(claudeCodeModeConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "api.anthropic.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + body := `{ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 8192, + "messages": [ + {"role": "system", "content": "You are a custom assistant."}, + {"role": "user", "content": "Hello"} + ] + }` + action := host.CallOnHttpRequestBody([]byte(body)) + require.Equal(t, types.ActionContinue, action) + + processedBody := host.GetRequestBody() + var request map[string]interface{} + err := json.Unmarshal(processedBody, &request) + require.NoError(t, err) + + // Should preserve custom system prompt (not default) + system, hasSystem := request["system"] + require.True(t, hasSystem) + + systemArr, ok := system.([]interface{}) + require.True(t, ok) + require.Len(t, systemArr, 1) + + systemBlock, ok := systemArr[0].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "You are a custom assistant.", systemBlock["text"]) + }) + + t.Run("claude code mode does not duplicate bash tool", func(t *testing.T) { + host, status := test.NewTestHost(claudeCodeModeConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "api.anthropic.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + body := `{ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 8192, + "messages": [ + {"role": "user", "content": "Hello"} + ], + "tools": [ + { + "type": "function", + "function": { + "name": "Bash", + "description": "Custom bash tool", + "parameters": {"type": "object"} + } + } + ] + }` + action := host.CallOnHttpRequestBody([]byte(body)) + require.Equal(t, types.ActionContinue, action) + + processedBody := host.GetRequestBody() + var request map[string]interface{} + err := json.Unmarshal(processedBody, &request) + require.NoError(t, err) + + // Should not duplicate Bash tool + tools, hasTools := request["tools"] + require.True(t, hasTools) + + toolsArr, ok := tools.([]interface{}) + require.True(t, ok) + require.Len(t, toolsArr, 1, "should not duplicate Bash tool") + }) + + t.Run("claude code mode adds bash tool alongside existing tools", func(t *testing.T) { + host, status := test.NewTestHost(claudeCodeModeConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "api.anthropic.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + body := `{ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 8192, + "messages": [ + {"role": "user", "content": "Hello"} + ], + "tools": [ + { + "type": "function", + "function": { + "name": "Read", + "description": "Read files", + "parameters": {"type": "object"} + } + } + ] + }` + action := host.CallOnHttpRequestBody([]byte(body)) + require.Equal(t, types.ActionContinue, action) + + processedBody := host.GetRequestBody() + var request map[string]interface{} + err := json.Unmarshal(processedBody, &request) + require.NoError(t, err) + + // Should have both Read and Bash tools + tools, hasTools := request["tools"] + require.True(t, hasTools) + + toolsArr, ok := tools.([]interface{}) + require.True(t, ok) + require.Len(t, toolsArr, 2, "should have Read tool plus injected Bash tool") + + // Verify both tools exist + toolNames := make([]string, 0) + for _, tool := range toolsArr { + toolMap, ok := tool.(map[string]interface{}) + if ok { + if name, hasName := toolMap["name"]; hasName { + toolNames = append(toolNames, name.(string)) + } + } + } + require.Contains(t, toolNames, "Read") + require.Contains(t, toolNames, "Bash") + }) + }) +} + +// Note: Response headers tests are skipped as they require complex mocking +// The response header transformation is covered by integration tests