Merge branch 'main' into test
冲突解决: - wire_gen.go: 合并 antigravityGatewayService 和 ProvideConcurrencyCache - user_repo_integration_test.go: 保留 NotFound 测试 - antigravity_gateway_service.go: 适配 httpUpstream.Do 新签名 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
25
AGENTS.md
25
AGENTS.md
@@ -1,25 +0,0 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# 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:END -->
|
||||
|
||||
## 强制语言规范
|
||||
|
||||
以下为强制规定:
|
||||
- 与用户交流一律使用中文。
|
||||
- 代码文档与代码注释一律使用中文。
|
||||
- OpenSpec 提案与相关说明一律使用中文。
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -390,4 +390,3 @@ func mustBindAccountToGroup(t *testing.T, client *dbent.Client, accountID, group
|
||||
Save(ctx)
|
||||
require.NoError(t, err, "create account_group")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 刷新
|
||||
|
||||
@@ -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) // 验证所有者的缓存被清除
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
0
build_image.sh
Executable file → Normal file
0
build_image.sh
Executable file → Normal file
@@ -96,7 +96,7 @@
|
||||
<!-- Antigravity OAuth accounts: show quota from extra field -->
|
||||
<template v-else-if="account.platform === 'antigravity' && account.type === 'oauth'">
|
||||
<!-- 账户类型徽章 -->
|
||||
<div v-if="antigravityTierLabel" class="mb-1">
|
||||
<div v-if="antigravityTierLabel" class="mb-1 flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
@@ -105,6 +105,28 @@
|
||||
>
|
||||
{{ antigravityTierLabel }}
|
||||
</span>
|
||||
<!-- 不合格账户警告图标 -->
|
||||
<span
|
||||
v-if="hasIneligibleTiers"
|
||||
class="group relative cursor-help"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-red-500"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="pointer-events-none absolute left-0 top-full z-50 mt-1 w-80 whitespace-normal break-words rounded bg-gray-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.ineligibleWarning') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasAntigravityQuota" class="space-y-1">
|
||||
@@ -403,11 +425,26 @@ const antigravityClaude45Usage = computed(() =>
|
||||
getAntigravityUsage(['claude-sonnet-4-5', 'claude-opus-4-5-thinking'])
|
||||
)
|
||||
|
||||
// Antigravity 账户类型
|
||||
// Antigravity 账户类型(从 load_code_assist 响应中提取)
|
||||
const antigravityTier = computed(() => {
|
||||
const extra = props.account.extra as Record<string, unknown> | undefined
|
||||
if (!extra || typeof extra.tier !== 'string') return null
|
||||
return extra.tier as string
|
||||
if (!extra) return null
|
||||
|
||||
const loadCodeAssist = extra.load_code_assist as Record<string, unknown> | undefined
|
||||
if (!loadCodeAssist) return null
|
||||
|
||||
// 优先取 paidTier,否则取 currentTier
|
||||
const paidTier = loadCodeAssist.paidTier as Record<string, unknown> | undefined
|
||||
if (paidTier && typeof paidTier.id === 'string') {
|
||||
return paidTier.id
|
||||
}
|
||||
|
||||
const currentTier = loadCodeAssist.currentTier as Record<string, unknown> | undefined
|
||||
if (currentTier && typeof currentTier.id === 'string') {
|
||||
return currentTier.id
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
// 账户类型显示标签
|
||||
@@ -438,6 +475,18 @@ const antigravityTierClass = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 检测账户是否有不合格状态(ineligibleTiers)
|
||||
const hasIneligibleTiers = computed(() => {
|
||||
const extra = props.account.extra as Record<string, unknown> | undefined
|
||||
if (!extra) return false
|
||||
|
||||
const loadCodeAssist = extra.load_code_assist as Record<string, unknown> | undefined
|
||||
if (!loadCodeAssist) return false
|
||||
|
||||
const ineligibleTiers = loadCodeAssist.ineligibleTiers as unknown[] | undefined
|
||||
return Array.isArray(ineligibleTiers) && ineligibleTiers.length > 0
|
||||
})
|
||||
|
||||
const loadUsage = async () => {
|
||||
// Fetch usage for Anthropic OAuth and Setup Token accounts
|
||||
// OpenAI usage comes from account.extra field (updated during forwarding)
|
||||
|
||||
@@ -1194,7 +1194,9 @@ export default {
|
||||
free: 'Free',
|
||||
pro: 'Pro',
|
||||
ultra: 'Ultra'
|
||||
}
|
||||
},
|
||||
ineligibleWarning:
|
||||
'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.'
|
||||
},
|
||||
|
||||
// Proxies
|
||||
|
||||
@@ -994,6 +994,8 @@ export default {
|
||||
pro: 'Pro',
|
||||
ultra: 'Ultra'
|
||||
},
|
||||
ineligibleWarning:
|
||||
'该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。',
|
||||
form: {
|
||||
nameLabel: '账号名称',
|
||||
namePlaceholder: '请输入账号名称',
|
||||
|
||||
Reference in New Issue
Block a user