Compare commits

...

5 Commits

Author SHA1 Message Date
johnlanni
e52792ce1f docs: update README with Claude Code mode documentation
Add comprehensive documentation for Claude Code mode in both Chinese and English:

- Added claudeCodeMode configuration field description
- Explained Claude Code mode behavior and features
- Added configuration examples for both languages
- Documented OAuth token authentication support
- Explained automatic system prompt and Bash tool injection
2026-02-07 15:11:09 +08:00
johnlanni
13cdcf6181 test(ai-proxy): add integration tests for Claude Code mode
Add integration tests in test/claude.go for Claude provider:

ParseConfig tests:
- claude standard config
- claude code mode config
- claude config without token fails

HttpRequestHeaders tests:
- claude standard mode uses x-api-key
- claude code mode uses bearer authorization
- claude code mode adds beta query param
- claude code mode with custom version

HttpRequestBody tests:
- claude standard mode does not inject defaults
- claude code mode injects default system prompt
- claude code mode injects bash tool
- claude code mode preserves existing system prompt
- claude code mode does not duplicate bash tool
- claude code mode adds bash tool alongside existing tools

All tests run in both go and wasm modes.
2026-02-07 15:05:13 +08:00
johnlanni
14e7aca426 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.
2026-02-07 08:32:52 +08:00
johnlanni
3714a2bd9c feat(ai-proxy): add default system prompt and Bash tool for Claude Code mode
- Add default Claude Code system prompt if not present in request
- Add default Bash tool if not present in request
- Only inject defaults when claudeCodeMode is enabled and fields are missing
2026-02-07 01:22:47 +08:00
johnlanni
caf910cf48 feat(ai-proxy): add Claude Code mode support for Claude provider
Add claudeCodeMode configuration option to emulate Claude Code client
requests, enabling OAuth token authentication with Claude API.

Changes:
- Add claudeCodeMode config field in ProviderConfig
- Transform request headers for Claude Code mode:
  - Use Bearer token authorization instead of x-api-key
  - Set user-agent to claude-cli/2.1.2 (external, cli)
  - Set x-app: cli header
  - Set anthropic-beta with OAuth and Claude Code features
  - Add ?beta=true query parameter to path
- Add cache_control with ephemeral type to system prompts in Claude Code mode

This allows users to use Claude Code OAuth tokens with Higress AI proxy.
2026-02-07 01:12:33 +08:00
7 changed files with 1091 additions and 7 deletions

View File

@@ -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 协议时,插件会自动进行协议转换:

View File

@@ -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:

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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"))
})
}

View File

@@ -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() != "" {

View File

@@ -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