feat(gemini): 优化 OAuth 和配额展示
主要改进: - 修复 google_one OAuth scopes 配置问题 - 添加 Gemini 账号配额展示组件 - 优化 Code Assist 类型检测逻辑 - 添加 OAuth 测试用例
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
113
backend/internal/pkg/geminicli/oauth_test.go
Normal file
113
backend/internal/pkg/geminicli/oauth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user