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}
}
// GetCapabilities retrieves OAuth configuration capabilities.
// GET /api/v1/admin/gemini/oauth/capabilities
func (h *GeminiOAuthHandler) GetCapabilities(c *gin.Context) {
cfg := h.geminiOAuthService.GetOAuthConfig()

View File

@@ -1,5 +1,10 @@
package geminicli
import (
"bytes"
"encoding/json"
)
// LoadCodeAssistRequest matches done-hub's internal Code Assist call.
type LoadCodeAssistRequest struct {
Metadata LoadCodeAssistMetadata `json:"metadata"`
@@ -11,12 +16,51 @@ type LoadCodeAssistMetadata struct {
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 {
CurrentTier string `json:"currentTier,omitempty"`
CurrentTier *TierInfo `json:"currentTier,omitempty"`
PaidTier *TierInfo `json:"paidTier,omitempty"`
CloudAICompanionProject string `json:"cloudaicompanionProject,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 {
ID string `json:"id"`
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
import "time"
@@ -29,7 +27,9 @@ const (
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)
// 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"
// 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
}
case "google_one":
// Google One accounts need generative-language scope for Gemini API access
// and drive.readonly scope for storage tier detection
effective.Scopes = DefaultGoogleOneScopes
// Google One uses built-in Gemini CLI client (same as code_assist)
// Built-in client can't request restricted scopes like generative-language.retriever
if isBuiltinClient {
effective.Scopes = DefaultCodeAssistScopes
} else {
effective.Scopes = DefaultGoogleOneScopes
}
default:
// Default to Code Assist scopes
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.
parts := strings.Fields(effective.Scopes)
filtered := make([]string, 0, len(parts))
for _, s := range parts {
if strings.Contains(s, "generative-language") {
if hasRestrictedScope(s) {
continue
}
filtered = append(filtered, s)
@@ -219,6 +223,11 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error
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) {
effectiveCfg, err := EffectiveOAuthConfig(cfg, oauthType)
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
}
switch a.Type {
case AccountTypeAPIKey:
case AccountTypeApiKey:
if strings.TrimSpace(a.GetCredential("api_key")) != "" {
return 0
}
@@ -351,7 +351,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
originalModel := req.Model
mappedModel := req.Model
if account.Type == AccountTypeAPIKey {
if account.Type == AccountTypeApiKey {
mappedModel = account.GetMappedModel(req.Model)
}
@@ -374,7 +374,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
}
switch account.Type {
case AccountTypeAPIKey:
case AccountTypeApiKey:
buildReq = func(ctx context.Context) (*http.Request, string, error) {
apiKey := account.GetCredential("api_key")
if strings.TrimSpace(apiKey) == "" {
@@ -539,7 +539,14 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
if resp.StatusCode >= 400 {
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)
if tempMatched {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
@@ -614,7 +621,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}
mappedModel := originalModel
if account.Type == AccountTypeAPIKey {
if account.Type == AccountTypeApiKey {
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)
switch account.Type {
case AccountTypeAPIKey:
case AccountTypeApiKey:
buildReq = func(ctx context.Context) (*http.Request, string, error) {
apiKey := account.GetCredential("api_key")
if strings.TrimSpace(apiKey) == "" {
@@ -825,6 +832,10 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
if resp.StatusCode >= 400 {
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)
// 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
}
if tempMatched {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
@@ -1758,7 +1772,7 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac
}
switch account.Type {
case AccountTypeAPIKey:
case AccountTypeApiKey:
apiKey := strings.TrimSpace(account.GetCredential("api_key"))
if apiKey == "" {
return nil, errors.New("gemini api_key not configured")
@@ -2177,10 +2191,12 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str
parts := make([]any, 0)
switch content := mm["content"].(type) {
case string:
if strings.TrimSpace(content) != "" {
parts = append(parts, map[string]any{"text": content})
}
// 字符串形式的 content保留所有内容包括空白
parts = append(parts, map[string]any{"text": content})
case []any:
// 如果只有一个 block不过滤空白让上游 API 报错)
singleBlock := len(content) == 1
for _, block := range content {
bm, ok := block.(map[string]any)
if !ok {
@@ -2189,8 +2205,12 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str
bt, _ := bm["type"].(string)
switch bt {
case "text":
if text, ok := bm["text"].(string); ok && strings.TrimSpace(text) != "" {
parts = append(parts, map[string]any{"text": text})
if text, ok := bm["text"].(string); ok {
// 单个 block 时保留所有内容(包括空白)
// 多个 blocks 时过滤掉空白
if singleBlock || strings.TrimSpace(text) != "" {
parts = append(parts, map[string]any{"text": text})
}
}
case "tool_use":
id, _ := bm["id"].(string)

View File

@@ -251,8 +251,20 @@ func inferGoogleOneTier(storageBytes int64) string {
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) {
// 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()
storageInfo, err := driveClient.GetStorageQuota(ctx, accessToken, proxyURL)
@@ -422,12 +434,15 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
}
case "google_one":
// 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 {
// Log warning but don't block - use fallback
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch Drive tier: %v\n", err)
tierID = TierGoogleOneUnknown
}
fmt.Printf("[GeminiOAuth] Google One tierID after fetch: %s\n", tierID)
// Store Drive info in extra field for caching
if storageInfo != nil {
@@ -452,7 +467,7 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
}
// ai_studio 模式不设置 tierID保持为空
return &GeminiTokenInfo{
result := &GeminiTokenInfo{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
TokenType: tokenResp.TokenType,
@@ -462,7 +477,9 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
ProjectID: projectID,
TierID: tierID,
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) {
@@ -669,6 +686,9 @@ func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo)
// Validate tier_id before storing
if err := validateTierID(tokenInfo.TierID); err == nil {
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)
}
@@ -698,7 +718,13 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
// Extract tierID from response (works whether CloudAICompanionProject is set or not)
tierID := "LEGACY"
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

View File

@@ -19,7 +19,7 @@ export interface GeminiOAuthCapabilities {
export interface GeminiAuthUrlRequest {
proxy_id?: number
project_id?: string
oauth_type?: 'code_assist' | 'ai_studio'
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
}
export interface GeminiExchangeCodeRequest {
@@ -27,10 +27,22 @@ export interface GeminiExchangeCodeRequest {
state: string
code: string
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(
payload: GeminiAuthUrlRequest

View File

@@ -93,7 +93,7 @@
<div v-else class="text-xs text-gray-400">-</div>
</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'">
<!-- 账户类型徽章 -->
<div v-if="antigravityTierLabel" class="mb-1 flex items-center gap-1">
@@ -129,40 +129,55 @@
</span>
</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 -->
<UsageProgressBar
v-if="antigravity3ProUsage !== null"
v-if="antigravity3ProUsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.gemini3Pro')"
:utilization="antigravity3ProUsage.utilization"
:resets-at="antigravity3ProUsage.resetTime"
:utilization="antigravity3ProUsageFromAPI.utilization"
:resets-at="antigravity3ProUsageFromAPI.resetTime"
color="indigo"
/>
<!-- Gemini 3 Flash -->
<UsageProgressBar
v-if="antigravity3FlashUsage !== null"
v-if="antigravity3FlashUsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.gemini3Flash')"
:utilization="antigravity3FlashUsage.utilization"
:resets-at="antigravity3FlashUsage.resetTime"
:utilization="antigravity3FlashUsageFromAPI.utilization"
:resets-at="antigravity3FlashUsageFromAPI.resetTime"
color="emerald"
/>
<!-- Gemini 3 Image -->
<UsageProgressBar
v-if="antigravity3ImageUsage !== null"
v-if="antigravity3ImageUsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.gemini3Image')"
:utilization="antigravity3ImageUsage.utilization"
:resets-at="antigravity3ImageUsage.resetTime"
:utilization="antigravity3ImageUsageFromAPI.utilization"
:resets-at="antigravity3ImageUsageFromAPI.resetTime"
color="purple"
/>
<!-- Claude 4.5 -->
<UsageProgressBar
v-if="antigravityClaude45Usage !== null"
v-if="antigravityClaude45UsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.claude45')"
:utilization="antigravityClaude45Usage.utilization"
:resets-at="antigravityClaude45Usage.resetTime"
:utilization="antigravityClaude45UsageFromAPI.utilization"
:resets-at="antigravityClaude45UsageFromAPI.resetTime"
color="amber"
/>
</div>
@@ -171,17 +186,17 @@
<!-- Gemini platform: show quota + local usage window -->
<template v-else-if="account.platform === 'gemini'">
<!-- 账户类型徽章 -->
<div v-if="geminiTierLabel" class="mb-1 flex items-center gap-1">
<!-- Auth Type + Tier Badge (first line) -->
<div v-if="geminiAuthTypeLabel" class="mb-1 flex items-center gap-1">
<span
:class="[
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
geminiTierClass
]"
>
{{ geminiTierLabel }}
{{ geminiAuthTypeLabel }}
</span>
<!-- 帮助图标 -->
<!-- Help icon -->
<span
class="group relative cursor-help"
>
@@ -205,7 +220,7 @@
<div><strong>{{ geminiQuotaPolicyChannel }}:</strong></div>
<div class="pl-2">• {{ geminiQuotaPolicyLimits }}</div>
<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') }} →
</a>
</div>
@@ -295,6 +310,9 @@ const shouldFetchUsage = computed(() => {
if (props.account.platform === 'gemini') {
return props.account.type === 'oauth'
}
if (props.account.platform === 'antigravity') {
return props.account.type === 'oauth'
}
return false
})
@@ -453,45 +471,35 @@ const codex7dResetAt = computed(() => {
return null
})
// Antigravity quota types
interface AntigravityModelQuota {
remaining: number // 剩余百分比 0-100
reset_time: string // ISO 8601 重置时间
}
interface AntigravityQuotaData {
[model: string]: AntigravityModelQuota
}
// Antigravity quota types (用于 API 返回的数据)
interface AntigravityUsageResult {
utilization: number
resetTime: string | null
}
// Antigravity quota computed properties
const hasAntigravityQuota = computed(() => {
const extra = props.account.extra as Record<string, unknown> | undefined
return extra && typeof extra.quota === 'object' && extra.quota !== null
// ===== Antigravity quota from API (usageInfo.antigravity_quota) =====
// 检查是否有从 API 获取的配额数据
const hasAntigravityQuotaFromAPI = computed(() => {
return usageInfo.value?.antigravity_quota && Object.keys(usageInfo.value.antigravity_quota).length > 0
})
// 从配额数据中获取使用率(多模型取最低剩余 = 最高使用)
const getAntigravityUsage = (
// 从 API 配额数据中获取使用率(多模型取最高使用
const getAntigravityUsageFromAPI = (
modelNames: string[]
): AntigravityUsageResult | null => {
const extra = props.account.extra as Record<string, unknown> | undefined
if (!extra || typeof extra.quota !== 'object' || extra.quota === null) return null
const quota = usageInfo.value?.antigravity_quota
if (!quota) return null
const quota = extra.quota as AntigravityQuotaData
let minRemaining = 100
let maxUtilization = 0
let earliestReset: string | null = null
for (const model of modelNames) {
const modelQuota = quota[model]
if (!modelQuota) continue
if (modelQuota.remaining < minRemaining) {
minRemaining = modelQuota.remaining
if (modelQuota.utilization > maxUtilization) {
maxUtilization = modelQuota.utilization
}
if (modelQuota.reset_time) {
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])
if (!hasAnyData) return null
}
return {
utilization: 100 - minRemaining,
utilization: maxUtilization,
resetTime: earliestReset
}
}
// Gemini 3 Pro: gemini-3-pro-low, gemini-3-pro-high, gemini-3-pro-preview
const antigravity3ProUsage = computed(() =>
getAntigravityUsage(['gemini-3-pro-low', 'gemini-3-pro-high', 'gemini-3-pro-preview'])
// Gemini 3 Pro from API
const antigravity3ProUsageFromAPI = computed(() =>
getAntigravityUsageFromAPI(['gemini-3-pro-low', 'gemini-3-pro-high', 'gemini-3-pro-preview'])
)
// Gemini 3 Flash: gemini-3-flash
const antigravity3FlashUsage = computed(() => getAntigravityUsage(['gemini-3-flash']))
// Gemini 3 Flash from API
const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-flash']))
// Gemini 3 Image: gemini-3-pro-image
const antigravity3ImageUsage = computed(() => getAntigravityUsage(['gemini-3-pro-image']))
// Gemini 3 Image from API
const antigravity3ImageUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-pro-image']))
// Claude 4.5: claude-sonnet-4-5, claude-opus-4-5-thinking
const antigravityClaude45Usage = computed(() =>
getAntigravityUsage(['claude-sonnet-4-5', 'claude-opus-4-5-thinking'])
// Claude 4.5 from API
const antigravityClaude45UsageFromAPI = computed(() =>
getAntigravityUsageFromAPI(['claude-sonnet-4-5', 'claude-opus-4-5-thinking'])
)
// Antigravity 账户类型(从 load_code_assist 响应中提取)
@@ -565,40 +572,55 @@ const isGeminiCodeAssist = computed(() => {
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
})
// Gemini 账户类型显示标签
const geminiTierLabel = computed(() => {
if (!geminiTier.value) return null
// Gemini 认证类型 + Tier 组合标签(简洁版)
const geminiAuthTypeLabel = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
const isGoogleOne = creds?.oauth_type === 'google_one'
const oauthType = creds?.oauth_type
if (isGoogleOne) {
// Google One tier 标签
// For API Key accounts, don't show auth type label
if (props.account.type !== 'oauth') return null
if (oauthType === 'google_one') {
// Google One: show "G1" + tier
const tierMap: Record<string, string> = {
AI_PREMIUM: t('admin.accounts.tier.aiPremium'),
GOOGLE_ONE_STANDARD: t('admin.accounts.tier.standard'),
GOOGLE_ONE_BASIC: t('admin.accounts.tier.basic'),
FREE: t('admin.accounts.tier.free'),
GOOGLE_ONE_UNKNOWN: t('admin.accounts.tier.personal'),
GOOGLE_ONE_UNLIMITED: t('admin.accounts.tier.unlimited')
AI_PREMIUM: 'AI Premium',
GOOGLE_ONE_STANDARD: 'Standard',
GOOGLE_ONE_BASIC: 'Basic',
FREE: 'Free',
GOOGLE_ONE_UNKNOWN: 'Personal',
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 标签
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
return null
})
// Gemini 账户类型徽章样式
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 ''
const creds = props.account.credentials as GeminiCredentials | undefined
const isGoogleOne = creds?.oauth_type === 'google_one'
if (isGoogleOne) {

View File

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

View File

@@ -12,6 +12,8 @@ export interface GeminiTokenInfo {
expires_at?: number | string
project_id?: string
oauth_type?: string
tier_id?: string
extra?: Record<string, unknown>
[key: string]: unknown
}
@@ -122,10 +124,16 @@ export function useGeminiOAuth() {
expires_at: expiresAt,
scope: tokenInfo.scope,
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> => {
try {
return await adminAPI.gemini.getCapabilities()
@@ -145,6 +153,7 @@ export function useGeminiOAuth() {
generateAuthUrl,
exchangeAuthCode,
buildCredentials,
buildExtraInfo,
getCapabilities
}
}