Files
sub2api/backend/internal/integration/e2e_gateway_test.go
song 995adaeee4 test: 添加 Claude signature 场景 e2e 测试
- 新增 TestClaudeMessagesWithClaudeSignature 测试
- 验证历史 thinking block 带有 Claude signature 时的处理
- 修复配额刷新服务的次要问题
2025-12-29 00:44:07 +08:00

834 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build e2e
package integration
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
var (
baseURL = getEnv("BASE_URL", "http://localhost:8080")
claudeAPIKey = "sk-8e572bc3b3de92ace4f41f4256c28600ca11805732a7b693b5c44741346bbbb3"
geminiAPIKey = "sk-5950197a2085b38bbe5a1b229cc02b8ece914963fc44cacc06d497ae8b87410f"
testInterval = 3 * time.Second // 测试间隔,防止限流
)
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
// Claude 模型列表
var claudeModels = []string{
// Opus 系列
"claude-opus-4-5-thinking", // 直接支持
"claude-opus-4", // 映射到 claude-opus-4-5-thinking
"claude-opus-4-5-20251101", // 映射到 claude-opus-4-5-thinking
// Sonnet 系列
"claude-sonnet-4-5", // 直接支持
"claude-sonnet-4-5-thinking", // 直接支持
"claude-sonnet-4-5-20250929", // 映射到 claude-sonnet-4-5-thinking
"claude-3-5-sonnet-20241022", // 映射到 claude-sonnet-4-5
// Haiku 系列(映射到 gemini-3-flash
"claude-haiku-4",
"claude-haiku-4-5",
"claude-haiku-4-5-20251001",
"claude-3-haiku-20240307",
}
// Gemini 模型列表
var geminiModels = []string{
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-3-flash",
"gemini-3-pro-low",
}
func TestMain(m *testing.M) {
fmt.Printf("\n🚀 E2E Gateway Tests - %s\n\n", baseURL)
os.Exit(m.Run())
}
// TestClaudeModelsList 测试 GET /v1/models
func TestClaudeModelsList(t *testing.T) {
url := baseURL + "/v1/models"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+claudeAPIKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["object"] != "list" {
t.Errorf("期望 object=list, 得到 %v", result["object"])
}
data, ok := result["data"].([]any)
if !ok {
t.Fatal("响应缺少 data 数组")
}
t.Logf("✅ 返回 %d 个模型", len(data))
}
// TestGeminiModelsList 测试 GET /v1beta/models
func TestGeminiModelsList(t *testing.T) {
url := baseURL + "/v1beta/models"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+geminiAPIKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
models, ok := result["models"].([]any)
if !ok {
t.Fatal("响应缺少 models 数组")
}
t.Logf("✅ 返回 %d 个模型", len(models))
}
// TestClaudeMessages 测试 Claude /v1/messages 接口
func TestClaudeMessages(t *testing.T) {
for i, model := range claudeModels {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_非流式", func(t *testing.T) {
testClaudeMessage(t, model, false)
})
time.Sleep(testInterval)
t.Run(model+"_流式", func(t *testing.T) {
testClaudeMessage(t, model, true)
})
}
}
func testClaudeMessage(t *testing.T, model string, stream bool) {
url := baseURL + "/v1/messages"
payload := map[string]any{
"model": model,
"max_tokens": 50,
"stream": stream,
"messages": []map[string]string{
{"role": "user", "content": "Say 'hello' in one word."},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+claudeAPIKey)
req.Header.Set("anthropic-version", "2023-06-01")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if stream {
// 流式:读取 SSE 事件
scanner := bufio.NewScanner(resp.Body)
eventCount := 0
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data:") {
eventCount++
if eventCount >= 3 {
break
}
}
}
if eventCount == 0 {
t.Fatal("未收到任何 SSE 事件")
}
t.Logf("✅ 收到 %d+ 个 SSE 事件", eventCount)
} else {
// 非流式:解析 JSON 响应
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["type"] != "message" {
t.Errorf("期望 type=message, 得到 %v", result["type"])
}
t.Logf("✅ 收到消息响应 id=%v", result["id"])
}
}
// TestGeminiGenerateContent 测试 Gemini /v1beta/models/:model 接口
func TestGeminiGenerateContent(t *testing.T) {
for i, model := range geminiModels {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_非流式", func(t *testing.T) {
testGeminiGenerate(t, model, false)
})
time.Sleep(testInterval)
t.Run(model+"_流式", func(t *testing.T) {
testGeminiGenerate(t, model, true)
})
}
}
func testGeminiGenerate(t *testing.T, model string, stream bool) {
action := "generateContent"
if stream {
action = "streamGenerateContent"
}
url := fmt.Sprintf("%s/v1beta/models/%s:%s", baseURL, model, action)
if stream {
url += "?alt=sse"
}
payload := map[string]any{
"contents": []map[string]any{
{
"role": "user",
"parts": []map[string]string{
{"text": "Say 'hello' in one word."},
},
},
},
"generationConfig": map[string]int{
"maxOutputTokens": 50,
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+geminiAPIKey)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if stream {
// 流式:读取 SSE 事件
scanner := bufio.NewScanner(resp.Body)
eventCount := 0
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data:") {
eventCount++
if eventCount >= 3 {
break
}
}
}
if eventCount == 0 {
t.Fatal("未收到任何 SSE 事件")
}
t.Logf("✅ 收到 %d+ 个 SSE 事件", eventCount)
} else {
// 非流式:解析 JSON 响应
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if _, ok := result["candidates"]; !ok {
t.Error("响应缺少 candidates 字段")
}
t.Log("✅ 收到 candidates 响应")
}
}
// TestClaudeMessagesWithComplexTools 测试带复杂工具 schema 的请求
// 模拟 Claude Code 发送的请求,包含需要清理的 JSON Schema 字段
func TestClaudeMessagesWithComplexTools(t *testing.T) {
// 测试模型列表(只测试几个代表性模型)
models := []string{
"claude-opus-4-5-20251101", // Claude 模型
"claude-haiku-4-5-20251001", // 映射到 Gemini
}
for i, model := range models {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_复杂工具", func(t *testing.T) {
testClaudeMessageWithTools(t, model)
})
}
}
func testClaudeMessageWithTools(t *testing.T, model string) {
url := baseURL + "/v1/messages"
// 构造包含复杂 schema 的工具定义(模拟 Claude Code 的工具)
// 这些字段需要被 cleanJSONSchema 清理
tools := []map[string]any{
{
"name": "read_file",
"description": "Read file contents",
"input_schema": map[string]any{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "File path",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\x00]+$",
},
"encoding": map[string]any{
"type": []string{"string", "null"},
"default": "utf-8",
"enum": []string{"utf-8", "ascii", "latin-1"},
},
},
"required": []string{"path"},
"additionalProperties": false,
},
},
{
"name": "write_file",
"description": "Write content to file",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"minLength": 1,
},
"content": map[string]any{
"type": "string",
"maxLength": 1048576,
},
},
"required": []string{"path", "content"},
"additionalProperties": false,
"strict": true,
},
},
{
"name": "list_files",
"description": "List files in directory",
"input_schema": map[string]any{
"$id": "https://example.com/list-files.schema.json",
"type": "object",
"properties": map[string]any{
"directory": map[string]any{
"type": "string",
},
"patterns": map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
"minLength": 1,
},
"minItems": 1,
"maxItems": 100,
"uniqueItems": true,
},
"recursive": map[string]any{
"type": "boolean",
"default": false,
},
},
"required": []string{"directory"},
"additionalProperties": false,
},
},
{
"name": "search_code",
"description": "Search code in files",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"minLength": 1,
"format": "regex",
},
"max_results": map[string]any{
"type": "integer",
"minimum": 1,
"maximum": 1000,
"exclusiveMinimum": 0,
"default": 100,
},
},
"required": []string{"query"},
"additionalProperties": false,
"examples": []map[string]any{
{"query": "function.*test", "max_results": 50},
},
},
},
// 测试 required 引用不存在的属性(应被自动过滤)
{
"name": "invalid_required_tool",
"description": "Tool with invalid required field",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
},
},
// "nonexistent_field" 不存在于 properties 中,应被过滤掉
"required": []string{"name", "nonexistent_field"},
},
},
// 测试没有 properties 的 schema应自动添加空 properties
{
"name": "no_properties_tool",
"description": "Tool without properties",
"input_schema": map[string]any{
"type": "object",
"required": []string{"should_be_removed"},
},
},
// 测试没有 type 的 schema应自动添加 type: OBJECT
{
"name": "no_type_tool",
"description": "Tool without type",
"input_schema": map[string]any{
"properties": map[string]any{
"value": map[string]any{
"type": "string",
},
},
},
},
}
payload := map[string]any{
"model": model,
"max_tokens": 100,
"stream": false,
"messages": []map[string]string{
{"role": "user", "content": "List files in the current directory"},
},
"tools": tools,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+claudeAPIKey)
req.Header.Set("anthropic-version", "2023-06-01")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
// 400 错误说明 schema 清理不完整
if resp.StatusCode == 400 {
t.Fatalf("Schema 清理失败,收到 400 错误: %s", string(respBody))
}
// 503 可能是账号限流,不算测试失败
if resp.StatusCode == 503 {
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
}
// 429 是限流
if resp.StatusCode == 429 {
t.Skipf("请求被限流 (429): %s", string(respBody))
}
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["type"] != "message" {
t.Errorf("期望 type=message, 得到 %v", result["type"])
}
t.Logf("✅ 复杂工具 schema 测试通过, id=%v", result["id"])
}
// TestClaudeMessagesWithThinkingAndTools 测试 thinking 模式下带工具调用的场景
// 验证:当历史 assistant 消息包含 tool_use 但没有 signature 时,
// 系统应自动添加 dummy thought_signature 避免 Gemini 400 错误
func TestClaudeMessagesWithThinkingAndTools(t *testing.T) {
models := []string{
"claude-haiku-4-5-20251001", // gemini-3-flash
}
for i, model := range models {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_thinking模式工具调用", func(t *testing.T) {
testClaudeThinkingWithToolHistory(t, model)
})
}
}
func testClaudeThinkingWithToolHistory(t *testing.T, model string) {
url := baseURL + "/v1/messages"
// 模拟历史对话:用户请求 → assistant 调用工具 → 工具返回 → 继续对话
// 注意tool_use 块故意不包含 signature测试系统是否能正确添加 dummy signature
payload := map[string]any{
"model": model,
"max_tokens": 200,
"stream": false,
// 开启 thinking 模式
"thinking": map[string]any{
"type": "enabled",
"budget_tokens": 1024,
},
"messages": []any{
map[string]any{
"role": "user",
"content": "List files in the current directory",
},
// assistant 消息包含 tool_use 但没有 signature
map[string]any{
"role": "assistant",
"content": []map[string]any{
{
"type": "text",
"text": "I'll list the files for you.",
},
{
"type": "tool_use",
"id": "toolu_01XGmNv",
"name": "Bash",
"input": map[string]any{"command": "ls -la"},
// 故意不包含 signature
},
},
},
// 工具结果
map[string]any{
"role": "user",
"content": []map[string]any{
{
"type": "tool_result",
"tool_use_id": "toolu_01XGmNv",
"content": "file1.txt\nfile2.txt\ndir1/",
},
},
},
},
"tools": []map[string]any{
{
"name": "Bash",
"description": "Execute bash commands",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
},
},
"required": []string{"command"},
},
},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+claudeAPIKey)
req.Header.Set("anthropic-version", "2023-06-01")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
// 400 错误说明 thought_signature 处理失败
if resp.StatusCode == 400 {
t.Fatalf("thought_signature 处理失败,收到 400 错误: %s", string(respBody))
}
// 503 可能是账号限流,不算测试失败
if resp.StatusCode == 503 {
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
}
// 429 是限流
if resp.StatusCode == 429 {
t.Skipf("请求被限流 (429): %s", string(respBody))
}
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["type"] != "message" {
t.Errorf("期望 type=message, 得到 %v", result["type"])
}
t.Logf("✅ thinking 模式工具调用测试通过, id=%v", result["id"])
}
// TestClaudeMessagesWithNoSignature 测试历史 thinking block 不带 signature 的场景
// 验证Gemini 模型接受没有 signature 的 thinking block
func TestClaudeMessagesWithNoSignature(t *testing.T) {
models := []string{
"claude-haiku-4-5-20251001", // gemini-3-flash - 支持无 signature
}
for i, model := range models {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_无signature", func(t *testing.T) {
testClaudeWithNoSignature(t, model)
})
}
}
func testClaudeWithNoSignature(t *testing.T, model string) {
url := baseURL + "/v1/messages"
// 模拟历史对话包含 thinking block 但没有 signature
payload := map[string]any{
"model": model,
"max_tokens": 200,
"stream": false,
// 开启 thinking 模式
"thinking": map[string]any{
"type": "enabled",
"budget_tokens": 1024,
},
"messages": []any{
map[string]any{
"role": "user",
"content": "What is 2+2?",
},
// assistant 消息包含 thinking block 但没有 signature
map[string]any{
"role": "assistant",
"content": []map[string]any{
{
"type": "thinking",
"thinking": "Let me calculate 2+2...",
// 故意不包含 signature
},
{
"type": "text",
"text": "2+2 equals 4.",
},
},
},
map[string]any{
"role": "user",
"content": "What is 3+3?",
},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+claudeAPIKey)
req.Header.Set("anthropic-version", "2023-06-01")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 400 {
t.Fatalf("无 signature thinking 处理失败,收到 400 错误: %s", string(respBody))
}
if resp.StatusCode == 503 {
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
}
if resp.StatusCode == 429 {
t.Skipf("请求被限流 (429): %s", string(respBody))
}
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["type"] != "message" {
t.Errorf("期望 type=message, 得到 %v", result["type"])
}
t.Logf("✅ 无 signature thinking 处理测试通过, id=%v", result["id"])
}
// TestClaudeMessagesWithClaudeSignature 测试历史 thinking block 带有 Claude signature 的场景
// 验证Claude 的 signature 格式与 Gemini 不兼容,发送到 Gemini 模型时应忽略(不传递)
func TestClaudeMessagesWithClaudeSignature(t *testing.T) {
models := []string{
"claude-haiku-4-5-20251001", // 映射到 gemini-3-flash
}
for i, model := range models {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_带Claude_signature", func(t *testing.T) {
testClaudeWithClaudeSignature(t, model)
})
}
}
func testClaudeWithClaudeSignature(t *testing.T, model string) {
url := baseURL + "/v1/messages"
// 模拟历史对话包含 thinking block 且带有 Claude 格式的 signature
// 这个 signature 是 Claude API 返回的格式,对 Gemini 无效
payload := map[string]any{
"model": model,
"max_tokens": 200,
"stream": false,
// 开启 thinking 模式
"thinking": map[string]any{
"type": "enabled",
"budget_tokens": 1024,
},
"messages": []any{
map[string]any{
"role": "user",
"content": "What is 2+2?",
},
// assistant 消息包含 thinking block 和 Claude 格式的 signature
map[string]any{
"role": "assistant",
"content": []map[string]any{
{
"type": "thinking",
"thinking": "Let me calculate 2+2. This is a simple arithmetic problem.",
// Claude API 返回的 signature 格式base64 编码的加密数据)
"signature": "zbbJDG5qqgNXD/BVLwwxxT3gVaAY2hQ6CcB+hVLZWPi8r6vvlRBQKMfFPE3x5...",
},
{
"type": "text",
"text": "2+2 equals 4.",
},
},
},
map[string]any{
"role": "user",
"content": "What is 3+3?",
},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+claudeAPIKey)
req.Header.Set("anthropic-version", "2023-06-01")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
// 400 错误说明 signature 未被正确忽略
if resp.StatusCode == 400 {
t.Fatalf("Claude signature 未被正确忽略,收到 400 错误: %s", string(respBody))
}
if resp.StatusCode == 503 {
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
}
if resp.StatusCode == 429 {
t.Skipf("请求被限流 (429): %s", string(respBody))
}
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["type"] != "message" {
t.Errorf("期望 type=message, 得到 %v", result["type"])
}
t.Logf("✅ Claude signature 忽略测试通过, id=%v", result["id"])
}