diff --git a/plugins/wasm-go/extensions/ai-proxy/README.md b/plugins/wasm-go/extensions/ai-proxy/README.md index 85ba9c377..8cda5d67d 100644 --- a/plugins/wasm-go/extensions/ai-proxy/README.md +++ b/plugins/wasm-go/extensions/ai-proxy/README.md @@ -224,6 +224,18 @@ Anthropic Claude 所对应的 `type` 为 `claude`。它特有的配置字段如 | 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | | --------------- | -------- | -------- | ------ | ----------------------------------------- | | `claudeVersion` | string | 可选 | - | Claude 服务的 API 版本,默认为 2023-06-01 | +| `claudeCodeMode` | boolean | 可选 | false | 启用 Claude Code 模式,用于支持 Claude Code OAuth 令牌认证。启用后将伪装成 Claude Code 客户端发起请求 | + +**Claude Code 模式说明** + +启用 `claudeCodeMode: true` 时,插件将: +- 使用 Bearer Token 认证替代 x-api-key(适配 Claude Code OAuth 令牌) +- 设置 Claude Code 特定的请求头(user-agent、x-app、anthropic-beta) +- 为请求 URL 添加 `?beta=true` 查询参数 +- 自动注入 Claude Code 的系统提示词(如未提供) +- 自动注入 Bash 工具定义(如未提供) + +这允许在 Higress 中直接使用 Claude Code 的 OAuth Token 进行身份验证。 #### Ollama @@ -1211,6 +1223,45 @@ URL: `http://your-domain/v1/messages` } ``` +### 使用 Claude Code 模式 + +Claude Code 是 Anthropic 提供的官方 CLI 工具。通过启用 `claudeCodeMode`,可以使用 Claude Code 的 OAuth Token 进行身份验证: + +**配置信息** + +```yaml +provider: + type: claude + apiTokens: + - 'sk-ant-oat01-xxxxx' # Claude Code OAuth Token + claudeCodeMode: true # 启用 Claude Code 模式 +``` + +启用此模式后,插件将自动: +- 使用 Bearer Token 认证(而非 x-api-key) +- 设置 Claude Code 特定的请求头和查询参数 +- 注入 Claude Code 的系统提示词和 Bash 工具(如未提供) + +**请求示例** + +```json +{ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 8192, + "messages": [ + { + "role": "user", + "content": "List files in current directory" + } + ] +} +``` + +插件将自动转换为适合 Claude Code 的请求格式,包括: +- 添加系统提示词:`"You are Claude Code, Anthropic's official CLI for Claude."` +- 添加 Bash 工具定义(用于执行命令) +- 设置适当的认证和请求头 + ### 使用智能协议转换 当目标供应商不原生支持 Claude 协议时,插件会自动进行协议转换: diff --git a/plugins/wasm-go/extensions/ai-proxy/README_EN.md b/plugins/wasm-go/extensions/ai-proxy/README_EN.md index 3699e8855..6c505390f 100644 --- a/plugins/wasm-go/extensions/ai-proxy/README_EN.md +++ b/plugins/wasm-go/extensions/ai-proxy/README_EN.md @@ -185,11 +185,23 @@ For MiniMax, the corresponding `type` is `minimax`. Its unique configuration fie #### Anthropic Claude -For Anthropic Claude, the corresponding `type` is `claude`. Its unique configuration field is: +For Anthropic Claude, the corresponding `type` is `claude`. Its unique configuration fields are: | Name | Data Type | Filling Requirements | Default Value | Description | |------------|-------------|----------------------|---------------|---------------------------------------------------------------------------------------------------------------| | `claudeVersion` | string | Optional | - | The version of the Claude service's API, default is 2023-06-01. | +| `claudeCodeMode` | boolean | Optional | false | Enable Claude Code mode for OAuth token authentication. When enabled, requests will be formatted as Claude Code client requests. | + +**Claude Code Mode** + +When `claudeCodeMode: true` is enabled, the plugin will: +- Use Bearer Token authentication instead of x-api-key (compatible with Claude Code OAuth tokens) +- Set Claude Code-specific request headers (user-agent, x-app, anthropic-beta) +- Add `?beta=true` query parameter to request URLs +- Automatically inject Claude Code system prompt if not provided +- Automatically inject Bash tool definition if not provided + +This enables direct use of Claude Code OAuth tokens for authentication in Higress. #### Ollama @@ -1148,6 +1160,45 @@ Both protocol formats will return responses in their respective formats: } ``` +### Using Claude Code Mode + +Claude Code is Anthropic's official CLI tool. By enabling `claudeCodeMode`, you can authenticate using Claude Code OAuth tokens: + +**Configuration Information** + +```yaml +provider: + type: claude + apiTokens: + - "sk-ant-oat01-xxxxx" # Claude Code OAuth Token + claudeCodeMode: true # Enable Claude Code mode +``` + +Once this mode is enabled, the plugin will automatically: +- Use Bearer Token authentication (instead of x-api-key) +- Set Claude Code-specific request headers and query parameters +- Inject Claude Code system prompt and Bash tool definitions if not provided + +**Request Example** + +```json +{ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 8192, + "messages": [ + { + "role": "user", + "content": "List files in current directory" + } + ] +} +``` + +The plugin will automatically transform the request into Claude Code format, including: +- Adding system prompt: `"You are Claude Code, Anthropic's official CLI for Claude."` +- Adding Bash tool definition (for command execution) +- Setting appropriate authentication and request headers + ### Using Intelligent Protocol Conversion When the target provider doesn't natively support Claude protocol, the plugin automatically performs protocol conversion: 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/provider/claude.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude.go index 6db9b5d48..5c9e40fdc 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude.go @@ -19,6 +19,13 @@ const ( claudeDomain = "api.anthropic.com" claudeDefaultVersion = "2023-06-01" claudeDefaultMaxTokens = 4096 + + // Claude Code mode constants + claudeCodeUserAgent = "claude-cli/2.1.2 (external, cli)" + claudeCodeBetaFeatures = "oauth-2025-04-20,interleaved-thinking-2025-05-14,claude-code-20250219" + claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." + claudeCodeBashToolName = "Bash" + claudeCodeBashToolDesc = "Run bash commands" ) type claudeProviderInitializer struct{} @@ -319,13 +326,36 @@ func (c *claudeProvider) TransformRequestHeaders(ctx wrapper.HttpContext, apiNam util.OverwriteRequestPathHeaderByCapability(headers, string(apiName), c.config.capabilities) util.OverwriteRequestHostHeader(headers, claudeDomain) - headers.Set("x-api-key", c.config.GetApiTokenInUse(ctx)) - if c.config.apiVersion == "" { c.config.apiVersion = claudeDefaultVersion } - headers.Set("anthropic-version", c.config.apiVersion) + + // Check if Claude Code mode is enabled + if c.config.claudeCodeMode { + // Claude Code mode: use OAuth token with Bearer authorization + token := c.config.GetApiTokenInUse(ctx) + headers.Set("authorization", "Bearer "+token) + headers.Del("x-api-key") + + // Set Claude Code specific headers + headers.Set("user-agent", claudeCodeUserAgent) + headers.Set("x-app", "cli") + headers.Set("anthropic-beta", claudeCodeBetaFeatures) + + // Add ?beta=true query parameter to the path + currentPath := headers.Get(":path") + if currentPath != "" && !strings.Contains(currentPath, "beta=true") { + if strings.Contains(currentPath, "?") { + headers.Set(":path", currentPath+"&beta=true") + } else { + headers.Set(":path", currentPath+"?beta=true") + } + } + } else { + // Standard mode: use x-api-key + headers.Set("x-api-key", c.config.GetApiTokenInUse(ctx)) + } } func (c *claudeProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName, body []byte) (types.Action, error) { @@ -413,11 +443,30 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe claudeRequest.MaxTokens = claudeDefaultMaxTokens } + // Track if system message exists in original request + hasSystemMessage := false for _, message := range origRequest.Messages { if message.Role == roleSystem { - claudeRequest.System = &claudeSystemPrompt{ - StringValue: message.StringContent(), - IsArray: false, + hasSystemMessage = true + // In Claude Code mode, use array format with cache_control + if c.config.claudeCodeMode { + claudeRequest.System = &claudeSystemPrompt{ + ArrayValue: []claudeChatMessageContent{ + { + Type: contentTypeText, + Text: message.StringContent(), + CacheControl: map[string]interface{}{ + "type": "ephemeral", + }, + }, + }, + IsArray: true, + } + } else { + claudeRequest.System = &claudeSystemPrompt{ + StringValue: message.StringContent(), + IsArray: false, + } } continue } @@ -478,6 +527,22 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe claudeRequest.Messages = append(claudeRequest.Messages, claudeMessage) } + // In Claude Code mode, add default system prompt if not present + if c.config.claudeCodeMode && !hasSystemMessage { + claudeRequest.System = &claudeSystemPrompt{ + ArrayValue: []claudeChatMessageContent{ + { + Type: contentTypeText, + Text: claudeCodeSystemPrompt, + CacheControl: map[string]interface{}{ + "type": "ephemeral", + }, + }, + }, + IsArray: true, + } + } + for _, tool := range origRequest.Tools { claudeTool := claudeTool{ Name: tool.Function.Name, @@ -487,6 +552,32 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe claudeRequest.Tools = append(claudeRequest.Tools, claudeTool) } + // In Claude Code mode, add Bash tool if not present + if c.config.claudeCodeMode { + hasBashTool := false + for _, tool := range claudeRequest.Tools { + if tool.Name == claudeCodeBashToolName { + hasBashTool = true + break + } + } + if !hasBashTool { + claudeRequest.Tools = append(claudeRequest.Tools, claudeTool{ + Name: claudeCodeBashToolName, + Description: claudeCodeBashToolDesc, + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "command": map[string]interface{}{ + "type": "string", + }, + }, + "required": []string{"command"}, + }, + }) + } + } + if tc := origRequest.getToolChoiceObject(); tc != nil { claudeRequest.ToolChoice = &claudeToolChoice{ Name: tc.Function.Name, 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")) + }) +} diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go index f7cebccf7..b46c7ed09 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go @@ -442,6 +442,9 @@ type ProviderConfig struct { // @Title zh-CN 豆包服务域名 // @Description zh-CN 仅适用于豆包服务,默认转发域名为 ark.cn-beijing.volces.com doubaoDomain string `required:"false" yaml:"doubaoDomain" json:"doubaoDomain"` + // @Title zh-CN Claude Code 模式 + // @Description zh-CN 仅适用于Claude服务。启用后将伪装成Claude Code客户端发起请求,支持使用Claude Code的OAuth Token进行认证。 + claudeCodeMode bool `required:"false" yaml:"claudeCodeMode" json:"claudeCodeMode"` } func (c *ProviderConfig) GetId() string { @@ -646,6 +649,7 @@ func (c *ProviderConfig) FromJson(json gjson.Result) { c.vllmServerHost = json.Get("vllmServerHost").String() c.vllmCustomUrl = json.Get("vllmCustomUrl").String() c.doubaoDomain = json.Get("doubaoDomain").String() + c.claudeCodeMode = json.Get("claudeCodeMode").Bool() c.contextCleanupCommands = make([]string, 0) for _, cmd := range json.Get("contextCleanupCommands").Array() { if cmd.String() != "" { 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