mirror of
https://github.com/alibaba/higress.git
synced 2026-02-27 06:00:51 +08:00
Compare commits
5 Commits
main
...
feat/claud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e52792ce1f | ||
|
|
13cdcf6181 | ||
|
|
14e7aca426 | ||
|
|
3714a2bd9c | ||
|
|
caf910cf48 |
@@ -224,6 +224,18 @@ Anthropic Claude 所对应的 `type` 为 `claude`。它特有的配置字段如
|
|||||||
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|
||||||
| --------------- | -------- | -------- | ------ | ----------------------------------------- |
|
| --------------- | -------- | -------- | ------ | ----------------------------------------- |
|
||||||
| `claudeVersion` | string | 可选 | - | Claude 服务的 API 版本,默认为 2023-06-01 |
|
| `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
|
#### 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 协议时,插件会自动进行协议转换:
|
当目标供应商不原生支持 Claude 协议时,插件会自动进行协议转换:
|
||||||
|
|||||||
@@ -185,11 +185,23 @@ For MiniMax, the corresponding `type` is `minimax`. Its unique configuration fie
|
|||||||
|
|
||||||
#### Anthropic Claude
|
#### 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 |
|
| Name | Data Type | Filling Requirements | Default Value | Description |
|
||||||
|------------|-------------|----------------------|---------------|---------------------------------------------------------------------------------------------------------------|
|
|------------|-------------|----------------------|---------------|---------------------------------------------------------------------------------------------------------------|
|
||||||
| `claudeVersion` | string | Optional | - | The version of the Claude service's API, default is 2023-06-01. |
|
| `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
|
#### 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
|
### Using Intelligent Protocol Conversion
|
||||||
|
|
||||||
When the target provider doesn't natively support Claude protocol, the plugin automatically performs protocol conversion:
|
When the target provider doesn't natively support Claude protocol, the plugin automatically performs protocol conversion:
|
||||||
|
|||||||
@@ -150,3 +150,9 @@ func TestBedrock(t *testing.T) {
|
|||||||
test.RunBedrockOnHttpResponseHeadersTests(t)
|
test.RunBedrockOnHttpResponseHeadersTests(t)
|
||||||
test.RunBedrockOnHttpResponseBodyTests(t)
|
test.RunBedrockOnHttpResponseBodyTests(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClaude(t *testing.T) {
|
||||||
|
test.RunClaudeParseConfigTests(t)
|
||||||
|
test.RunClaudeOnHttpRequestHeadersTests(t)
|
||||||
|
test.RunClaudeOnHttpRequestBodyTests(t)
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ const (
|
|||||||
claudeDomain = "api.anthropic.com"
|
claudeDomain = "api.anthropic.com"
|
||||||
claudeDefaultVersion = "2023-06-01"
|
claudeDefaultVersion = "2023-06-01"
|
||||||
claudeDefaultMaxTokens = 4096
|
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{}
|
type claudeProviderInitializer struct{}
|
||||||
@@ -319,13 +326,36 @@ func (c *claudeProvider) TransformRequestHeaders(ctx wrapper.HttpContext, apiNam
|
|||||||
util.OverwriteRequestPathHeaderByCapability(headers, string(apiName), c.config.capabilities)
|
util.OverwriteRequestPathHeaderByCapability(headers, string(apiName), c.config.capabilities)
|
||||||
util.OverwriteRequestHostHeader(headers, claudeDomain)
|
util.OverwriteRequestHostHeader(headers, claudeDomain)
|
||||||
|
|
||||||
headers.Set("x-api-key", c.config.GetApiTokenInUse(ctx))
|
|
||||||
|
|
||||||
if c.config.apiVersion == "" {
|
if c.config.apiVersion == "" {
|
||||||
c.config.apiVersion = claudeDefaultVersion
|
c.config.apiVersion = claudeDefaultVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.Set("anthropic-version", c.config.apiVersion)
|
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) {
|
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
|
claudeRequest.MaxTokens = claudeDefaultMaxTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track if system message exists in original request
|
||||||
|
hasSystemMessage := false
|
||||||
for _, message := range origRequest.Messages {
|
for _, message := range origRequest.Messages {
|
||||||
if message.Role == roleSystem {
|
if message.Role == roleSystem {
|
||||||
claudeRequest.System = &claudeSystemPrompt{
|
hasSystemMessage = true
|
||||||
StringValue: message.StringContent(),
|
// In Claude Code mode, use array format with cache_control
|
||||||
IsArray: false,
|
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
|
continue
|
||||||
}
|
}
|
||||||
@@ -478,6 +527,22 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe
|
|||||||
claudeRequest.Messages = append(claudeRequest.Messages, claudeMessage)
|
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 {
|
for _, tool := range origRequest.Tools {
|
||||||
claudeTool := claudeTool{
|
claudeTool := claudeTool{
|
||||||
Name: tool.Function.Name,
|
Name: tool.Function.Name,
|
||||||
@@ -487,6 +552,32 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe
|
|||||||
claudeRequest.Tools = append(claudeRequest.Tools, claudeTool)
|
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 {
|
if tc := origRequest.getToolChoiceObject(); tc != nil {
|
||||||
claudeRequest.ToolChoice = &claudeToolChoice{
|
claudeRequest.ToolChoice = &claudeToolChoice{
|
||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
|
|||||||
418
plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go
Normal file
418
plugins/wasm-go/extensions/ai-proxy/provider/claude_test.go
Normal 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"))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -442,6 +442,9 @@ type ProviderConfig struct {
|
|||||||
// @Title zh-CN 豆包服务域名
|
// @Title zh-CN 豆包服务域名
|
||||||
// @Description zh-CN 仅适用于豆包服务,默认转发域名为 ark.cn-beijing.volces.com
|
// @Description zh-CN 仅适用于豆包服务,默认转发域名为 ark.cn-beijing.volces.com
|
||||||
doubaoDomain string `required:"false" yaml:"doubaoDomain" json:"doubaoDomain"`
|
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 {
|
func (c *ProviderConfig) GetId() string {
|
||||||
@@ -646,6 +649,7 @@ func (c *ProviderConfig) FromJson(json gjson.Result) {
|
|||||||
c.vllmServerHost = json.Get("vllmServerHost").String()
|
c.vllmServerHost = json.Get("vllmServerHost").String()
|
||||||
c.vllmCustomUrl = json.Get("vllmCustomUrl").String()
|
c.vllmCustomUrl = json.Get("vllmCustomUrl").String()
|
||||||
c.doubaoDomain = json.Get("doubaoDomain").String()
|
c.doubaoDomain = json.Get("doubaoDomain").String()
|
||||||
|
c.claudeCodeMode = json.Get("claudeCodeMode").Bool()
|
||||||
c.contextCleanupCommands = make([]string, 0)
|
c.contextCleanupCommands = make([]string, 0)
|
||||||
for _, cmd := range json.Get("contextCleanupCommands").Array() {
|
for _, cmd := range json.Get("contextCleanupCommands").Array() {
|
||||||
if cmd.String() != "" {
|
if cmd.String() != "" {
|
||||||
|
|||||||
463
plugins/wasm-go/extensions/ai-proxy/test/claude.go
Normal file
463
plugins/wasm-go/extensions/ai-proxy/test/claude.go
Normal 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
|
||||||
Reference in New Issue
Block a user