fix(antigravity): 修复 Gemini 3 thought_signature 和 schema 验证问题
- 添加 dummyThoughtSignature 常量,在 thinking 模式下为无 signature 的 tool_use 自动添加 - 增强 cleanJSONSchema:过滤 required 中不存在的属性,确保 type/properties 字段存在 - 扩展 excludedSchemaKeys:增加 $id, $ref, strict, const, examples 等不支持的字段 - 修复 429 重试逻辑:仅在所有重试失败后才标记账户为 rate_limited - 添加 e2e 集成测试:TestClaudeMessagesWithThinkingAndTools
This commit is contained in:
632
backend/internal/integration/e2e_gateway_test.go
Normal file
632
backend/internal/integration/e2e_gateway_test.go
Normal file
@@ -0,0 +1,632 @@
|
||||
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"])
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT
|
||||
role = "model"
|
||||
}
|
||||
|
||||
parts, err := buildParts(msg.Content, toolIDToName)
|
||||
parts, err := buildParts(msg.Content, toolIDToName, isThinkingEnabled)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build parts for message %d: %w", i, err)
|
||||
}
|
||||
@@ -157,8 +157,12 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
// dummyThoughtSignature 用于跳过 Gemini 3 thought_signature 验证
|
||||
// 参考: https://ai.google.dev/gemini-api/docs/thought-signatures
|
||||
const dummyThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// buildParts 构建消息的 parts
|
||||
func buildParts(content json.RawMessage, toolIDToName map[string]string) ([]GeminiPart, error) {
|
||||
func buildParts(content json.RawMessage, toolIDToName map[string]string, isThinkingEnabled bool) ([]GeminiPart, error) {
|
||||
var parts []GeminiPart
|
||||
|
||||
// 尝试解析为字符串
|
||||
@@ -216,8 +220,11 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string) ([]Gemi
|
||||
ID: block.ID,
|
||||
},
|
||||
}
|
||||
// Gemini 3 要求 thinking 模式下 functionCall 必须有 thought_signature
|
||||
if block.Signature != "" {
|
||||
part.ThoughtSignature = block.Signature
|
||||
} else if isThinkingEnabled {
|
||||
part.ThoughtSignature = dummyThoughtSignature
|
||||
}
|
||||
parts = append(parts, part)
|
||||
|
||||
@@ -380,57 +387,128 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
||||
}}
|
||||
}
|
||||
|
||||
// cleanJSONSchema 清理 JSON Schema,移除 Gemini 不支持的字段
|
||||
// cleanJSONSchema 清理 JSON Schema,移除 Antigravity/Gemini 不支持的字段
|
||||
// 参考 proxycast 的实现,确保 schema 符合 JSON Schema draft 2020-12
|
||||
func cleanJSONSchema(schema map[string]interface{}) map[string]interface{} {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range schema {
|
||||
// 移除不支持的字段
|
||||
switch k {
|
||||
case "$schema", "additionalProperties", "minLength", "maxLength",
|
||||
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum",
|
||||
"pattern", "format", "default":
|
||||
continue
|
||||
}
|
||||
|
||||
// 递归处理嵌套对象
|
||||
if nested, ok := v.(map[string]interface{}); ok {
|
||||
result[k] = cleanJSONSchema(nested)
|
||||
} else if k == "type" {
|
||||
// 处理类型字段,转换为大写
|
||||
if typeStr, ok := v.(string); ok {
|
||||
result[k] = strings.ToUpper(typeStr)
|
||||
} else if typeArr, ok := v.([]interface{}); ok {
|
||||
// 处理联合类型 ["string", "null"] -> "STRING"
|
||||
for _, t := range typeArr {
|
||||
if ts, ok := t.(string); ok && ts != "null" {
|
||||
result[k] = strings.ToUpper(ts)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
cleaned := cleanSchemaValue(schema)
|
||||
result, ok := cleaned.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 递归处理 properties
|
||||
if props, ok := result["properties"].(map[string]interface{}); ok {
|
||||
cleanedProps := make(map[string]interface{})
|
||||
for name, prop := range props {
|
||||
if propMap, ok := prop.(map[string]interface{}); ok {
|
||||
cleanedProps[name] = cleanJSONSchema(propMap)
|
||||
// 确保有 type 字段(默认 OBJECT)
|
||||
if _, hasType := result["type"]; !hasType {
|
||||
result["type"] = "OBJECT"
|
||||
}
|
||||
|
||||
// 确保有 properties 字段(默认空对象)
|
||||
if _, hasProps := result["properties"]; !hasProps {
|
||||
result["properties"] = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// 验证 required 中的字段都存在于 properties 中
|
||||
if required, ok := result["required"].([]interface{}); ok {
|
||||
if props, ok := result["properties"].(map[string]interface{}); ok {
|
||||
validRequired := make([]interface{}, 0, len(required))
|
||||
for _, r := range required {
|
||||
if reqName, ok := r.(string); ok {
|
||||
if _, exists := props[reqName]; exists {
|
||||
validRequired = append(validRequired, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(validRequired) > 0 {
|
||||
result["required"] = validRequired
|
||||
} else {
|
||||
cleanedProps[name] = prop
|
||||
delete(result, "required")
|
||||
}
|
||||
}
|
||||
result["properties"] = cleanedProps
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// excludedSchemaKeys 不支持的 schema 字段
|
||||
var excludedSchemaKeys = map[string]bool{
|
||||
"$schema": true,
|
||||
"$id": true,
|
||||
"$ref": true,
|
||||
"additionalProperties": true,
|
||||
"minLength": true,
|
||||
"maxLength": true,
|
||||
"minItems": true,
|
||||
"maxItems": true,
|
||||
"uniqueItems": true,
|
||||
"minimum": true,
|
||||
"maximum": true,
|
||||
"exclusiveMinimum": true,
|
||||
"exclusiveMaximum": true,
|
||||
"pattern": true,
|
||||
"format": true,
|
||||
"default": true,
|
||||
"strict": true,
|
||||
"const": true,
|
||||
"examples": true,
|
||||
"deprecated": true,
|
||||
"readOnly": true,
|
||||
"writeOnly": true,
|
||||
"contentMediaType": true,
|
||||
"contentEncoding": true,
|
||||
}
|
||||
|
||||
// cleanSchemaValue 递归清理 schema 值
|
||||
func cleanSchemaValue(value interface{}) interface{} {
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]interface{})
|
||||
for k, val := range v {
|
||||
// 跳过不支持的字段
|
||||
if excludedSchemaKeys[k] {
|
||||
continue
|
||||
}
|
||||
|
||||
// 特殊处理 type 字段
|
||||
if k == "type" {
|
||||
result[k] = cleanTypeValue(val)
|
||||
continue
|
||||
}
|
||||
|
||||
// 递归清理所有值
|
||||
result[k] = cleanSchemaValue(val)
|
||||
}
|
||||
return result
|
||||
|
||||
case []interface{}:
|
||||
// 递归处理数组中的每个元素
|
||||
cleaned := make([]interface{}, 0, len(v))
|
||||
for _, item := range v {
|
||||
cleaned = append(cleaned, cleanSchemaValue(item))
|
||||
}
|
||||
return cleaned
|
||||
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// cleanTypeValue 处理 type 字段,转换为大写
|
||||
func cleanTypeValue(value interface{}) interface{} {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.ToUpper(v)
|
||||
case []interface{}:
|
||||
// 联合类型 ["string", "null"] -> 取第一个非 null 类型
|
||||
for _, t := range v {
|
||||
if ts, ok := t.(string); ok && ts != "null" {
|
||||
return strings.ToUpper(ts)
|
||||
}
|
||||
}
|
||||
// 如果只有 null,返回 STRING
|
||||
return "STRING"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,14 +271,15 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
||||
}
|
||||
if attempt < antigravityMaxRetries {
|
||||
log.Printf("Antigravity account %d: upstream status %d, retry %d/%d", account.ID, resp.StatusCode, attempt, antigravityMaxRetries)
|
||||
sleepAntigravityBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
// 所有重试都失败,标记限流状态
|
||||
if resp.StatusCode == 429 {
|
||||
s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
||||
}
|
||||
// 最后一次尝试也失败
|
||||
resp = &http.Response{
|
||||
StatusCode: resp.StatusCode,
|
||||
|
||||
Reference in New Issue
Block a user