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}
|
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()
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user