diff --git a/backend/internal/integration/e2e_gateway_test.go b/backend/internal/integration/e2e_gateway_test.go new file mode 100644 index 00000000..38505418 --- /dev/null +++ b/backend/internal/integration/e2e_gateway_test.go @@ -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"]) +} diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 026aaa09..a41168ed 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -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 + } +} diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index b55a835c..dc4ec531 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -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,