mirror of
https://github.com/alibaba/higress.git
synced 2026-04-21 20:17:29 +08:00
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.
419 lines
12 KiB
Go
419 lines
12 KiB
Go
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"))
|
|
})
|
|
}
|