feat(gemini): 优化 OAuth 和配额展示

主要改进:
- 修复 google_one OAuth scopes 配置问题
- 添加 Gemini 账号配额展示组件
- 优化 Code Assist 类型检测逻辑
- 添加 OAuth 测试用例
This commit is contained in:
ianshaw
2026-01-03 06:32:04 -08:00
parent 26438f7232
commit 26106eb0ac
11 changed files with 363 additions and 109 deletions

View File

@@ -18,7 +18,6 @@ func NewGeminiOAuthHandler(geminiOAuthService *service.GeminiOAuthService) *Gemi
return &GeminiOAuthHandler{geminiOAuthService: geminiOAuthService} return &GeminiOAuthHandler{geminiOAuthService: geminiOAuthService}
} }
// GetCapabilities retrieves OAuth configuration capabilities.
// GET /api/v1/admin/gemini/oauth/capabilities // GET /api/v1/admin/gemini/oauth/capabilities
func (h *GeminiOAuthHandler) GetCapabilities(c *gin.Context) { func (h *GeminiOAuthHandler) GetCapabilities(c *gin.Context) {
cfg := h.geminiOAuthService.GetOAuthConfig() cfg := h.geminiOAuthService.GetOAuthConfig()

View File

@@ -1,5 +1,10 @@
package geminicli package geminicli
import (
"bytes"
"encoding/json"
)
// LoadCodeAssistRequest matches done-hub's internal Code Assist call. // LoadCodeAssistRequest matches done-hub's internal Code Assist call.
type LoadCodeAssistRequest struct { type LoadCodeAssistRequest struct {
Metadata LoadCodeAssistMetadata `json:"metadata"` Metadata LoadCodeAssistMetadata `json:"metadata"`
@@ -11,12 +16,51 @@ type LoadCodeAssistMetadata struct {
PluginType string `json:"pluginType"` PluginType string `json:"pluginType"`
} }
type TierInfo struct {
ID string `json:"id"`
}
// UnmarshalJSON supports both legacy string tiers and object tiers.
func (t *TierInfo) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 || string(data) == "null" {
return nil
}
if data[0] == '"' {
var id string
if err := json.Unmarshal(data, &id); err != nil {
return err
}
t.ID = id
return nil
}
type alias TierInfo
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*t = TierInfo(decoded)
return nil
}
type LoadCodeAssistResponse struct { type LoadCodeAssistResponse struct {
CurrentTier string `json:"currentTier,omitempty"` CurrentTier *TierInfo `json:"currentTier,omitempty"`
PaidTier *TierInfo `json:"paidTier,omitempty"`
CloudAICompanionProject string `json:"cloudaicompanionProject,omitempty"` CloudAICompanionProject string `json:"cloudaicompanionProject,omitempty"`
AllowedTiers []AllowedTier `json:"allowedTiers,omitempty"` AllowedTiers []AllowedTier `json:"allowedTiers,omitempty"`
} }
// GetTier extracts tier ID, prioritizing paidTier over currentTier
func (r *LoadCodeAssistResponse) GetTier() string {
if r.PaidTier != nil && r.PaidTier.ID != "" {
return r.PaidTier.ID
}
if r.CurrentTier != nil {
return r.CurrentTier.ID
}
return ""
}
type AllowedTier struct { type AllowedTier struct {
ID string `json:"id"` ID string `json:"id"`
IsDefault bool `json:"isDefault,omitempty"` IsDefault bool `json:"isDefault,omitempty"`

View File

@@ -1,5 +1,3 @@
// Package geminicli provides OAuth authentication and API client functionality
// for Google's Gemini AI services, supporting both AI Studio and Code Assist endpoints.
package geminicli package geminicli
import "time" import "time"
@@ -29,7 +27,9 @@ const (
DefaultAIStudioScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever" DefaultAIStudioScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever"
// DefaultScopes for Google One (personal Google accounts with Gemini access) // DefaultScopes for Google One (personal Google accounts with Gemini access)
// Includes generative-language for Gemini API access and drive.readonly for storage tier detection // Only used when a custom OAuth client is configured. When using the built-in Gemini CLI client,
// Google One uses DefaultCodeAssistScopes (same as code_assist) because the built-in client
// cannot request restricted scopes like generative-language.retriever or drive.readonly.
DefaultGoogleOneScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" DefaultGoogleOneScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
// GeminiCLIRedirectURI is the redirect URI used by Gemini CLI for Code Assist OAuth. // GeminiCLIRedirectURI is the redirect URI used by Gemini CLI for Code Assist OAuth.

View File

@@ -181,19 +181,23 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error
effective.Scopes = DefaultAIStudioScopes effective.Scopes = DefaultAIStudioScopes
} }
case "google_one": case "google_one":
// Google One accounts need generative-language scope for Gemini API access // Google One uses built-in Gemini CLI client (same as code_assist)
// and drive.readonly scope for storage tier detection // Built-in client can't request restricted scopes like generative-language.retriever
effective.Scopes = DefaultGoogleOneScopes if isBuiltinClient {
effective.Scopes = DefaultCodeAssistScopes
} else {
effective.Scopes = DefaultGoogleOneScopes
}
default: default:
// Default to Code Assist scopes // Default to Code Assist scopes
effective.Scopes = DefaultCodeAssistScopes effective.Scopes = DefaultCodeAssistScopes
} }
} else if oauthType == "ai_studio" && isBuiltinClient { } else if (oauthType == "ai_studio" || oauthType == "google_one") && isBuiltinClient {
// If user overrides scopes while still using the built-in client, strip restricted scopes. // If user overrides scopes while still using the built-in client, strip restricted scopes.
parts := strings.Fields(effective.Scopes) parts := strings.Fields(effective.Scopes)
filtered := make([]string, 0, len(parts)) filtered := make([]string, 0, len(parts))
for _, s := range parts { for _, s := range parts {
if strings.Contains(s, "generative-language") { if hasRestrictedScope(s) {
continue continue
} }
filtered = append(filtered, s) filtered = append(filtered, s)
@@ -219,6 +223,11 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error
return effective, nil return effective, nil
} }
func hasRestrictedScope(scope string) bool {
return strings.HasPrefix(scope, "https://www.googleapis.com/auth/generative-language") ||
strings.HasPrefix(scope, "https://www.googleapis.com/auth/drive")
}
func BuildAuthorizationURL(cfg OAuthConfig, state, codeChallenge, redirectURI, projectID, oauthType string) (string, error) { func BuildAuthorizationURL(cfg OAuthConfig, state, codeChallenge, redirectURI, projectID, oauthType string) (string, error) {
effectiveCfg, err := EffectiveOAuthConfig(cfg, oauthType) effectiveCfg, err := EffectiveOAuthConfig(cfg, oauthType)
if err != nil { if err != nil {

View File

@@ -0,0 +1,113 @@
package geminicli
import (
"strings"
"testing"
)
func TestEffectiveOAuthConfig_GoogleOne(t *testing.T) {
tests := []struct {
name string
input OAuthConfig
oauthType string
wantClientID string
wantScopes string
wantErr bool
}{
{
name: "Google One with built-in client (empty config)",
input: OAuthConfig{},
oauthType: "google_one",
wantClientID: GeminiCLIOAuthClientID,
wantScopes: DefaultCodeAssistScopes,
wantErr: false,
},
{
name: "Google One with custom client",
input: OAuthConfig{
ClientID: "custom-client-id",
ClientSecret: "custom-client-secret",
},
oauthType: "google_one",
wantClientID: "custom-client-id",
wantScopes: DefaultGoogleOneScopes,
wantErr: false,
},
{
name: "Google One with built-in client and custom scopes (should filter restricted scopes)",
input: OAuthConfig{
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly",
},
oauthType: "google_one",
wantClientID: GeminiCLIOAuthClientID,
wantScopes: "https://www.googleapis.com/auth/cloud-platform",
wantErr: false,
},
{
name: "Google One with built-in client and only restricted scopes (should fallback to default)",
input: OAuthConfig{
Scopes: "https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly",
},
oauthType: "google_one",
wantClientID: GeminiCLIOAuthClientID,
wantScopes: DefaultCodeAssistScopes,
wantErr: false,
},
{
name: "Code Assist with built-in client",
input: OAuthConfig{},
oauthType: "code_assist",
wantClientID: GeminiCLIOAuthClientID,
wantScopes: DefaultCodeAssistScopes,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := EffectiveOAuthConfig(tt.input, tt.oauthType)
if (err != nil) != tt.wantErr {
t.Errorf("EffectiveOAuthConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return
}
if got.ClientID != tt.wantClientID {
t.Errorf("EffectiveOAuthConfig() ClientID = %v, want %v", got.ClientID, tt.wantClientID)
}
if got.Scopes != tt.wantScopes {
t.Errorf("EffectiveOAuthConfig() Scopes = %v, want %v", got.Scopes, tt.wantScopes)
}
})
}
}
func TestEffectiveOAuthConfig_ScopeFiltering(t *testing.T) {
// Test that Google One with built-in client filters out restricted scopes
cfg, err := EffectiveOAuthConfig(OAuthConfig{
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/userinfo.profile",
}, "google_one")
if err != nil {
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
}
// Should only contain cloud-platform, userinfo.email, and userinfo.profile
// Should NOT contain generative-language or drive scopes
if strings.Contains(cfg.Scopes, "generative-language") {
t.Errorf("Scopes should not contain generative-language when using built-in client, got: %v", cfg.Scopes)
}
if strings.Contains(cfg.Scopes, "drive") {
t.Errorf("Scopes should not contain drive when using built-in client, got: %v", cfg.Scopes)
}
if !strings.Contains(cfg.Scopes, "cloud-platform") {
t.Errorf("Scopes should contain cloud-platform, got: %v", cfg.Scopes)
}
if !strings.Contains(cfg.Scopes, "userinfo.email") {
t.Errorf("Scopes should contain userinfo.email, got: %v", cfg.Scopes)
}
if !strings.Contains(cfg.Scopes, "userinfo.profile") {
t.Errorf("Scopes should contain userinfo.profile, got: %v", cfg.Scopes)
}
}

View File

@@ -273,7 +273,7 @@ func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx cont
return 999 return 999
} }
switch a.Type { switch a.Type {
case AccountTypeAPIKey: case AccountTypeApiKey:
if strings.TrimSpace(a.GetCredential("api_key")) != "" { if strings.TrimSpace(a.GetCredential("api_key")) != "" {
return 0 return 0
} }
@@ -351,7 +351,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
originalModel := req.Model originalModel := req.Model
mappedModel := req.Model mappedModel := req.Model
if account.Type == AccountTypeAPIKey { if account.Type == AccountTypeApiKey {
mappedModel = account.GetMappedModel(req.Model) mappedModel = account.GetMappedModel(req.Model)
} }
@@ -374,7 +374,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
} }
switch account.Type { switch account.Type {
case AccountTypeAPIKey: case AccountTypeApiKey:
buildReq = func(ctx context.Context) (*http.Request, string, error) { buildReq = func(ctx context.Context) (*http.Request, string, error) {
apiKey := account.GetCredential("api_key") apiKey := account.GetCredential("api_key")
if strings.TrimSpace(apiKey) == "" { if strings.TrimSpace(apiKey) == "" {
@@ -539,7 +539,14 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
tempMatched := false
if s.rateLimitService != nil {
tempMatched = s.rateLimitService.HandleTempUnschedulable(ctx, account, resp.StatusCode, respBody)
}
s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
if tempMatched {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) { if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
} }
@@ -614,7 +621,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
} }
mappedModel := originalModel mappedModel := originalModel
if account.Type == AccountTypeAPIKey { if account.Type == AccountTypeApiKey {
mappedModel = account.GetMappedModel(originalModel) mappedModel = account.GetMappedModel(originalModel)
} }
@@ -636,7 +643,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
var buildReq func(ctx context.Context) (*http.Request, string, error) var buildReq func(ctx context.Context) (*http.Request, string, error)
switch account.Type { switch account.Type {
case AccountTypeAPIKey: case AccountTypeApiKey:
buildReq = func(ctx context.Context) (*http.Request, string, error) { buildReq = func(ctx context.Context) (*http.Request, string, error) {
apiKey := account.GetCredential("api_key") apiKey := account.GetCredential("api_key")
if strings.TrimSpace(apiKey) == "" { if strings.TrimSpace(apiKey) == "" {
@@ -825,6 +832,10 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
tempMatched := false
if s.rateLimitService != nil {
tempMatched = s.rateLimitService.HandleTempUnschedulable(ctx, account, resp.StatusCode, respBody)
}
s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
// Best-effort fallback for OAuth tokens missing AI Studio scopes when calling countTokens. // Best-effort fallback for OAuth tokens missing AI Studio scopes when calling countTokens.
@@ -842,6 +853,9 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}, nil }, nil
} }
if tempMatched {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) { if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
} }
@@ -1758,7 +1772,7 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac
} }
switch account.Type { switch account.Type {
case AccountTypeAPIKey: case AccountTypeApiKey:
apiKey := strings.TrimSpace(account.GetCredential("api_key")) apiKey := strings.TrimSpace(account.GetCredential("api_key"))
if apiKey == "" { if apiKey == "" {
return nil, errors.New("gemini api_key not configured") return nil, errors.New("gemini api_key not configured")
@@ -2177,10 +2191,12 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str
parts := make([]any, 0) parts := make([]any, 0)
switch content := mm["content"].(type) { switch content := mm["content"].(type) {
case string: case string:
if strings.TrimSpace(content) != "" { // 字符串形式的 content保留所有内容包括空白
parts = append(parts, map[string]any{"text": content}) parts = append(parts, map[string]any{"text": content})
}
case []any: case []any:
// 如果只有一个 block不过滤空白让上游 API 报错)
singleBlock := len(content) == 1
for _, block := range content { for _, block := range content {
bm, ok := block.(map[string]any) bm, ok := block.(map[string]any)
if !ok { if !ok {
@@ -2189,8 +2205,12 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str
bt, _ := bm["type"].(string) bt, _ := bm["type"].(string)
switch bt { switch bt {
case "text": case "text":
if text, ok := bm["text"].(string); ok && strings.TrimSpace(text) != "" { if text, ok := bm["text"].(string); ok {
parts = append(parts, map[string]any{"text": text}) // 单个 block 时保留所有内容(包括空白)
// 多个 blocks 时过滤掉空白
if singleBlock || strings.TrimSpace(text) != "" {
parts = append(parts, map[string]any{"text": text})
}
} }
case "tool_use": case "tool_use":
id, _ := bm["id"].(string) id, _ := bm["id"].(string)

View File

@@ -251,8 +251,20 @@ func inferGoogleOneTier(storageBytes int64) string {
return TierGoogleOneUnknown return TierGoogleOneUnknown
} }
// FetchGoogleOneTier fetches Google One tier from Drive API // fetchGoogleOneTier fetches Google One tier from Drive API or LoadCodeAssist API
func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken, proxyURL string) (string, *geminicli.DriveStorageInfo, error) { func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken, proxyURL string) (string, *geminicli.DriveStorageInfo, error) {
// First try LoadCodeAssist API (works for accounts with GCP projects)
if s.codeAssist != nil {
loadResp, err := s.codeAssist.LoadCodeAssist(ctx, accessToken, proxyURL, nil)
if err == nil && loadResp != nil {
if tier := loadResp.GetTier(); tier != "" {
fmt.Printf("[GeminiOAuth] Got tier from LoadCodeAssist: %s\n", tier)
return tier, nil, nil
}
}
}
// Fallback to Drive API (requires drive.readonly scope)
driveClient := geminicli.NewDriveClient() driveClient := geminicli.NewDriveClient()
storageInfo, err := driveClient.GetStorageQuota(ctx, accessToken, proxyURL) storageInfo, err := driveClient.GetStorageQuota(ctx, accessToken, proxyURL)
@@ -422,12 +434,15 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
} }
case "google_one": case "google_one":
// Attempt to fetch Drive storage tier // Attempt to fetch Drive storage tier
tierID, storageInfo, err := s.FetchGoogleOneTier(ctx, tokenResp.AccessToken, proxyURL) var storageInfo *geminicli.DriveStorageInfo
var err error
tierID, storageInfo, err = s.FetchGoogleOneTier(ctx, tokenResp.AccessToken, proxyURL)
if err != nil { if err != nil {
// Log warning but don't block - use fallback // Log warning but don't block - use fallback
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch Drive tier: %v\n", err) fmt.Printf("[GeminiOAuth] Warning: Failed to fetch Drive tier: %v\n", err)
tierID = TierGoogleOneUnknown tierID = TierGoogleOneUnknown
} }
fmt.Printf("[GeminiOAuth] Google One tierID after fetch: %s\n", tierID)
// Store Drive info in extra field for caching // Store Drive info in extra field for caching
if storageInfo != nil { if storageInfo != nil {
@@ -452,7 +467,7 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
} }
// ai_studio 模式不设置 tierID保持为空 // ai_studio 模式不设置 tierID保持为空
return &GeminiTokenInfo{ result := &GeminiTokenInfo{
AccessToken: tokenResp.AccessToken, AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken, RefreshToken: tokenResp.RefreshToken,
TokenType: tokenResp.TokenType, TokenType: tokenResp.TokenType,
@@ -462,7 +477,9 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
ProjectID: projectID, ProjectID: projectID,
TierID: tierID, TierID: tierID,
OAuthType: oauthType, OAuthType: oauthType,
}, nil }
fmt.Printf("[GeminiOAuth] ExchangeCode returning tierID: %s\n", result.TierID)
return result, nil
} }
func (s *GeminiOAuthService) RefreshToken(ctx context.Context, oauthType, refreshToken, proxyURL string) (*GeminiTokenInfo, error) { func (s *GeminiOAuthService) RefreshToken(ctx context.Context, oauthType, refreshToken, proxyURL string) (*GeminiTokenInfo, error) {
@@ -669,6 +686,9 @@ func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo)
// Validate tier_id before storing // Validate tier_id before storing
if err := validateTierID(tokenInfo.TierID); err == nil { if err := validateTierID(tokenInfo.TierID); err == nil {
creds["tier_id"] = tokenInfo.TierID creds["tier_id"] = tokenInfo.TierID
fmt.Printf("[GeminiOAuth] Storing tier_id: %s\n", tokenInfo.TierID)
} else {
fmt.Printf("[GeminiOAuth] Invalid tier_id %s: %v\n", tokenInfo.TierID, err)
} }
// Silently skip invalid tier_id (don't block account creation) // Silently skip invalid tier_id (don't block account creation)
} }
@@ -698,7 +718,13 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
// Extract tierID from response (works whether CloudAICompanionProject is set or not) // Extract tierID from response (works whether CloudAICompanionProject is set or not)
tierID := "LEGACY" tierID := "LEGACY"
if loadResp != nil { if loadResp != nil {
tierID = extractTierIDFromAllowedTiers(loadResp.AllowedTiers) // First try to get tier from currentTier/paidTier fields
if tier := loadResp.GetTier(); tier != "" {
tierID = tier
} else {
// Fallback to extracting from allowedTiers
tierID = extractTierIDFromAllowedTiers(loadResp.AllowedTiers)
}
} }
// If LoadCodeAssist returned a project, use it // If LoadCodeAssist returned a project, use it

View File

@@ -19,7 +19,7 @@ export interface GeminiOAuthCapabilities {
export interface GeminiAuthUrlRequest { export interface GeminiAuthUrlRequest {
proxy_id?: number proxy_id?: number
project_id?: string project_id?: string
oauth_type?: 'code_assist' | 'ai_studio' oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
} }
export interface GeminiExchangeCodeRequest { export interface GeminiExchangeCodeRequest {
@@ -27,10 +27,22 @@ export interface GeminiExchangeCodeRequest {
state: string state: string
code: string code: string
proxy_id?: number proxy_id?: number
oauth_type?: 'code_assist' | 'ai_studio' oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
} }
export type GeminiTokenInfo = Record<string, unknown> export type GeminiTokenInfo = {
access_token?: string
refresh_token?: string
token_type?: string
scope?: string
expires_in?: number
expires_at?: number
project_id?: string
oauth_type?: string
tier_id?: string
extra?: Record<string, unknown>
[key: string]: unknown
}
export async function generateAuthUrl( export async function generateAuthUrl(
payload: GeminiAuthUrlRequest payload: GeminiAuthUrlRequest

View File

@@ -93,7 +93,7 @@
<div v-else class="text-xs text-gray-400">-</div> <div v-else class="text-xs text-gray-400">-</div>
</template> </template>
<!-- Antigravity OAuth accounts: show quota from extra field --> <!-- Antigravity OAuth accounts: fetch usage from API -->
<template v-else-if="account.platform === 'antigravity' && account.type === 'oauth'"> <template v-else-if="account.platform === 'antigravity' && account.type === 'oauth'">
<!-- 账户类型徽章 --> <!-- 账户类型徽章 -->
<div v-if="antigravityTierLabel" class="mb-1 flex items-center gap-1"> <div v-if="antigravityTierLabel" class="mb-1 flex items-center gap-1">
@@ -129,40 +129,55 @@
</span> </span>
</div> </div>
<div v-if="hasAntigravityQuota" class="space-y-1"> <!-- Loading state -->
<div v-if="loading" class="space-y-1.5">
<div class="flex items-center gap-1">
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-xs text-red-500">
{{ error }}
</div>
<!-- Usage data from API -->
<div v-else-if="hasAntigravityQuotaFromAPI" class="space-y-1">
<!-- Gemini 3 Pro --> <!-- Gemini 3 Pro -->
<UsageProgressBar <UsageProgressBar
v-if="antigravity3ProUsage !== null" v-if="antigravity3ProUsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.gemini3Pro')" :label="t('admin.accounts.usageWindow.gemini3Pro')"
:utilization="antigravity3ProUsage.utilization" :utilization="antigravity3ProUsageFromAPI.utilization"
:resets-at="antigravity3ProUsage.resetTime" :resets-at="antigravity3ProUsageFromAPI.resetTime"
color="indigo" color="indigo"
/> />
<!-- Gemini 3 Flash --> <!-- Gemini 3 Flash -->
<UsageProgressBar <UsageProgressBar
v-if="antigravity3FlashUsage !== null" v-if="antigravity3FlashUsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.gemini3Flash')" :label="t('admin.accounts.usageWindow.gemini3Flash')"
:utilization="antigravity3FlashUsage.utilization" :utilization="antigravity3FlashUsageFromAPI.utilization"
:resets-at="antigravity3FlashUsage.resetTime" :resets-at="antigravity3FlashUsageFromAPI.resetTime"
color="emerald" color="emerald"
/> />
<!-- Gemini 3 Image --> <!-- Gemini 3 Image -->
<UsageProgressBar <UsageProgressBar
v-if="antigravity3ImageUsage !== null" v-if="antigravity3ImageUsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.gemini3Image')" :label="t('admin.accounts.usageWindow.gemini3Image')"
:utilization="antigravity3ImageUsage.utilization" :utilization="antigravity3ImageUsageFromAPI.utilization"
:resets-at="antigravity3ImageUsage.resetTime" :resets-at="antigravity3ImageUsageFromAPI.resetTime"
color="purple" color="purple"
/> />
<!-- Claude 4.5 --> <!-- Claude 4.5 -->
<UsageProgressBar <UsageProgressBar
v-if="antigravityClaude45Usage !== null" v-if="antigravityClaude45UsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.claude45')" :label="t('admin.accounts.usageWindow.claude45')"
:utilization="antigravityClaude45Usage.utilization" :utilization="antigravityClaude45UsageFromAPI.utilization"
:resets-at="antigravityClaude45Usage.resetTime" :resets-at="antigravityClaude45UsageFromAPI.resetTime"
color="amber" color="amber"
/> />
</div> </div>
@@ -171,17 +186,17 @@
<!-- Gemini platform: show quota + local usage window --> <!-- Gemini platform: show quota + local usage window -->
<template v-else-if="account.platform === 'gemini'"> <template v-else-if="account.platform === 'gemini'">
<!-- 账户类型徽章 --> <!-- Auth Type + Tier Badge (first line) -->
<div v-if="geminiTierLabel" class="mb-1 flex items-center gap-1"> <div v-if="geminiAuthTypeLabel" class="mb-1 flex items-center gap-1">
<span <span
:class="[ :class="[
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', 'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
geminiTierClass geminiTierClass
]" ]"
> >
{{ geminiTierLabel }} {{ geminiAuthTypeLabel }}
</span> </span>
<!-- 帮助图标 --> <!-- Help icon -->
<span <span
class="group relative cursor-help" class="group relative cursor-help"
> >
@@ -205,7 +220,7 @@
<div><strong>{{ geminiQuotaPolicyChannel }}:</strong></div> <div><strong>{{ geminiQuotaPolicyChannel }}:</strong></div>
<div class="pl-2">• {{ geminiQuotaPolicyLimits }}</div> <div class="pl-2">• {{ geminiQuotaPolicyLimits }}</div>
<div class="mt-2"> <div class="mt-2">
<a :href="geminiQuotaPolicyDocsUrl" target="_blank" class="text-blue-400 hover:text-blue-300 underline"> <a :href="geminiQuotaPolicyDocsUrl" target="_blank" rel="noopener noreferrer" class="text-blue-400 hover:text-blue-300 underline">
{{ t('admin.accounts.gemini.quotaPolicy.columns.docs') }} → {{ t('admin.accounts.gemini.quotaPolicy.columns.docs') }} →
</a> </a>
</div> </div>
@@ -295,6 +310,9 @@ const shouldFetchUsage = computed(() => {
if (props.account.platform === 'gemini') { if (props.account.platform === 'gemini') {
return props.account.type === 'oauth' return props.account.type === 'oauth'
} }
if (props.account.platform === 'antigravity') {
return props.account.type === 'oauth'
}
return false return false
}) })
@@ -453,45 +471,35 @@ const codex7dResetAt = computed(() => {
return null return null
}) })
// Antigravity quota types // Antigravity quota types (用于 API 返回的数据)
interface AntigravityModelQuota {
remaining: number // 剩余百分比 0-100
reset_time: string // ISO 8601 重置时间
}
interface AntigravityQuotaData {
[model: string]: AntigravityModelQuota
}
interface AntigravityUsageResult { interface AntigravityUsageResult {
utilization: number utilization: number
resetTime: string | null resetTime: string | null
} }
// Antigravity quota computed properties // ===== Antigravity quota from API (usageInfo.antigravity_quota) =====
const hasAntigravityQuota = computed(() => {
const extra = props.account.extra as Record<string, unknown> | undefined // 检查是否有从 API 获取的配额数据
return extra && typeof extra.quota === 'object' && extra.quota !== null const hasAntigravityQuotaFromAPI = computed(() => {
return usageInfo.value?.antigravity_quota && Object.keys(usageInfo.value.antigravity_quota).length > 0
}) })
// 从配额数据中获取使用率(多模型取最低剩余 = 最高使用) // 从 API 配额数据中获取使用率(多模型取最高使用
const getAntigravityUsage = ( const getAntigravityUsageFromAPI = (
modelNames: string[] modelNames: string[]
): AntigravityUsageResult | null => { ): AntigravityUsageResult | null => {
const extra = props.account.extra as Record<string, unknown> | undefined const quota = usageInfo.value?.antigravity_quota
if (!extra || typeof extra.quota !== 'object' || extra.quota === null) return null if (!quota) return null
const quota = extra.quota as AntigravityQuotaData let maxUtilization = 0
let minRemaining = 100
let earliestReset: string | null = null let earliestReset: string | null = null
for (const model of modelNames) { for (const model of modelNames) {
const modelQuota = quota[model] const modelQuota = quota[model]
if (!modelQuota) continue if (!modelQuota) continue
if (modelQuota.remaining < minRemaining) { if (modelQuota.utilization > maxUtilization) {
minRemaining = modelQuota.remaining maxUtilization = modelQuota.utilization
} }
if (modelQuota.reset_time) { if (modelQuota.reset_time) {
if (!earliestReset || modelQuota.reset_time < earliestReset) { if (!earliestReset || modelQuota.reset_time < earliestReset) {
@@ -501,32 +509,31 @@ const getAntigravityUsage = (
} }
// 如果没有找到任何匹配的模型 // 如果没有找到任何匹配的模型
if (minRemaining === 100 && earliestReset === null) { if (maxUtilization === 0 && earliestReset === null) {
// 检查是否至少有一个模型有数据
const hasAnyData = modelNames.some((m) => quota[m]) const hasAnyData = modelNames.some((m) => quota[m])
if (!hasAnyData) return null if (!hasAnyData) return null
} }
return { return {
utilization: 100 - minRemaining, utilization: maxUtilization,
resetTime: earliestReset resetTime: earliestReset
} }
} }
// Gemini 3 Pro: gemini-3-pro-low, gemini-3-pro-high, gemini-3-pro-preview // Gemini 3 Pro from API
const antigravity3ProUsage = computed(() => const antigravity3ProUsageFromAPI = computed(() =>
getAntigravityUsage(['gemini-3-pro-low', 'gemini-3-pro-high', 'gemini-3-pro-preview']) getAntigravityUsageFromAPI(['gemini-3-pro-low', 'gemini-3-pro-high', 'gemini-3-pro-preview'])
) )
// Gemini 3 Flash: gemini-3-flash // Gemini 3 Flash from API
const antigravity3FlashUsage = computed(() => getAntigravityUsage(['gemini-3-flash'])) const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-flash']))
// Gemini 3 Image: gemini-3-pro-image // Gemini 3 Image from API
const antigravity3ImageUsage = computed(() => getAntigravityUsage(['gemini-3-pro-image'])) const antigravity3ImageUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-pro-image']))
// Claude 4.5: claude-sonnet-4-5, claude-opus-4-5-thinking // Claude 4.5 from API
const antigravityClaude45Usage = computed(() => const antigravityClaude45UsageFromAPI = computed(() =>
getAntigravityUsage(['claude-sonnet-4-5', 'claude-opus-4-5-thinking']) getAntigravityUsageFromAPI(['claude-sonnet-4-5', 'claude-opus-4-5-thinking'])
) )
// Antigravity 账户类型(从 load_code_assist 响应中提取) // Antigravity 账户类型(从 load_code_assist 响应中提取)
@@ -565,40 +572,55 @@ const isGeminiCodeAssist = computed(() => {
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id) return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
}) })
// Gemini 账户类型显示标签 // Gemini 认证类型 + Tier 组合标签(简洁版)
const geminiTierLabel = computed(() => { const geminiAuthTypeLabel = computed(() => {
if (!geminiTier.value) return null
const creds = props.account.credentials as GeminiCredentials | undefined const creds = props.account.credentials as GeminiCredentials | undefined
const isGoogleOne = creds?.oauth_type === 'google_one' const oauthType = creds?.oauth_type
if (isGoogleOne) { // For API Key accounts, don't show auth type label
// Google One tier 标签 if (props.account.type !== 'oauth') return null
if (oauthType === 'google_one') {
// Google One: show "G1" + tier
const tierMap: Record<string, string> = { const tierMap: Record<string, string> = {
AI_PREMIUM: t('admin.accounts.tier.aiPremium'), AI_PREMIUM: 'AI Premium',
GOOGLE_ONE_STANDARD: t('admin.accounts.tier.standard'), GOOGLE_ONE_STANDARD: 'Standard',
GOOGLE_ONE_BASIC: t('admin.accounts.tier.basic'), GOOGLE_ONE_BASIC: 'Basic',
FREE: t('admin.accounts.tier.free'), FREE: 'Free',
GOOGLE_ONE_UNKNOWN: t('admin.accounts.tier.personal'), GOOGLE_ONE_UNKNOWN: 'Personal',
GOOGLE_ONE_UNLIMITED: t('admin.accounts.tier.unlimited') GOOGLE_ONE_UNLIMITED: 'Unlimited'
} }
return tierMap[geminiTier.value] || t('admin.accounts.tier.personal') const tierLabel = geminiTier.value ? tierMap[geminiTier.value] || 'Personal' : 'Personal'
return `G1 ${tierLabel}`
} else if (oauthType === 'code_assist' || (!oauthType && isGeminiCodeAssist.value)) {
// Code Assist: show "CLI" + tier
const tierMap: Record<string, string> = {
LEGACY: 'Free',
PRO: 'Pro',
ULTRA: 'Ultra'
}
const tierLabel = geminiTier.value ? tierMap[geminiTier.value] || 'Free' : 'Free'
return `CLI ${tierLabel}`
} else if (oauthType === 'ai_studio') {
// AI Studio: just show "AI Studio" (no tier)
return 'AI Studio'
} }
// Code Assist tier 标签 return null
const tierMap: Record<string, string> = {
LEGACY: t('admin.accounts.tier.free'),
PRO: t('admin.accounts.tier.pro'),
ULTRA: t('admin.accounts.tier.ultra')
}
return tierMap[geminiTier.value] || null
}) })
// Gemini 账户类型徽章样式 // Gemini 账户类型徽章样式
const geminiTierClass = computed(() => { const geminiTierClass = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
const oauthType = creds?.oauth_type
// AI Studio: use neutral gray color (no tier)
if (oauthType === 'ai_studio') {
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}
if (!geminiTier.value) return '' if (!geminiTier.value) return ''
const creds = props.account.credentials as GeminiCredentials | undefined
const isGoogleOne = creds?.oauth_type === 'google_one' const isGoogleOne = creds?.oauth_type === 'google_one'
if (isGoogleOne) { if (isGoogleOne) {

View File

@@ -111,12 +111,12 @@ const displayPercent = computed(() => {
// Format reset time // Format reset time
const formatResetTime = computed(() => { const formatResetTime = computed(() => {
if (!props.resetsAt) return 'N/A' if (!props.resetsAt) return t('common.notAvailable')
const date = new Date(props.resetsAt) const date = new Date(props.resetsAt)
const now = new Date() const now = new Date()
const diffMs = date.getTime() - now.getTime() const diffMs = date.getTime() - now.getTime()
if (diffMs <= 0) return 'Now' if (diffMs <= 0) return t('common.now')
const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))

View File

@@ -12,6 +12,8 @@ export interface GeminiTokenInfo {
expires_at?: number | string expires_at?: number | string
project_id?: string project_id?: string
oauth_type?: string oauth_type?: string
tier_id?: string
extra?: Record<string, unknown>
[key: string]: unknown [key: string]: unknown
} }
@@ -122,10 +124,16 @@ export function useGeminiOAuth() {
expires_at: expiresAt, expires_at: expiresAt,
scope: tokenInfo.scope, scope: tokenInfo.scope,
project_id: tokenInfo.project_id, project_id: tokenInfo.project_id,
oauth_type: tokenInfo.oauth_type oauth_type: tokenInfo.oauth_type,
tier_id: tokenInfo.tier_id
} }
} }
const buildExtraInfo = (tokenInfo: GeminiTokenInfo): Record<string, unknown> | undefined => {
if (!tokenInfo.extra || typeof tokenInfo.extra !== 'object') return undefined
return tokenInfo.extra
}
const getCapabilities = async (): Promise<GeminiOAuthCapabilities | null> => { const getCapabilities = async (): Promise<GeminiOAuthCapabilities | null> => {
try { try {
return await adminAPI.gemini.getCapabilities() return await adminAPI.gemini.getCapabilities()
@@ -145,6 +153,7 @@ export function useGeminiOAuth() {
generateAuthUrl, generateAuthUrl,
exchangeAuthCode, exchangeAuthCode,
buildCredentials, buildCredentials,
buildExtraInfo,
getCapabilities getCapabilities
} }
} }