diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 99e1f981..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,25 +0,0 @@ - -# OpenSpec Instructions - -These instructions are for AI assistants working in this project. - -Always open `@/openspec/AGENTS.md` when the request: -- Mentions planning or proposals (words like proposal, spec, change, plan) -- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work -- Sounds ambiguous and you need the authoritative spec before coding - -Use `@/openspec/AGENTS.md` to learn: -- How to create and apply change proposals -- Spec format and conventions -- Project structure and guidelines - -Keep this managed block so 'openspec update' can refresh the instructions. - - - -## 强制语言规范 - -以下为强制规定: -- 与用户交流一律使用中文。 -- 代码文档与代码注释一律使用中文。 -- OpenSpec 提案与相关说明一律使用中文。 diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index ebbaa172..d469dcbb 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -93,8 +93,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher) geminiTokenCache := repository.NewGeminiTokenCache(redisClient) geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService) + gatewayCache := repository.NewGatewayCache(redisClient) + antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository) + antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService) httpUpstream := repository.NewHTTPUpstream(configConfig) - accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, geminiTokenProvider, httpUpstream) + antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream) + accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, geminiTokenProvider, antigravityGatewayService, httpUpstream) concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) concurrencyService := service.NewConcurrencyService(concurrencyCache) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService) @@ -102,7 +106,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) - antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository) antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) proxyHandler := admin.NewProxyHandler(adminService) adminRedeemHandler := admin.NewRedeemHandler(adminService) @@ -115,7 +118,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService) adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService) adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler) - gatewayCache := repository.NewGatewayCache(redisClient) pricingRemoteClient := repository.NewPricingRemoteClient() pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient) if err != nil { @@ -127,8 +129,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { timingWheelService := service.ProvideTimingWheelService() deferredService := service.ProvideDeferredService(accountRepository, timingWheelService) gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService) - antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService) - antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream) geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService) gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 5fa2f4e1..ac938f8c 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -918,6 +918,37 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { return } + // Handle Antigravity accounts: return Claude + Gemini models + if account.Platform == service.PlatformAntigravity { + // Antigravity 支持 Claude 和部分 Gemini 模型 + type UnifiedModel struct { + ID string `json:"id"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + } + + var models []UnifiedModel + + // 添加 Claude 模型 + for _, m := range claude.DefaultModels { + models = append(models, UnifiedModel{ + ID: m.ID, + Type: m.Type, + DisplayName: m.DisplayName, + }) + } + + // 添加 Gemini 3 系列模型用于测试 + geminiTestModels := []UnifiedModel{ + {ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash"}, + {ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview"}, + } + models = append(models, geminiTestModels...) + + response.Success(c, models) + return + } + // Handle Claude/Anthropic accounts // For OAuth and Setup-Token accounts: return default models if account.IsOAuth() { diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index d425b881..3bcbf26b 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -1,6 +1,7 @@ package antigravity import ( + "bytes" "context" "encoding/json" "fmt" @@ -11,6 +12,19 @@ import ( "time" ) +// NewAPIRequest 创建 Antigravity API 请求(v1internal 端点) +func NewAPIRequest(ctx context.Context, action, accessToken string, body []byte) (*http.Request, error) { + apiURL := fmt.Sprintf("%s/v1internal:%s", BaseURL, action) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("User-Agent", UserAgent) + return req, nil +} + // TokenResponse Google OAuth token 响应 type TokenResponse struct { AccessToken string `json:"access_token"` @@ -201,20 +215,20 @@ func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo return &userInfo, nil } -// LoadCodeAssist 获取 project_id -func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, error) { +// LoadCodeAssist 获取账户信息,返回解析后的结构体和原始 JSON +func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, map[string]any, error) { reqBody := LoadCodeAssistRequest{} reqBody.Metadata.IDEType = "ANTIGRAVITY" bodyBytes, err := json.Marshal(reqBody) if err != nil { - return nil, fmt.Errorf("序列化请求失败: %w", err) + return nil, nil, fmt.Errorf("序列化请求失败: %w", err) } url := BaseURL + "/v1internal:loadCodeAssist" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes))) if err != nil { - return nil, fmt.Errorf("创建请求失败: %w", err) + return nil, nil, fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") @@ -222,25 +236,29 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("loadCodeAssist 请求失败: %w", err) + return nil, nil, fmt.Errorf("loadCodeAssist 请求失败: %w", err) } defer func() { _ = resp.Body.Close() }() respBodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("读取响应失败: %w", err) + return nil, nil, fmt.Errorf("读取响应失败: %w", err) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("loadCodeAssist 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes)) + return nil, nil, fmt.Errorf("loadCodeAssist 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes)) } var loadResp LoadCodeAssistResponse if err := json.Unmarshal(respBodyBytes, &loadResp); err != nil { - return nil, fmt.Errorf("响应解析失败: %w", err) + return nil, nil, fmt.Errorf("响应解析失败: %w", err) } - return &loadResp, nil + // 解析原始 JSON 为 map + var rawResp map[string]any + _ = json.Unmarshal(respBodyBytes, &rawResp) + + return &loadResp, rawResp, nil } // ModelQuotaInfo 模型配额信息 @@ -264,18 +282,18 @@ type FetchAvailableModelsResponse struct { Models map[string]ModelInfo `json:"models"` } -// FetchAvailableModels 获取可用模型和配额信息 -func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string) (*FetchAvailableModelsResponse, error) { +// FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON +func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string) (*FetchAvailableModelsResponse, map[string]any, error) { reqBody := FetchAvailableModelsRequest{Project: projectID} bodyBytes, err := json.Marshal(reqBody) if err != nil { - return nil, fmt.Errorf("序列化请求失败: %w", err) + return nil, nil, fmt.Errorf("序列化请求失败: %w", err) } apiURL := BaseURL + "/v1internal:fetchAvailableModels" req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(bodyBytes))) if err != nil { - return nil, fmt.Errorf("创建请求失败: %w", err) + return nil, nil, fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") @@ -283,23 +301,27 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("fetchAvailableModels 请求失败: %w", err) + return nil, nil, fmt.Errorf("fetchAvailableModels 请求失败: %w", err) } defer func() { _ = resp.Body.Close() }() respBodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("读取响应失败: %w", err) + return nil, nil, fmt.Errorf("读取响应失败: %w", err) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("fetchAvailableModels 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes)) + return nil, nil, fmt.Errorf("fetchAvailableModels 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes)) } var modelsResp FetchAvailableModelsResponse if err := json.Unmarshal(respBodyBytes, &modelsResp); err != nil { - return nil, fmt.Errorf("响应解析失败: %w", err) + return nil, nil, fmt.Errorf("响应解析失败: %w", err) } - return &modelsResp, nil + // 解析原始 JSON 为 map + var rawResp map[string]any + _ = json.Unmarshal(respBodyBytes, &rawResp) + + return &modelsResp, rawResp, nil } diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go index 54ac8bb1..bdc018f2 100644 --- a/backend/internal/pkg/antigravity/oauth.go +++ b/backend/internal/pkg/antigravity/oauth.go @@ -177,3 +177,24 @@ func BuildAuthorizationURL(state, codeChallenge string) string { return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode()) } + +// GenerateMockProjectID 生成随机 project_id(当 API 不返回时使用) +// 格式:{形容词}-{名词}-{5位随机字符} +func GenerateMockProjectID() string { + adjectives := []string{"useful", "bright", "swift", "calm", "bold"} + nouns := []string{"fuze", "wave", "spark", "flow", "core"} + + randBytes, _ := GenerateRandomBytes(7) + + adj := adjectives[int(randBytes[0])%len(adjectives)] + noun := nouns[int(randBytes[1])%len(nouns)] + + // 生成 5 位随机字符(a-z0-9) + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + suffix := make([]byte, 5) + for i := 0; i < 5; i++ { + suffix[i] = charset[int(randBytes[i+2])%len(charset)] + } + + return fmt.Sprintf("%s-%s-%s", adj, noun, string(suffix)) +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 23e85e9a..631f4ee4 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -356,7 +356,7 @@ func (r *accountRepository) BatchUpdateLastUsed(ctx context.Context, updates map idx := 1 for id, ts := range updates { - caseSQL += " WHEN $" + itoa(idx) + " THEN $" + itoa(idx+1) + caseSQL += " WHEN $" + itoa(idx) + " THEN $" + itoa(idx+1) + "::timestamptz" args = append(args, id, ts) ids = append(ids, id) idx += 2 diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index 253f24f0..8f13c532 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -390,4 +390,3 @@ func mustBindAccountToGroup(t *testing.T, client *dbent.Client, accountID, group Save(ctx) require.NoError(t, err, "create account_group") } - diff --git a/backend/internal/repository/user_repo_integration_test.go b/backend/internal/repository/user_repo_integration_test.go index c5c9e78c..55db00c3 100644 --- a/backend/internal/repository/user_repo_integration_test.go +++ b/backend/internal/repository/user_repo_integration_test.go @@ -76,8 +76,8 @@ func (s *UserRepoSuite) mustCreateSubscription(userID, groupID int64, mutate fun create := s.client.UserSubscription.Create(). SetUserID(userID). SetGroupID(groupID). - SetStartsAt(now.Add(-1*time.Hour)). - SetExpiresAt(now.Add(24*time.Hour)). + SetStartsAt(now.Add(-1 * time.Hour)). + SetExpiresAt(now.Add(24 * time.Hour)). SetStatus(service.SubscriptionStatusActive). SetAssignedAt(now). SetNotes("") @@ -528,4 +528,3 @@ func (s *UserRepoSuite) TestDeductBalance_NotFound() { // DeductBalance 在用户不存在时返回 ErrInsufficientBalance 因为 WHERE 条件不匹配 s.Require().ErrorIs(err, service.ErrInsufficientBalance) } - diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index bfa9b60f..318be8b8 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -44,11 +44,12 @@ type TestEvent struct { // AccountTestService handles account testing operations type AccountTestService struct { - accountRepo AccountRepository - oauthService *OAuthService - openaiOAuthService *OpenAIOAuthService - geminiTokenProvider *GeminiTokenProvider - httpUpstream HTTPUpstream + accountRepo AccountRepository + oauthService *OAuthService + openaiOAuthService *OpenAIOAuthService + geminiTokenProvider *GeminiTokenProvider + antigravityGatewayService *AntigravityGatewayService + httpUpstream HTTPUpstream } // NewAccountTestService creates a new AccountTestService @@ -57,14 +58,16 @@ func NewAccountTestService( oauthService *OAuthService, openaiOAuthService *OpenAIOAuthService, geminiTokenProvider *GeminiTokenProvider, + antigravityGatewayService *AntigravityGatewayService, httpUpstream HTTPUpstream, ) *AccountTestService { return &AccountTestService{ - accountRepo: accountRepo, - oauthService: oauthService, - openaiOAuthService: openaiOAuthService, - geminiTokenProvider: geminiTokenProvider, - httpUpstream: httpUpstream, + accountRepo: accountRepo, + oauthService: oauthService, + openaiOAuthService: openaiOAuthService, + geminiTokenProvider: geminiTokenProvider, + antigravityGatewayService: antigravityGatewayService, + httpUpstream: httpUpstream, } } @@ -141,6 +144,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.testGeminiAccountConnection(c, account, modelID) } + if account.Platform == PlatformAntigravity { + return s.testAntigravityAccountConnection(c, account, modelID) + } + return s.testClaudeAccountConnection(c, account, modelID) } @@ -328,7 +335,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account if baseURL == "" { baseURL = "https://api.openai.com" } - apiURL = strings.TrimSuffix(baseURL, "/") + "/v1/responses" + apiURL = strings.TrimSuffix(baseURL, "/") + "/responses" } else { return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type)) } @@ -457,6 +464,46 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account return s.processGeminiStream(c, resp.Body) } +// testAntigravityAccountConnection tests an Antigravity account's connection +// 支持 Claude 和 Gemini 两种协议,使用非流式请求 +func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error { + ctx := c.Request.Context() + + // 默认模型:Claude 使用 claude-sonnet-4-5,Gemini 使用 gemini-3-pro-preview + testModelID := modelID + if testModelID == "" { + testModelID = "claude-sonnet-4-5" + } + + if s.antigravityGatewayService == nil { + return s.sendErrorAndEnd(c, "Antigravity gateway service not configured") + } + + // Set SSE headers + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.Flush() + + // Send test_start event + s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) + + // 调用 AntigravityGatewayService.TestConnection(复用协议转换逻辑) + result, err := s.antigravityGatewayService.TestConnection(ctx, account, testModelID) + if err != nil { + return s.sendErrorAndEnd(c, err.Error()) + } + + // 发送响应内容 + if result.Text != "" { + s.sendEvent(c, TestEvent{Type: "content", Text: result.Text}) + } + + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil +} + // buildGeminiAPIKeyRequest builds request for Gemini API Key accounts func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) { apiKey := account.GetCredential("api_key") @@ -514,7 +561,12 @@ func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, accoun return req, nil } - // Wrap payload in Code Assist format + // Code Assist mode (with project_id) + return s.buildCodeAssistRequest(ctx, accessToken, projectID, modelID, payload) +} + +// buildCodeAssistRequest builds request for Google Code Assist API (used by Gemini CLI and Antigravity) +func (s *AccountTestService) buildCodeAssistRequest(ctx context.Context, accessToken, projectID, modelID string, payload []byte) (*http.Request, error) { var inner map[string]any if err := json.Unmarshal(payload, &inner); err != nil { return nil, err diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 25d9066b..ae2976f8 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -130,6 +130,158 @@ func (s *AntigravityGatewayService) IsModelSupported(requestedModel string) bool return false } +// TestConnectionResult 测试连接结果 +type TestConnectionResult struct { + Text string // 响应文本 + MappedModel string // 实际使用的模型 +} + +// TestConnection 测试 Antigravity 账号连接(非流式,无重试、无计费) +// 支持 Claude 和 Gemini 两种协议,根据 modelID 前缀自动选择 +func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account *Account, modelID string) (*TestConnectionResult, error) { + // 获取 token + if s.tokenProvider == nil { + return nil, errors.New("antigravity token provider not configured") + } + accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, fmt.Errorf("获取 access_token 失败: %w", err) + } + + // 获取 project_id(部分账户类型可能没有) + projectID := strings.TrimSpace(account.GetCredential("project_id")) + + // 模型映射 + mappedModel := s.getMappedModel(account, modelID) + + // 构建请求体 + var requestBody []byte + if strings.HasPrefix(modelID, "gemini-") { + // Gemini 模型:直接使用 Gemini 格式 + requestBody, err = s.buildGeminiTestRequest(projectID, mappedModel) + } else { + // Claude 模型:使用协议转换 + requestBody, err = s.buildClaudeTestRequest(projectID, mappedModel) + } + if err != nil { + return nil, fmt.Errorf("构建请求失败: %w", err) + } + + // 构建 HTTP 请求(非流式) + req, err := antigravity.NewAPIRequest(ctx, "generateContent", accessToken, requestBody) + if err != nil { + return nil, err + } + + // 代理 URL + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + // 发送请求 + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return nil, fmt.Errorf("请求失败: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // 读取响应 + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API 返回 %d: %s", resp.StatusCode, string(respBody)) + } + + // 解包 v1internal 响应 + unwrapped, err := s.unwrapV1InternalResponse(respBody) + if err != nil { + return nil, fmt.Errorf("解包响应失败: %w", err) + } + + // 提取响应文本 + text := extractGeminiResponseText(unwrapped) + + return &TestConnectionResult{ + Text: text, + MappedModel: mappedModel, + }, nil +} + +// buildGeminiTestRequest 构建 Gemini 格式测试请求 +func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model string) ([]byte, error) { + payload := map[string]any{ + "contents": []map[string]any{ + { + "role": "user", + "parts": []map[string]any{ + {"text": "hi"}, + }, + }, + }, + } + payloadBytes, _ := json.Marshal(payload) + return s.wrapV1InternalRequest(projectID, model, payloadBytes) +} + +// buildClaudeTestRequest 构建 Claude 格式测试请求并转换为 Gemini 格式 +func (s *AntigravityGatewayService) buildClaudeTestRequest(projectID, mappedModel string) ([]byte, error) { + claudeReq := &antigravity.ClaudeRequest{ + Model: mappedModel, + Messages: []antigravity.ClaudeMessage{ + { + Role: "user", + Content: json.RawMessage(`"hi"`), + }, + }, + MaxTokens: 1024, + Stream: false, + } + return antigravity.TransformClaudeToGemini(claudeReq, projectID, mappedModel) +} + +// extractGeminiResponseText 从 Gemini 响应中提取文本 +func extractGeminiResponseText(respBody []byte) string { + var resp map[string]any + if err := json.Unmarshal(respBody, &resp); err != nil { + return "" + } + + candidates, ok := resp["candidates"].([]any) + if !ok || len(candidates) == 0 { + return "" + } + + candidate, ok := candidates[0].(map[string]any) + if !ok { + return "" + } + + content, ok := candidate["content"].(map[string]any) + if !ok { + return "" + } + + parts, ok := content["parts"].([]any) + if !ok { + return "" + } + + var texts []string + for _, part := range parts { + if partMap, ok := part.(map[string]any); ok { + if text, ok := partMap["text"].(string); ok && text != "" { + texts = append(texts, text) + } + } + } + + return strings.Join(texts, "") +} + // wrapV1InternalRequest 包装请求为 v1internal 格式 func (s *AntigravityGatewayService) wrapV1InternalRequest(projectID, model string, originalBody []byte) ([]byte, error) { var request any @@ -191,11 +343,8 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, return nil, fmt.Errorf("获取 access_token 失败: %w", err) } - // 获取 project_id + // 获取 project_id(部分账户类型可能没有) projectID := strings.TrimSpace(account.GetCredential("project_id")) - if projectID == "" { - return nil, errors.New("project_id not found in credentials") - } // 代理 URL proxyURL := "" @@ -209,26 +358,19 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, return nil, fmt.Errorf("transform request: %w", err) } - // 构建上游 URL + // 构建上游 action action := "generateContent" if claudeReq.Stream { - action = "streamGenerateContent" - } - fullURL := fmt.Sprintf("%s/v1internal:%s", antigravity.BaseURL, action) - if claudeReq.Stream { - fullURL += "?alt=sse" + action = "streamGenerateContent?alt=sse" } // 重试循环 var resp *http.Response for attempt := 1; attempt <= antigravityMaxRetries; attempt++ { - upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(geminiBody)) + upstreamReq, err := antigravity.NewAPIRequest(ctx, action, accessToken, geminiBody) if err != nil { return nil, err } - upstreamReq.Header.Set("Content-Type", "application/json") - upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) - upstreamReq.Header.Set("User-Agent", antigravity.UserAgent) resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { @@ -341,11 +483,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co return nil, fmt.Errorf("获取 access_token 失败: %w", err) } - // 获取 project_id + // 获取 project_id(部分账户类型可能没有) projectID := strings.TrimSpace(account.GetCredential("project_id")) - if projectID == "" { - return nil, errors.New("project_id not found in credentials") - } // 代理 URL proxyURL := "" @@ -359,26 +498,22 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co return nil, err } - // 构建上游 URL + // 构建上游 action upstreamAction := action if action == "generateContent" && stream { upstreamAction = "streamGenerateContent" } - fullURL := fmt.Sprintf("%s/v1internal:%s", antigravity.BaseURL, upstreamAction) if stream || upstreamAction == "streamGenerateContent" { - fullURL += "?alt=sse" + upstreamAction += "?alt=sse" } // 重试循环 var resp *http.Response for attempt := 1; attempt <= antigravityMaxRetries; attempt++ { - upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(wrappedBody)) + upstreamReq, err := antigravity.NewAPIRequest(ctx, upstreamAction, accessToken, wrappedBody) if err != nil { return nil, err } - upstreamReq.Header.Set("Content-Type", "application/json") - upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) - upstreamReq.Header.Set("User-Agent", antigravity.UserAgent) resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { diff --git a/backend/internal/service/antigravity_oauth_service.go b/backend/internal/service/antigravity_oauth_service.go index fc6cc74d..ecf0a553 100644 --- a/backend/internal/service/antigravity_oauth_service.go +++ b/backend/internal/service/antigravity_oauth_service.go @@ -141,14 +141,20 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig result.Email = userInfo.Email } - // 获取 project_id - loadResp, err := client.LoadCodeAssist(ctx, tokenResp.AccessToken) + // 获取 project_id(部分账户类型可能没有) + loadResp, _, err := client.LoadCodeAssist(ctx, tokenResp.AccessToken) if err != nil { fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败: %v\n", err) } else if loadResp != nil && loadResp.CloudAICompanionProject != "" { result.ProjectID = loadResp.CloudAICompanionProject } + // 兜底:随机生成 project_id + if result.ProjectID == "" { + result.ProjectID = antigravity.GenerateMockProjectID() + fmt.Printf("[AntigravityOAuth] 使用随机生成的 project_id: %s\n", result.ProjectID) + } + return result, nil } @@ -168,7 +174,10 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken client := antigravity.NewClient(proxyURL) tokenResp, err := client.RefreshToken(ctx, refreshToken) if err == nil { - expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300 + now := time.Now() + expiresAt := now.Unix() + tokenResp.ExpiresIn - 300 + fmt.Printf("[AntigravityOAuth] Token refreshed: expires_in=%d, expires_at=%d (%s)\n", + tokenResp.ExpiresIn, expiresAt, time.Unix(expiresAt, 0).Format("2006-01-02 15:04:05")) return &AntigravityTokenInfo{ AccessToken: tokenResp.AccessToken, RefreshToken: tokenResp.RefreshToken, diff --git a/backend/internal/service/antigravity_quota_refresher.go b/backend/internal/service/antigravity_quota_refresher.go index 5ed59d2f..dd579ef1 100644 --- a/backend/internal/service/antigravity_quota_refresher.go +++ b/backend/internal/service/antigravity_quota_refresher.go @@ -125,8 +125,8 @@ func (r *AntigravityQuotaRefresher) refreshAccountQuota(ctx context.Context, acc accessToken := account.GetCredential("access_token") projectID := account.GetCredential("project_id") - if accessToken == "" || projectID == "" { - return nil // 没有有效凭证,跳过 + if accessToken == "" { + return nil // 没有 access_token,跳过 } // token 过期则跳过,由 TokenRefreshService 负责刷新 @@ -145,21 +145,46 @@ func (r *AntigravityQuotaRefresher) refreshAccountQuota(ctx context.Context, acc client := antigravity.NewClient(proxyURL) - // 获取账户类型(tier) - loadResp, _ := client.LoadCodeAssist(ctx, accessToken) + if account.Extra == nil { + account.Extra = make(map[string]any) + } + + // 获取账户信息(tier、project_id 等) + loadResp, loadRaw, _ := client.LoadCodeAssist(ctx, accessToken) + if loadRaw != nil { + account.Extra["load_code_assist"] = loadRaw + } if loadResp != nil { - r.updateAccountTier(account, loadResp) + // 尝试从 API 获取 project_id + if projectID == "" && loadResp.CloudAICompanionProject != "" { + projectID = loadResp.CloudAICompanionProject + account.Credentials["project_id"] = projectID + } + } + + // 如果仍然没有 project_id,随机生成一个并保存 + if projectID == "" { + projectID = antigravity.GenerateMockProjectID() + account.Credentials["project_id"] = projectID + log.Printf("[AntigravityQuotaRefresher] 为账户 %d 生成随机 project_id: %s", account.ID, projectID) } // 调用 API 获取配额 - modelsResp, err := client.FetchAvailableModels(ctx, accessToken, projectID) + modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID) if err != nil { - return err + return r.accountRepo.Update(ctx, account) // 保存已有的 load_code_assist 信息 } - // 解析配额数据并更新 extra 字段 + // 保存完整的配额响应 + if modelsRaw != nil { + account.Extra["available_models"] = modelsRaw + } + + // 解析配额数据为前端使用的格式 r.updateAccountQuota(account, modelsResp) + account.Extra["last_refresh"] = time.Now().Format(time.RFC3339) + // 保存到数据库 return r.accountRepo.Update(ctx, account) } @@ -175,35 +200,8 @@ func (r *AntigravityQuotaRefresher) isTokenExpired(account *Account) bool { return time.Now().Add(5 * time.Minute).After(*expiresAt) } -// updateAccountTier 更新账户类型信息 -func (r *AntigravityQuotaRefresher) updateAccountTier(account *Account, loadResp *antigravity.LoadCodeAssistResponse) { - if account.Extra == nil { - account.Extra = make(map[string]any) - } - - tier := loadResp.GetTier() - if tier != "" { - account.Extra["tier"] = tier - } - - // 保存不符合条件的原因(如 INELIGIBLE_ACCOUNT) - if len(loadResp.IneligibleTiers) > 0 && loadResp.IneligibleTiers[0] != nil { - ineligible := loadResp.IneligibleTiers[0] - if ineligible.ReasonCode != "" { - account.Extra["ineligible_reason_code"] = ineligible.ReasonCode - } - if ineligible.ReasonMessage != "" { - account.Extra["ineligible_reason_message"] = ineligible.ReasonMessage - } - } -} - -// updateAccountQuota 更新账户的配额信息 +// updateAccountQuota 更新账户的配额信息(前端使用的格式) func (r *AntigravityQuotaRefresher) updateAccountQuota(account *Account, modelsResp *antigravity.FetchAvailableModelsResponse) { - if account.Extra == nil { - account.Extra = make(map[string]any) - } - quota := make(map[string]any) for modelName, modelInfo := range modelsResp.Models { @@ -221,5 +219,4 @@ func (r *AntigravityQuotaRefresher) updateAccountQuota(account *Account, modelsR } account.Extra["quota"] = quota - account.Extra["last_quota_check"] = time.Now().Format(time.RFC3339) } diff --git a/backend/internal/service/antigravity_token_refresher.go b/backend/internal/service/antigravity_token_refresher.go index 1d2b8f15..8ee2d25c 100644 --- a/backend/internal/service/antigravity_token_refresher.go +++ b/backend/internal/service/antigravity_token_refresher.go @@ -6,6 +6,12 @@ import ( "time" ) +const ( + // antigravityRefreshWindow Antigravity token 提前刷新窗口:15分钟 + // Google OAuth token 有效期55分钟,提前15分钟刷新 + antigravityRefreshWindow = 15 * time.Minute +) + // AntigravityTokenRefresher 实现 TokenRefresher 接口 type AntigravityTokenRefresher struct { antigravityOAuthService *AntigravityOAuthService @@ -23,7 +29,8 @@ func (r *AntigravityTokenRefresher) CanRefresh(account *Account) bool { } // NeedsRefresh 检查账户是否需要刷新 -func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool { +// Antigravity 使用固定的10分钟刷新窗口,忽略全局配置 +func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, _ time.Duration) bool { if !r.CanRefresh(account) { return false } @@ -36,7 +43,7 @@ func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, refreshWindow return false } expiryTime := time.Unix(expiresAt, 0) - return time.Until(expiryTime) < refreshWindow + return time.Until(expiryTime) < antigravityRefreshWindow } // Refresh 执行 token 刷新 diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go index a531d0b8..deac8499 100644 --- a/backend/internal/service/api_key_service_delete_test.go +++ b/backend/internal/service/api_key_service_delete_test.go @@ -146,8 +146,8 @@ func TestApiKeyService_Delete_OwnerMismatch(t *testing.T) { err := svc.Delete(context.Background(), 10, 2) // API Key ID=10, 调用者 userID=2 require.ErrorIs(t, err, ErrInsufficientPerms) - require.Empty(t, repo.deletedIDs) // 验证删除操作未被调用 - require.Empty(t, cache.invalidated) // 验证缓存未被清除 + require.Empty(t, repo.deletedIDs) // 验证删除操作未被调用 + require.Empty(t, cache.invalidated) // 验证缓存未被清除 } // TestApiKeyService_Delete_Success 测试所有者成功删除 API Key 的场景。 @@ -164,7 +164,7 @@ func TestApiKeyService_Delete_Success(t *testing.T) { err := svc.Delete(context.Background(), 42, 7) // API Key ID=42, 调用者 userID=7 require.NoError(t, err) - require.Equal(t, []int64{42}, repo.deletedIDs) // 验证正确的 API Key 被删除 + require.Equal(t, []int64{42}, repo.deletedIDs) // 验证正确的 API Key 被删除 require.Equal(t, []int64{7}, cache.invalidated) // 验证所有者的缓存被清除 } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 769d0c3c..0c3faaf1 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -371,7 +371,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // API Key accounts use Platform API or custom base URL baseURL := account.GetOpenAIBaseURL() if baseURL != "" { - targetURL = baseURL + "/v1/responses" + targetURL = baseURL + "/responses" } else { targetURL = openaiPlatformAPIURL } diff --git a/build_image.sh b/build_image.sh old mode 100755 new mode 100644 diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index ea222c33..d064c55a 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -96,7 +96,7 @@