diff --git a/.gitignore b/.gitignore index d64676dd..5e9296f9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ coverage.html # 依赖(使用 go mod) vendor/ +# Go 编译缓存 +backend/.gocache/ + # =================== # Node.js / Vue 前端 # =================== diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 7d6ec065..596c8516 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -69,6 +69,7 @@ func provideCleanup( emailQueue *service.EmailQueueService, oauth *service.OAuthService, openaiOAuth *service.OpenAIOAuthService, + geminiOAuth *service.GeminiOAuthService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -99,6 +100,10 @@ func provideCleanup( openaiOAuth.Stop() return nil }}, + {"GeminiOAuthService", func() error { + geminiOAuth.Stop() + return nil + }}, {"Redis", func() error { return rdb.Close() }}, diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 827526e5..bde3271b 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -80,17 +80,23 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient) openAIOAuthClient := repository.NewOpenAIOAuthClient() openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient) + geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) + geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() + geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig) rateLimitService := service.NewRateLimitService(accountRepository, configConfig) claudeUsageFetcher := repository.NewClaudeUsageFetcher() accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher) + geminiTokenCache := repository.NewGeminiTokenCache(client) + geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService) httpUpstream := repository.NewHTTPUpstream(configConfig) - accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream) + accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, geminiTokenProvider, httpUpstream) concurrencyCache := repository.NewConcurrencyCache(client) concurrencyService := service.NewConcurrencyService(concurrencyCache) - crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService) - accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) + crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService) + accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) + geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) proxyHandler := admin.NewProxyHandler(adminService) adminRedeemHandler := admin.NewRedeemHandler(adminService) settingHandler := admin.NewSettingHandler(settingService, emailService) @@ -101,7 +107,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { systemHandler := handler.ProvideSystemHandler(updateService) adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService) adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler) gatewayCache := repository.NewGatewayCache(client) pricingRemoteClient := repository.NewPricingRemoteClient() pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient) @@ -112,7 +118,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { identityCache := repository.NewIdentityCache(client) identityService := service.NewIdentityService(identityCache) gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream) - gatewayHandler := handler.NewGatewayHandler(gatewayService, userService, concurrencyService, billingCacheService) + geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream) + gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, userService, concurrencyService, billingCacheService) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) @@ -120,10 +127,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService) - engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware) + engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService) httpServer := server.ProvideHTTPServer(configConfig, engine) - tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, configConfig) - v := provideCleanup(db, client, tokenRefreshService, pricingService, emailQueueService, oAuthService, openAIOAuthService) + tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) + v := provideCleanup(db, client, tokenRefreshService, pricingService, emailQueueService, oAuthService, openAIOAuthService, geminiOAuthService) application := &Application{ Server: httpServer, Cleanup: v, @@ -153,6 +160,7 @@ func provideCleanup( emailQueue *service.EmailQueueService, oauth *service.OAuthService, openaiOAuth *service.OpenAIOAuthService, + geminiOAuth *service.GeminiOAuthService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -182,6 +190,10 @@ func provideCleanup( openaiOAuth.Stop() return nil }}, + {"GeminiOAuthService", func() error { + geminiOAuth.Stop() + return nil + }}, {"Redis", func() error { return rdb.Close() }}, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 18fb162d..485ed42d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -18,6 +18,17 @@ type Config struct { Gateway GatewayConfig `mapstructure:"gateway"` TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"` Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" + Gemini GeminiConfig `mapstructure:"gemini"` +} + +type GeminiConfig struct { + OAuth GeminiOAuthConfig `mapstructure:"oauth"` +} + +type GeminiOAuthConfig struct { + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + Scopes string `mapstructure:"scopes"` } // TokenRefreshConfig OAuth token自动刷新配置 @@ -211,9 +222,16 @@ func setDefaults() { // TokenRefresh viper.SetDefault("token_refresh.enabled", true) viper.SetDefault("token_refresh.check_interval_minutes", 5) // 每5分钟检查一次 - viper.SetDefault("token_refresh.refresh_before_expiry_hours", 1.5) // 提前1.5小时刷新 + viper.SetDefault("token_refresh.refresh_before_expiry_hours", 0.5) // 提前30分钟刷新(适配Google 1小时token) viper.SetDefault("token_refresh.max_retries", 3) // 最多重试3次 viper.SetDefault("token_refresh.retry_backoff_seconds", 2) // 重试退避基础2秒 + + // Gemini OAuth - configure via environment variables or config file + // GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET + // Default: uses Gemini CLI public credentials (set via environment) + viper.SetDefault("gemini.oauth.client_id", "") + viper.SetDefault("gemini.oauth.client_secret", "") + viper.SetDefault("gemini.oauth.scopes", "") } func (c *Config) Validate() error { diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index af7f8eff..5fa2f4e1 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -2,9 +2,11 @@ package admin import ( "strconv" + "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" @@ -30,6 +32,7 @@ type AccountHandler struct { adminService service.AdminService oauthService *service.OAuthService openaiOAuthService *service.OpenAIOAuthService + geminiOAuthService *service.GeminiOAuthService rateLimitService *service.RateLimitService accountUsageService *service.AccountUsageService accountTestService *service.AccountTestService @@ -42,6 +45,7 @@ func NewAccountHandler( adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, + geminiOAuthService *service.GeminiOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, @@ -52,6 +56,7 @@ func NewAccountHandler( adminService: adminService, oauthService: oauthService, openaiOAuthService: openaiOAuthService, + geminiOAuthService: geminiOAuthService, rateLimitService: rateLimitService, accountUsageService: accountUsageService, accountTestService: accountTestService, @@ -345,6 +350,19 @@ func (h *AccountHandler) Refresh(c *gin.Context) { newCredentials[k] = v } } + } else if account.Platform == service.PlatformGemini { + tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(c.Request.Context(), account) + if err != nil { + response.InternalError(c, "Failed to refresh credentials: "+err.Error()) + return + } + + newCredentials = h.geminiOAuthService.BuildAccountCredentials(tokenInfo) + for k, v := range account.Credentials { + if _, exists := newCredentials[k]; !exists { + newCredentials[k] = v + } + } } else { // Use Anthropic/Claude OAuth service to refresh token tokenInfo, err := h.oauthService.RefreshAccountToken(c.Request.Context(), account) @@ -362,10 +380,14 @@ func (h *AccountHandler) Refresh(c *gin.Context) { // Update token-related fields newCredentials["access_token"] = tokenInfo.AccessToken newCredentials["token_type"] = tokenInfo.TokenType - newCredentials["expires_in"] = tokenInfo.ExpiresIn - newCredentials["expires_at"] = tokenInfo.ExpiresAt - newCredentials["refresh_token"] = tokenInfo.RefreshToken - newCredentials["scope"] = tokenInfo.Scope + newCredentials["expires_in"] = strconv.FormatInt(tokenInfo.ExpiresIn, 10) + newCredentials["expires_at"] = strconv.FormatInt(tokenInfo.ExpiresAt, 10) + if strings.TrimSpace(tokenInfo.RefreshToken) != "" { + newCredentials["refresh_token"] = tokenInfo.RefreshToken + } + if strings.TrimSpace(tokenInfo.Scope) != "" { + newCredentials["scope"] = tokenInfo.Scope + } } updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{ @@ -858,6 +880,44 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { return } + // Handle Gemini accounts + if account.IsGemini() { + // For OAuth accounts: return default Gemini models + if account.IsOAuth() { + response.Success(c, geminicli.DefaultModels) + return + } + + // For API Key accounts: return models based on model_mapping + mapping := account.GetModelMapping() + if len(mapping) == 0 { + response.Success(c, geminicli.DefaultModels) + return + } + + var models []geminicli.Model + for requestedModel := range mapping { + var found bool + for _, dm := range geminicli.DefaultModels { + if dm.ID == requestedModel { + models = append(models, dm) + found = true + break + } + } + if !found { + models = append(models, geminicli.Model{ + ID: requestedModel, + Type: "model", + DisplayName: requestedModel, + CreatedAt: "", + }) + } + } + response.Success(c, models) + return + } + // Handle Claude/Anthropic accounts // For OAuth and Setup-Token accounts: return default models if account.IsOAuth() { diff --git a/backend/internal/handler/admin/gemini_oauth_handler.go b/backend/internal/handler/admin/gemini_oauth_handler.go new file mode 100644 index 00000000..4440aa21 --- /dev/null +++ b/backend/internal/handler/admin/gemini_oauth_handler.go @@ -0,0 +1,135 @@ +package admin + +import ( + "fmt" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +type GeminiOAuthHandler struct { + geminiOAuthService *service.GeminiOAuthService +} + +func NewGeminiOAuthHandler(geminiOAuthService *service.GeminiOAuthService) *GeminiOAuthHandler { + return &GeminiOAuthHandler{geminiOAuthService: geminiOAuthService} +} + +// GET /api/v1/admin/gemini/oauth/capabilities +func (h *GeminiOAuthHandler) GetCapabilities(c *gin.Context) { + cfg := h.geminiOAuthService.GetOAuthConfig() + response.Success(c, cfg) +} + +type GeminiGenerateAuthURLRequest struct { + ProxyID *int64 `json:"proxy_id"` + ProjectID string `json:"project_id"` + // OAuth 类型: "code_assist" (需要 project_id) 或 "ai_studio" (不需要 project_id) + // 默认为 "code_assist" 以保持向后兼容 + OAuthType string `json:"oauth_type"` +} + +// GenerateAuthURL generates Google OAuth authorization URL for Gemini. +// POST /api/v1/admin/gemini/oauth/auth-url +func (h *GeminiOAuthHandler) GenerateAuthURL(c *gin.Context) { + var req GeminiGenerateAuthURLRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // 默认使用 code_assist 以保持向后兼容 + oauthType := strings.TrimSpace(req.OAuthType) + if oauthType == "" { + oauthType = "code_assist" + } + if oauthType != "code_assist" && oauthType != "ai_studio" { + response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'") + return + } + + // Always pass the "hosted" callback URI; the OAuth service may override it depending on + // oauth_type and whether the built-in Gemini CLI OAuth client is used. + redirectURI := deriveGeminiRedirectURI(c) + result, err := h.geminiOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, redirectURI, req.ProjectID, oauthType) + if err != nil { + msg := err.Error() + // Treat missing/invalid OAuth client configuration as a user/config error. + if strings.Contains(msg, "OAuth client not configured") || strings.Contains(msg, "requires your own OAuth Client") { + response.BadRequest(c, "Failed to generate auth URL: "+msg) + return + } + response.InternalError(c, "Failed to generate auth URL: "+msg) + return + } + + response.Success(c, result) +} + +type GeminiExchangeCodeRequest struct { + SessionID string `json:"session_id" binding:"required"` + State string `json:"state" binding:"required"` + Code string `json:"code" binding:"required"` + ProxyID *int64 `json:"proxy_id"` + // OAuth 类型: "code_assist" 或 "ai_studio",需要与 GenerateAuthURL 时的类型一致 + OAuthType string `json:"oauth_type"` +} + +// ExchangeCode exchanges authorization code for tokens. +// POST /api/v1/admin/gemini/oauth/exchange-code +func (h *GeminiOAuthHandler) ExchangeCode(c *gin.Context) { + var req GeminiExchangeCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // 默认使用 code_assist 以保持向后兼容 + oauthType := strings.TrimSpace(req.OAuthType) + if oauthType == "" { + oauthType = "code_assist" + } + if oauthType != "code_assist" && oauthType != "ai_studio" { + response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'") + return + } + + tokenInfo, err := h.geminiOAuthService.ExchangeCode(c.Request.Context(), &service.GeminiExchangeCodeInput{ + SessionID: req.SessionID, + State: req.State, + Code: req.Code, + ProxyID: req.ProxyID, + OAuthType: oauthType, + }) + if err != nil { + response.BadRequest(c, "Failed to exchange code: "+err.Error()) + return + } + + response.Success(c, tokenInfo) +} + +func deriveGeminiRedirectURI(c *gin.Context) string { + origin := strings.TrimSpace(c.GetHeader("Origin")) + if origin != "" { + return strings.TrimRight(origin, "/") + "/auth/callback" + } + + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + if xfProto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); xfProto != "" { + scheme = strings.TrimSpace(strings.Split(xfProto, ",")[0]) + } + + host := strings.TrimSpace(c.Request.Host) + if xfHost := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); xfHost != "" { + host = strings.TrimSpace(strings.Split(xfHost, ",")[0]) + } + + return fmt.Sprintf("%s://%s/auth/callback", scheme, host) +} diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index b281aef2..afb1c572 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -21,15 +21,23 @@ import ( // GatewayHandler handles API gateway requests type GatewayHandler struct { gatewayService *service.GatewayService + geminiCompatService *service.GeminiMessagesCompatService userService *service.UserService billingCacheService *service.BillingCacheService concurrencyHelper *ConcurrencyHelper } // NewGatewayHandler creates a new GatewayHandler -func NewGatewayHandler(gatewayService *service.GatewayService, userService *service.UserService, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService) *GatewayHandler { +func NewGatewayHandler( + gatewayService *service.GatewayService, + geminiCompatService *service.GeminiMessagesCompatService, + userService *service.UserService, + concurrencyService *service.ConcurrencyService, + billingCacheService *service.BillingCacheService, +) *GatewayHandler { return &GatewayHandler{ gatewayService: gatewayService, + geminiCompatService: geminiCompatService, userService: userService, billingCacheService: billingCacheService, concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude), @@ -114,8 +122,18 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // 计算粘性会话hash sessionHash := h.gatewayService.GenerateSessionHash(body) + platform := "" + if apiKey.Group != nil { + platform = apiKey.Group.Platform + } + // 选择支持该模型的账号 - account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model) + var account *service.Account + if platform == service.PlatformGemini { + account, err = h.geminiCompatService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model) + } else { + account, err = h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model) + } if err != nil { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted) return @@ -143,7 +161,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } // 转发请求 - result, err := h.gatewayService.Forward(c.Request.Context(), c, account, body) + var result *service.ForwardResult + if platform == service.PlatformGemini { + result, err = h.geminiCompatService.Forward(c.Request.Context(), c, account, body) + } else { + result, err = h.gatewayService.Forward(c.Request.Context(), c, account, body) + } if err != nil { // 错误响应已在Forward中处理,这里只记录日志 log.Printf("Forward request failed: %v", err) diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go new file mode 100644 index 00000000..6a9e2e15 --- /dev/null +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -0,0 +1,272 @@ +package handler + +import ( + "context" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/gemini" + "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// GeminiV1BetaListModels proxies: +// GET /v1beta/models +func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { + apiKey, ok := middleware.GetApiKeyFromContext(c) + if !ok || apiKey == nil { + googleError(c, http.StatusUnauthorized, "Invalid API key") + return + } + if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGemini { + googleError(c, http.StatusBadRequest, "API key group platform is not gemini") + return + } + + account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID) + if err != nil { + googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error()) + return + } + + res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models") + if err != nil { + googleError(c, http.StatusBadGateway, err.Error()) + return + } + if shouldFallbackGeminiModels(res) { + c.JSON(http.StatusOK, gemini.FallbackModelsList()) + return + } + writeUpstreamResponse(c, res) +} + +// GeminiV1BetaGetModel proxies: +// GET /v1beta/models/{model} +func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { + apiKey, ok := middleware.GetApiKeyFromContext(c) + if !ok || apiKey == nil { + googleError(c, http.StatusUnauthorized, "Invalid API key") + return + } + if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGemini { + googleError(c, http.StatusBadRequest, "API key group platform is not gemini") + return + } + + modelName := strings.TrimSpace(c.Param("model")) + if modelName == "" { + googleError(c, http.StatusBadRequest, "Missing model in URL") + return + } + + account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID) + if err != nil { + googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error()) + return + } + + res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models/"+modelName) + if err != nil { + googleError(c, http.StatusBadGateway, err.Error()) + return + } + if shouldFallbackGeminiModels(res) { + c.JSON(http.StatusOK, gemini.FallbackModel(modelName)) + return + } + writeUpstreamResponse(c, res) +} + +// GeminiV1BetaModels proxies Gemini native REST endpoints like: +// POST /v1beta/models/{model}:generateContent +// POST /v1beta/models/{model}:streamGenerateContent?alt=sse +func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { + apiKey, ok := middleware.GetApiKeyFromContext(c) + if !ok || apiKey == nil { + googleError(c, http.StatusUnauthorized, "Invalid API key") + return + } + authSubject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok { + googleError(c, http.StatusInternalServerError, "User context not found") + return + } + + if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGemini { + googleError(c, http.StatusBadRequest, "API key group platform is not gemini") + return + } + + modelName, action, err := parseGeminiModelAction(strings.TrimPrefix(c.Param("modelAction"), "/")) + if err != nil { + googleError(c, http.StatusNotFound, err.Error()) + return + } + + stream := action == "streamGenerateContent" + + body, err := io.ReadAll(c.Request.Body) + if err != nil { + googleError(c, http.StatusBadRequest, "Failed to read request body") + return + } + if len(body) == 0 { + googleError(c, http.StatusBadRequest, "Request body is empty") + return + } + + // Get subscription (may be nil) + subscription, _ := middleware.GetSubscriptionFromContext(c) + + // For Gemini native API, do not send Claude-style ping frames. + geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone) + + // 0) wait queue check + maxWait := service.CalculateMaxWait(authSubject.Concurrency) + canWait, err := geminiConcurrency.IncrementWaitCount(c.Request.Context(), authSubject.UserID, maxWait) + if err != nil { + log.Printf("Increment wait count failed: %v", err) + } else if !canWait { + googleError(c, http.StatusTooManyRequests, "Too many pending requests, please retry later") + return + } + defer geminiConcurrency.DecrementWaitCount(c.Request.Context(), authSubject.UserID) + + // 1) user concurrency slot + streamStarted := false + userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, stream, &streamStarted) + if err != nil { + googleError(c, http.StatusTooManyRequests, err.Error()) + return + } + if userReleaseFunc != nil { + defer userReleaseFunc() + } + + // 2) billing eligibility check (after wait) + if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription); err != nil { + googleError(c, http.StatusForbidden, err.Error()) + return + } + + // 3) select account (sticky session based on request body) + sessionHash := h.gatewayService.GenerateSessionHash(body) + account, err := h.geminiCompatService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, modelName) + if err != nil { + googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error()) + return + } + + // 4) account concurrency slot + accountReleaseFunc, err := geminiConcurrency.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, stream, &streamStarted) + if err != nil { + googleError(c, http.StatusTooManyRequests, err.Error()) + return + } + if accountReleaseFunc != nil { + defer accountReleaseFunc() + } + + // 5) forward (writes response to client) + result, err := h.geminiCompatService.ForwardNative(c.Request.Context(), c, account, modelName, action, stream, body) + if err != nil { + // ForwardNative already wrote the response + log.Printf("Gemini native forward failed: %v", err) + return + } + + // 6) record usage async + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ + Result: result, + ApiKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + }); err != nil { + log.Printf("Record usage failed: %v", err) + } + }() +} + +func parseGeminiModelAction(rest string) (model string, action string, err error) { + rest = strings.TrimSpace(rest) + if rest == "" { + return "", "", &pathParseError{"missing path"} + } + + // Standard: {model}:{action} + if i := strings.Index(rest, ":"); i > 0 && i < len(rest)-1 { + return rest[:i], rest[i+1:], nil + } + + // Fallback: {model}/{action} + if i := strings.Index(rest, "/"); i > 0 && i < len(rest)-1 { + return rest[:i], rest[i+1:], nil + } + + return "", "", &pathParseError{"invalid model action path"} +} + +type pathParseError struct{ msg string } + +func (e *pathParseError) Error() string { return e.msg } + +func googleError(c *gin.Context, status int, message string) { + c.JSON(status, gin.H{ + "error": gin.H{ + "code": status, + "message": message, + "status": googleapi.HTTPStatusToGoogleStatus(status), + }, + }) +} + +func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) { + if res == nil { + googleError(c, http.StatusBadGateway, "Empty upstream response") + return + } + for k, vv := range res.Headers { + // Avoid overriding content-length and hop-by-hop headers. + if strings.EqualFold(k, "Content-Length") || strings.EqualFold(k, "Transfer-Encoding") || strings.EqualFold(k, "Connection") { + continue + } + for _, v := range vv { + c.Writer.Header().Add(k, v) + } + } + contentType := res.Headers.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + c.Data(res.StatusCode, contentType, res.Body) +} + +func shouldFallbackGeminiModels(res *service.UpstreamHTTPResult) bool { + if res == nil { + return true + } + if res.StatusCode != http.StatusUnauthorized && res.StatusCode != http.StatusForbidden { + return false + } + if strings.Contains(strings.ToLower(res.Headers.Get("Www-Authenticate")), "insufficient_scope") { + return true + } + if strings.Contains(strings.ToLower(string(res.Body)), "insufficient authentication scopes") { + return true + } + if strings.Contains(strings.ToLower(string(res.Body)), "access_token_scope_insufficient") { + return true + } + return false +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 11c9dcf1..af28bc1f 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -12,6 +12,7 @@ type AdminHandlers struct { Account *admin.AccountHandler OAuth *admin.OAuthHandler OpenAIOAuth *admin.OpenAIOAuthHandler + GeminiOAuth *admin.GeminiOAuthHandler Proxy *admin.ProxyHandler Redeem *admin.RedeemHandler Setting *admin.SettingHandler diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index a37cb3e6..f6e2c031 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -15,6 +15,7 @@ func ProvideAdminHandlers( accountHandler *admin.AccountHandler, oauthHandler *admin.OAuthHandler, openaiOAuthHandler *admin.OpenAIOAuthHandler, + geminiOAuthHandler *admin.GeminiOAuthHandler, proxyHandler *admin.ProxyHandler, redeemHandler *admin.RedeemHandler, settingHandler *admin.SettingHandler, @@ -29,6 +30,7 @@ func ProvideAdminHandlers( Account: accountHandler, OAuth: oauthHandler, OpenAIOAuth: openaiOAuthHandler, + GeminiOAuth: geminiOAuthHandler, Proxy: proxyHandler, Redeem: redeemHandler, Setting: settingHandler, @@ -95,6 +97,7 @@ var ProviderSet = wire.NewSet( admin.NewAccountHandler, admin.NewOAuthHandler, admin.NewOpenAIOAuthHandler, + admin.NewGeminiOAuthHandler, admin.NewProxyHandler, admin.NewRedeemHandler, admin.NewSettingHandler, diff --git a/backend/internal/pkg/gemini/models.go b/backend/internal/pkg/gemini/models.go new file mode 100644 index 00000000..0af6003d --- /dev/null +++ b/backend/internal/pkg/gemini/models.go @@ -0,0 +1,42 @@ +package gemini + +// This package provides minimal fallback model metadata for Gemini native endpoints. +// It is used when upstream model listing is unavailable (e.g. OAuth token missing AI Studio scopes). + +type Model struct { + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Description string `json:"description,omitempty"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods,omitempty"` +} + +type ModelsListResponse struct { + Models []Model `json:"models"` +} + +func DefaultModels() []Model { + methods := []string{"generateContent", "streamGenerateContent"} + return []Model{ + {Name: "models/gemini-3-pro-preview", SupportedGenerationMethods: methods}, + {Name: "models/gemini-2.0-flash", SupportedGenerationMethods: methods}, + {Name: "models/gemini-2.0-flash-lite", SupportedGenerationMethods: methods}, + {Name: "models/gemini-1.5-pro", SupportedGenerationMethods: methods}, + {Name: "models/gemini-1.5-flash", SupportedGenerationMethods: methods}, + {Name: "models/gemini-1.5-flash-8b", SupportedGenerationMethods: methods}, + } +} + +func FallbackModelsList() ModelsListResponse { + return ModelsListResponse{Models: DefaultModels()} +} + +func FallbackModel(model string) Model { + methods := []string{"generateContent", "streamGenerateContent"} + if model == "" { + return Model{Name: "models/unknown", SupportedGenerationMethods: methods} + } + if len(model) >= 7 && model[:7] == "models/" { + return Model{Name: model, SupportedGenerationMethods: methods} + } + return Model{Name: "models/" + model, SupportedGenerationMethods: methods} +} diff --git a/backend/internal/pkg/geminicli/codeassist_types.go b/backend/internal/pkg/geminicli/codeassist_types.go new file mode 100644 index 00000000..59d3ef78 --- /dev/null +++ b/backend/internal/pkg/geminicli/codeassist_types.go @@ -0,0 +1,38 @@ +package geminicli + +// LoadCodeAssistRequest matches done-hub's internal Code Assist call. +type LoadCodeAssistRequest struct { + Metadata LoadCodeAssistMetadata `json:"metadata"` +} + +type LoadCodeAssistMetadata struct { + IDEType string `json:"ideType"` + Platform string `json:"platform"` + PluginType string `json:"pluginType"` +} + +type LoadCodeAssistResponse struct { + CurrentTier string `json:"currentTier,omitempty"` + CloudAICompanionProject string `json:"cloudaicompanionProject,omitempty"` + AllowedTiers []AllowedTier `json:"allowedTiers,omitempty"` +} + +type AllowedTier struct { + ID string `json:"id"` + IsDefault bool `json:"isDefault,omitempty"` +} + +type OnboardUserRequest struct { + TierID string `json:"tierId"` + Metadata LoadCodeAssistMetadata `json:"metadata"` +} + +type OnboardUserResponse struct { + Done bool `json:"done"` + Response *OnboardUserResultData `json:"response,omitempty"` + Name string `json:"name,omitempty"` +} + +type OnboardUserResultData struct { + CloudAICompanionProject any `json:"cloudaicompanionProject,omitempty"` +} diff --git a/backend/internal/pkg/geminicli/constants.go b/backend/internal/pkg/geminicli/constants.go new file mode 100644 index 00000000..63f48727 --- /dev/null +++ b/backend/internal/pkg/geminicli/constants.go @@ -0,0 +1,42 @@ +package geminicli + +import "time" + +const ( + AIStudioBaseURL = "https://generativelanguage.googleapis.com" + GeminiCliBaseURL = "https://cloudcode-pa.googleapis.com" + + AuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth" + TokenURL = "https://oauth2.googleapis.com/token" + + // AIStudioOAuthRedirectURI is the default redirect URI used for AI Studio OAuth. + // This matches the "copy/paste callback URL" flow used by OpenAI OAuth in this project. + // Note: You still need to register this redirect URI in your Google OAuth client + // unless you use an OAuth client type that permits localhost redirect URIs. + AIStudioOAuthRedirectURI = "http://localhost:1455/auth/callback" + + // DefaultScopes for Code Assist (includes cloud-platform for API access plus userinfo scopes) + // Required by Google's Code Assist API. + DefaultCodeAssistScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" + + // DefaultScopes for AI Studio (uses generativelanguage API with OAuth) + // Reference: https://ai.google.dev/gemini-api/docs/oauth + // For regular Google accounts, supports API calls to generativelanguage.googleapis.com + // Note: Google Auth platform currently documents the OAuth scope as + // https://www.googleapis.com/auth/generative-language.retriever (often with cloud-platform). + DefaultAIStudioScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever" + + // GeminiCLIRedirectURI is the redirect URI used by Gemini CLI for Code Assist OAuth. + GeminiCLIRedirectURI = "https://codeassist.google.com/authcode" + + // GeminiCLIOAuthClientID/Secret are the public OAuth client credentials used by Google Gemini CLI. + // They enable the "login without creating your own OAuth client" experience, but Google may + // restrict which scopes are allowed for this client. + GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" + GeminiCLIOAuthClientSecret = "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" + + SessionTTL = 30 * time.Minute + + // GeminiCLIUserAgent mimics Gemini CLI to maximize compatibility with internal endpoints. + GeminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)" +) diff --git a/backend/internal/pkg/geminicli/models.go b/backend/internal/pkg/geminicli/models.go new file mode 100644 index 00000000..065c7a10 --- /dev/null +++ b/backend/internal/pkg/geminicli/models.go @@ -0,0 +1,21 @@ +package geminicli + +// Model represents a selectable Gemini model for UI/testing purposes. +// Keep JSON fields consistent with existing frontend expectations. +type Model struct { + ID string `json:"id"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + CreatedAt string `json:"created_at"` +} + +// DefaultModels is the curated Gemini model list used by the admin UI "test account" flow. +var DefaultModels = []Model{ + {ID: "gemini-3-pro", Type: "model", DisplayName: "Gemini 3 Pro", CreatedAt: ""}, + {ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash", CreatedAt: ""}, + {ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""}, + {ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""}, +} + +// DefaultTestModel is the default model to preselect in test flows. +const DefaultTestModel = "gemini-2.5-pro" diff --git a/backend/internal/pkg/geminicli/oauth.go b/backend/internal/pkg/geminicli/oauth.go new file mode 100644 index 00000000..f93d99b9 --- /dev/null +++ b/backend/internal/pkg/geminicli/oauth.go @@ -0,0 +1,243 @@ +package geminicli + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "strings" + "sync" + "time" +) + +type OAuthConfig struct { + ClientID string + ClientSecret string + Scopes string +} + +type OAuthSession struct { + State string `json:"state"` + CodeVerifier string `json:"code_verifier"` + ProxyURL string `json:"proxy_url,omitempty"` + RedirectURI string `json:"redirect_uri"` + ProjectID string `json:"project_id,omitempty"` + OAuthType string `json:"oauth_type"` // "code_assist" 或 "ai_studio" + CreatedAt time.Time `json:"created_at"` +} + +type SessionStore struct { + mu sync.RWMutex + sessions map[string]*OAuthSession + stopCh chan struct{} +} + +func NewSessionStore() *SessionStore { + store := &SessionStore{ + sessions: make(map[string]*OAuthSession), + stopCh: make(chan struct{}), + } + go store.cleanup() + return store +} + +func (s *SessionStore) Set(sessionID string, session *OAuthSession) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[sessionID] = session +} + +func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + session, ok := s.sessions[sessionID] + if !ok { + return nil, false + } + if time.Since(session.CreatedAt) > SessionTTL { + return nil, false + } + return session, true +} + +func (s *SessionStore) Delete(sessionID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, sessionID) +} + +func (s *SessionStore) Stop() { + select { + case <-s.stopCh: + return + default: + close(s.stopCh) + } +} + +func (s *SessionStore) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.mu.Lock() + for id, session := range s.sessions { + if time.Since(session.CreatedAt) > SessionTTL { + delete(s.sessions, id) + } + } + s.mu.Unlock() + } + } +} + +func GenerateRandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + return b, nil +} + +func GenerateState() (string, error) { + bytes, err := GenerateRandomBytes(32) + if err != nil { + return "", err + } + return base64URLEncode(bytes), nil +} + +func GenerateSessionID() (string, error) { + bytes, err := GenerateRandomBytes(16) + if err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// GenerateCodeVerifier returns an RFC 7636 compatible code verifier (43+ chars). +func GenerateCodeVerifier() (string, error) { + bytes, err := GenerateRandomBytes(32) + if err != nil { + return "", err + } + return base64URLEncode(bytes), nil +} + +func GenerateCodeChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64URLEncode(hash[:]) +} + +func base64URLEncode(data []byte) string { + return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=") +} + +// EffectiveOAuthConfig returns the effective OAuth configuration. +// oauthType: "code_assist" or "ai_studio" (defaults to "code_assist" if empty). +// +// If ClientID/ClientSecret is not provided, this falls back to the built-in Gemini CLI OAuth client. +// +// Note: The built-in Gemini CLI OAuth client is restricted and may reject some scopes (e.g. +// https://www.googleapis.com/auth/generative-language), which will surface as +// "restricted_client" / "Unregistered scope(s)" errors during browser authorization. +func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error) { + effective := OAuthConfig{ + ClientID: strings.TrimSpace(cfg.ClientID), + ClientSecret: strings.TrimSpace(cfg.ClientSecret), + Scopes: strings.TrimSpace(cfg.Scopes), + } + + // Normalize scopes: allow comma-separated input but send space-delimited scopes to Google. + if effective.Scopes != "" { + effective.Scopes = strings.Join(strings.Fields(strings.ReplaceAll(effective.Scopes, ",", " ")), " ") + } + + // Fall back to built-in Gemini CLI OAuth client when not configured. + if effective.ClientID == "" && effective.ClientSecret == "" { + effective.ClientID = GeminiCLIOAuthClientID + effective.ClientSecret = GeminiCLIOAuthClientSecret + } else if effective.ClientID == "" || effective.ClientSecret == "" { + return OAuthConfig{}, fmt.Errorf("OAuth client not configured: please set both client_id and client_secret (or leave both empty to use the built-in Gemini CLI client)") + } + + isBuiltinClient := effective.ClientID == GeminiCLIOAuthClientID && + effective.ClientSecret == GeminiCLIOAuthClientSecret + + if effective.Scopes == "" { + // Use different default scopes based on OAuth type + if oauthType == "ai_studio" { + // Built-in client can't request some AI Studio scopes (notably generative-language). + if isBuiltinClient { + effective.Scopes = DefaultCodeAssistScopes + } else { + effective.Scopes = DefaultAIStudioScopes + } + } else { + // Default to Code Assist scopes + effective.Scopes = DefaultCodeAssistScopes + } + } else if oauthType == "ai_studio" && 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") { + continue + } + filtered = append(filtered, s) + } + if len(filtered) == 0 { + effective.Scopes = DefaultCodeAssistScopes + } else { + effective.Scopes = strings.Join(filtered, " ") + } + } + + // Backward compatibility: normalize older AI Studio scope to the currently documented one. + if oauthType == "ai_studio" && effective.Scopes != "" { + parts := strings.Fields(effective.Scopes) + for i := range parts { + if parts[i] == "https://www.googleapis.com/auth/generative-language" { + parts[i] = "https://www.googleapis.com/auth/generative-language.retriever" + } + } + effective.Scopes = strings.Join(parts, " ") + } + + return effective, nil +} + +func BuildAuthorizationURL(cfg OAuthConfig, state, codeChallenge, redirectURI, projectID, oauthType string) (string, error) { + effectiveCfg, err := EffectiveOAuthConfig(cfg, oauthType) + if err != nil { + return "", err + } + redirectURI = strings.TrimSpace(redirectURI) + if redirectURI == "" { + return "", fmt.Errorf("redirect_uri is required") + } + + params := url.Values{} + params.Set("response_type", "code") + params.Set("client_id", effectiveCfg.ClientID) + params.Set("redirect_uri", redirectURI) + params.Set("scope", effectiveCfg.Scopes) + params.Set("state", state) + params.Set("code_challenge", codeChallenge) + params.Set("code_challenge_method", "S256") + params.Set("access_type", "offline") + params.Set("prompt", "consent") + params.Set("include_granted_scopes", "true") + if strings.TrimSpace(projectID) != "" { + params.Set("project_id", strings.TrimSpace(projectID)) + } + + return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode()), nil +} diff --git a/backend/internal/pkg/geminicli/sanitize.go b/backend/internal/pkg/geminicli/sanitize.go new file mode 100644 index 00000000..f5c407e4 --- /dev/null +++ b/backend/internal/pkg/geminicli/sanitize.go @@ -0,0 +1,46 @@ +package geminicli + +import "strings" + +const maxLogBodyLen = 2048 + +func SanitizeBodyForLogs(body string) string { + body = truncateBase64InMessage(body) + if len(body) > maxLogBodyLen { + body = body[:maxLogBodyLen] + "...[truncated]" + } + return body +} + +func truncateBase64InMessage(message string) string { + const maxBase64Length = 50 + + result := message + offset := 0 + for { + idx := strings.Index(result[offset:], ";base64,") + if idx == -1 { + break + } + actualIdx := offset + idx + start := actualIdx + len(";base64,") + + end := start + for end < len(result) && isBase64Char(result[end]) { + end++ + } + + if end-start > maxBase64Length { + result = result[:start+maxBase64Length] + "...[truncated]" + result[end:] + offset = start + maxBase64Length + len("...[truncated]") + continue + } + offset = end + } + + return result +} + +func isBase64Char(c byte) bool { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=' +} diff --git a/backend/internal/pkg/geminicli/token_types.go b/backend/internal/pkg/geminicli/token_types.go new file mode 100644 index 00000000..f3cfbaed --- /dev/null +++ b/backend/internal/pkg/geminicli/token_types.go @@ -0,0 +1,9 @@ +package geminicli + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope,omitempty"` +} diff --git a/backend/internal/pkg/googleapi/status.go b/backend/internal/pkg/googleapi/status.go new file mode 100644 index 00000000..b8def1eb --- /dev/null +++ b/backend/internal/pkg/googleapi/status.go @@ -0,0 +1,24 @@ +package googleapi + +import "net/http" + +// HTTPStatusToGoogleStatus maps HTTP status codes to Google-style error status strings. +func HTTPStatusToGoogleStatus(status int) string { + switch status { + case http.StatusBadRequest: + return "INVALID_ARGUMENT" + case http.StatusUnauthorized: + return "UNAUTHENTICATED" + case http.StatusForbidden: + return "PERMISSION_DENIED" + case http.StatusNotFound: + return "NOT_FOUND" + case http.StatusTooManyRequests: + return "RESOURCE_EXHAUSTED" + default: + if status >= 500 { + return "INTERNAL" + } + return "UNKNOWN" + } +} diff --git a/backend/internal/repository/gemini_oauth_client.go b/backend/internal/repository/gemini_oauth_client.go new file mode 100644 index 00000000..4e9bae3e --- /dev/null +++ b/backend/internal/repository/gemini_oauth_client.go @@ -0,0 +1,117 @@ +package repository + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/imroc/req/v3" +) + +type geminiOAuthClient struct { + tokenURL string + cfg *config.Config +} + +func NewGeminiOAuthClient(cfg *config.Config) service.GeminiOAuthClient { + return &geminiOAuthClient{ + tokenURL: geminicli.TokenURL, + cfg: cfg, + } +} + +func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, codeVerifier, redirectURI, proxyURL string) (*geminicli.TokenResponse, error) { + client := createGeminiReqClient(proxyURL) + + // Use different OAuth clients based on oauthType: + // - code_assist: always use built-in Gemini CLI OAuth client (public) + // - ai_studio: requires a user-provided OAuth client + oauthCfgInput := geminicli.OAuthConfig{ + ClientID: c.cfg.Gemini.OAuth.ClientID, + ClientSecret: c.cfg.Gemini.OAuth.ClientSecret, + Scopes: c.cfg.Gemini.OAuth.Scopes, + } + if oauthType == "code_assist" { + oauthCfgInput.ClientID = "" + oauthCfgInput.ClientSecret = "" + } + + oauthCfg, err := geminicli.EffectiveOAuthConfig(oauthCfgInput, oauthType) + if err != nil { + return nil, err + } + + formData := url.Values{} + formData.Set("grant_type", "authorization_code") + formData.Set("client_id", oauthCfg.ClientID) + formData.Set("client_secret", oauthCfg.ClientSecret) + formData.Set("code", code) + formData.Set("code_verifier", codeVerifier) + formData.Set("redirect_uri", redirectURI) + + var tokenResp geminicli.TokenResponse + resp, err := client.R(). + SetContext(ctx). + SetFormDataFromValues(formData). + SetSuccessResult(&tokenResp). + Post(c.tokenURL) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("token exchange failed: status %d, body: %s", resp.StatusCode, geminicli.SanitizeBodyForLogs(resp.String())) + } + return &tokenResp, nil +} + +func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refreshToken, proxyURL string) (*geminicli.TokenResponse, error) { + client := createGeminiReqClient(proxyURL) + + oauthCfgInput := geminicli.OAuthConfig{ + ClientID: c.cfg.Gemini.OAuth.ClientID, + ClientSecret: c.cfg.Gemini.OAuth.ClientSecret, + Scopes: c.cfg.Gemini.OAuth.Scopes, + } + if oauthType == "code_assist" { + oauthCfgInput.ClientID = "" + oauthCfgInput.ClientSecret = "" + } + + oauthCfg, err := geminicli.EffectiveOAuthConfig(oauthCfgInput, oauthType) + if err != nil { + return nil, err + } + + formData := url.Values{} + formData.Set("grant_type", "refresh_token") + formData.Set("refresh_token", refreshToken) + formData.Set("client_id", oauthCfg.ClientID) + formData.Set("client_secret", oauthCfg.ClientSecret) + + var tokenResp geminicli.TokenResponse + resp, err := client.R(). + SetContext(ctx). + SetFormDataFromValues(formData). + SetSuccessResult(&tokenResp). + Post(c.tokenURL) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("token refresh failed: status %d, body: %s", resp.StatusCode, geminicli.SanitizeBodyForLogs(resp.String())) + } + return &tokenResp, nil +} + +func createGeminiReqClient(proxyURL string) *req.Client { + client := req.C().SetTimeout(60 * time.Second) + if proxyURL != "" { + client.SetProxyURL(proxyURL) + } + return client +} diff --git a/backend/internal/repository/gemini_token_cache.go b/backend/internal/repository/gemini_token_cache.go new file mode 100644 index 00000000..a7270556 --- /dev/null +++ b/backend/internal/repository/gemini_token_cache.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/redis/go-redis/v9" +) + +const ( + geminiTokenKeyPrefix = "gemini:token:" + geminiRefreshLockKeyPrefix = "gemini:refresh_lock:" +) + +type geminiTokenCache struct { + rdb *redis.Client +} + +func NewGeminiTokenCache(rdb *redis.Client) service.GeminiTokenCache { + return &geminiTokenCache{rdb: rdb} +} + +func (c *geminiTokenCache) GetAccessToken(ctx context.Context, cacheKey string) (string, error) { + key := fmt.Sprintf("%s%s", geminiTokenKeyPrefix, cacheKey) + return c.rdb.Get(ctx, key).Result() +} + +func (c *geminiTokenCache) SetAccessToken(ctx context.Context, cacheKey string, token string, ttl time.Duration) error { + key := fmt.Sprintf("%s%s", geminiTokenKeyPrefix, cacheKey) + return c.rdb.Set(ctx, key, token, ttl).Err() +} + +func (c *geminiTokenCache) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, error) { + key := fmt.Sprintf("%s%s", geminiRefreshLockKeyPrefix, cacheKey) + return c.rdb.SetNX(ctx, key, 1, ttl).Result() +} + +func (c *geminiTokenCache) ReleaseRefreshLock(ctx context.Context, cacheKey string) error { + key := fmt.Sprintf("%s%s", geminiRefreshLockKeyPrefix, cacheKey) + return c.rdb.Del(ctx, key).Err() +} diff --git a/backend/internal/repository/geminicli_codeassist_client.go b/backend/internal/repository/geminicli_codeassist_client.go new file mode 100644 index 00000000..0a5d813c --- /dev/null +++ b/backend/internal/repository/geminicli_codeassist_client.go @@ -0,0 +1,105 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/imroc/req/v3" +) + +type geminiCliCodeAssistClient struct { + baseURL string +} + +func NewGeminiCliCodeAssistClient() service.GeminiCliCodeAssistClient { + return &geminiCliCodeAssistClient{baseURL: geminicli.GeminiCliBaseURL} +} + +func (c *geminiCliCodeAssistClient) LoadCodeAssist(ctx context.Context, accessToken, proxyURL string, reqBody *geminicli.LoadCodeAssistRequest) (*geminicli.LoadCodeAssistResponse, error) { + if reqBody == nil { + reqBody = defaultLoadCodeAssistRequest() + } + + var out geminicli.LoadCodeAssistResponse + resp, err := createGeminiCliReqClient(proxyURL).R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+accessToken). + SetHeader("Content-Type", "application/json"). + SetHeader("User-Agent", geminicli.GeminiCLIUserAgent). + SetBody(reqBody). + SetSuccessResult(&out). + Post(c.baseURL + "/v1internal:loadCodeAssist") + if err != nil { + fmt.Printf("[CodeAssist] LoadCodeAssist request error: %v\n", err) + return nil, fmt.Errorf("request failed: %w", err) + } + if !resp.IsSuccessState() { + body := geminicli.SanitizeBodyForLogs(resp.String()) + fmt.Printf("[CodeAssist] LoadCodeAssist failed: status %d, body: %s\n", resp.StatusCode, body) + return nil, fmt.Errorf("loadCodeAssist failed: status %d, body: %s", resp.StatusCode, body) + } + fmt.Printf("[CodeAssist] LoadCodeAssist success: status %d, response: %+v\n", resp.StatusCode, out) + return &out, nil +} + +func (c *geminiCliCodeAssistClient) OnboardUser(ctx context.Context, accessToken, proxyURL string, reqBody *geminicli.OnboardUserRequest) (*geminicli.OnboardUserResponse, error) { + if reqBody == nil { + reqBody = defaultOnboardUserRequest() + } + + fmt.Printf("[CodeAssist] OnboardUser request body: %+v\n", reqBody) + + var out geminicli.OnboardUserResponse + resp, err := createGeminiCliReqClient(proxyURL).R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+accessToken). + SetHeader("Content-Type", "application/json"). + SetHeader("User-Agent", geminicli.GeminiCLIUserAgent). + SetBody(reqBody). + SetSuccessResult(&out). + Post(c.baseURL + "/v1internal:onboardUser") + if err != nil { + fmt.Printf("[CodeAssist] OnboardUser request error: %v\n", err) + return nil, fmt.Errorf("request failed: %w", err) + } + if !resp.IsSuccessState() { + body := geminicli.SanitizeBodyForLogs(resp.String()) + fmt.Printf("[CodeAssist] OnboardUser failed: status %d, body: %s\n", resp.StatusCode, body) + return nil, fmt.Errorf("onboardUser failed: status %d, body: %s", resp.StatusCode, body) + } + fmt.Printf("[CodeAssist] OnboardUser success: status %d, response: %+v\n", resp.StatusCode, out) + return &out, nil +} + +func createGeminiCliReqClient(proxyURL string) *req.Client { + client := req.C().SetTimeout(30 * time.Second) + if proxyURL != "" { + client.SetProxyURL(proxyURL) + } + return client +} + +func defaultLoadCodeAssistRequest() *geminicli.LoadCodeAssistRequest { + return &geminicli.LoadCodeAssistRequest{ + Metadata: geminicli.LoadCodeAssistMetadata{ + IDEType: "ANTIGRAVITY", + Platform: "PLATFORM_UNSPECIFIED", + PluginType: "GEMINI", + }, + } +} + +func defaultOnboardUserRequest() *geminicli.OnboardUserRequest { + return &geminicli.OnboardUserRequest{ + TierID: "LEGACY", + Metadata: geminicli.LoadCodeAssistMetadata{ + IDEType: "ANTIGRAVITY", + Platform: "PLATFORM_UNSPECIFIED", + PluginType: "GEMINI", + }, + } +} diff --git a/backend/internal/repository/pricing_service_test.go b/backend/internal/repository/pricing_service_test.go index 8cfc8222..c51317a4 100644 --- a/backend/internal/repository/pricing_service_test.go +++ b/backend/internal/repository/pricing_service_test.go @@ -120,10 +120,9 @@ func (s *PricingServiceSuite) TestFetchHashText_WhitespaceOnly() { func (s *PricingServiceSuite) TestFetchPricingJSON_ContextCancel() { started := make(chan struct{}) - block := make(chan struct{}) s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started) - <-block + <-r.Context().Done() })) ctx, cancel := context.WithCancel(s.ctx) @@ -136,7 +135,6 @@ func (s *PricingServiceSuite) TestFetchPricingJSON_ContextCancel() { <-started cancel() - close(block) err := <-done require.Error(s.T(), err) diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index ceeb82fc..53d42d90 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -25,6 +25,7 @@ var ProviderSet = wire.NewSet( NewIdentityCache, NewRedeemCache, NewUpdateCache, + NewGeminiTokenCache, // HTTP service ports (DI Strategy A: return interface directly) NewTurnstileVerifier, @@ -35,4 +36,6 @@ var ProviderSet = wire.NewSet( NewClaudeOAuthClient, NewHTTPUpstream, NewOpenAIOAuthClient, + NewGeminiOAuthClient, + NewGeminiCliCodeAssistClient, ) diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index f673925d..88833d63 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -7,6 +7,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/google/wire" @@ -25,6 +26,8 @@ func ProvideRouter( jwtAuth middleware2.JWTAuthMiddleware, adminAuth middleware2.AdminAuthMiddleware, apiKeyAuth middleware2.ApiKeyAuthMiddleware, + apiKeyService *service.ApiKeyService, + subscriptionService *service.SubscriptionService, ) *gin.Engine { if cfg.Server.Mode == "release" { gin.SetMode(gin.ReleaseMode) @@ -33,7 +36,7 @@ func ProvideRouter( r := gin.New() r.Use(middleware2.Recovery()) - return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth) + return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService) } // ProvideHTTPServer 提供 HTTP 服务器 diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index 19d866b8..c4620d91 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -35,9 +35,24 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti apiKeyString = c.GetHeader("x-api-key") } - // 如果两个header都没有API key + // 如果x-api-key header中没有,尝试从x-goog-api-key header中提取(Gemini CLI兼容) if apiKeyString == "" { - AbortWithError(c, 401, "API_KEY_REQUIRED", "API key is required in Authorization header (Bearer scheme) or x-api-key header") + apiKeyString = c.GetHeader("x-goog-api-key") + } + + // 如果header中没有,尝试从query参数中提取(Google API key风格) + if apiKeyString == "" { + apiKeyString = c.Query("key") + } + + // 兼容常见别名 + if apiKeyString == "" { + apiKeyString = c.Query("api_key") + } + + // 如果所有header都没有API key + if apiKeyString == "" { + AbortWithError(c, 401, "API_KEY_REQUIRED", "API key is required in Authorization header (Bearer scheme), x-api-key header, x-goog-api-key header, or key/api_key query parameter") return } diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go new file mode 100644 index 00000000..7cef27a6 --- /dev/null +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -0,0 +1,121 @@ +package middleware + +import ( + "errors" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ApiKeyAuthGoogle is a Google-style error wrapper for API key auth. +func ApiKeyAuthGoogle(apiKeyService *service.ApiKeyService) gin.HandlerFunc { + return ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil) +} + +// ApiKeyAuthWithSubscriptionGoogle behaves like ApiKeyAuthWithSubscription but returns Google-style errors: +// {"error":{"code":401,"message":"...","status":"UNAUTHENTICATED"}} +// +// It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations. +func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) gin.HandlerFunc { + return func(c *gin.Context) { + apiKeyString := extractAPIKeyFromRequest(c) + if apiKeyString == "" { + abortWithGoogleError(c, 401, "API key is required") + return + } + + apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + abortWithGoogleError(c, 401, "Invalid API key") + return + } + abortWithGoogleError(c, 500, "Failed to validate API key") + return + } + + if !apiKey.IsActive() { + abortWithGoogleError(c, 401, "API key is disabled") + return + } + if apiKey.User == nil { + abortWithGoogleError(c, 401, "User associated with API key not found") + return + } + if !apiKey.User.IsActive() { + abortWithGoogleError(c, 401, "User account is not active") + return + } + + isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType() + if isSubscriptionType && subscriptionService != nil { + subscription, err := subscriptionService.GetActiveSubscription( + c.Request.Context(), + apiKey.User.ID, + apiKey.Group.ID, + ) + if err != nil { + abortWithGoogleError(c, 403, "No active subscription found for this group") + return + } + if err := subscriptionService.ValidateSubscription(c.Request.Context(), subscription); err != nil { + abortWithGoogleError(c, 403, err.Error()) + return + } + _ = subscriptionService.CheckAndActivateWindow(c.Request.Context(), subscription) + _ = subscriptionService.CheckAndResetWindows(c.Request.Context(), subscription) + if err := subscriptionService.CheckUsageLimits(c.Request.Context(), subscription, apiKey.Group, 0); err != nil { + abortWithGoogleError(c, 429, err.Error()) + return + } + c.Set(string(ContextKeySubscription), subscription) + } else { + if apiKey.User.Balance <= 0 { + abortWithGoogleError(c, 403, "Insufficient account balance") + return + } + } + + c.Set(string(ContextKeyApiKey), apiKey) + c.Set(string(ContextKeyUser), apiKey.User) + c.Next() + } +} + +func extractAPIKeyFromRequest(c *gin.Context) string { + authHeader := c.GetHeader("Authorization") + if authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" && strings.TrimSpace(parts[1]) != "" { + return strings.TrimSpace(parts[1]) + } + } + if v := strings.TrimSpace(c.GetHeader("x-api-key")); v != "" { + return v + } + if v := strings.TrimSpace(c.GetHeader("x-goog-api-key")); v != "" { + return v + } + if v := strings.TrimSpace(c.Query("key")); v != "" { + return v + } + if v := strings.TrimSpace(c.Query("api_key")); v != "" { + return v + } + return "" +} + +func abortWithGoogleError(c *gin.Context, status int, message string) { + c.JSON(status, gin.H{ + "error": gin.H{ + "code": status, + "message": message, + "status": googleapi.HTTPStatusToGoogleStatus(status), + }, + }) + c.Abort() +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 226fe99b..5489468b 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -4,6 +4,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/server/routes" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/web" "github.com/gin-gonic/gin" @@ -16,6 +17,8 @@ func SetupRouter( jwtAuth middleware2.JWTAuthMiddleware, adminAuth middleware2.AdminAuthMiddleware, apiKeyAuth middleware2.ApiKeyAuthMiddleware, + apiKeyService *service.ApiKeyService, + subscriptionService *service.SubscriptionService, ) *gin.Engine { // 应用中间件 r.Use(middleware2.Logger()) @@ -27,7 +30,7 @@ func SetupRouter( } // 注册路由 - registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth) + registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService) return r } @@ -39,6 +42,8 @@ func registerRoutes( jwtAuth middleware2.JWTAuthMiddleware, adminAuth middleware2.AdminAuthMiddleware, apiKeyAuth middleware2.ApiKeyAuthMiddleware, + apiKeyService *service.ApiKeyService, + subscriptionService *service.SubscriptionService, ) { // 通用路由(健康检查、状态等) routes.RegisterCommonRoutes(r) @@ -50,5 +55,5 @@ func registerRoutes( routes.RegisterAuthRoutes(v1, h, jwtAuth) routes.RegisterUserRoutes(v1, h, jwtAuth) routes.RegisterAdminRoutes(v1, h, adminAuth) - routes.RegisterGatewayRoutes(r, h, apiKeyAuth) + routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService) } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 06ef142a..591335dd 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -31,6 +31,9 @@ func RegisterAdminRoutes( // OpenAI OAuth registerOpenAIOAuthRoutes(admin, h) + // Gemini OAuth + registerGeminiOAuthRoutes(admin, h) + // 代理管理 registerProxyRoutes(admin, h) @@ -136,6 +139,15 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } +func registerGeminiOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + gemini := admin.Group("/gemini") + { + gemini.POST("/oauth/auth-url", h.Admin.GeminiOAuth.GenerateAuthURL) + gemini.POST("/oauth/exchange-code", h.Admin.GeminiOAuth.ExchangeCode) + gemini.GET("/oauth/capabilities", h.Admin.GeminiOAuth.GetCapabilities) + } +} + func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) { proxies := admin.Group("/proxies") { diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 3f25b6d2..eab36ef8 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -3,15 +3,18 @@ package routes import ( "github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) -// RegisterGatewayRoutes 注册 API 网关路由(Claude/OpenAI 兼容) +// RegisterGatewayRoutes 注册 API 网关路由(Claude/OpenAI/Gemini 兼容) func RegisterGatewayRoutes( r *gin.Engine, h *handler.Handlers, apiKeyAuth middleware.ApiKeyAuthMiddleware, + apiKeyService *service.ApiKeyService, + subscriptionService *service.SubscriptionService, ) { // API网关(Claude API兼容) gateway := r.Group("/v1") @@ -25,6 +28,16 @@ func RegisterGatewayRoutes( gateway.POST("/responses", h.OpenAIGateway.Responses) } + // Gemini 原生 API 兼容层(Gemini SDK/CLI 直连) + gemini := r.Group("/v1beta") + gemini.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService)) + { + gemini.GET("/models", h.Gateway.GeminiV1BetaListModels) + gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel) + // Gin treats ":" as a param marker, but Gemini uses "{model}:{action}" in the same segment. + gemini.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels) + } + // OpenAI Responses API(不带v1前缀的别名) r.POST("/responses", gin.HandlerFunc(apiKeyAuth), h.OpenAIGateway.Responses) } diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 373ad4a9..51b7a4f1 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -70,6 +70,10 @@ func (a *Account) IsOAuth() bool { return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken } +func (a *Account) IsGemini() bool { + return a.Platform == PlatformGemini +} + func (a *Account) CanGetUsage() bool { return a.Type == AccountTypeOAuth } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 9fae64d2..6296f2fe 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -3,6 +3,7 @@ package service import ( "bufio" "bytes" + "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -16,6 +17,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -42,19 +44,27 @@ type TestEvent struct { // AccountTestService handles account testing operations type AccountTestService struct { - accountRepo AccountRepository - oauthService *OAuthService - openaiOAuthService *OpenAIOAuthService - httpUpstream HTTPUpstream + accountRepo AccountRepository + oauthService *OAuthService + openaiOAuthService *OpenAIOAuthService + geminiTokenProvider *GeminiTokenProvider + httpUpstream HTTPUpstream } // NewAccountTestService creates a new AccountTestService -func NewAccountTestService(accountRepo AccountRepository, oauthService *OAuthService, openaiOAuthService *OpenAIOAuthService, httpUpstream HTTPUpstream) *AccountTestService { +func NewAccountTestService( + accountRepo AccountRepository, + oauthService *OAuthService, + openaiOAuthService *OpenAIOAuthService, + geminiTokenProvider *GeminiTokenProvider, + httpUpstream HTTPUpstream, +) *AccountTestService { return &AccountTestService{ - accountRepo: accountRepo, - oauthService: oauthService, - openaiOAuthService: openaiOAuthService, - httpUpstream: httpUpstream, + accountRepo: accountRepo, + oauthService: oauthService, + openaiOAuthService: openaiOAuthService, + geminiTokenProvider: geminiTokenProvider, + httpUpstream: httpUpstream, } } @@ -127,6 +137,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.testOpenAIAccountConnection(c, account, modelID) } + if account.IsGemini() { + return s.testGeminiAccountConnection(c, account, modelID) + } + return s.testClaudeAccountConnection(c, account, modelID) } @@ -372,6 +386,252 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account return s.processOpenAIStream(c, resp.Body) } +// testGeminiAccountConnection tests a Gemini account's connection +func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account *Account, modelID string) error { + ctx := c.Request.Context() + + // Determine the model to use + testModelID := modelID + if testModelID == "" { + testModelID = geminicli.DefaultTestModel + } + + // For API Key accounts with model mapping, map the model + if account.Type == AccountTypeApiKey { + mapping := account.GetModelMapping() + if len(mapping) > 0 { + if mappedModel, exists := mapping[testModelID]; exists { + testModelID = mappedModel + } + } + } + + // Set SSE headers + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.Flush() + + // Create test payload (Gemini format) + payload := createGeminiTestPayload() + + // Build request based on account type + var req *http.Request + var err error + + switch account.Type { + case AccountTypeApiKey: + req, err = s.buildGeminiAPIKeyRequest(ctx, account, testModelID, payload) + case AccountTypeOAuth: + req, err = s.buildGeminiOAuthRequest(ctx, account, testModelID, payload) + default: + return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type)) + } + + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build request: %s", err.Error())) + } + + // Send test_start event + s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) + + // Get proxy and execute request + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + resp, err := s.httpUpstream.Do(req, proxyURL) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) + } + + // Process SSE stream + return s.processGeminiStream(c, resp.Body) +} + +// buildGeminiAPIKeyRequest builds request for Gemini API Key accounts +func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) { + apiKey := account.GetCredential("api_key") + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("no API key available") + } + + baseURL := account.GetCredential("base_url") + if baseURL == "" { + baseURL = geminicli.AIStudioBaseURL + } + + // Use streamGenerateContent for real-time feedback + fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", + strings.TrimRight(baseURL, "/"), modelID) + + req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-goog-api-key", apiKey) + + return req, nil +} + +// buildGeminiOAuthRequest builds request for Gemini OAuth accounts +func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) { + if s.geminiTokenProvider == nil { + return nil, fmt.Errorf("gemini token provider not configured") + } + + // Get access token (auto-refreshes if needed) + accessToken, err := s.geminiTokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + + projectID := strings.TrimSpace(account.GetCredential("project_id")) + if projectID == "" { + // AI Studio OAuth mode (no project_id): call generativelanguage API directly with Bearer token. + baseURL := account.GetCredential("base_url") + if strings.TrimSpace(baseURL) == "" { + baseURL = geminicli.AIStudioBaseURL + } + fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", strings.TrimRight(baseURL, "/"), modelID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + return req, nil + } + + // Wrap payload in Code Assist format + var inner map[string]any + if err := json.Unmarshal(payload, &inner); err != nil { + return nil, err + } + + wrapped := map[string]any{ + "model": modelID, + "project": projectID, + "request": inner, + } + wrappedBytes, _ := json.Marshal(wrapped) + + fullURL := fmt.Sprintf("%s/v1internal:streamGenerateContent?alt=sse", geminicli.GeminiCliBaseURL) + + req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(wrappedBytes)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent) + + return req, nil +} + +// createGeminiTestPayload creates a minimal test payload for Gemini API +func createGeminiTestPayload() []byte { + payload := map[string]any{ + "contents": []map[string]any{ + { + "role": "user", + "parts": []map[string]any{ + {"text": "hi"}, + }, + }, + }, + "systemInstruction": map[string]any{ + "parts": []map[string]any{ + {"text": "You are a helpful AI assistant."}, + }, + }, + } + bytes, _ := json.Marshal(payload) + return bytes +} + +// processGeminiStream processes SSE stream from Gemini API +func (s *AccountTestService) processGeminiStream(c *gin.Context, body io.Reader) error { + reader := bufio.NewReader(body) + + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error())) + } + + line = strings.TrimSpace(line) + if line == "" || !strings.HasPrefix(line, "data: ") { + continue + } + + jsonStr := strings.TrimPrefix(line, "data: ") + if jsonStr == "[DONE]" { + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil + } + + var data map[string]any + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + continue + } + + // Support two Gemini response formats: + // - AI Studio: {"candidates": [...]} + // - Gemini CLI: {"response": {"candidates": [...]}} + if resp, ok := data["response"].(map[string]any); ok && resp != nil { + data = resp + } + if candidates, ok := data["candidates"].([]any); ok && len(candidates) > 0 { + if candidate, ok := candidates[0].(map[string]any); ok { + // Check for completion + if finishReason, ok := candidate["finishReason"].(string); ok && finishReason != "" { + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil + } + + // Extract content + if content, ok := candidate["content"].(map[string]any); ok { + if parts, ok := content["parts"].([]any); ok { + for _, part := range parts { + if partMap, ok := part.(map[string]any); ok { + if text, ok := partMap["text"].(string); ok && text != "" { + s.sendEvent(c, TestEvent{Type: "content", Text: text}) + } + } + } + } + } + } + } + + // Handle errors + if errData, ok := data["error"].(map[string]any); ok { + errorMsg := "Unknown error" + if msg, ok := errData["message"].(string); ok { + errorMsg = msg + } + return s.sendErrorAndEnd(c, errorMsg) + } + } +} + // createOpenAITestPayload creates a test payload for OpenAI Responses API func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any { payload := map[string]any{ diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index 90a63f10..cd1dbcec 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" ) @@ -18,6 +19,7 @@ type CRSSyncService struct { proxyRepo ProxyRepository oauthService *OAuthService openaiOAuthService *OpenAIOAuthService + geminiOAuthService *GeminiOAuthService } func NewCRSSyncService( @@ -25,12 +27,14 @@ func NewCRSSyncService( proxyRepo ProxyRepository, oauthService *OAuthService, openaiOAuthService *OpenAIOAuthService, + geminiOAuthService *GeminiOAuthService, ) *CRSSyncService { return &CRSSyncService{ accountRepo: accountRepo, proxyRepo: proxyRepo, oauthService: oauthService, openaiOAuthService: openaiOAuthService, + geminiOAuthService: geminiOAuthService, } } @@ -75,6 +79,8 @@ type crsExportResponse struct { ClaudeConsoleAccounts []crsConsoleAccount `json:"claudeConsoleAccounts"` OpenAIOAuthAccounts []crsOpenAIOAuthAccount `json:"openaiOAuthAccounts"` OpenAIResponsesAccounts []crsOpenAIResponsesAccount `json:"openaiResponsesAccounts"` + GeminiOAuthAccounts []crsGeminiOAuthAccount `json:"geminiOAuthAccounts"` + GeminiAPIKeyAccounts []crsGeminiAPIKeyAccount `json:"geminiApiKeyAccounts"` } `json:"data"` } @@ -147,6 +153,37 @@ type crsOpenAIOAuthAccount struct { Extra map[string]any `json:"extra"` } +type crsGeminiOAuthAccount struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + AuthType string `json:"authType"` // oauth + IsActive bool `json:"isActive"` + Schedulable bool `json:"schedulable"` + Priority int `json:"priority"` + Status string `json:"status"` + Proxy *crsProxy `json:"proxy"` + Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` +} + +type crsGeminiAPIKeyAccount struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + IsActive bool `json:"isActive"` + Schedulable bool `json:"schedulable"` + Priority int `json:"priority"` + Status string `json:"status"` + Proxy *crsProxy `json:"proxy"` + Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` +} + func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) { baseURL, err := normalizeBaseURL(input.BaseURL) if err != nil { @@ -174,7 +211,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput Items: make( []SyncFromCRSItemResult, 0, - len(exported.Data.ClaudeAccounts)+len(exported.Data.ClaudeConsoleAccounts)+len(exported.Data.OpenAIOAuthAccounts)+len(exported.Data.OpenAIResponsesAccounts), + len(exported.Data.ClaudeAccounts)+len(exported.Data.ClaudeConsoleAccounts)+len(exported.Data.OpenAIOAuthAccounts)+len(exported.Data.OpenAIResponsesAccounts)+len(exported.Data.GeminiOAuthAccounts)+len(exported.Data.GeminiAPIKeyAccounts), ), } @@ -678,6 +715,225 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput result.Items = append(result.Items, item) } + // Gemini OAuth -> sub2api gemini oauth + for _, src := range exported.Data.GeminiOAuthAccounts { + item := SyncFromCRSItemResult{ + CRSAccountID: src.ID, + Kind: src.Kind, + Name: src.Name, + } + + refreshToken, _ := src.Credentials["refresh_token"].(string) + if strings.TrimSpace(refreshToken) == "" { + item.Action = "failed" + item.Error = "missing refresh_token" + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + proxyID, err := s.mapOrCreateProxy(ctx, input.SyncProxies, &proxies, src.Proxy, fmt.Sprintf("crs-%s", src.Name)) + if err != nil { + item.Action = "failed" + item.Error = "proxy sync failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + credentials := sanitizeCredentialsMap(src.Credentials) + if v, ok := credentials["token_type"].(string); !ok || strings.TrimSpace(v) == "" { + credentials["token_type"] = "Bearer" + } + // Convert expires_at from RFC3339 to Unix seconds string (recommended to keep consistent with GetCredential()) + if expiresAtStr, ok := credentials["expires_at"].(string); ok && strings.TrimSpace(expiresAtStr) != "" { + if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil { + credentials["expires_at"] = strconv.FormatInt(t.Unix(), 10) + } + } + + extra := make(map[string]any) + if src.Extra != nil { + for k, v := range src.Extra { + extra[k] = v + } + } + extra["crs_account_id"] = src.ID + extra["crs_kind"] = src.Kind + extra["crs_synced_at"] = now + + existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) + if err != nil { + item.Action = "failed" + item.Error = "db lookup failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if existing == nil { + account := &Account{ + Name: defaultName(src.Name, src.ID), + Platform: PlatformGemini, + Type: AccountTypeOAuth, + Credentials: credentials, + Extra: extra, + ProxyID: proxyID, + Concurrency: 3, + Priority: clampPriority(src.Priority), + Status: mapCRSStatus(src.IsActive, src.Status), + Schedulable: src.Schedulable, + } + if err := s.accountRepo.Create(ctx, account); err != nil { + item.Action = "failed" + item.Error = "create failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + if refreshedCreds := s.refreshOAuthToken(ctx, account); refreshedCreds != nil { + account.Credentials = refreshedCreds + _ = s.accountRepo.Update(ctx, account) + } + item.Action = "created" + result.Created++ + result.Items = append(result.Items, item) + continue + } + + existing.Extra = mergeMap(existing.Extra, extra) + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = PlatformGemini + existing.Type = AccountTypeOAuth + existing.Credentials = mergeMap(existing.Credentials, credentials) + if proxyID != nil { + existing.ProxyID = proxyID + } + existing.Concurrency = 3 + existing.Priority = clampPriority(src.Priority) + existing.Status = mapCRSStatus(src.IsActive, src.Status) + existing.Schedulable = src.Schedulable + + if err := s.accountRepo.Update(ctx, existing); err != nil { + item.Action = "failed" + item.Error = "update failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if refreshedCreds := s.refreshOAuthToken(ctx, existing); refreshedCreds != nil { + existing.Credentials = refreshedCreds + _ = s.accountRepo.Update(ctx, existing) + } + + item.Action = "updated" + result.Updated++ + result.Items = append(result.Items, item) + } + + // Gemini API Key -> sub2api gemini apikey + for _, src := range exported.Data.GeminiAPIKeyAccounts { + item := SyncFromCRSItemResult{ + CRSAccountID: src.ID, + Kind: src.Kind, + Name: src.Name, + } + + apiKey, _ := src.Credentials["api_key"].(string) + if strings.TrimSpace(apiKey) == "" { + item.Action = "failed" + item.Error = "missing api_key" + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + proxyID, err := s.mapOrCreateProxy(ctx, input.SyncProxies, &proxies, src.Proxy, fmt.Sprintf("crs-%s", src.Name)) + if err != nil { + item.Action = "failed" + item.Error = "proxy sync failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + credentials := sanitizeCredentialsMap(src.Credentials) + if baseURL, ok := credentials["base_url"].(string); !ok || strings.TrimSpace(baseURL) == "" { + credentials["base_url"] = "https://generativelanguage.googleapis.com" + } + + extra := make(map[string]any) + if src.Extra != nil { + for k, v := range src.Extra { + extra[k] = v + } + } + extra["crs_account_id"] = src.ID + extra["crs_kind"] = src.Kind + extra["crs_synced_at"] = now + + existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) + if err != nil { + item.Action = "failed" + item.Error = "db lookup failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if existing == nil { + account := &Account{ + Name: defaultName(src.Name, src.ID), + Platform: PlatformGemini, + Type: AccountTypeApiKey, + Credentials: credentials, + Extra: extra, + ProxyID: proxyID, + Concurrency: 3, + Priority: clampPriority(src.Priority), + Status: mapCRSStatus(src.IsActive, src.Status), + Schedulable: src.Schedulable, + } + if err := s.accountRepo.Create(ctx, account); err != nil { + item.Action = "failed" + item.Error = "create failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + item.Action = "created" + result.Created++ + result.Items = append(result.Items, item) + continue + } + + existing.Extra = mergeMap(existing.Extra, extra) + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = PlatformGemini + existing.Type = AccountTypeApiKey + existing.Credentials = mergeMap(existing.Credentials, credentials) + if proxyID != nil { + existing.ProxyID = proxyID + } + existing.Concurrency = 3 + existing.Priority = clampPriority(src.Priority) + existing.Status = mapCRSStatus(src.IsActive, src.Status) + existing.Schedulable = src.Schedulable + + if err := s.accountRepo.Update(ctx, existing); err != nil { + item.Action = "failed" + item.Error = "update failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + item.Action = "updated" + result.Updated++ + result.Items = append(result.Items, item) + } + return result, nil } @@ -944,6 +1200,21 @@ func (s *CRSSyncService) refreshOAuthToken(ctx context.Context, account *Account } } } + case PlatformGemini: + if s.geminiOAuthService == nil { + return nil + } + tokenInfo, refreshErr := s.geminiOAuthService.RefreshAccountToken(ctx, account) + if refreshErr != nil { + err = refreshErr + } else { + newCredentials = s.geminiOAuthService.BuildAccountCredentials(tokenInfo) + for k, v := range account.Credentials { + if _, exists := newCredentials[k]; !exists { + newCredentials[k] = v + } + } + } default: return nil } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 5ae05b82..d25bb314 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -320,8 +320,17 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int selected = acc } else if acc.Priority == selected.Priority { // 优先级相同时,选最久未用的 - if acc.LastUsedAt == nil || (selected.LastUsedAt != nil && acc.LastUsedAt.Before(*selected.LastUsedAt)) { + switch { + case acc.LastUsedAt == nil && selected.LastUsedAt != nil: selected = acc + case acc.LastUsedAt != nil && selected.LastUsedAt == nil: + // keep selected (never used is preferred) + case acc.LastUsedAt == nil && selected.LastUsedAt == nil: + // keep selected (both never used) + default: + if acc.LastUsedAt.Before(*selected.LastUsedAt) { + selected = acc + } } } } diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go new file mode 100644 index 00000000..e2462f3a --- /dev/null +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -0,0 +1,2176 @@ +package service + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math" + mathrand "math/rand" + "net/http" + "regexp" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" + "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" + + "github.com/gin-gonic/gin" +) + +const geminiStickySessionTTL = time.Hour + +const ( + geminiMaxRetries = 5 + geminiRetryBaseDelay = 1 * time.Second + geminiRetryMaxDelay = 16 * time.Second +) + +type GeminiMessagesCompatService struct { + accountRepo AccountRepository + cache GatewayCache + tokenProvider *GeminiTokenProvider + rateLimitService *RateLimitService + httpUpstream HTTPUpstream +} + +func NewGeminiMessagesCompatService( + accountRepo AccountRepository, + cache GatewayCache, + tokenProvider *GeminiTokenProvider, + rateLimitService *RateLimitService, + httpUpstream HTTPUpstream, +) *GeminiMessagesCompatService { + return &GeminiMessagesCompatService{ + accountRepo: accountRepo, + cache: cache, + tokenProvider: tokenProvider, + rateLimitService: rateLimitService, + httpUpstream: httpUpstream, + } +} + +// GetTokenProvider returns the token provider for OAuth accounts +func (s *GeminiMessagesCompatService) GetTokenProvider() *GeminiTokenProvider { + return s.tokenProvider +} + +func (s *GeminiMessagesCompatService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) { + cacheKey := "gemini:" + sessionHash + if sessionHash != "" { + accountID, err := s.cache.GetSessionAccountID(ctx, cacheKey) + if err == nil && accountID > 0 { + account, err := s.accountRepo.GetByID(ctx, accountID) + if err == nil && account.IsSchedulable() && account.Platform == PlatformGemini && (requestedModel == "" || account.IsModelSupported(requestedModel)) { + _ = s.cache.RefreshSessionTTL(ctx, cacheKey, geminiStickySessionTTL) + return account, nil + } + } + } + + var accounts []Account + var err error + if groupID != nil { + accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformGemini) + } else { + accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformGemini) + } + if err != nil { + return nil, fmt.Errorf("query accounts failed: %w", err) + } + + var selected *Account + for i := range accounts { + acc := &accounts[i] + if requestedModel != "" && !acc.IsModelSupported(requestedModel) { + continue + } + if selected == nil { + selected = acc + continue + } + if acc.Priority < selected.Priority { + selected = acc + } else if acc.Priority == selected.Priority { + switch { + case acc.LastUsedAt == nil && selected.LastUsedAt != nil: + selected = acc + case acc.LastUsedAt != nil && selected.LastUsedAt == nil: + // keep selected (never used is preferred) + case acc.LastUsedAt == nil && selected.LastUsedAt == nil: + // Prefer OAuth accounts when both are unused (more compatible for Code Assist flows). + if acc.Type == AccountTypeOAuth && selected.Type != AccountTypeOAuth { + selected = acc + } + default: + if acc.LastUsedAt.Before(*selected.LastUsedAt) { + selected = acc + } + } + } + } + + if selected == nil { + if requestedModel != "" { + return nil, fmt.Errorf("no available Gemini accounts supporting model: %s", requestedModel) + } + return nil, errors.New("no available Gemini accounts") + } + + if sessionHash != "" { + _ = s.cache.SetSessionAccountID(ctx, cacheKey, selected.ID, geminiStickySessionTTL) + } + + return selected, nil +} + +// SelectAccountForAIStudioEndpoints selects an account that is likely to succeed against +// generativelanguage.googleapis.com (e.g. GET /v1beta/models). +// +// Preference order: +// 1) API key accounts (AI Studio) +// 2) OAuth accounts without project_id (AI Studio OAuth) +// 3) OAuth accounts explicitly marked as ai_studio +// 4) Any remaining Gemini accounts (fallback) +func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx context.Context, groupID *int64) (*Account, error) { + var accounts []Account + var err error + if groupID != nil { + accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformGemini) + } else { + accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformGemini) + } + if err != nil { + return nil, fmt.Errorf("query accounts failed: %w", err) + } + if len(accounts) == 0 { + return nil, errors.New("no available Gemini accounts") + } + + rank := func(a *Account) int { + if a == nil { + return 999 + } + switch a.Type { + case AccountTypeApiKey: + if strings.TrimSpace(a.GetCredential("api_key")) != "" { + return 0 + } + return 9 + case AccountTypeOAuth: + if strings.TrimSpace(a.GetCredential("project_id")) == "" { + return 1 + } + if strings.TrimSpace(a.GetCredential("oauth_type")) == "ai_studio" { + return 2 + } + // Code Assist OAuth tokens often lack AI Studio scopes for models listing. + return 3 + default: + return 10 + } + } + + var selected *Account + for i := range accounts { + acc := &accounts[i] + if selected == nil { + selected = acc + continue + } + + r1, r2 := rank(acc), rank(selected) + if r1 < r2 { + selected = acc + continue + } + if r1 > r2 { + continue + } + + if acc.Priority < selected.Priority { + selected = acc + } else if acc.Priority == selected.Priority { + switch { + case acc.LastUsedAt == nil && selected.LastUsedAt != nil: + selected = acc + case acc.LastUsedAt != nil && selected.LastUsedAt == nil: + // keep selected + case acc.LastUsedAt == nil && selected.LastUsedAt == nil: + if acc.Type == AccountTypeOAuth && selected.Type != AccountTypeOAuth { + selected = acc + } + default: + if acc.LastUsedAt.Before(*selected.LastUsedAt) { + selected = acc + } + } + } + } + + if selected == nil { + return nil, errors.New("no available Gemini accounts") + } + return selected, nil +} + +func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + startTime := time.Now() + + var req struct { + Model string `json:"model"` + Stream bool `json:"stream"` + } + if err := json.Unmarshal(body, &req); err != nil { + return nil, fmt.Errorf("parse request: %w", err) + } + if strings.TrimSpace(req.Model) == "" { + return nil, fmt.Errorf("missing model") + } + + originalModel := req.Model + mappedModel := req.Model + if account.Type == AccountTypeApiKey { + mappedModel = account.GetMappedModel(req.Model) + } + + geminiReq, err := convertClaudeMessagesToGeminiGenerateContent(body) + if err != nil { + return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + var requestIDHeader string + var buildReq func(ctx context.Context) (*http.Request, string, error) + useUpstreamStream := req.Stream + if account.Type == AccountTypeOAuth && !req.Stream && strings.TrimSpace(account.GetCredential("project_id")) != "" { + // Code Assist's non-streaming generateContent may return no content; use streaming upstream and aggregate. + useUpstreamStream = true + } + + switch account.Type { + case AccountTypeApiKey: + buildReq = func(ctx context.Context) (*http.Request, string, error) { + apiKey := account.GetCredential("api_key") + if strings.TrimSpace(apiKey) == "" { + return nil, "", errors.New("gemini api_key not configured") + } + + baseURL := strings.TrimRight(account.GetCredential("base_url"), "/") + if baseURL == "" { + baseURL = geminicli.AIStudioBaseURL + } + + action := "generateContent" + if req.Stream { + action = "streamGenerateContent" + } + fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(baseURL, "/"), mappedModel, action) + if req.Stream { + fullURL += "?alt=sse" + } + + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(geminiReq)) + if err != nil { + return nil, "", err + } + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("x-goog-api-key", apiKey) + return upstreamReq, "x-request-id", nil + } + requestIDHeader = "x-request-id" + + case AccountTypeOAuth: + buildReq = func(ctx context.Context) (*http.Request, string, error) { + if s.tokenProvider == nil { + return nil, "", errors.New("gemini token provider not configured") + } + accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, "", err + } + + projectID := strings.TrimSpace(account.GetCredential("project_id")) + + action := "generateContent" + if useUpstreamStream { + action = "streamGenerateContent" + } + + // Two modes for OAuth: + // 1. With project_id -> Code Assist API (wrapped request) + // 2. Without project_id -> AI Studio API (direct OAuth, like API key but with Bearer token) + if projectID != "" { + // Mode 1: Code Assist API + fullURL := fmt.Sprintf("%s/v1internal:%s", geminicli.GeminiCliBaseURL, action) + if useUpstreamStream { + fullURL += "?alt=sse" + } + + wrapped := map[string]any{ + "model": mappedModel, + "project": projectID, + } + var inner any + if err := json.Unmarshal(geminiReq, &inner); err != nil { + return nil, "", fmt.Errorf("failed to parse gemini request: %w", err) + } + wrapped["request"] = inner + wrappedBytes, _ := json.Marshal(wrapped) + + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(wrappedBytes)) + if err != nil { + return nil, "", err + } + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) + upstreamReq.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent) + return upstreamReq, "x-request-id", nil + } else { + // Mode 2: AI Studio API with OAuth (like API key mode, but using Bearer token) + baseURL := strings.TrimRight(account.GetCredential("base_url"), "/") + if baseURL == "" { + baseURL = geminicli.AIStudioBaseURL + } + + fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", baseURL, mappedModel, action) + if useUpstreamStream { + fullURL += "?alt=sse" + } + + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(geminiReq)) + if err != nil { + return nil, "", err + } + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) + return upstreamReq, "x-request-id", nil + } + } + requestIDHeader = "x-request-id" + + default: + return nil, fmt.Errorf("unsupported account type: %s", account.Type) + } + + var resp *http.Response + for attempt := 1; attempt <= geminiMaxRetries; attempt++ { + upstreamReq, idHeader, err := buildReq(ctx) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + // Local build error: don't retry. + if strings.Contains(err.Error(), "missing project_id") { + return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", err.Error()) + } + requestIDHeader = idHeader + + resp, err = s.httpUpstream.Do(upstreamReq, proxyURL) + if err != nil { + if attempt < geminiMaxRetries { + log.Printf("Gemini account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, geminiMaxRetries, err) + sleepGeminiBackoff(attempt) + continue + } + return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries: "+sanitizeUpstreamErrorMessage(err.Error())) + } + + if resp.StatusCode >= 400 && s.shouldRetryGeminiUpstreamError(account, resp.StatusCode) { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + _ = resp.Body.Close() + // Don't treat insufficient-scope as transient. + if resp.StatusCode == 403 && isGeminiInsufficientScope(resp.Header, respBody) { + resp = &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + } + break + } + if resp.StatusCode == 429 { + // Mark as rate-limited early so concurrent requests avoid this account. + s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + } + if attempt < geminiMaxRetries { + log.Printf("Gemini account %d: upstream status %d, retry %d/%d", account.ID, resp.StatusCode, attempt, geminiMaxRetries) + sleepGeminiBackoff(attempt) + continue + } + // Final attempt: surface the upstream error body (mapped below) instead of a generic retry error. + resp = &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + } + break + } + + break + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + return nil, s.writeGeminiMappedError(c, resp.StatusCode, respBody) + } + + requestID := resp.Header.Get(requestIDHeader) + if requestID == "" { + requestID = resp.Header.Get("x-goog-request-id") + } + if requestID != "" { + c.Header("x-request-id", requestID) + } + + var usage *ClaudeUsage + var firstTokenMs *int + if req.Stream { + streamRes, err := s.handleStreamingResponse(c, resp, startTime, originalModel) + if err != nil { + return nil, err + } + usage = streamRes.usage + firstTokenMs = streamRes.firstTokenMs + } else { + if useUpstreamStream { + collected, usageObj, err := collectGeminiSSE(resp.Body, true) + if err != nil { + return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to read upstream stream") + } + claudeResp, usageObj2 := convertGeminiToClaudeMessage(collected, originalModel) + c.JSON(http.StatusOK, claudeResp) + usage = usageObj2 + if usageObj != nil && (usageObj.InputTokens > 0 || usageObj.OutputTokens > 0) { + usage = usageObj + } + } else { + usage, err = s.handleNonStreamingResponse(c, resp, originalModel) + if err != nil { + return nil, err + } + } + } + + return &ForwardResult{ + RequestID: requestID, + Usage: *usage, + Model: originalModel, + Stream: req.Stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + }, nil +} + +func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte) (*ForwardResult, error) { + startTime := time.Now() + + if strings.TrimSpace(originalModel) == "" { + return nil, s.writeGoogleError(c, http.StatusBadRequest, "Missing model in URL") + } + if strings.TrimSpace(action) == "" { + return nil, s.writeGoogleError(c, http.StatusBadRequest, "Missing action in URL") + } + if len(body) == 0 { + return nil, s.writeGoogleError(c, http.StatusBadRequest, "Request body is empty") + } + + switch action { + case "generateContent", "streamGenerateContent", "countTokens": + // ok + default: + return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action) + } + + mappedModel := originalModel + if account.Type == AccountTypeApiKey { + mappedModel = account.GetMappedModel(originalModel) + } + + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + useUpstreamStream := stream + upstreamAction := action + if account.Type == AccountTypeOAuth && !stream && action == "generateContent" && strings.TrimSpace(account.GetCredential("project_id")) != "" { + // Code Assist's non-streaming generateContent may return no content; use streaming upstream and aggregate. + useUpstreamStream = true + upstreamAction = "streamGenerateContent" + } + forceAIStudio := action == "countTokens" + + var requestIDHeader string + var buildReq func(ctx context.Context) (*http.Request, string, error) + + switch account.Type { + case AccountTypeApiKey: + buildReq = func(ctx context.Context) (*http.Request, string, error) { + apiKey := account.GetCredential("api_key") + if strings.TrimSpace(apiKey) == "" { + return nil, "", errors.New("gemini api_key not configured") + } + + baseURL := strings.TrimRight(account.GetCredential("base_url"), "/") + if baseURL == "" { + baseURL = geminicli.AIStudioBaseURL + } + + fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(baseURL, "/"), mappedModel, upstreamAction) + if useUpstreamStream { + fullURL += "?alt=sse" + } + + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(body)) + if err != nil { + return nil, "", err + } + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("x-goog-api-key", apiKey) + return upstreamReq, "x-request-id", nil + } + requestIDHeader = "x-request-id" + + case AccountTypeOAuth: + buildReq = func(ctx context.Context) (*http.Request, string, error) { + if s.tokenProvider == nil { + return nil, "", errors.New("gemini token provider not configured") + } + accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, "", err + } + + projectID := strings.TrimSpace(account.GetCredential("project_id")) + + // Two modes for OAuth: + // 1. With project_id -> Code Assist API (wrapped request) + // 2. Without project_id -> AI Studio API (direct OAuth, like API key but with Bearer token) + if projectID != "" && !forceAIStudio { + // Mode 1: Code Assist API + fullURL := fmt.Sprintf("%s/v1internal:%s", geminicli.GeminiCliBaseURL, upstreamAction) + if useUpstreamStream { + fullURL += "?alt=sse" + } + + wrapped := map[string]any{ + "model": mappedModel, + "project": projectID, + } + var inner any + if err := json.Unmarshal(body, &inner); err != nil { + return nil, "", fmt.Errorf("failed to parse gemini request: %w", err) + } + wrapped["request"] = inner + wrappedBytes, _ := json.Marshal(wrapped) + + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(wrappedBytes)) + if err != nil { + return nil, "", err + } + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) + upstreamReq.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent) + return upstreamReq, "x-request-id", nil + } else { + // Mode 2: AI Studio API with OAuth (like API key mode, but using Bearer token) + baseURL := strings.TrimRight(account.GetCredential("base_url"), "/") + if baseURL == "" { + baseURL = geminicli.AIStudioBaseURL + } + + fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", baseURL, mappedModel, upstreamAction) + if useUpstreamStream { + fullURL += "?alt=sse" + } + + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(body)) + if err != nil { + return nil, "", err + } + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) + return upstreamReq, "x-request-id", nil + } + } + requestIDHeader = "x-request-id" + + default: + return nil, s.writeGoogleError(c, http.StatusBadGateway, "Unsupported account type: "+account.Type) + } + + var resp *http.Response + for attempt := 1; attempt <= geminiMaxRetries; attempt++ { + upstreamReq, idHeader, err := buildReq(ctx) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + // Local build error: don't retry. + if strings.Contains(err.Error(), "missing project_id") { + return nil, s.writeGoogleError(c, http.StatusBadRequest, err.Error()) + } + return nil, s.writeGoogleError(c, http.StatusBadGateway, err.Error()) + } + requestIDHeader = idHeader + + resp, err = s.httpUpstream.Do(upstreamReq, proxyURL) + if err != nil { + if attempt < geminiMaxRetries { + log.Printf("Gemini account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, geminiMaxRetries, err) + sleepGeminiBackoff(attempt) + continue + } + if action == "countTokens" { + estimated := estimateGeminiCountTokens(body) + c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) + return &ForwardResult{ + RequestID: "", + Usage: ClaudeUsage{}, + Model: originalModel, + Stream: false, + Duration: time.Since(startTime), + FirstTokenMs: nil, + }, nil + } + return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries: "+sanitizeUpstreamErrorMessage(err.Error())) + } + + if resp.StatusCode >= 400 && s.shouldRetryGeminiUpstreamError(account, resp.StatusCode) { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + _ = resp.Body.Close() + // Don't treat insufficient-scope as transient. + if resp.StatusCode == 403 && isGeminiInsufficientScope(resp.Header, respBody) { + resp = &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + } + break + } + if resp.StatusCode == 429 { + s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + } + if attempt < geminiMaxRetries { + log.Printf("Gemini account %d: upstream status %d, retry %d/%d", account.ID, resp.StatusCode, attempt, geminiMaxRetries) + sleepGeminiBackoff(attempt) + continue + } + if action == "countTokens" { + estimated := estimateGeminiCountTokens(body) + c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) + return &ForwardResult{ + RequestID: "", + Usage: ClaudeUsage{}, + Model: originalModel, + Stream: false, + Duration: time.Since(startTime), + FirstTokenMs: nil, + }, nil + } + // Final attempt: surface the upstream error body (passed through below) instead of a generic retry error. + resp = &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + } + break + } + + break + } + defer func() { _ = resp.Body.Close() }() + + requestID := resp.Header.Get(requestIDHeader) + if requestID == "" { + requestID = resp.Header.Get("x-goog-request-id") + } + if requestID != "" { + c.Header("x-request-id", requestID) + } + + isOAuth := account.Type == AccountTypeOAuth + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + + // Best-effort fallback for OAuth tokens missing AI Studio scopes when calling countTokens. + // This avoids Gemini SDKs failing hard during preflight token counting. + if action == "countTokens" && isOAuth && isGeminiInsufficientScope(resp.Header, respBody) { + estimated := estimateGeminiCountTokens(body) + c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) + return &ForwardResult{ + RequestID: requestID, + Usage: ClaudeUsage{}, + Model: originalModel, + Stream: false, + Duration: time.Since(startTime), + FirstTokenMs: nil, + }, nil + } + + respBody = unwrapIfNeeded(isOAuth, respBody) + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + c.Data(resp.StatusCode, contentType, respBody) + return nil, fmt.Errorf("gemini upstream error: %d", resp.StatusCode) + } + + var usage *ClaudeUsage + var firstTokenMs *int + + if stream { + streamRes, err := s.handleNativeStreamingResponse(c, resp, startTime, isOAuth) + if err != nil { + return nil, err + } + usage = streamRes.usage + firstTokenMs = streamRes.firstTokenMs + } else { + if useUpstreamStream { + collected, usageObj, err := collectGeminiSSE(resp.Body, isOAuth) + if err != nil { + return nil, s.writeGoogleError(c, http.StatusBadGateway, "Failed to read upstream stream") + } + b, _ := json.Marshal(collected) + c.Data(http.StatusOK, "application/json", b) + usage = usageObj + } else { + usageResp, err := s.handleNativeNonStreamingResponse(c, resp, isOAuth) + if err != nil { + return nil, err + } + usage = usageResp + } + } + + if usage == nil { + usage = &ClaudeUsage{} + } + + return &ForwardResult{ + RequestID: requestID, + Usage: *usage, + Model: originalModel, + Stream: stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + }, nil +} + +func (s *GeminiMessagesCompatService) shouldRetryGeminiUpstreamError(account *Account, statusCode int) bool { + switch statusCode { + case 429, 500, 502, 503, 504, 529: + return true + case 403: + // GeminiCli OAuth occasionally returns 403 transiently (activation/quota propagation); allow retry. + if account == nil || account.Type != AccountTypeOAuth { + return false + } + oauthType := strings.ToLower(strings.TrimSpace(account.GetCredential("oauth_type"))) + if oauthType == "" && strings.TrimSpace(account.GetCredential("project_id")) != "" { + // Legacy/implicit Code Assist OAuth accounts. + oauthType = "code_assist" + } + return oauthType == "code_assist" + default: + return false + } +} + +func sleepGeminiBackoff(attempt int) { + delay := geminiRetryBaseDelay * time.Duration(1< geminiRetryMaxDelay { + delay = geminiRetryMaxDelay + } + + // +/- 20% jitter + r := mathrand.New(mathrand.NewSource(time.Now().UnixNano())) + jitter := time.Duration(float64(delay) * 0.2 * (r.Float64()*2 - 1)) + sleepFor := delay + jitter + if sleepFor < 0 { + sleepFor = 0 + } + time.Sleep(sleepFor) +} + +var sensitiveQueryParamRegex = regexp.MustCompile(`(?i)([?&](?:key|client_secret|access_token|refresh_token)=)[^&"\s]+`) + +func sanitizeUpstreamErrorMessage(msg string) string { + if msg == "" { + return msg + } + return sensitiveQueryParamRegex.ReplaceAllString(msg, `$1***`) +} + +func (s *GeminiMessagesCompatService) writeGeminiMappedError(c *gin.Context, upstreamStatus int, body []byte) error { + var statusCode int + var errType, errMsg string + + if mapped := mapGeminiErrorBodyToClaudeError(body); mapped != nil { + errType = mapped.Type + if mapped.Message != "" { + errMsg = mapped.Message + } + if mapped.StatusCode > 0 { + statusCode = mapped.StatusCode + } + } + + switch upstreamStatus { + case 400: + if statusCode == 0 { + statusCode = http.StatusBadRequest + } + if errType == "" { + errType = "invalid_request_error" + } + if errMsg == "" { + errMsg = "Invalid request" + } + case 401: + if statusCode == 0 { + statusCode = http.StatusBadGateway + } + if errType == "" { + errType = "authentication_error" + } + if errMsg == "" { + errMsg = "Upstream authentication failed, please contact administrator" + } + case 403: + if statusCode == 0 { + statusCode = http.StatusBadGateway + } + if errType == "" { + errType = "permission_error" + } + if errMsg == "" { + errMsg = "Upstream access forbidden, please contact administrator" + } + case 404: + if statusCode == 0 { + statusCode = http.StatusNotFound + } + if errType == "" { + errType = "not_found_error" + } + if errMsg == "" { + errMsg = "Resource not found" + } + case 429: + if statusCode == 0 { + statusCode = http.StatusTooManyRequests + } + if errType == "" { + errType = "rate_limit_error" + } + if errMsg == "" { + errMsg = "Upstream rate limit exceeded, please retry later" + } + case 529: + if statusCode == 0 { + statusCode = http.StatusServiceUnavailable + } + if errType == "" { + errType = "overloaded_error" + } + if errMsg == "" { + errMsg = "Upstream service overloaded, please retry later" + } + case 500, 502, 503, 504: + if statusCode == 0 { + statusCode = http.StatusBadGateway + } + if errType == "" { + switch upstreamStatus { + case 504: + errType = "timeout_error" + case 503: + errType = "overloaded_error" + default: + errType = "api_error" + } + } + if errMsg == "" { + errMsg = "Upstream service temporarily unavailable" + } + default: + if statusCode == 0 { + statusCode = http.StatusBadGateway + } + if errType == "" { + errType = "upstream_error" + } + if errMsg == "" { + errMsg = "Upstream request failed" + } + } + + c.JSON(statusCode, gin.H{ + "type": "error", + "error": gin.H{"type": errType, "message": errMsg}, + }) + return fmt.Errorf("upstream error: %d", upstreamStatus) +} + +type claudeErrorMapping struct { + Type string + Message string + StatusCode int +} + +func mapGeminiErrorBodyToClaudeError(body []byte) *claudeErrorMapping { + if len(body) == 0 { + return nil + } + + var parsed struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil + } + if strings.TrimSpace(parsed.Error.Status) == "" && parsed.Error.Code == 0 && strings.TrimSpace(parsed.Error.Message) == "" { + return nil + } + + mapped := &claudeErrorMapping{ + Type: mapGeminiStatusToClaudeErrorType(parsed.Error.Status), + Message: "", + } + if mapped.Type == "" { + mapped.Type = "upstream_error" + } + + switch strings.ToUpper(strings.TrimSpace(parsed.Error.Status)) { + case "INVALID_ARGUMENT": + mapped.StatusCode = http.StatusBadRequest + case "NOT_FOUND": + mapped.StatusCode = http.StatusNotFound + case "RESOURCE_EXHAUSTED": + mapped.StatusCode = http.StatusTooManyRequests + default: + // Keep StatusCode unset and let HTTP status mapping decide. + } + + // Keep messages generic by default; upstream error message can be long or include sensitive fragments. + return mapped +} + +func mapGeminiStatusToClaudeErrorType(status string) string { + switch strings.ToUpper(strings.TrimSpace(status)) { + case "INVALID_ARGUMENT": + return "invalid_request_error" + case "PERMISSION_DENIED": + return "permission_error" + case "NOT_FOUND": + return "not_found_error" + case "RESOURCE_EXHAUSTED": + return "rate_limit_error" + case "UNAUTHENTICATED": + return "authentication_error" + case "UNAVAILABLE": + return "overloaded_error" + case "INTERNAL": + return "api_error" + case "DEADLINE_EXCEEDED": + return "timeout_error" + default: + return "" + } +} + +type geminiStreamResult struct { + usage *ClaudeUsage + firstTokenMs *int +} + +func (s *GeminiMessagesCompatService) handleNonStreamingResponse(c *gin.Context, resp *http.Response, originalModel string) (*ClaudeUsage, error) { + body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to read upstream response") + } + + geminiResp, err := unwrapGeminiResponse(body) + if err != nil { + return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response") + } + + claudeResp, usage := convertGeminiToClaudeMessage(geminiResp, originalModel) + c.JSON(http.StatusOK, claudeResp) + + return usage, nil +} + +func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*geminiStreamResult, error) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return nil, errors.New("streaming not supported") + } + + messageID := "msg_" + randomHex(12) + messageStart := map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": messageID, + "type": "message", + "role": "assistant", + "model": originalModel, + "content": []any{}, + "stop_reason": nil, + "stop_sequence": nil, + "usage": map[string]any{ + "input_tokens": 0, + "output_tokens": 0, + }, + }, + } + writeSSE(c.Writer, "message_start", messageStart) + flusher.Flush() + + var firstTokenMs *int + var usage ClaudeUsage + finishReason := "" + sawToolUse := false + + nextBlockIndex := 0 + openBlockIndex := -1 + openBlockType := "" + seenText := "" + openToolIndex := -1 + openToolID := "" + openToolName := "" + seenToolJSON := "" + + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("stream read error: %w", err) + } + + if !strings.HasPrefix(line, "data:") { + if errors.Is(err, io.EOF) { + break + } + continue + } + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if payload == "" || payload == "[DONE]" { + if errors.Is(err, io.EOF) { + break + } + continue + } + + geminiResp, err := unwrapGeminiResponse([]byte(payload)) + if err != nil { + continue + } + + if fr := extractGeminiFinishReason(geminiResp); fr != "" { + finishReason = fr + } + + parts := extractGeminiParts(geminiResp) + for _, part := range parts { + if text, ok := part["text"].(string); ok && text != "" { + delta, newSeen := computeGeminiTextDelta(seenText, text) + seenText = newSeen + if delta == "" { + continue + } + + if openBlockType != "text" { + if openBlockIndex >= 0 { + writeSSE(c.Writer, "content_block_stop", map[string]any{ + "type": "content_block_stop", + "index": openBlockIndex, + }) + } + openBlockType = "text" + openBlockIndex = nextBlockIndex + nextBlockIndex++ + writeSSE(c.Writer, "content_block_start", map[string]any{ + "type": "content_block_start", + "index": openBlockIndex, + "content_block": map[string]any{ + "type": "text", + "text": "", + }, + }) + } + + if firstTokenMs == nil { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + writeSSE(c.Writer, "content_block_delta", map[string]any{ + "type": "content_block_delta", + "index": openBlockIndex, + "delta": map[string]any{ + "type": "text_delta", + "text": delta, + }, + }) + flusher.Flush() + continue + } + + if fc, ok := part["functionCall"].(map[string]any); ok && fc != nil { + name, _ := fc["name"].(string) + args := fc["args"] + if strings.TrimSpace(name) == "" { + name = "tool" + } + + // Close any open text block before tool_use. + if openBlockIndex >= 0 { + writeSSE(c.Writer, "content_block_stop", map[string]any{ + "type": "content_block_stop", + "index": openBlockIndex, + }) + openBlockIndex = -1 + openBlockType = "" + } + + // If we receive streamed tool args in pieces, keep a single tool block open and emit deltas. + if openToolIndex >= 0 && openToolName != name { + writeSSE(c.Writer, "content_block_stop", map[string]any{ + "type": "content_block_stop", + "index": openToolIndex, + }) + openToolIndex = -1 + openToolName = "" + seenToolJSON = "" + } + + if openToolIndex < 0 { + openToolID = "toolu_" + randomHex(8) + openToolIndex = nextBlockIndex + openToolName = name + nextBlockIndex++ + sawToolUse = true + + writeSSE(c.Writer, "content_block_start", map[string]any{ + "type": "content_block_start", + "index": openToolIndex, + "content_block": map[string]any{ + "type": "tool_use", + "id": openToolID, + "name": name, + "input": map[string]any{}, + }, + }) + } + + argsJSONText := "{}" + switch v := args.(type) { + case nil: + // keep default "{}" + case string: + if strings.TrimSpace(v) != "" { + argsJSONText = v + } + default: + if b, err := json.Marshal(args); err == nil && len(b) > 0 { + argsJSONText = string(b) + } + } + + delta, newSeen := computeGeminiTextDelta(seenToolJSON, argsJSONText) + seenToolJSON = newSeen + if delta != "" { + writeSSE(c.Writer, "content_block_delta", map[string]any{ + "type": "content_block_delta", + "index": openToolIndex, + "delta": map[string]any{ + "type": "input_json_delta", + "partial_json": delta, + }, + }) + } + flusher.Flush() + } + } + + if u := extractGeminiUsage(geminiResp); u != nil { + usage = *u + } + + // Process the final unterminated line at EOF as well. + if errors.Is(err, io.EOF) { + break + } + } + + if openBlockIndex >= 0 { + writeSSE(c.Writer, "content_block_stop", map[string]any{ + "type": "content_block_stop", + "index": openBlockIndex, + }) + } + if openToolIndex >= 0 { + writeSSE(c.Writer, "content_block_stop", map[string]any{ + "type": "content_block_stop", + "index": openToolIndex, + }) + } + + stopReason := mapGeminiFinishReasonToClaudeStopReason(finishReason) + if sawToolUse { + stopReason = "tool_use" + } + + usageObj := map[string]any{ + "output_tokens": usage.OutputTokens, + } + if usage.InputTokens > 0 { + usageObj["input_tokens"] = usage.InputTokens + } + writeSSE(c.Writer, "message_delta", map[string]any{ + "type": "message_delta", + "delta": map[string]any{ + "stop_reason": stopReason, + "stop_sequence": nil, + }, + "usage": usageObj, + }) + writeSSE(c.Writer, "message_stop", map[string]any{ + "type": "message_stop", + }) + flusher.Flush() + + return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil +} + +func writeSSE(w io.Writer, event string, data any) { + if event != "" { + _, _ = fmt.Fprintf(w, "event: %s\n", event) + } + b, _ := json.Marshal(data) + _, _ = fmt.Fprintf(w, "data: %s\n\n", string(b)) +} + +func randomHex(nBytes int) string { + b := make([]byte, nBytes) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func (s *GeminiMessagesCompatService) writeClaudeError(c *gin.Context, status int, errType, message string) error { + c.JSON(status, gin.H{ + "type": "error", + "error": gin.H{"type": errType, "message": message}, + }) + return fmt.Errorf("%s", message) +} + +func (s *GeminiMessagesCompatService) writeGoogleError(c *gin.Context, status int, message string) error { + c.JSON(status, gin.H{ + "error": gin.H{ + "code": status, + "message": message, + "status": googleapi.HTTPStatusToGoogleStatus(status), + }, + }) + return fmt.Errorf("%s", message) +} + +func unwrapIfNeeded(isOAuth bool, raw []byte) []byte { + if !isOAuth { + return raw + } + inner, err := unwrapGeminiResponse(raw) + if err != nil { + return raw + } + b, err := json.Marshal(inner) + if err != nil { + return raw + } + return b +} + +func collectGeminiSSE(body io.Reader, isOAuth bool) (map[string]any, *ClaudeUsage, error) { + reader := bufio.NewReader(body) + + var last map[string]any + var lastWithParts map[string]any + usage := &ClaudeUsage{} + + for { + line, err := reader.ReadString('\n') + if len(line) > 0 { + trimmed := strings.TrimRight(line, "\r\n") + if strings.HasPrefix(trimmed, "data:") { + payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + switch payload { + case "", "[DONE]": + if payload == "[DONE]" { + return pickGeminiCollectResult(last, lastWithParts), usage, nil + } + default: + var parsed map[string]any + if isOAuth { + inner, err := unwrapGeminiResponse([]byte(payload)) + if err == nil && inner != nil { + parsed = inner + } + } else { + _ = json.Unmarshal([]byte(payload), &parsed) + } + if parsed != nil { + last = parsed + if u := extractGeminiUsage(parsed); u != nil { + usage = u + } + if parts := extractGeminiParts(parsed); len(parts) > 0 { + lastWithParts = parsed + } + } + } + } + } + + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, nil, err + } + } + + return pickGeminiCollectResult(last, lastWithParts), usage, nil +} + +func pickGeminiCollectResult(last map[string]any, lastWithParts map[string]any) map[string]any { + if lastWithParts != nil { + return lastWithParts + } + if last != nil { + return last + } + return map[string]any{} +} + +type geminiNativeStreamResult struct { + usage *ClaudeUsage + firstTokenMs *int +} + +func isGeminiInsufficientScope(headers http.Header, body []byte) bool { + if strings.Contains(strings.ToLower(headers.Get("Www-Authenticate")), "insufficient_scope") { + return true + } + lower := strings.ToLower(string(body)) + return strings.Contains(lower, "insufficient authentication scopes") || strings.Contains(lower, "access_token_scope_insufficient") +} + +func estimateGeminiCountTokens(reqBody []byte) int { + var obj map[string]any + if err := json.Unmarshal(reqBody, &obj); err != nil { + return 0 + } + + var texts []string + + // systemInstruction.parts[].text + if si, ok := obj["systemInstruction"].(map[string]any); ok { + if parts, ok := si["parts"].([]any); ok { + for _, p := range parts { + if pm, ok := p.(map[string]any); ok { + if t, ok := pm["text"].(string); ok && strings.TrimSpace(t) != "" { + texts = append(texts, t) + } + } + } + } + } + + // contents[].parts[].text + if contents, ok := obj["contents"].([]any); ok { + for _, c := range contents { + cm, ok := c.(map[string]any) + if !ok { + continue + } + parts, ok := cm["parts"].([]any) + if !ok { + continue + } + for _, p := range parts { + pm, ok := p.(map[string]any) + if !ok { + continue + } + if t, ok := pm["text"].(string); ok && strings.TrimSpace(t) != "" { + texts = append(texts, t) + } + } + } + } + + total := 0 + for _, t := range texts { + total += estimateTokensForText(t) + } + if total < 0 { + return 0 + } + return total +} + +func estimateTokensForText(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + runes := []rune(s) + if len(runes) == 0 { + return 0 + } + ascii := 0 + for _, r := range runes { + if r <= 0x7f { + ascii++ + } + } + asciiRatio := float64(ascii) / float64(len(runes)) + if asciiRatio >= 0.8 { + // Roughly 4 chars per token for English-like text. + return (len(runes) + 3) / 4 + } + // For CJK-heavy text, approximate 1 rune per token. + return len(runes) +} + +type UpstreamHTTPResult struct { + StatusCode int + Headers http.Header + Body []byte +} + +func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Context, resp *http.Response, isOAuth bool) (*ClaudeUsage, error) { + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var parsed map[string]any + if isOAuth { + parsed, err = unwrapGeminiResponse(respBody) + if err == nil && parsed != nil { + respBody, _ = json.Marshal(parsed) + } + } else { + _ = json.Unmarshal(respBody, &parsed) + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + c.Data(resp.StatusCode, contentType, respBody) + + if parsed != nil { + if u := extractGeminiUsage(parsed); u != nil { + return u, nil + } + } + return &ClaudeUsage{}, nil +} + +func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, isOAuth bool) (*geminiNativeStreamResult, error) { + c.Status(resp.StatusCode) + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "text/event-stream; charset=utf-8" + } + c.Header("Content-Type", contentType) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return nil, errors.New("streaming not supported") + } + + reader := bufio.NewReader(resp.Body) + usage := &ClaudeUsage{} + var firstTokenMs *int + + for { + line, err := reader.ReadString('\n') + if len(line) > 0 { + trimmed := strings.TrimRight(line, "\r\n") + if strings.HasPrefix(trimmed, "data:") { + payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + // Keepalive / done markers + if payload == "" || payload == "[DONE]" { + _, _ = io.WriteString(c.Writer, line) + flusher.Flush() + } else { + var rawToWrite string + rawToWrite = payload + + var parsed map[string]any + if isOAuth { + inner, err := unwrapGeminiResponse([]byte(payload)) + if err == nil && inner != nil { + parsed = inner + if b, err := json.Marshal(inner); err == nil { + rawToWrite = string(b) + } + } + } else { + _ = json.Unmarshal([]byte(payload), &parsed) + } + + if parsed != nil { + if u := extractGeminiUsage(parsed); u != nil { + usage = u + } + } + + if firstTokenMs == nil { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + + if isOAuth { + // SSE format requires double newline (\n\n) to separate events + _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", rawToWrite) + } else { + // Pass-through for AI Studio responses. + _, _ = io.WriteString(c.Writer, line) + } + flusher.Flush() + } + } else { + _, _ = io.WriteString(c.Writer, line) + flusher.Flush() + } + } + + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + } + + return &geminiNativeStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil +} + +// ForwardAIStudioGET forwards a GET request to AI Studio (generativelanguage.googleapis.com) for +// endpoints like /v1beta/models and /v1beta/models/{model}. +// +// This is used to support Gemini SDKs that call models listing endpoints before generation. +func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, account *Account, path string) (*UpstreamHTTPResult, error) { + if account == nil { + return nil, errors.New("account is nil") + } + path = strings.TrimSpace(path) + if path == "" || !strings.HasPrefix(path, "/") { + return nil, errors.New("invalid path") + } + + baseURL := strings.TrimRight(account.GetCredential("base_url"), "/") + if baseURL == "" { + baseURL = geminicli.AIStudioBaseURL + } + fullURL := strings.TrimRight(baseURL, "/") + path + + var proxyURL string + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, err + } + + switch account.Type { + case AccountTypeApiKey: + apiKey := strings.TrimSpace(account.GetCredential("api_key")) + if apiKey == "" { + return nil, errors.New("gemini api_key not configured") + } + req.Header.Set("x-goog-api-key", apiKey) + case AccountTypeOAuth: + if s.tokenProvider == nil { + return nil, errors.New("gemini token provider not configured") + } + accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + default: + return nil, fmt.Errorf("unsupported account type: %s", account.Type) + } + + resp, err := s.httpUpstream.Do(req, proxyURL) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + return &UpstreamHTTPResult{ + StatusCode: resp.StatusCode, + Headers: resp.Header.Clone(), + Body: body, + }, nil +} + +func unwrapGeminiResponse(raw []byte) (map[string]any, error) { + var outer map[string]any + if err := json.Unmarshal(raw, &outer); err != nil { + return nil, err + } + if resp, ok := outer["response"].(map[string]any); ok && resp != nil { + return resp, nil + } + return outer, nil +} + +func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel string) (map[string]any, *ClaudeUsage) { + usage := extractGeminiUsage(geminiResp) + if usage == nil { + usage = &ClaudeUsage{} + } + + contentBlocks := make([]any, 0) + sawToolUse := false + if candidates, ok := geminiResp["candidates"].([]any); ok && len(candidates) > 0 { + if cand, ok := candidates[0].(map[string]any); ok { + if content, ok := cand["content"].(map[string]any); ok { + if parts, ok := content["parts"].([]any); ok { + for _, part := range parts { + pm, ok := part.(map[string]any) + if !ok { + continue + } + if text, ok := pm["text"].(string); ok && text != "" { + contentBlocks = append(contentBlocks, map[string]any{ + "type": "text", + "text": text, + }) + } + if fc, ok := pm["functionCall"].(map[string]any); ok { + name, _ := fc["name"].(string) + if strings.TrimSpace(name) == "" { + name = "tool" + } + args := fc["args"] + sawToolUse = true + contentBlocks = append(contentBlocks, map[string]any{ + "type": "tool_use", + "id": "toolu_" + randomHex(8), + "name": name, + "input": args, + }) + } + } + } + } + } + } + + stopReason := mapGeminiFinishReasonToClaudeStopReason(extractGeminiFinishReason(geminiResp)) + if sawToolUse { + stopReason = "tool_use" + } + + resp := map[string]any{ + "id": "msg_" + randomHex(12), + "type": "message", + "role": "assistant", + "model": originalModel, + "content": contentBlocks, + "stop_reason": stopReason, + "stop_sequence": nil, + "usage": map[string]any{ + "input_tokens": usage.InputTokens, + "output_tokens": usage.OutputTokens, + }, + } + + return resp, usage +} + +func extractGeminiUsage(geminiResp map[string]any) *ClaudeUsage { + usageMeta, ok := geminiResp["usageMetadata"].(map[string]any) + if !ok || usageMeta == nil { + return nil + } + prompt, _ := asInt(usageMeta["promptTokenCount"]) + cand, _ := asInt(usageMeta["candidatesTokenCount"]) + return &ClaudeUsage{ + InputTokens: prompt, + OutputTokens: cand, + } +} + +func asInt(v any) (int, bool) { + switch t := v.(type) { + case float64: + return int(t), true + case int: + return t, true + case int64: + return int(t), true + case json.Number: + i, err := t.Int64() + if err != nil { + return 0, false + } + return int(i), true + default: + return 0, false + } +} + +func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, body []byte) { + if s.rateLimitService != nil && (statusCode == 401 || statusCode == 403 || statusCode == 529) { + s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, headers, body) + return + } + if statusCode != 429 { + return + } + resetAt := parseGeminiRateLimitResetTime(body) + if resetAt == nil { + ra := time.Now().Add(5 * time.Minute) + _ = s.accountRepo.SetRateLimited(ctx, account.ID, ra) + return + } + _ = s.accountRepo.SetRateLimited(ctx, account.ID, time.Unix(*resetAt, 0)) +} + +func parseGeminiRateLimitResetTime(body []byte) *int64 { + // Try to parse metadata.quotaResetDelay like "12.345s" + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err == nil { + if errObj, ok := parsed["error"].(map[string]any); ok { + if msg, ok := errObj["message"].(string); ok { + if looksLikeGeminiDailyQuota(msg) { + if ts := nextGeminiDailyResetUnix(); ts != nil { + return ts + } + } + } + if details, ok := errObj["details"].([]any); ok { + for _, d := range details { + dm, ok := d.(map[string]any) + if !ok { + continue + } + if meta, ok := dm["metadata"].(map[string]any); ok { + if v, ok := meta["quotaResetDelay"].(string); ok { + if dur, err := time.ParseDuration(v); err == nil { + ts := time.Now().Unix() + int64(dur.Seconds()) + return &ts + } + } + } + } + } + } + } + + // Match "Please retry in Xs" + retryInRegex := regexp.MustCompile(`Please retry in ([0-9.]+)s`) + matches := retryInRegex.FindStringSubmatch(string(body)) + if len(matches) == 2 { + if dur, err := time.ParseDuration(matches[1] + "s"); err == nil { + ts := time.Now().Unix() + int64(math.Ceil(dur.Seconds())) + return &ts + } + } + + return nil +} + +func looksLikeGeminiDailyQuota(message string) bool { + m := strings.ToLower(message) + if strings.Contains(m, "per day") || strings.Contains(m, "requests per day") || strings.Contains(m, "quota") && strings.Contains(m, "per day") { + return true + } + return false +} + +func nextGeminiDailyResetUnix() *int64 { + loc, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + // Fallback: PST without DST. + loc = time.FixedZone("PST", -8*3600) + } + now := time.Now().In(loc) + reset := time.Date(now.Year(), now.Month(), now.Day(), 0, 5, 0, 0, loc) + if !reset.After(now) { + reset = reset.Add(24 * time.Hour) + } + ts := reset.Unix() + return &ts +} + +func extractGeminiFinishReason(geminiResp map[string]any) string { + if candidates, ok := geminiResp["candidates"].([]any); ok && len(candidates) > 0 { + if cand, ok := candidates[0].(map[string]any); ok { + if fr, ok := cand["finishReason"].(string); ok { + return fr + } + } + } + return "" +} + +func extractGeminiParts(geminiResp map[string]any) []map[string]any { + if candidates, ok := geminiResp["candidates"].([]any); ok && len(candidates) > 0 { + if cand, ok := candidates[0].(map[string]any); ok { + if content, ok := cand["content"].(map[string]any); ok { + if partsAny, ok := content["parts"].([]any); ok && len(partsAny) > 0 { + out := make([]map[string]any, 0, len(partsAny)) + for _, p := range partsAny { + pm, ok := p.(map[string]any) + if !ok { + continue + } + out = append(out, pm) + } + return out + } + } + } + } + return nil +} + +func computeGeminiTextDelta(seen, incoming string) (delta, newSeen string) { + incoming = strings.TrimSuffix(incoming, "\u0000") + if incoming == "" { + return "", seen + } + + // Cumulative mode: incoming contains full text so far. + if strings.HasPrefix(incoming, seen) { + return strings.TrimPrefix(incoming, seen), incoming + } + // Duplicate/rewind: ignore. + if strings.HasPrefix(seen, incoming) { + return "", seen + } + // Delta mode: treat incoming as incremental chunk. + return incoming, seen + incoming +} + +func mapGeminiFinishReasonToClaudeStopReason(finishReason string) string { + switch strings.ToUpper(strings.TrimSpace(finishReason)) { + case "MAX_TOKENS": + return "max_tokens" + case "STOP": + return "end_turn" + default: + return "end_turn" + } +} + +func convertClaudeMessagesToGeminiGenerateContent(body []byte) ([]byte, error) { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return nil, err + } + + toolUseIDToName := make(map[string]string) + + systemText := extractClaudeSystemText(req["system"]) + contents, err := convertClaudeMessagesToGeminiContents(req["messages"], toolUseIDToName) + if err != nil { + return nil, err + } + + out := make(map[string]any) + if systemText != "" { + out["systemInstruction"] = map[string]any{ + "parts": []any{map[string]any{"text": systemText}}, + } + } + out["contents"] = contents + + if tools := convertClaudeToolsToGeminiTools(req["tools"]); tools != nil { + out["tools"] = tools + } + + generationConfig := convertClaudeGenerationConfig(req) + if generationConfig != nil { + out["generationConfig"] = generationConfig + } + + stripGeminiFunctionIDs(out) + return json.Marshal(out) +} + +func stripGeminiFunctionIDs(req map[string]any) { + // Defensive cleanup: some upstreams reject unexpected `id` fields in functionCall/functionResponse. + contents, ok := req["contents"].([]any) + if !ok { + return + } + for _, c := range contents { + cm, ok := c.(map[string]any) + if !ok { + continue + } + contentParts, ok := cm["parts"].([]any) + if !ok { + continue + } + for _, p := range contentParts { + pm, ok := p.(map[string]any) + if !ok { + continue + } + if fc, ok := pm["functionCall"].(map[string]any); ok && fc != nil { + delete(fc, "id") + } + if fr, ok := pm["functionResponse"].(map[string]any); ok && fr != nil { + delete(fr, "id") + } + } + } +} + +func extractClaudeSystemText(system any) string { + switch v := system.(type) { + case string: + return strings.TrimSpace(v) + case []any: + var parts []string + for _, p := range v { + pm, ok := p.(map[string]any) + if !ok { + continue + } + if t, _ := pm["type"].(string); t != "text" { + continue + } + if text, ok := pm["text"].(string); ok && strings.TrimSpace(text) != "" { + parts = append(parts, text) + } + } + return strings.TrimSpace(strings.Join(parts, "\n")) + default: + return "" + } +} + +func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[string]string) ([]any, error) { + arr, ok := messages.([]any) + if !ok { + return nil, errors.New("messages must be an array") + } + + out := make([]any, 0, len(arr)) + for _, m := range arr { + mm, ok := m.(map[string]any) + if !ok { + continue + } + role, _ := mm["role"].(string) + role = strings.ToLower(strings.TrimSpace(role)) + gRole := "user" + if role == "assistant" { + gRole = "model" + } + + parts := make([]any, 0) + switch content := mm["content"].(type) { + case string: + if strings.TrimSpace(content) != "" { + parts = append(parts, map[string]any{"text": content}) + } + case []any: + for _, block := range content { + bm, ok := block.(map[string]any) + if !ok { + continue + } + 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}) + } + case "tool_use": + id, _ := bm["id"].(string) + name, _ := bm["name"].(string) + if strings.TrimSpace(id) != "" && strings.TrimSpace(name) != "" { + toolUseIDToName[id] = name + } + parts = append(parts, map[string]any{ + "functionCall": map[string]any{ + "name": name, + "args": bm["input"], + }, + }) + case "tool_result": + toolUseID, _ := bm["tool_use_id"].(string) + name := toolUseIDToName[toolUseID] + if name == "" { + name = "tool" + } + parts = append(parts, map[string]any{ + "functionResponse": map[string]any{ + "name": name, + "response": map[string]any{ + "content": extractClaudeContentText(bm["content"]), + }, + }, + }) + case "image": + if src, ok := bm["source"].(map[string]any); ok { + if srcType, _ := src["type"].(string); srcType == "base64" { + mediaType, _ := src["media_type"].(string) + data, _ := src["data"].(string) + if mediaType != "" && data != "" { + parts = append(parts, map[string]any{ + "inlineData": map[string]any{ + "mimeType": mediaType, + "data": data, + }, + }) + } + } + } + default: + // best-effort: preserve unknown blocks as text + if b, err := json.Marshal(bm); err == nil { + parts = append(parts, map[string]any{"text": string(b)}) + } + } + } + default: + // ignore + } + + out = append(out, map[string]any{ + "role": gRole, + "parts": parts, + }) + } + return out, nil +} + +func extractClaudeContentText(v any) string { + switch t := v.(type) { + case string: + return t + case []any: + var sb strings.Builder + for _, part := range t { + pm, ok := part.(map[string]any) + if !ok { + continue + } + if pm["type"] == "text" { + if text, ok := pm["text"].(string); ok { + _, _ = sb.WriteString(text) + } + } + } + return sb.String() + default: + b, _ := json.Marshal(t) + return string(b) + } +} + +func convertClaudeToolsToGeminiTools(tools any) []any { + arr, ok := tools.([]any) + if !ok || len(arr) == 0 { + return nil + } + + funcDecls := make([]any, 0, len(arr)) + for _, t := range arr { + tm, ok := t.(map[string]any) + if !ok { + continue + } + name, _ := tm["name"].(string) + desc, _ := tm["description"].(string) + params := tm["input_schema"] + if name == "" { + continue + } + funcDecls = append(funcDecls, map[string]any{ + "name": name, + "description": desc, + "parameters": params, + }) + } + + if len(funcDecls) == 0 { + return nil + } + return []any{ + map[string]any{ + "functionDeclarations": funcDecls, + }, + } +} + +func convertClaudeGenerationConfig(req map[string]any) map[string]any { + out := make(map[string]any) + if mt, ok := asInt(req["max_tokens"]); ok && mt > 0 { + out["maxOutputTokens"] = mt + } + if temp, ok := req["temperature"].(float64); ok { + out["temperature"] = temp + } + if topP, ok := req["top_p"].(float64); ok { + out["topP"] = topP + } + if stopSeq, ok := req["stop_sequences"].([]any); ok && len(stopSeq) > 0 { + out["stopSequences"] = stopSeq + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/backend/internal/service/gemini_oauth.go b/backend/internal/service/gemini_oauth.go new file mode 100644 index 00000000..d129ae52 --- /dev/null +++ b/backend/internal/service/gemini_oauth.go @@ -0,0 +1,13 @@ +package service + +import ( + "context" + + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" +) + +// GeminiOAuthClient performs Google OAuth token exchange/refresh for Gemini integration. +type GeminiOAuthClient interface { + ExchangeCode(ctx context.Context, oauthType, code, codeVerifier, redirectURI, proxyURL string) (*geminicli.TokenResponse, error) + RefreshToken(ctx context.Context, oauthType, refreshToken, proxyURL string) (*geminicli.TokenResponse, error) +} diff --git a/backend/internal/service/gemini_oauth_service.go b/backend/internal/service/gemini_oauth_service.go new file mode 100644 index 00000000..36257667 --- /dev/null +++ b/backend/internal/service/gemini_oauth_service.go @@ -0,0 +1,555 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" +) + +type GeminiOAuthService struct { + sessionStore *geminicli.SessionStore + proxyRepo ProxyRepository + oauthClient GeminiOAuthClient + codeAssist GeminiCliCodeAssistClient + cfg *config.Config +} + +type GeminiOAuthCapabilities struct { + AIStudioOAuthEnabled bool `json:"ai_studio_oauth_enabled"` + RequiredRedirectURIs []string `json:"required_redirect_uris"` +} + +func NewGeminiOAuthService( + proxyRepo ProxyRepository, + oauthClient GeminiOAuthClient, + codeAssist GeminiCliCodeAssistClient, + cfg *config.Config, +) *GeminiOAuthService { + return &GeminiOAuthService{ + sessionStore: geminicli.NewSessionStore(), + proxyRepo: proxyRepo, + oauthClient: oauthClient, + codeAssist: codeAssist, + cfg: cfg, + } +} + +func (s *GeminiOAuthService) GetOAuthConfig() *GeminiOAuthCapabilities { + // AI Studio OAuth is only enabled when the operator configures a custom OAuth client. + clientID := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientID) + clientSecret := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientSecret) + enabled := clientID != "" && clientSecret != "" && + (clientID != geminicli.GeminiCLIOAuthClientID || clientSecret != geminicli.GeminiCLIOAuthClientSecret) + + return &GeminiOAuthCapabilities{ + AIStudioOAuthEnabled: enabled, + RequiredRedirectURIs: []string{geminicli.AIStudioOAuthRedirectURI}, + } +} + +type GeminiAuthURLResult struct { + AuthURL string `json:"auth_url"` + SessionID string `json:"session_id"` + State string `json:"state"` +} + +func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, redirectURI, projectID, oauthType string) (*GeminiAuthURLResult, error) { + state, err := geminicli.GenerateState() + if err != nil { + return nil, fmt.Errorf("failed to generate state: %w", err) + } + codeVerifier, err := geminicli.GenerateCodeVerifier() + if err != nil { + return nil, fmt.Errorf("failed to generate code verifier: %w", err) + } + codeChallenge := geminicli.GenerateCodeChallenge(codeVerifier) + sessionID, err := geminicli.GenerateSessionID() + if err != nil { + return nil, fmt.Errorf("failed to generate session ID: %w", err) + } + + var proxyURL string + if proxyID != nil { + proxy, err := s.proxyRepo.GetByID(ctx, *proxyID) + if err == nil && proxy != nil { + proxyURL = proxy.URL() + } + } + + // OAuth client selection: + // - code_assist: always use built-in Gemini CLI OAuth client (public), regardless of configured client_id/secret. + // - ai_studio: requires a user-provided OAuth client. + oauthCfg := geminicli.OAuthConfig{ + ClientID: s.cfg.Gemini.OAuth.ClientID, + ClientSecret: s.cfg.Gemini.OAuth.ClientSecret, + Scopes: s.cfg.Gemini.OAuth.Scopes, + } + if oauthType == "code_assist" { + oauthCfg.ClientID = "" + oauthCfg.ClientSecret = "" + } + + session := &geminicli.OAuthSession{ + State: state, + CodeVerifier: codeVerifier, + ProxyURL: proxyURL, + RedirectURI: redirectURI, + ProjectID: strings.TrimSpace(projectID), + OAuthType: oauthType, + CreatedAt: time.Now(), + } + s.sessionStore.Set(sessionID, session) + + effectiveCfg, err := geminicli.EffectiveOAuthConfig(oauthCfg, oauthType) + if err != nil { + return nil, err + } + + isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID && + effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret + + // AI Studio OAuth requires a user-provided OAuth client (built-in Gemini CLI client is scope-restricted). + if oauthType == "ai_studio" && isBuiltinClient { + return nil, fmt.Errorf("AI Studio OAuth requires a custom OAuth Client (GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET). If you don't want to configure an OAuth client, please use an AI Studio API Key account instead") + } + + // Redirect URI strategy: + // - code_assist: use Gemini CLI redirect URI (codeassist.google.com/authcode) + // - ai_studio: use localhost callback for manual copy/paste flow + if oauthType == "code_assist" { + redirectURI = geminicli.GeminiCLIRedirectURI + } else { + redirectURI = geminicli.AIStudioOAuthRedirectURI + } + session.RedirectURI = redirectURI + s.sessionStore.Set(sessionID, session) + + authURL, err := geminicli.BuildAuthorizationURL(effectiveCfg, state, codeChallenge, redirectURI, session.ProjectID, oauthType) + if err != nil { + return nil, err + } + + return &GeminiAuthURLResult{ + AuthURL: authURL, + SessionID: sessionID, + State: state, + }, nil +} + +type GeminiExchangeCodeInput struct { + SessionID string + State string + Code string + ProxyID *int64 + OAuthType string // "code_assist" 或 "ai_studio" +} + +type GeminiTokenInfo struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + ExpiresAt int64 `json:"expires_at"` + TokenType string `json:"token_type"` + Scope string `json:"scope,omitempty"` + ProjectID string `json:"project_id,omitempty"` + OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio" +} + +func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) { + session, ok := s.sessionStore.Get(input.SessionID) + if !ok { + return nil, fmt.Errorf("session not found or expired") + } + if strings.TrimSpace(input.State) == "" || input.State != session.State { + return nil, fmt.Errorf("invalid state") + } + + proxyURL := session.ProxyURL + if input.ProxyID != nil { + proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID) + if err == nil && proxy != nil { + proxyURL = proxy.URL() + } + } + + redirectURI := session.RedirectURI + + // Resolve oauth_type early (defaults to code_assist for backward compatibility). + oauthType := session.OAuthType + if oauthType == "" { + oauthType = "code_assist" + } + + // If the session was created for AI Studio OAuth, ensure a custom OAuth client is configured. + if oauthType == "ai_studio" { + effectiveCfg, err := geminicli.EffectiveOAuthConfig(geminicli.OAuthConfig{ + ClientID: s.cfg.Gemini.OAuth.ClientID, + ClientSecret: s.cfg.Gemini.OAuth.ClientSecret, + Scopes: s.cfg.Gemini.OAuth.Scopes, + }, "ai_studio") + if err != nil { + return nil, err + } + isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID && + effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret + if isBuiltinClient { + return nil, fmt.Errorf("AI Studio OAuth requires a custom OAuth Client. Please use an AI Studio API Key account, or configure GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and re-authorize") + } + } + + // code_assist always uses the built-in client and its fixed redirect URI. + if oauthType == "code_assist" { + redirectURI = geminicli.GeminiCLIRedirectURI + } + + tokenResp, err := s.oauthClient.ExchangeCode(ctx, oauthType, input.Code, session.CodeVerifier, redirectURI, proxyURL) + if err != nil { + return nil, fmt.Errorf("failed to exchange code: %w", err) + } + sessionProjectID := strings.TrimSpace(session.ProjectID) + s.sessionStore.Delete(input.SessionID) + + // 计算过期时间时减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差 + expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300 + + projectID := sessionProjectID + + // 对于 code_assist 模式,project_id 是必需的 + // 对于 ai_studio 模式,project_id 是可选的(不影响使用 AI Studio API) + if oauthType == "code_assist" { + if projectID == "" { + var err error + projectID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL) + if err != nil { + // 记录警告但不阻断流程,允许后续补充 project_id + fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err) + } + } + if strings.TrimSpace(projectID) == "" { + return nil, fmt.Errorf("missing project_id for Code Assist OAuth: please fill Project ID (optional field) and regenerate the auth URL, or ensure your Google account has an ACTIVE GCP project") + } + } + + return &GeminiTokenInfo{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + TokenType: tokenResp.TokenType, + ExpiresIn: tokenResp.ExpiresIn, + ExpiresAt: expiresAt, + Scope: tokenResp.Scope, + ProjectID: projectID, + OAuthType: oauthType, + }, nil +} + +func (s *GeminiOAuthService) RefreshToken(ctx context.Context, oauthType, refreshToken, proxyURL string) (*GeminiTokenInfo, error) { + var lastErr error + + for attempt := 0; attempt <= 3; attempt++ { + if attempt > 0 { + backoff := time.Duration(1< 30*time.Second { + backoff = 30 * time.Second + } + time.Sleep(backoff) + } + + tokenResp, err := s.oauthClient.RefreshToken(ctx, oauthType, refreshToken, proxyURL) + if err == nil { + // 计算过期时间时减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差 + expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300 + return &GeminiTokenInfo{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + TokenType: tokenResp.TokenType, + ExpiresIn: tokenResp.ExpiresIn, + ExpiresAt: expiresAt, + Scope: tokenResp.Scope, + }, nil + } + + if isNonRetryableGeminiOAuthError(err) { + return nil, err + } + lastErr = err + } + + return nil, fmt.Errorf("token refresh failed after retries: %w", lastErr) +} + +func isNonRetryableGeminiOAuthError(err error) bool { + msg := err.Error() + nonRetryable := []string{ + "invalid_grant", + "invalid_client", + "unauthorized_client", + "access_denied", + } + for _, needle := range nonRetryable { + if strings.Contains(msg, needle) { + return true + } + } + return false +} + +func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*GeminiTokenInfo, error) { + if account.Platform != PlatformGemini || account.Type != AccountTypeOAuth { + return nil, fmt.Errorf("account is not a Gemini OAuth account") + } + + refreshToken := account.GetCredential("refresh_token") + if strings.TrimSpace(refreshToken) == "" { + return nil, fmt.Errorf("no refresh token available") + } + + // Preserve oauth_type from the account (defaults to code_assist for backward compatibility). + oauthType := strings.TrimSpace(account.GetCredential("oauth_type")) + if oauthType == "" { + oauthType = "code_assist" + } + + var proxyURL string + if account.ProxyID != nil { + proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID) + if err == nil && proxy != nil { + proxyURL = proxy.URL() + } + } + + tokenInfo, err := s.RefreshToken(ctx, oauthType, refreshToken, proxyURL) + // Backward compatibility: + // Older versions could refresh Code Assist tokens using a user-provided OAuth client when configured. + // If the refresh token was originally issued to that custom client, forcing the built-in client will + // fail with "unauthorized_client". In that case, retry with the custom client (ai_studio path) when available. + if err != nil && oauthType == "code_assist" && strings.Contains(err.Error(), "unauthorized_client") && s.GetOAuthConfig().AIStudioOAuthEnabled { + if alt, altErr := s.RefreshToken(ctx, "ai_studio", refreshToken, proxyURL); altErr == nil { + tokenInfo = alt + err = nil + } + } + if err != nil { + // Provide a more actionable error for common OAuth client mismatch issues. + if strings.Contains(err.Error(), "unauthorized_client") { + return nil, fmt.Errorf("%w (OAuth client mismatch: the refresh_token is bound to the OAuth client used during authorization; please re-authorize this account or restore the original GEMINI_OAUTH_CLIENT_ID/SECRET)", err) + } + return nil, err + } + + tokenInfo.OAuthType = oauthType + + // Preserve account's project_id when present. + existingProjectID := strings.TrimSpace(account.GetCredential("project_id")) + if existingProjectID != "" { + tokenInfo.ProjectID = existingProjectID + } + + // For Code Assist, project_id is required. Auto-detect if missing. + // For AI Studio OAuth, project_id is optional and should not block refresh. + if oauthType == "code_assist" && strings.TrimSpace(tokenInfo.ProjectID) == "" { + projectID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL) + if err != nil { + return nil, fmt.Errorf("failed to auto-detect project_id: %w", err) + } + projectID = strings.TrimSpace(projectID) + if projectID == "" { + return nil, fmt.Errorf("failed to auto-detect project_id: empty result") + } + tokenInfo.ProjectID = projectID + } + + return tokenInfo, nil +} + +func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo) map[string]any { + creds := map[string]any{ + "access_token": tokenInfo.AccessToken, + "expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10), + } + if tokenInfo.RefreshToken != "" { + creds["refresh_token"] = tokenInfo.RefreshToken + } + if tokenInfo.TokenType != "" { + creds["token_type"] = tokenInfo.TokenType + } + if tokenInfo.Scope != "" { + creds["scope"] = tokenInfo.Scope + } + if tokenInfo.ProjectID != "" { + creds["project_id"] = tokenInfo.ProjectID + } + if tokenInfo.OAuthType != "" { + creds["oauth_type"] = tokenInfo.OAuthType + } + return creds +} + +func (s *GeminiOAuthService) Stop() { + s.sessionStore.Stop() +} + +func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, proxyURL string) (string, error) { + if s.codeAssist == nil { + return "", errors.New("code assist client not configured") + } + + loadResp, loadErr := s.codeAssist.LoadCodeAssist(ctx, accessToken, proxyURL, nil) + if loadErr == nil && loadResp != nil && strings.TrimSpace(loadResp.CloudAICompanionProject) != "" { + return strings.TrimSpace(loadResp.CloudAICompanionProject), nil + } + + // Pick tier from allowedTiers; if no default tier is marked, pick the first non-empty tier ID. + tierID := "LEGACY" + if loadResp != nil { + for _, tier := range loadResp.AllowedTiers { + if tier.IsDefault && strings.TrimSpace(tier.ID) != "" { + tierID = strings.TrimSpace(tier.ID) + break + } + } + if strings.TrimSpace(tierID) == "" || tierID == "LEGACY" { + for _, tier := range loadResp.AllowedTiers { + if strings.TrimSpace(tier.ID) != "" { + tierID = strings.TrimSpace(tier.ID) + break + } + } + } + } + + req := &geminicli.OnboardUserRequest{ + TierID: tierID, + Metadata: geminicli.LoadCodeAssistMetadata{ + IDEType: "ANTIGRAVITY", + Platform: "PLATFORM_UNSPECIFIED", + PluginType: "GEMINI", + }, + } + + maxAttempts := 5 + for attempt := 1; attempt <= maxAttempts; attempt++ { + resp, err := s.codeAssist.OnboardUser(ctx, accessToken, proxyURL, req) + if err != nil { + // If Code Assist onboarding fails (e.g. INVALID_ARGUMENT), fallback to Cloud Resource Manager projects. + fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL) + if fbErr == nil && strings.TrimSpace(fallback) != "" { + return strings.TrimSpace(fallback), nil + } + return "", err + } + if resp.Done { + if resp.Response != nil && resp.Response.CloudAICompanionProject != nil { + switch v := resp.Response.CloudAICompanionProject.(type) { + case string: + return strings.TrimSpace(v), nil + case map[string]any: + if id, ok := v["id"].(string); ok { + return strings.TrimSpace(id), nil + } + } + } + + fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL) + if fbErr == nil && strings.TrimSpace(fallback) != "" { + return strings.TrimSpace(fallback), nil + } + return "", errors.New("onboardUser completed but no project_id returned") + } + time.Sleep(2 * time.Second) + } + + fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL) + if fbErr == nil && strings.TrimSpace(fallback) != "" { + return strings.TrimSpace(fallback), nil + } + if loadErr != nil { + return "", fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts) + } + return "", fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts) +} + +type googleCloudProject struct { + ProjectID string `json:"projectId"` + DisplayName string `json:"name"` + LifecycleState string `json:"lifecycleState"` +} + +type googleCloudProjectsResponse struct { + Projects []googleCloudProject `json:"projects"` +} + +func fetchProjectIDFromResourceManager(ctx context.Context, accessToken, proxyURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil) + if err != nil { + return "", fmt.Errorf("failed to create resource manager request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent) + + client := &http.Client{Timeout: 30 * time.Second} + if strings.TrimSpace(proxyURL) != "" { + if proxyURLParsed, err := url.Parse(strings.TrimSpace(proxyURL)); err == nil { + client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURLParsed)} + } + } + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("resource manager request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read resource manager response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("resource manager HTTP %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var projectsResp googleCloudProjectsResponse + if err := json.Unmarshal(bodyBytes, &projectsResp); err != nil { + return "", fmt.Errorf("failed to parse resource manager response: %w", err) + } + + active := make([]googleCloudProject, 0, len(projectsResp.Projects)) + for _, p := range projectsResp.Projects { + if p.LifecycleState == "ACTIVE" && strings.TrimSpace(p.ProjectID) != "" { + active = append(active, p) + } + } + if len(active) == 0 { + return "", errors.New("no ACTIVE projects found from resource manager") + } + + // Prefer likely companion projects first. + for _, p := range active { + id := strings.ToLower(strings.TrimSpace(p.ProjectID)) + name := strings.ToLower(strings.TrimSpace(p.DisplayName)) + if strings.Contains(id, "cloud-ai-companion") || strings.Contains(name, "cloud ai companion") || strings.Contains(name, "code assist") { + return strings.TrimSpace(p.ProjectID), nil + } + } + // Then prefer "default". + for _, p := range active { + id := strings.ToLower(strings.TrimSpace(p.ProjectID)) + name := strings.ToLower(strings.TrimSpace(p.DisplayName)) + if strings.Contains(id, "default") || strings.Contains(name, "default") { + return strings.TrimSpace(p.ProjectID), nil + } + } + + return strings.TrimSpace(active[0].ProjectID), nil +} diff --git a/backend/internal/service/gemini_token_cache.go b/backend/internal/service/gemini_token_cache.go new file mode 100644 index 00000000..d5e64f9a --- /dev/null +++ b/backend/internal/service/gemini_token_cache.go @@ -0,0 +1,16 @@ +package service + +import ( + "context" + "time" +) + +// GeminiTokenCache stores short-lived access tokens and coordinates refresh to avoid stampedes. +type GeminiTokenCache interface { + // cacheKey should be stable for the token scope; for GeminiCli OAuth we primarily use project_id. + GetAccessToken(ctx context.Context, cacheKey string) (string, error) + SetAccessToken(ctx context.Context, cacheKey string, token string, ttl time.Duration) error + + AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, error) + ReleaseRefreshLock(ctx context.Context, cacheKey string) error +} diff --git a/backend/internal/service/gemini_token_provider.go b/backend/internal/service/gemini_token_provider.go new file mode 100644 index 00000000..f587b500 --- /dev/null +++ b/backend/internal/service/gemini_token_provider.go @@ -0,0 +1,171 @@ +package service + +import ( + "context" + "errors" + "log" + "strconv" + "strings" + "time" +) + +const ( + geminiTokenRefreshSkew = 3 * time.Minute + geminiTokenCacheSkew = 5 * time.Minute +) + +type GeminiTokenProvider struct { + accountRepo AccountRepository + tokenCache GeminiTokenCache + geminiOAuthService *GeminiOAuthService +} + +func NewGeminiTokenProvider( + accountRepo AccountRepository, + tokenCache GeminiTokenCache, + geminiOAuthService *GeminiOAuthService, +) *GeminiTokenProvider { + return &GeminiTokenProvider{ + accountRepo: accountRepo, + tokenCache: tokenCache, + geminiOAuthService: geminiOAuthService, + } +} + +func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Account) (string, error) { + if account == nil { + return "", errors.New("account is nil") + } + if account.Platform != PlatformGemini || account.Type != AccountTypeOAuth { + return "", errors.New("not a gemini oauth account") + } + + cacheKey := geminiTokenCacheKey(account) + + // 1) Try cache first. + if p.tokenCache != nil { + if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" { + return token, nil + } + } + + // 2) Refresh if needed (pre-expiry skew). + expiresAt := parseExpiresAt(account) + needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= geminiTokenRefreshSkew + if needsRefresh && p.tokenCache != nil { + locked, err := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second) + if err == nil && locked { + defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }() + + // Re-check after lock (another worker may have refreshed). + if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" { + return token, nil + } + + fresh, err := p.accountRepo.GetByID(ctx, account.ID) + if err == nil && fresh != nil { + account = fresh + } + expiresAt = parseExpiresAt(account) + if expiresAt == nil || time.Until(*expiresAt) <= geminiTokenRefreshSkew { + if p.geminiOAuthService == nil { + return "", errors.New("gemini oauth service not configured") + } + tokenInfo, err := p.geminiOAuthService.RefreshAccountToken(ctx, account) + if err != nil { + return "", err + } + newCredentials := p.geminiOAuthService.BuildAccountCredentials(tokenInfo) + for k, v := range account.Credentials { + if _, exists := newCredentials[k]; !exists { + newCredentials[k] = v + } + } + account.Credentials = newCredentials + _ = p.accountRepo.Update(ctx, account) + expiresAt = parseExpiresAt(account) + } + } + } + + accessToken := account.GetCredential("access_token") + if strings.TrimSpace(accessToken) == "" { + return "", errors.New("access_token not found in credentials") + } + + // project_id is optional now: + // - If present: will use Code Assist API (requires project_id) + // - If absent: will use AI Studio API with OAuth token (like regular API key mode) + // Auto-detect project_id only if explicitly enabled via a credential flag + projectID := strings.TrimSpace(account.GetCredential("project_id")) + autoDetectProjectID := account.GetCredential("auto_detect_project_id") == "true" + + if projectID == "" && autoDetectProjectID { + if p.geminiOAuthService == nil { + return accessToken, nil // Fallback to AI Studio API mode + } + + var proxyURL string + if account.ProxyID != nil && p.geminiOAuthService.proxyRepo != nil { + if proxy, err := p.geminiOAuthService.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && proxy != nil { + proxyURL = proxy.URL() + } + } + + detected, err := p.geminiOAuthService.fetchProjectID(ctx, accessToken, proxyURL) + if err != nil { + log.Printf("[GeminiTokenProvider] Auto-detect project_id failed: %v, fallback to AI Studio API mode", err) + return accessToken, nil + } + detected = strings.TrimSpace(detected) + if detected != "" { + if account.Credentials == nil { + account.Credentials = make(map[string]any) + } + account.Credentials["project_id"] = detected + _ = p.accountRepo.Update(ctx, account) + } + } + + // 3) Populate cache with TTL. + if p.tokenCache != nil { + ttl := 30 * time.Minute + if expiresAt != nil { + until := time.Until(*expiresAt) + switch { + case until > geminiTokenCacheSkew: + ttl = until - geminiTokenCacheSkew + case until > 0: + ttl = until + default: + ttl = time.Minute + } + } + _ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl) + } + + return accessToken, nil +} + +func geminiTokenCacheKey(account *Account) string { + projectID := strings.TrimSpace(account.GetCredential("project_id")) + if projectID != "" { + return projectID + } + return "account:" + strconv.FormatInt(account.ID, 10) +} + +func parseExpiresAt(account *Account) *time.Time { + raw := strings.TrimSpace(account.GetCredential("expires_at")) + if raw == "" { + return nil + } + if unixSec, err := strconv.ParseInt(raw, 10, 64); err == nil && unixSec > 0 { + t := time.Unix(unixSec, 0) + return &t + } + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return &t + } + return nil +} diff --git a/backend/internal/service/gemini_token_refresher.go b/backend/internal/service/gemini_token_refresher.go new file mode 100644 index 00000000..19ba9424 --- /dev/null +++ b/backend/internal/service/gemini_token_refresher.go @@ -0,0 +1,51 @@ +package service + +import ( + "context" + "strconv" + "time" +) + +type GeminiTokenRefresher struct { + geminiOAuthService *GeminiOAuthService +} + +func NewGeminiTokenRefresher(geminiOAuthService *GeminiOAuthService) *GeminiTokenRefresher { + return &GeminiTokenRefresher{geminiOAuthService: geminiOAuthService} +} + +func (r *GeminiTokenRefresher) CanRefresh(account *Account) bool { + return account.Platform == PlatformGemini && account.Type == AccountTypeOAuth +} + +func (r *GeminiTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool { + if !r.CanRefresh(account) { + return false + } + expiresAtStr := account.GetCredential("expires_at") + if expiresAtStr == "" { + return false + } + expiresAt, err := strconv.ParseInt(expiresAtStr, 10, 64) + if err != nil { + return false + } + expiryTime := time.Unix(expiresAt, 0) + return time.Until(expiryTime) < refreshWindow +} + +func (r *GeminiTokenRefresher) Refresh(ctx context.Context, account *Account) (map[string]any, error) { + tokenInfo, err := r.geminiOAuthService.RefreshAccountToken(ctx, account) + if err != nil { + return nil, err + } + + newCredentials := r.geminiOAuthService.BuildAccountCredentials(tokenInfo) + for k, v := range account.Credentials { + if _, exists := newCredentials[k]; !exists { + newCredentials[k] = v + } + } + + return newCredentials, nil +} diff --git a/backend/internal/service/geminicli_codeassist.go b/backend/internal/service/geminicli_codeassist.go new file mode 100644 index 00000000..0fe7f1cf --- /dev/null +++ b/backend/internal/service/geminicli_codeassist.go @@ -0,0 +1,13 @@ +package service + +import ( + "context" + + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" +) + +// GeminiCliCodeAssistClient calls GeminiCli internal Code Assist endpoints. +type GeminiCliCodeAssistClient interface { + LoadCodeAssist(ctx context.Context, accessToken, proxyURL string, req *geminicli.LoadCodeAssistRequest) (*geminicli.LoadCodeAssistResponse, error) + OnboardUser(ctx context.Context, accessToken, proxyURL string, req *geminicli.OnboardUserRequest) (*geminicli.OnboardUserResponse, error) +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index a78aafd6..f57d361b 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -170,9 +170,18 @@ func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupI if acc.Priority < selected.Priority { selected = acc } else if acc.Priority == selected.Priority { - // Same priority, select least recently used - if acc.LastUsedAt == nil || (selected.LastUsedAt != nil && acc.LastUsedAt.Before(*selected.LastUsedAt)) { + switch { + case acc.LastUsedAt == nil && selected.LastUsedAt != nil: selected = acc + case acc.LastUsedAt != nil && selected.LastUsedAt == nil: + // keep selected (never used is preferred) + case acc.LastUsedAt == nil && selected.LastUsedAt == nil: + // keep selected (both never used) + default: + // Same priority, select least recently used + if acc.LastUsedAt.Before(*selected.LastUsedAt) { + selected = acc + } } } } diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 56aab2bc..e2e263d9 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -393,27 +393,32 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing return nil } - // 标准化模型名称 - modelLower := strings.ToLower(modelName) + // 标准化模型名称(同时兼容 "models/xxx"、VertexAI 资源名等前缀) + modelLower := strings.ToLower(strings.TrimSpace(modelName)) + lookupCandidates := s.buildModelLookupCandidates(modelLower) // 1. 精确匹配 - if pricing, ok := s.pricingData[modelLower]; ok { - return pricing - } - if pricing, ok := s.pricingData[modelName]; ok { - return pricing + for _, candidate := range lookupCandidates { + if candidate == "" { + continue + } + if pricing, ok := s.pricingData[candidate]; ok { + return pricing + } } // 2. 处理常见的模型名称变体 // claude-opus-4-5-20251101 -> claude-opus-4.5-20251101 - normalized := strings.ReplaceAll(modelLower, "-4-5-", "-4.5-") - if pricing, ok := s.pricingData[normalized]; ok { - return pricing + for _, candidate := range lookupCandidates { + normalized := strings.ReplaceAll(candidate, "-4-5-", "-4.5-") + if pricing, ok := s.pricingData[normalized]; ok { + return pricing + } } // 3. 尝试模糊匹配(去掉版本号后缀) // claude-opus-4-5-20251101 -> claude-opus-4.5 - baseName := s.extractBaseName(modelLower) + baseName := s.extractBaseName(lookupCandidates[0]) for key, pricing := range s.pricingData { keyBase := s.extractBaseName(strings.ToLower(key)) if keyBase == baseName { @@ -422,18 +427,77 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing } // 4. 基于模型系列匹配(Claude) - if pricing := s.matchByModelFamily(modelLower); pricing != nil { + if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil { return pricing } // 5. OpenAI 模型回退策略 - if strings.HasPrefix(modelLower, "gpt-") { - return s.matchOpenAIModel(modelLower) + if strings.HasPrefix(lookupCandidates[0], "gpt-") { + return s.matchOpenAIModel(lookupCandidates[0]) } return nil } +func (s *PricingService) buildModelLookupCandidates(modelLower string) []string { + // Prefer canonical model name first (this also improves billing compatibility with "models/xxx"). + candidates := []string{ + normalizeModelNameForPricing(modelLower), + modelLower, + } + candidates = append(candidates, + strings.TrimPrefix(modelLower, "models/"), + lastSegment(modelLower), + lastSegment(strings.TrimPrefix(modelLower, "models/")), + ) + + seen := make(map[string]struct{}, len(candidates)) + out := make([]string, 0, len(candidates)) + for _, c := range candidates { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + out = append(out, c) + } + if len(out) == 0 { + return []string{modelLower} + } + return out +} + +func normalizeModelNameForPricing(model string) string { + // Common Gemini/VertexAI forms: + // - models/gemini-2.0-flash-exp + // - publishers/google/models/gemini-1.5-pro + // - projects/.../locations/.../publishers/google/models/gemini-1.5-pro + model = strings.TrimSpace(model) + model = strings.TrimLeft(model, "/") + model = strings.TrimPrefix(model, "models/") + model = strings.TrimPrefix(model, "publishers/google/models/") + + if idx := strings.LastIndex(model, "/publishers/google/models/"); idx != -1 { + model = model[idx+len("/publishers/google/models/"):] + } + if idx := strings.LastIndex(model, "/models/"); idx != -1 { + model = model[idx+len("/models/"):] + } + + model = strings.TrimLeft(model, "/") + return model +} + +func lastSegment(model string) string { + if idx := strings.LastIndex(model, "/"); idx != -1 { + return model[idx+1:] + } + return model +} + // extractBaseName 提取基础模型名称(去掉日期版本号) func (s *PricingService) extractBaseName(model string) string { // 移除日期后缀 (如 -20251101, -20241022) diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go index f93d09ea..5420dd3e 100644 --- a/backend/internal/service/token_refresh_service.go +++ b/backend/internal/service/token_refresh_service.go @@ -26,6 +26,7 @@ func NewTokenRefreshService( accountRepo AccountRepository, oauthService *OAuthService, openaiOAuthService *OpenAIOAuthService, + geminiOAuthService *GeminiOAuthService, cfg *config.Config, ) *TokenRefreshService { s := &TokenRefreshService{ @@ -38,6 +39,7 @@ func NewTokenRefreshService( s.refreshers = []TokenRefresher{ NewClaudeTokenRefresher(oauthService), NewOpenAITokenRefresher(openaiOAuthService), + NewGeminiTokenRefresher(geminiOAuthService), } return s diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 02ef2392..d60ec737 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -36,9 +36,10 @@ func ProvideTokenRefreshService( accountRepo AccountRepository, oauthService *OAuthService, openaiOAuthService *OpenAIOAuthService, + geminiOAuthService *GeminiOAuthService, cfg *config.Config, ) *TokenRefreshService { - svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, cfg) + svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, geminiOAuthService, cfg) svc.Start() return svc } @@ -63,6 +64,9 @@ var ProviderSet = wire.NewSet( NewOpenAIGatewayService, NewOAuthService, NewOpenAIOAuthService, + NewGeminiOAuthService, + NewGeminiTokenProvider, + NewGeminiMessagesCompatService, NewRateLimitService, NewAccountUsageService, NewAccountTestService, diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go index 287de3e3..62f1069c 100644 --- a/backend/internal/web/embed_on.go +++ b/backend/internal/web/embed_on.go @@ -27,6 +27,7 @@ func ServeEmbeddedFrontend() gin.HandlerFunc { if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v1/") || + strings.HasPrefix(path, "/v1beta/") || strings.HasPrefix(path, "/setup/") || path == "/health" { c.Next() diff --git a/deploy/.env.example b/deploy/.env.example index be991857..de7ea722 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -53,3 +53,32 @@ ADMIN_PASSWORD= # Leave empty to auto-generate (recommended) JWT_SECRET= JWT_EXPIRE_HOUR=24 + +# ----------------------------------------------------------------------------- +# Gemini OAuth (OPTIONAL, required only for Gemini OAuth accounts) +# ----------------------------------------------------------------------------- +# Sub2API supports TWO Gemini OAuth modes: +# +# 1. Code Assist OAuth (需要 GCP project_id) +# - Uses: cloudcode-pa.googleapis.com (Code Assist API) +# - Auto scopes: cloud-platform + userinfo.email + userinfo.profile +# - OAuth Client: Can use built-in Gemini CLI client (留空即可) +# - Requires: Google Cloud Platform project with Code Assist enabled +# +# 2. AI Studio OAuth (不需要 project_id) +# - Uses: generativelanguage.googleapis.com (AI Studio API) +# - Default scopes: generative-language +# - OAuth Client: Requires your own OAuth 2.0 Client (内置 Gemini CLI client 不能申请 generative-language scope) +# - Requires: Create OAuth 2.0 Client in GCP Console + OAuth consent screen +# - Setup Guide: https://ai.google.dev/gemini-api/docs/oauth +# - ⚠️ IMPORTANT: OAuth Client 必须发布为正式版本 (Production) +# Testing 模式限制: 只能添加 100 个测试用户, refresh token 7 天后过期 +# 发布步骤: GCP Console → OAuth consent screen → PUBLISH APP +# +# Configuration: +# Leave empty to use the built-in Gemini CLI OAuth client (Code Assist OAuth only). +# To enable AI Studio OAuth, set your own OAuth client ID/secret here. +GEMINI_OAUTH_CLIENT_ID= +GEMINI_OAUTH_CLIENT_SECRET= +# Optional; leave empty to auto-select scopes based on oauth_type +GEMINI_OAUTH_SCOPES= diff --git a/deploy/README.md b/deploy/README.md index f110451b..5b127fc1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -96,11 +96,107 @@ docker-compose down -v | `ADMIN_PASSWORD` | No | *(auto-generated)* | Admin password | | `JWT_SECRET` | No | *(auto-generated)* | JWT secret | | `TZ` | No | `Asia/Shanghai` | Timezone | +| `GEMINI_OAUTH_CLIENT_ID` | No | *(builtin)* | Google OAuth client ID (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. | +| `GEMINI_OAUTH_CLIENT_SECRET` | No | *(builtin)* | Google OAuth client secret (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. | +| `GEMINI_OAUTH_SCOPES` | No | *(default)* | OAuth scopes (Gemini OAuth) | See `.env.example` for all available options. --- +## Gemini OAuth Configuration + +Sub2API supports three methods to connect to Gemini: + +### Method 1: Code Assist OAuth (Recommended for GCP Users) + +**No configuration needed** - always uses the built-in Gemini CLI OAuth client (public). + +1. Leave `GEMINI_OAUTH_CLIENT_ID` and `GEMINI_OAUTH_CLIENT_SECRET` empty +2. In the Admin UI, create a Gemini OAuth account and select **"Code Assist"** type +3. Complete the OAuth flow in your browser + +> Note: Even if you configure `GEMINI_OAUTH_CLIENT_ID` / `GEMINI_OAUTH_CLIENT_SECRET` for AI Studio OAuth, +> Code Assist OAuth will still use the built-in Gemini CLI client. + +**Requirements:** +- Google account with access to Google Cloud Platform +- A GCP project (auto-detected or manually specified) + +**How to get Project ID (if auto-detection fails):** +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Click the project dropdown at the top of the page +3. Copy the Project ID (not the project name) from the list +4. Common formats: `my-project-123456` or `cloud-ai-companion-xxxxx` + +### Method 2: AI Studio OAuth (For Regular Google Accounts) + +Requires your own OAuth client credentials. + +**Step 1: Create OAuth Client in Google Cloud Console** + +1. Go to [Google Cloud Console - Credentials](https://console.cloud.google.com/apis/credentials) +2. Create a new project or select an existing one +3. **Enable the Generative Language API:** + - Go to "APIs & Services" → "Library" + - Search for "Generative Language API" + - Click "Enable" +4. **Configure OAuth Consent Screen** (if not done): + - Go to "APIs & Services" → "OAuth consent screen" + - Choose "External" user type + - Fill in app name, user support email, developer contact + - Add scopes: `https://www.googleapis.com/auth/generative-language.retriever` (and optionally `https://www.googleapis.com/auth/cloud-platform`) + - Add test users (your Google account email) +5. **Create OAuth 2.0 credentials:** + - Go to "APIs & Services" → "Credentials" + - Click "Create Credentials" → "OAuth client ID" + - Application type: **Web application** (or **Desktop app**) + - Name: e.g., "Sub2API Gemini" + - Authorized redirect URIs: Add `http://localhost:1455/auth/callback` +6. Copy the **Client ID** and **Client Secret** +7. **⚠️ Publish to Production (IMPORTANT):** + - Go to "APIs & Services" → "OAuth consent screen" + - Click "PUBLISH APP" to move from Testing to Production + - **Testing mode limitations:** + - Only manually added test users can authenticate (max 100 users) + - Refresh tokens expire after 7 days + - Users must be re-added periodically + - **Production mode:** Any Google user can authenticate, tokens don't expire + - Note: For sensitive scopes, Google may require verification (demo video, privacy policy) + +**Step 2: Configure Environment Variables** + +```bash +GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com +GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret +``` + +**Step 3: Create Account in Admin UI** + +1. Create a Gemini OAuth account and select **"AI Studio"** type +2. Complete the OAuth flow + - After consent, your browser will be redirected to `http://localhost:1455/auth/callback?code=...&state=...` + - Copy the full callback URL (recommended) or just the `code` and paste it back into the Admin UI + +### Method 3: API Key (Simplest) + +1. Go to [Google AI Studio](https://aistudio.google.com/app/apikey) +2. Click "Create API key" +3. In Admin UI, create a Gemini **API Key** account +4. Paste your API key (starts with `AIza...`) + +### Comparison Table + +| Feature | Code Assist OAuth | AI Studio OAuth | API Key | +|---------|-------------------|-----------------|---------| +| Setup Complexity | Easy (no config) | Medium (OAuth client) | Easy | +| GCP Project Required | Yes | No | No | +| Custom OAuth Client | No (built-in) | Yes (required) | N/A | +| Rate Limits | GCP quota | Standard | Standard | +| Best For | GCP developers | Regular users needing OAuth | Quick testing | + +--- + ## Binary Installation For production servers using systemd. diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 1e466244..b6df4f65 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -87,3 +87,23 @@ pricing: update_interval_hours: 24 # Hash check interval in minutes hash_check_interval_minutes: 10 + +# ============================================================================= +# Gemini OAuth (Required for Gemini accounts) +# ============================================================================= +# Sub2API supports TWO Gemini OAuth modes: +# +# 1. Code Assist OAuth (需要 GCP project_id) +# - Uses: cloudcode-pa.googleapis.com (Code Assist API) +# +# 2. AI Studio OAuth (不需要 project_id) +# - Uses: generativelanguage.googleapis.com (AI Studio API) +# +# Default: Uses Gemini CLI's public OAuth credentials (same as Google's official CLI tool) +gemini: + oauth: + # Gemini CLI public OAuth credentials (works for both Code Assist and AI Studio) + client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" + client_secret: "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" + # Optional scopes (space-separated). Leave empty to auto-select based on oauth_type. + scopes: "" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 9b802415..4e2de67f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -78,6 +78,13 @@ services: # Common values: Asia/Shanghai, America/New_York, Europe/London, UTC # ======================================================================= - TZ=${TZ:-Asia/Shanghai} + + # ======================================================================= + # Gemini OAuth Configuration (for Gemini accounts) + # ======================================================================= + - GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-} + - GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-} + - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} depends_on: postgres: condition: service_healthy diff --git a/deploy/install.sh b/deploy/install.sh index 819cfc0c..4f4f9161 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -122,6 +122,10 @@ declare -A MSG_ZH=( ["removing_user"]="正在移除用户..." ["config_not_removed"]="配置目录未被移除" ["remove_manually"]="如不再需要,请手动删除" + ["removing_install_lock"]="正在移除安装锁文件..." + ["install_lock_removed"]="安装锁文件已移除,重新安装时将进入设置向导" + ["purge_prompt"]="是否同时删除配置目录?这将清除所有配置和数据 [y/N]: " + ["removing_config_dir"]="正在移除配置目录..." ["uninstall_complete"]="Sub2API 已卸载" # Help @@ -243,6 +247,10 @@ declare -A MSG_EN=( ["removing_user"]="Removing user..." ["config_not_removed"]="Config directory was NOT removed." ["remove_manually"]="Remove it manually if you no longer need it." + ["removing_install_lock"]="Removing install lock file..." + ["install_lock_removed"]="Install lock removed. Setup wizard will appear on next install." + ["purge_prompt"]="Also remove config directory? This will delete all config and data [y/N]: " + ["removing_config_dir"]="Removing config directory..." ["uninstall_complete"]="Sub2API has been uninstalled" # Help @@ -926,8 +934,31 @@ uninstall() { print_info "$(msg 'removing_user')" userdel "$SERVICE_USER" 2>/dev/null || true - print_warning "$(msg 'config_not_removed'): $CONFIG_DIR" - print_warning "$(msg 'remove_manually')" + # Remove install lock file (.installed) to allow fresh setup on reinstall + print_info "$(msg 'removing_install_lock')" + rm -f "$CONFIG_DIR/.installed" 2>/dev/null || true + rm -f "$INSTALL_DIR/.installed" 2>/dev/null || true + print_success "$(msg 'install_lock_removed')" + + # Ask about config directory removal (interactive mode only) + local remove_config=false + if [ "${PURGE:-}" = "true" ]; then + remove_config=true + elif is_interactive; then + read -p "$(msg 'purge_prompt')" -n 1 -r < /dev/tty + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + remove_config=true + fi + fi + + if [ "$remove_config" = true ]; then + print_info "$(msg 'removing_config_dir')" + rm -rf "$CONFIG_DIR" + else + print_warning "$(msg 'config_not_removed'): $CONFIG_DIR" + print_warning "$(msg 'remove_manually')" + fi print_success "$(msg 'uninstall_complete')" } @@ -944,6 +975,10 @@ main() { FORCE_YES="true" shift ;; + --purge) + PURGE="true" + shift + ;; -v|--version) if [ -n "${2:-}" ] && [[ ! "$2" =~ ^- ]]; then target_version="$2" diff --git a/frontend/index.html b/frontend/index.html index fae51f59..3180a5fb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,4 +1,4 @@ - + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 56172b40..1166cca3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -494,6 +494,180 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/@intlify/core-base": { "version": "9.14.5", "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.5.tgz", @@ -941,6 +1115,7 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -951,6 +1126,14 @@ "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -1172,6 +1355,35 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/alien-signals": { "version": "1.0.13", "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", @@ -1192,6 +1404,23 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", @@ -1220,6 +1449,14 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0", + "optional": true + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", @@ -1347,6 +1584,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1374,6 +1612,17 @@ "node": ">= 0.4" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1405,11 +1654,30 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/chart.js": { "version": "4.5.1", "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -1455,6 +1723,28 @@ "node": ">= 6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1477,6 +1767,41 @@ "node": ">= 6" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", @@ -1503,6 +1828,33 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1526,6 +1878,20 @@ "dev": true, "license": "MIT" }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1653,12 +2019,135 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1689,6 +2178,22 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", @@ -1699,6 +2204,20 @@ "reusify": "^1.0.4" } }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", @@ -1712,6 +2231,48 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1762,6 +2323,14 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -1823,6 +2392,29 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1836,6 +2428,49 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", @@ -1848,6 +2483,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1897,6 +2551,67 @@ "he": "bin/he" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1959,12 +2674,32 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -1976,6 +2711,70 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1996,6 +2795,31 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", @@ -2075,6 +2899,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", @@ -2112,6 +2944,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", @@ -2166,6 +3006,84 @@ "node": ">= 6" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", @@ -2173,6 +3091,28 @@ "dev": true, "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", @@ -2273,6 +3213,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2416,12 +3357,34 @@ "dev": true, "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2487,6 +3450,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", @@ -2498,6 +3472,24 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.53.5", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.53.5.tgz", @@ -2564,6 +3556,31 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2589,6 +3606,20 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", @@ -2612,6 +3643,20 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -2663,6 +3708,14 @@ "node": ">=14.0.0" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", @@ -2734,6 +3787,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2761,12 +3815,41 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2826,6 +3909,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2839,6 +3933,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3010,6 +4105,7 @@ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", @@ -3103,6 +4199,7 @@ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" @@ -3113,6 +4210,56 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2e7af2b7..2b75bd8a 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ export default { plugins: { tailwindcss: {}, - autoprefixer: {}, - }, + autoprefixer: {} + } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5a327688..89aa91bc 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -26,17 +26,25 @@ function updateFavicon(logoUrl: string) { } // Watch for site settings changes and update favicon/title -watch(() => appStore.siteLogo, (newLogo) => { - if (newLogo) { - updateFavicon(newLogo) - } -}, { immediate: true }) +watch( + () => appStore.siteLogo, + (newLogo) => { + if (newLogo) { + updateFavicon(newLogo) + } + }, + { immediate: true } +) -watch(() => appStore.siteName, (newName) => { - if (newName) { - document.title = `${newName} - AI API Gateway` - } -}, { immediate: true }) +watch( + () => appStore.siteName, + (newName) => { + if (newName) { + document.title = `${newName} - AI API Gateway` + } + }, + { immediate: true } +) onMounted(async () => { // Check if setup is needed diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 99146bf2..cac50232 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -12,7 +12,7 @@ import type { AccountUsageInfo, WindowStats, ClaudeModel, - AccountUsageStatsResponse, + AccountUsageStatsResponse } from '@/types' /** @@ -36,8 +36,8 @@ export async function list( params: { page, page_size: pageSize, - ...filters, - }, + ...filters + } }) return data } @@ -129,7 +129,7 @@ export async function refreshCredentials(id: number): Promise { */ export async function getStats(id: number, days: number = 30): Promise { const { data } = await apiClient.get(`/admin/accounts/${id}/stats`, { - params: { days }, + params: { days } }) return data } @@ -254,7 +254,7 @@ export async function bulkUpdate( results: Array<{ account_id: number; success: boolean; error?: string }> }>('/admin/accounts/bulk-update', { account_ids: accountIds, - ...updates, + ...updates }) return data } @@ -277,7 +277,7 @@ export async function getTodayStats(id: number): Promise { */ export async function setSchedulable(id: number, schedulable: boolean): Promise { const { data } = await apiClient.post(`/admin/accounts/${id}/schedulable`, { - schedulable, + schedulable }) return data } @@ -335,7 +335,7 @@ export const accountsAPI = { batchCreate, batchUpdateCredentials, bulkUpdate, - syncFromCrs, + syncFromCrs } export default accountsAPI diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index 39253538..83e56c0e 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -3,16 +3,22 @@ * Provides system-wide statistics and metrics */ -import { apiClient } from '../client'; -import type { DashboardStats, TrendDataPoint, ModelStat, ApiKeyUsageTrendPoint, UserUsageTrendPoint } from '@/types'; +import { apiClient } from '../client' +import type { + DashboardStats, + TrendDataPoint, + ModelStat, + ApiKeyUsageTrendPoint, + UserUsageTrendPoint +} from '@/types' /** * Get dashboard statistics * @returns Dashboard statistics including users, keys, accounts, and token usage */ export async function getStats(): Promise { - const { data } = await apiClient.get('/admin/dashboard/stats'); - return data; + const { data } = await apiClient.get('/admin/dashboard/stats') + return data } /** @@ -20,33 +26,33 @@ export async function getStats(): Promise { * @returns Real-time system metrics */ export async function getRealtimeMetrics(): Promise<{ - active_requests: number; - requests_per_minute: number; - average_response_time: number; - error_rate: number; + active_requests: number + requests_per_minute: number + average_response_time: number + error_rate: number }> { const { data } = await apiClient.get<{ - active_requests: number; - requests_per_minute: number; - average_response_time: number; - error_rate: number; - }>('/admin/dashboard/realtime'); - return data; + active_requests: number + requests_per_minute: number + average_response_time: number + error_rate: number + }>('/admin/dashboard/realtime') + return data } export interface TrendParams { - start_date?: string; - end_date?: string; - granularity?: 'day' | 'hour'; - user_id?: number; - api_key_id?: number; + start_date?: string + end_date?: string + granularity?: 'day' | 'hour' + user_id?: number + api_key_id?: number } export interface TrendResponse { - trend: TrendDataPoint[]; - start_date: string; - end_date: string; - granularity: string; + trend: TrendDataPoint[] + start_date: string + end_date: string + granularity: string } /** @@ -55,21 +61,21 @@ export interface TrendResponse { * @returns Usage trend data */ export async function getUsageTrend(params?: TrendParams): Promise { - const { data } = await apiClient.get('/admin/dashboard/trend', { params }); - return data; + const { data } = await apiClient.get('/admin/dashboard/trend', { params }) + return data } export interface ModelStatsParams { - start_date?: string; - end_date?: string; - user_id?: number; - api_key_id?: number; + start_date?: string + end_date?: string + user_id?: number + api_key_id?: number } export interface ModelStatsResponse { - models: ModelStat[]; - start_date: string; - end_date: string; + models: ModelStat[] + start_date: string + end_date: string } /** @@ -78,19 +84,19 @@ export interface ModelStatsResponse { * @returns Model usage statistics */ export async function getModelStats(params?: ModelStatsParams): Promise { - const { data } = await apiClient.get('/admin/dashboard/models', { params }); - return data; + const { data } = await apiClient.get('/admin/dashboard/models', { params }) + return data } export interface ApiKeyTrendParams extends TrendParams { - limit?: number; + limit?: number } export interface ApiKeyTrendResponse { - trend: ApiKeyUsageTrendPoint[]; - start_date: string; - end_date: string; - granularity: string; + trend: ApiKeyUsageTrendPoint[] + start_date: string + end_date: string + granularity: string } /** @@ -98,20 +104,24 @@ export interface ApiKeyTrendResponse { * @param params - Query parameters for filtering * @returns API key usage trend data */ -export async function getApiKeyUsageTrend(params?: ApiKeyTrendParams): Promise { - const { data } = await apiClient.get('/admin/dashboard/api-keys-trend', { params }); - return data; +export async function getApiKeyUsageTrend( + params?: ApiKeyTrendParams +): Promise { + const { data } = await apiClient.get('/admin/dashboard/api-keys-trend', { + params + }) + return data } export interface UserTrendParams extends TrendParams { - limit?: number; + limit?: number } export interface UserTrendResponse { - trend: UserUsageTrendPoint[]; - start_date: string; - end_date: string; - granularity: string; + trend: UserUsageTrendPoint[] + start_date: string + end_date: string + granularity: string } /** @@ -120,18 +130,20 @@ export interface UserTrendResponse { * @returns User usage trend data */ export async function getUserUsageTrend(params?: UserTrendParams): Promise { - const { data } = await apiClient.get('/admin/dashboard/users-trend', { params }); - return data; + const { data } = await apiClient.get('/admin/dashboard/users-trend', { + params + }) + return data } export interface BatchUserUsageStats { - user_id: number; - today_actual_cost: number; - total_actual_cost: number; + user_id: number + today_actual_cost: number + total_actual_cost: number } export interface BatchUsersUsageResponse { - stats: Record; + stats: Record } /** @@ -141,19 +153,19 @@ export interface BatchUsersUsageResponse { */ export async function getBatchUsersUsage(userIds: number[]): Promise { const { data } = await apiClient.post('/admin/dashboard/users-usage', { - user_ids: userIds, - }); - return data; + user_ids: userIds + }) + return data } export interface BatchApiKeyUsageStats { - api_key_id: number; - today_actual_cost: number; - total_actual_cost: number; + api_key_id: number + today_actual_cost: number + total_actual_cost: number } export interface BatchApiKeysUsageResponse { - stats: Record; + stats: Record } /** @@ -161,11 +173,16 @@ export interface BatchApiKeysUsageResponse { * @param apiKeyIds - Array of API key IDs * @returns Usage stats map keyed by API key ID */ -export async function getBatchApiKeysUsage(apiKeyIds: number[]): Promise { - const { data } = await apiClient.post('/admin/dashboard/api-keys-usage', { - api_key_ids: apiKeyIds, - }); - return data; +export async function getBatchApiKeysUsage( + apiKeyIds: number[] +): Promise { + const { data } = await apiClient.post( + '/admin/dashboard/api-keys-usage', + { + api_key_ids: apiKeyIds + } + ) + return data } export const dashboardAPI = { @@ -176,7 +193,7 @@ export const dashboardAPI = { getApiKeyUsageTrend, getUserUsageTrend, getBatchUsersUsage, - getBatchApiKeysUsage, -}; + getBatchApiKeysUsage +} -export default dashboardAPI; +export default dashboardAPI diff --git a/frontend/src/api/admin/gemini.ts b/frontend/src/api/admin/gemini.ts new file mode 100644 index 00000000..a01793dd --- /dev/null +++ b/frontend/src/api/admin/gemini.ts @@ -0,0 +1,58 @@ +/** + * Admin Gemini API endpoints + * Handles Gemini OAuth flows for administrators + */ + +import { apiClient } from '../client' + +export interface GeminiAuthUrlResponse { + auth_url: string + session_id: string + state: string +} + +export interface GeminiOAuthCapabilities { + ai_studio_oauth_enabled: boolean + required_redirect_uris: string[] +} + +export interface GeminiAuthUrlRequest { + proxy_id?: number + project_id?: string + oauth_type?: 'code_assist' | 'ai_studio' +} + +export interface GeminiExchangeCodeRequest { + session_id: string + state: string + code: string + proxy_id?: number + oauth_type?: 'code_assist' | 'ai_studio' +} + +export type GeminiTokenInfo = Record + +export async function generateAuthUrl( + payload: GeminiAuthUrlRequest +): Promise { + const { data } = await apiClient.post( + '/admin/gemini/oauth/auth-url', + payload + ) + return data +} + +export async function exchangeCode(payload: GeminiExchangeCodeRequest): Promise { + const { data } = await apiClient.post( + '/admin/gemini/oauth/exchange-code', + payload + ) + return data +} + +export async function getCapabilities(): Promise { + const { data } = await apiClient.get('/admin/gemini/oauth/capabilities') + return data +} + +export default { generateAuthUrl, exchangeCode, getCapabilities } diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 1bc530ec..d48792e7 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -3,14 +3,14 @@ * Handles API key group management for administrators */ -import { apiClient } from '../client'; +import { apiClient } from '../client' import type { Group, GroupPlatform, CreateGroupRequest, UpdateGroupRequest, - PaginatedResponse, -} from '@/types'; + PaginatedResponse +} from '@/types' /** * List all groups with pagination @@ -23,19 +23,19 @@ export async function list( page: number = 1, pageSize: number = 20, filters?: { - platform?: GroupPlatform; - status?: 'active' | 'inactive'; - is_exclusive?: boolean; + platform?: GroupPlatform + status?: 'active' | 'inactive' + is_exclusive?: boolean } ): Promise> { const { data } = await apiClient.get>('/admin/groups', { params: { page, page_size: pageSize, - ...filters, - }, - }); - return data; + ...filters + } + }) + return data } /** @@ -46,8 +46,8 @@ export async function list( export async function getAll(platform?: GroupPlatform): Promise { const { data } = await apiClient.get('/admin/groups/all', { params: platform ? { platform } : undefined - }); - return data; + }) + return data } /** @@ -56,7 +56,7 @@ export async function getAll(platform?: GroupPlatform): Promise { * @returns List of groups for the specified platform */ export async function getByPlatform(platform: GroupPlatform): Promise { - return getAll(platform); + return getAll(platform) } /** @@ -65,8 +65,8 @@ export async function getByPlatform(platform: GroupPlatform): Promise { * @returns Group details */ export async function getById(id: number): Promise { - const { data } = await apiClient.get(`/admin/groups/${id}`); - return data; + const { data } = await apiClient.get(`/admin/groups/${id}`) + return data } /** @@ -75,8 +75,8 @@ export async function getById(id: number): Promise { * @returns Created group */ export async function create(groupData: CreateGroupRequest): Promise { - const { data } = await apiClient.post('/admin/groups', groupData); - return data; + const { data } = await apiClient.post('/admin/groups', groupData) + return data } /** @@ -86,8 +86,8 @@ export async function create(groupData: CreateGroupRequest): Promise { * @returns Updated group */ export async function update(id: number, updates: UpdateGroupRequest): Promise { - const { data } = await apiClient.put(`/admin/groups/${id}`, updates); - return data; + const { data } = await apiClient.put(`/admin/groups/${id}`, updates) + return data } /** @@ -96,8 +96,8 @@ export async function update(id: number, updates: UpdateGroupRequest): Promise { - const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}`); - return data; + const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}`) + return data } /** @@ -106,11 +106,8 @@ export async function deleteGroup(id: number): Promise<{ message: string }> { * @param status - New status * @returns Updated group */ -export async function toggleStatus( - id: number, - status: 'active' | 'inactive' -): Promise { - return update(id, { status }); +export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise { + return update(id, { status }) } /** @@ -119,18 +116,18 @@ export async function toggleStatus( * @returns Group usage statistics */ export async function getStats(id: number): Promise<{ - total_api_keys: number; - active_api_keys: number; - total_requests: number; - total_cost: number; + total_api_keys: number + active_api_keys: number + total_requests: number + total_cost: number }> { const { data } = await apiClient.get<{ - total_api_keys: number; - active_api_keys: number; - total_requests: number; - total_cost: number; - }>(`/admin/groups/${id}/stats`); - return data; + total_api_keys: number + active_api_keys: number + total_requests: number + total_cost: number + }>(`/admin/groups/${id}/stats`) + return data } /** @@ -145,13 +142,10 @@ export async function getGroupApiKeys( page: number = 1, pageSize: number = 20 ): Promise> { - const { data } = await apiClient.get>( - `/admin/groups/${id}/api-keys`, - { - params: { page, page_size: pageSize }, - } - ); - return data; + const { data } = await apiClient.get>(`/admin/groups/${id}/api-keys`, { + params: { page, page_size: pageSize } + }) + return data } export const groupsAPI = { @@ -164,7 +158,7 @@ export const groupsAPI = { delete: deleteGroup, toggleStatus, getStats, - getGroupApiKeys, -}; + getGroupApiKeys +} -export default groupsAPI; +export default groupsAPI diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 4c2baea9..55477c87 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -3,16 +3,17 @@ * Centralized exports for all admin API modules */ -import dashboardAPI from './dashboard'; -import usersAPI from './users'; -import groupsAPI from './groups'; -import accountsAPI from './accounts'; -import proxiesAPI from './proxies'; -import redeemAPI from './redeem'; -import settingsAPI from './settings'; -import systemAPI from './system'; -import subscriptionsAPI from './subscriptions'; -import usageAPI from './usage'; +import dashboardAPI from './dashboard' +import usersAPI from './users' +import groupsAPI from './groups' +import accountsAPI from './accounts' +import proxiesAPI from './proxies' +import redeemAPI from './redeem' +import settingsAPI from './settings' +import systemAPI from './system' +import subscriptionsAPI from './subscriptions' +import usageAPI from './usage' +import geminiAPI from './gemini' /** * Unified admin API object for convenient access @@ -28,8 +29,21 @@ export const adminAPI = { system: systemAPI, subscriptions: subscriptionsAPI, usage: usageAPI, -}; + gemini: geminiAPI +} -export { dashboardAPI, usersAPI, groupsAPI, accountsAPI, proxiesAPI, redeemAPI, settingsAPI, systemAPI, subscriptionsAPI, usageAPI }; +export { + dashboardAPI, + usersAPI, + groupsAPI, + accountsAPI, + proxiesAPI, + redeemAPI, + settingsAPI, + systemAPI, + subscriptionsAPI, + usageAPI, + geminiAPI +} -export default adminAPI; +export default adminAPI diff --git a/frontend/src/api/admin/proxies.ts b/frontend/src/api/admin/proxies.ts index 0ec092a7..273e1f8a 100644 --- a/frontend/src/api/admin/proxies.ts +++ b/frontend/src/api/admin/proxies.ts @@ -3,13 +3,8 @@ * Handles proxy server management for administrators */ -import { apiClient } from '../client'; -import type { - Proxy, - CreateProxyRequest, - UpdateProxyRequest, - PaginatedResponse, -} from '@/types'; +import { apiClient } from '../client' +import type { Proxy, CreateProxyRequest, UpdateProxyRequest, PaginatedResponse } from '@/types' /** * List all proxies with pagination @@ -22,19 +17,19 @@ export async function list( page: number = 1, pageSize: number = 20, filters?: { - protocol?: string; - status?: 'active' | 'inactive'; - search?: string; + protocol?: string + status?: 'active' | 'inactive' + search?: string } ): Promise> { const { data } = await apiClient.get>('/admin/proxies', { params: { page, page_size: pageSize, - ...filters, - }, - }); - return data; + ...filters + } + }) + return data } /** @@ -42,8 +37,8 @@ export async function list( * @returns List of all active proxies */ export async function getAll(): Promise { - const { data } = await apiClient.get('/admin/proxies/all'); - return data; + const { data } = await apiClient.get('/admin/proxies/all') + return data } /** @@ -52,9 +47,9 @@ export async function getAll(): Promise { */ export async function getAllWithCount(): Promise { const { data } = await apiClient.get('/admin/proxies/all', { - params: { with_count: 'true' }, - }); - return data; + params: { with_count: 'true' } + }) + return data } /** @@ -63,8 +58,8 @@ export async function getAllWithCount(): Promise { * @returns Proxy details */ export async function getById(id: number): Promise { - const { data } = await apiClient.get(`/admin/proxies/${id}`); - return data; + const { data } = await apiClient.get(`/admin/proxies/${id}`) + return data } /** @@ -73,8 +68,8 @@ export async function getById(id: number): Promise { * @returns Created proxy */ export async function create(proxyData: CreateProxyRequest): Promise { - const { data } = await apiClient.post('/admin/proxies', proxyData); - return data; + const { data } = await apiClient.post('/admin/proxies', proxyData) + return data } /** @@ -84,8 +79,8 @@ export async function create(proxyData: CreateProxyRequest): Promise { * @returns Updated proxy */ export async function update(id: number, updates: UpdateProxyRequest): Promise { - const { data } = await apiClient.put(`/admin/proxies/${id}`, updates); - return data; + const { data } = await apiClient.put(`/admin/proxies/${id}`, updates) + return data } /** @@ -94,8 +89,8 @@ export async function update(id: number, updates: UpdateProxyRequest): Promise

{ - const { data } = await apiClient.delete<{ message: string }>(`/admin/proxies/${id}`); - return data; + const { data } = await apiClient.delete<{ message: string }>(`/admin/proxies/${id}`) + return data } /** @@ -104,11 +99,8 @@ export async function deleteProxy(id: number): Promise<{ message: string }> { * @param status - New status * @returns Updated proxy */ -export async function toggleStatus( - id: number, - status: 'active' | 'inactive' -): Promise { - return update(id, { status }); +export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise { + return update(id, { status }) } /** @@ -117,24 +109,24 @@ export async function toggleStatus( * @returns Test result with IP info */ export async function testProxy(id: number): Promise<{ - success: boolean; - message: string; - latency_ms?: number; - ip_address?: string; - city?: string; - region?: string; - country?: string; + success: boolean + message: string + latency_ms?: number + ip_address?: string + city?: string + region?: string + country?: string }> { const { data } = await apiClient.post<{ - success: boolean; - message: string; - latency_ms?: number; - ip_address?: string; - city?: string; - region?: string; - country?: string; - }>(`/admin/proxies/${id}/test`); - return data; + success: boolean + message: string + latency_ms?: number + ip_address?: string + city?: string + region?: string + country?: string + }>(`/admin/proxies/${id}/test`) + return data } /** @@ -143,20 +135,20 @@ export async function testProxy(id: number): Promise<{ * @returns Proxy usage statistics */ export async function getStats(id: number): Promise<{ - total_accounts: number; - active_accounts: number; - total_requests: number; - success_rate: number; - average_latency: number; + total_accounts: number + active_accounts: number + total_requests: number + success_rate: number + average_latency: number }> { const { data } = await apiClient.get<{ - total_accounts: number; - active_accounts: number; - total_requests: number; - success_rate: number; - average_latency: number; - }>(`/admin/proxies/${id}/stats`); - return data; + total_accounts: number + active_accounts: number + total_requests: number + success_rate: number + average_latency: number + }>(`/admin/proxies/${id}/stats`) + return data } /** @@ -165,10 +157,8 @@ export async function getStats(id: number): Promise<{ * @returns List of accounts using the proxy */ export async function getProxyAccounts(id: number): Promise> { - const { data } = await apiClient.get>( - `/admin/proxies/${id}/accounts` - ); - return data; + const { data } = await apiClient.get>(`/admin/proxies/${id}/accounts`) + return data } /** @@ -176,21 +166,23 @@ export async function getProxyAccounts(id: number): Promise): Promise<{ - created: number; - skipped: number; +export async function batchCreate( + proxies: Array<{ + protocol: string + host: string + port: number + username?: string + password?: string + }> +): Promise<{ + created: number + skipped: number }> { const { data } = await apiClient.post<{ - created: number; - skipped: number; - }>('/admin/proxies/batch', { proxies }); - return data; + created: number + skipped: number + }>('/admin/proxies/batch', { proxies }) + return data } export const proxiesAPI = { @@ -205,7 +197,7 @@ export const proxiesAPI = { testProxy, getStats, getProxyAccounts, - batchCreate, -}; + batchCreate +} -export default proxiesAPI; +export default proxiesAPI diff --git a/frontend/src/api/admin/redeem.ts b/frontend/src/api/admin/redeem.ts index d5af176b..738b1519 100644 --- a/frontend/src/api/admin/redeem.ts +++ b/frontend/src/api/admin/redeem.ts @@ -3,13 +3,13 @@ * Handles redeem code generation and management for administrators */ -import { apiClient } from '../client'; +import { apiClient } from '../client' import type { RedeemCode, GenerateRedeemCodesRequest, RedeemCodeType, - PaginatedResponse, -} from '@/types'; + PaginatedResponse +} from '@/types' /** * List all redeem codes with pagination @@ -22,19 +22,19 @@ export async function list( page: number = 1, pageSize: number = 20, filters?: { - type?: RedeemCodeType; - status?: 'active' | 'used' | 'expired' | 'unused'; - search?: string; + type?: RedeemCodeType + status?: 'active' | 'used' | 'expired' | 'unused' + search?: string } ): Promise> { const { data } = await apiClient.get>('/admin/redeem-codes', { params: { page, page_size: pageSize, - ...filters, - }, - }); - return data; + ...filters + } + }) + return data } /** @@ -43,8 +43,8 @@ export async function list( * @returns Redeem code details */ export async function getById(id: number): Promise { - const { data } = await apiClient.get(`/admin/redeem-codes/${id}`); - return data; + const { data } = await apiClient.get(`/admin/redeem-codes/${id}`) + return data } /** @@ -66,19 +66,19 @@ export async function generate( const payload: GenerateRedeemCodesRequest = { count, type, - value, - }; + value + } // 订阅类型专用字段 if (type === 'subscription') { - payload.group_id = groupId; + payload.group_id = groupId if (validityDays && validityDays > 0) { - payload.validity_days = validityDays; + payload.validity_days = validityDays } } - const { data } = await apiClient.post('/admin/redeem-codes/generate', payload); - return data; + const { data } = await apiClient.post('/admin/redeem-codes/generate', payload) + return data } /** @@ -87,8 +87,8 @@ export async function generate( * @returns Success confirmation */ export async function deleteCode(id: number): Promise<{ message: string }> { - const { data } = await apiClient.delete<{ message: string }>(`/admin/redeem-codes/${id}`); - return data; + const { data } = await apiClient.delete<{ message: string }>(`/admin/redeem-codes/${id}`) + return data } /** @@ -97,14 +97,14 @@ export async function deleteCode(id: number): Promise<{ message: string }> { * @returns Success confirmation */ export async function batchDelete(ids: number[]): Promise<{ - deleted: number; - message: string; + deleted: number + message: string }> { const { data } = await apiClient.post<{ - deleted: number; - message: string; - }>('/admin/redeem-codes/batch-delete', { ids }); - return data; + deleted: number + message: string + }>('/admin/redeem-codes/batch-delete', { ids }) + return data } /** @@ -113,8 +113,8 @@ export async function batchDelete(ids: number[]): Promise<{ * @returns Updated redeem code */ export async function expire(id: number): Promise { - const { data } = await apiClient.post(`/admin/redeem-codes/${id}/expire`); - return data; + const { data } = await apiClient.post(`/admin/redeem-codes/${id}/expire`) + return data } /** @@ -122,22 +122,22 @@ export async function expire(id: number): Promise { * @returns Statistics about redeem codes */ export async function getStats(): Promise<{ - total_codes: number; - active_codes: number; - used_codes: number; - expired_codes: number; - total_value_distributed: number; - by_type: Record; + total_codes: number + active_codes: number + used_codes: number + expired_codes: number + total_value_distributed: number + by_type: Record }> { const { data } = await apiClient.get<{ - total_codes: number; - active_codes: number; - used_codes: number; - expired_codes: number; - total_value_distributed: number; - by_type: Record; - }>('/admin/redeem-codes/stats'); - return data; + total_codes: number + active_codes: number + used_codes: number + expired_codes: number + total_value_distributed: number + by_type: Record + }>('/admin/redeem-codes/stats') + return data } /** @@ -146,14 +146,14 @@ export async function getStats(): Promise<{ * @returns CSV data as blob */ export async function exportCodes(filters?: { - type?: RedeemCodeType; - status?: 'active' | 'used' | 'expired'; + type?: RedeemCodeType + status?: 'active' | 'used' | 'expired' }): Promise { const response = await apiClient.get('/admin/redeem-codes/export', { params: filters, - responseType: 'blob', - }); - return response.data; + responseType: 'blob' + }) + return response.data } export const redeemAPI = { @@ -164,7 +164,7 @@ export const redeemAPI = { batchDelete, expire, getStats, - exportCodes, -}; + exportCodes +} -export default redeemAPI; +export default redeemAPI diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 56a74ccc..cf5cba6d 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -3,37 +3,37 @@ * Handles system settings management for administrators */ -import { apiClient } from '../client'; +import { apiClient } from '../client' /** * System settings interface */ export interface SystemSettings { // Registration settings - registration_enabled: boolean; - email_verify_enabled: boolean; + registration_enabled: boolean + email_verify_enabled: boolean // Default settings - default_balance: number; - default_concurrency: number; + default_balance: number + default_concurrency: number // OEM settings - site_name: string; - site_logo: string; - site_subtitle: string; - api_base_url: string; - contact_info: string; - doc_url: string; + site_name: string + site_logo: string + site_subtitle: string + api_base_url: string + contact_info: string + doc_url: string // SMTP settings - smtp_host: string; - smtp_port: number; - smtp_username: string; - smtp_password: string; - smtp_from_email: string; - smtp_from_name: string; - smtp_use_tls: boolean; + smtp_host: string + smtp_port: number + smtp_username: string + smtp_password: string + smtp_from_email: string + smtp_from_name: string + smtp_use_tls: boolean // Cloudflare Turnstile settings - turnstile_enabled: boolean; - turnstile_site_key: string; - turnstile_secret_key: string; + turnstile_enabled: boolean + turnstile_site_key: string + turnstile_secret_key: string } /** @@ -41,8 +41,8 @@ export interface SystemSettings { * @returns System settings */ export async function getSettings(): Promise { - const { data } = await apiClient.get('/admin/settings'); - return data; + const { data } = await apiClient.get('/admin/settings') + return data } /** @@ -51,19 +51,19 @@ export async function getSettings(): Promise { * @returns Updated settings */ export async function updateSettings(settings: Partial): Promise { - const { data } = await apiClient.put('/admin/settings', settings); - return data; + const { data } = await apiClient.put('/admin/settings', settings) + return data } /** * Test SMTP connection request */ export interface TestSmtpRequest { - smtp_host: string; - smtp_port: number; - smtp_username: string; - smtp_password: string; - smtp_use_tls: boolean; + smtp_host: string + smtp_port: number + smtp_username: string + smtp_password: string + smtp_use_tls: boolean } /** @@ -72,22 +72,22 @@ export interface TestSmtpRequest { * @returns Test result message */ export async function testSmtpConnection(config: TestSmtpRequest): Promise<{ message: string }> { - const { data } = await apiClient.post<{ message: string }>('/admin/settings/test-smtp', config); - return data; + const { data } = await apiClient.post<{ message: string }>('/admin/settings/test-smtp', config) + return data } /** * Send test email request */ export interface SendTestEmailRequest { - email: string; - smtp_host: string; - smtp_port: number; - smtp_username: string; - smtp_password: string; - smtp_from_email: string; - smtp_from_name: string; - smtp_use_tls: boolean; + email: string + smtp_host: string + smtp_port: number + smtp_username: string + smtp_password: string + smtp_from_email: string + smtp_from_name: string + smtp_use_tls: boolean } /** @@ -96,16 +96,19 @@ export interface SendTestEmailRequest { * @returns Test result message */ export async function sendTestEmail(request: SendTestEmailRequest): Promise<{ message: string }> { - const { data } = await apiClient.post<{ message: string }>('/admin/settings/send-test-email', request); - return data; + const { data } = await apiClient.post<{ message: string }>( + '/admin/settings/send-test-email', + request + ) + return data } /** * Admin API Key status response */ export interface AdminApiKeyStatus { - exists: boolean; - masked_key: string; + exists: boolean + masked_key: string } /** @@ -113,8 +116,8 @@ export interface AdminApiKeyStatus { * @returns Status indicating if key exists and masked version */ export async function getAdminApiKey(): Promise { - const { data } = await apiClient.get('/admin/settings/admin-api-key'); - return data; + const { data } = await apiClient.get('/admin/settings/admin-api-key') + return data } /** @@ -122,8 +125,8 @@ export async function getAdminApiKey(): Promise { * @returns The new full API key (only shown once) */ export async function regenerateAdminApiKey(): Promise<{ key: string }> { - const { data } = await apiClient.post<{ key: string }>('/admin/settings/admin-api-key/regenerate'); - return data; + const { data } = await apiClient.post<{ key: string }>('/admin/settings/admin-api-key/regenerate') + return data } /** @@ -131,8 +134,8 @@ export async function regenerateAdminApiKey(): Promise<{ key: string }> { * @returns Success message */ export async function deleteAdminApiKey(): Promise<{ message: string }> { - const { data } = await apiClient.delete<{ message: string }>('/admin/settings/admin-api-key'); - return data; + const { data } = await apiClient.delete<{ message: string }>('/admin/settings/admin-api-key') + return data } export const settingsAPI = { @@ -142,7 +145,7 @@ export const settingsAPI = { sendTestEmail, getAdminApiKey, regenerateAdminApiKey, - deleteAdminApiKey, -}; + deleteAdminApiKey +} -export default settingsAPI; +export default settingsAPI diff --git a/frontend/src/api/admin/subscriptions.ts b/frontend/src/api/admin/subscriptions.ts index 5d000b9a..ceabd4ee 100644 --- a/frontend/src/api/admin/subscriptions.ts +++ b/frontend/src/api/admin/subscriptions.ts @@ -3,15 +3,15 @@ * Handles user subscription management for administrators */ -import { apiClient } from '../client'; +import { apiClient } from '../client' import type { UserSubscription, SubscriptionProgress, AssignSubscriptionRequest, BulkAssignSubscriptionRequest, ExtendSubscriptionRequest, - PaginatedResponse, -} from '@/types'; + PaginatedResponse +} from '@/types' /** * List all subscriptions with pagination @@ -24,19 +24,22 @@ export async function list( page: number = 1, pageSize: number = 20, filters?: { - status?: 'active' | 'expired' | 'revoked'; - user_id?: number; - group_id?: number; + status?: 'active' | 'expired' | 'revoked' + user_id?: number + group_id?: number } ): Promise> { - const { data } = await apiClient.get>('/admin/subscriptions', { - params: { - page, - page_size: pageSize, - ...filters, - }, - }); - return data; + const { data } = await apiClient.get>( + '/admin/subscriptions', + { + params: { + page, + page_size: pageSize, + ...filters + } + } + ) + return data } /** @@ -45,8 +48,8 @@ export async function list( * @returns Subscription details */ export async function getById(id: number): Promise { - const { data } = await apiClient.get(`/admin/subscriptions/${id}`); - return data; + const { data } = await apiClient.get(`/admin/subscriptions/${id}`) + return data } /** @@ -55,8 +58,8 @@ export async function getById(id: number): Promise { * @returns Subscription progress with usage stats */ export async function getProgress(id: number): Promise { - const { data } = await apiClient.get(`/admin/subscriptions/${id}/progress`); - return data; + const { data } = await apiClient.get(`/admin/subscriptions/${id}/progress`) + return data } /** @@ -65,8 +68,8 @@ export async function getProgress(id: number): Promise { * @returns Created subscription */ export async function assign(request: AssignSubscriptionRequest): Promise { - const { data } = await apiClient.post('/admin/subscriptions/assign', request); - return data; + const { data } = await apiClient.post('/admin/subscriptions/assign', request) + return data } /** @@ -74,9 +77,14 @@ export async function assign(request: AssignSubscriptionRequest): Promise { - const { data } = await apiClient.post('/admin/subscriptions/bulk-assign', request); - return data; +export async function bulkAssign( + request: BulkAssignSubscriptionRequest +): Promise { + const { data } = await apiClient.post( + '/admin/subscriptions/bulk-assign', + request + ) + return data } /** @@ -85,9 +93,15 @@ export async function bulkAssign(request: BulkAssignSubscriptionRequest): Promis * @param request - Extension request with days * @returns Updated subscription */ -export async function extend(id: number, request: ExtendSubscriptionRequest): Promise { - const { data } = await apiClient.post(`/admin/subscriptions/${id}/extend`, request); - return data; +export async function extend( + id: number, + request: ExtendSubscriptionRequest +): Promise { + const { data } = await apiClient.post( + `/admin/subscriptions/${id}/extend`, + request + ) + return data } /** @@ -96,8 +110,8 @@ export async function extend(id: number, request: ExtendSubscriptionRequest): Pr * @returns Success confirmation */ export async function revoke(id: number): Promise<{ message: string }> { - const { data } = await apiClient.delete<{ message: string }>(`/admin/subscriptions/${id}`); - return data; + const { data } = await apiClient.delete<{ message: string }>(`/admin/subscriptions/${id}`) + return data } /** @@ -115,10 +129,10 @@ export async function listByGroup( const { data } = await apiClient.get>( `/admin/groups/${groupId}/subscriptions`, { - params: { page, page_size: pageSize }, + params: { page, page_size: pageSize } } - ); - return data; + ) + return data } /** @@ -136,10 +150,10 @@ export async function listByUser( const { data } = await apiClient.get>( `/admin/users/${userId}/subscriptions`, { - params: { page, page_size: pageSize }, + params: { page, page_size: pageSize } } - ); - return data; + ) + return data } export const subscriptionsAPI = { @@ -151,7 +165,7 @@ export const subscriptionsAPI = { extend, revoke, listByGroup, - listByUser, -}; + listByUser +} -export default subscriptionsAPI; +export default subscriptionsAPI diff --git a/frontend/src/api/admin/system.ts b/frontend/src/api/admin/system.ts index cb3e39a5..9ea312d5 100644 --- a/frontend/src/api/admin/system.ts +++ b/frontend/src/api/admin/system.ts @@ -2,31 +2,31 @@ * System API endpoints for admin operations */ -import { apiClient } from '../client'; +import { apiClient } from '../client' export interface ReleaseInfo { - name: string; - body: string; - published_at: string; - html_url: string; + name: string + body: string + published_at: string + html_url: string } export interface VersionInfo { - current_version: string; - latest_version: string; - has_update: boolean; - release_info?: ReleaseInfo; - cached: boolean; - warning?: string; - build_type: string; // "source" for manual builds, "release" for CI builds + current_version: string + latest_version: string + has_update: boolean + release_info?: ReleaseInfo + cached: boolean + warning?: string + build_type: string // "source" for manual builds, "release" for CI builds } /** * Get current version */ export async function getVersion(): Promise<{ version: string }> { - const { data } = await apiClient.get<{ version: string }>('/admin/system/version'); - return data; + const { data } = await apiClient.get<{ version: string }>('/admin/system/version') + return data } /** @@ -35,14 +35,14 @@ export async function getVersion(): Promise<{ version: string }> { */ export async function checkUpdates(force = false): Promise { const { data } = await apiClient.get('/admin/system/check-updates', { - params: force ? { force: 'true' } : undefined, - }); - return data; + params: force ? { force: 'true' } : undefined + }) + return data } export interface UpdateResult { - message: string; - need_restart: boolean; + message: string + need_restart: boolean } /** @@ -50,24 +50,24 @@ export interface UpdateResult { * Downloads and applies the latest version */ export async function performUpdate(): Promise { - const { data } = await apiClient.post('/admin/system/update'); - return data; + const { data } = await apiClient.post('/admin/system/update') + return data } /** * Rollback to previous version */ export async function rollback(): Promise { - const { data } = await apiClient.post('/admin/system/rollback'); - return data; + const { data } = await apiClient.post('/admin/system/rollback') + return data } /** * Restart the service */ export async function restartService(): Promise<{ message: string }> { - const { data } = await apiClient.post<{ message: string }>('/admin/system/restart'); - return data; + const { data } = await apiClient.post<{ message: string }>('/admin/system/restart') + return data } export const systemAPI = { @@ -75,7 +75,7 @@ export const systemAPI = { checkUpdates, performUpdate, rollback, - restartService, -}; + restartService +} -export default systemAPI; +export default systemAPI diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 2c5e800e..5d4896d3 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -3,39 +3,35 @@ * Handles admin-level usage logs and statistics retrieval */ -import { apiClient } from '../client'; -import type { - UsageLog, - UsageQueryParams, - PaginatedResponse, -} from '@/types'; +import { apiClient } from '../client' +import type { UsageLog, UsageQueryParams, PaginatedResponse } from '@/types' // ==================== Types ==================== export interface AdminUsageStatsResponse { - total_requests: number; - total_input_tokens: number; - total_output_tokens: number; - total_cache_tokens: number; - total_tokens: number; - total_cost: number; - total_actual_cost: number; - average_duration_ms: number; + total_requests: number + total_input_tokens: number + total_output_tokens: number + total_cache_tokens: number + total_tokens: number + total_cost: number + total_actual_cost: number + average_duration_ms: number } export interface SimpleUser { - id: number; - email: string; + id: number + email: string } export interface SimpleApiKey { - id: number; - name: string; - user_id: number; + id: number + name: string + user_id: number } export interface AdminUsageQueryParams extends UsageQueryParams { - user_id?: number; + user_id?: number } // ==================== API Functions ==================== @@ -47,9 +43,9 @@ export interface AdminUsageQueryParams extends UsageQueryParams { */ export async function list(params: AdminUsageQueryParams): Promise> { const { data } = await apiClient.get>('/admin/usage', { - params, - }); - return data; + params + }) + return data } /** @@ -58,16 +54,16 @@ export async function list(params: AdminUsageQueryParams): Promise { const { data } = await apiClient.get('/admin/usage/stats', { - params, - }); - return data; + params + }) + return data } /** @@ -77,9 +73,9 @@ export async function getStats(params: { */ export async function searchUsers(keyword: string): Promise { const { data } = await apiClient.get('/admin/usage/search-users', { - params: { q: keyword }, - }); - return data; + params: { q: keyword } + }) + return data } /** @@ -89,24 +85,24 @@ export async function searchUsers(keyword: string): Promise { * @returns List of matching API keys (max 30) */ export async function searchApiKeys(userId?: number, keyword?: string): Promise { - const params: Record = {}; + const params: Record = {} if (userId !== undefined) { - params.user_id = userId; + params.user_id = userId } if (keyword) { - params.q = keyword; + params.q = keyword } const { data } = await apiClient.get('/admin/usage/search-api-keys', { - params, - }); - return data; + params + }) + return data } export const adminUsageAPI = { list, getStats, searchUsers, - searchApiKeys, -}; + searchApiKeys +} -export default adminUsageAPI; +export default adminUsageAPI diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 8aa81775..9ba58e8b 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -3,8 +3,8 @@ * Handles user management for administrators */ -import { apiClient } from '../client'; -import type { User, UpdateUserRequest, PaginatedResponse } from '@/types'; +import { apiClient } from '../client' +import type { User, UpdateUserRequest, PaginatedResponse } from '@/types' /** * List all users with pagination @@ -17,19 +17,19 @@ export async function list( page: number = 1, pageSize: number = 20, filters?: { - status?: 'active' | 'disabled'; - role?: 'admin' | 'user'; - search?: string; + status?: 'active' | 'disabled' + role?: 'admin' | 'user' + search?: string } ): Promise> { const { data } = await apiClient.get>('/admin/users', { params: { page, page_size: pageSize, - ...filters, - }, - }); - return data; + ...filters + } + }) + return data } /** @@ -38,8 +38,8 @@ export async function list( * @returns User details */ export async function getById(id: number): Promise { - const { data } = await apiClient.get(`/admin/users/${id}`); - return data; + const { data } = await apiClient.get(`/admin/users/${id}`) + return data } /** @@ -48,14 +48,14 @@ export async function getById(id: number): Promise { * @returns Created user */ export async function create(userData: { - email: string; - password: string; - balance?: number; - concurrency?: number; - allowed_groups?: number[] | null; + email: string + password: string + balance?: number + concurrency?: number + allowed_groups?: number[] | null }): Promise { - const { data } = await apiClient.post('/admin/users', userData); - return data; + const { data } = await apiClient.post('/admin/users', userData) + return data } /** @@ -65,8 +65,8 @@ export async function create(userData: { * @returns Updated user */ export async function update(id: number, updates: UpdateUserRequest): Promise { - const { data } = await apiClient.put(`/admin/users/${id}`, updates); - return data; + const { data } = await apiClient.put(`/admin/users/${id}`, updates) + return data } /** @@ -75,8 +75,8 @@ export async function update(id: number, updates: UpdateUserRequest): Promise { - const { data } = await apiClient.delete<{ message: string }>(`/admin/users/${id}`); - return data; + const { data } = await apiClient.delete<{ message: string }>(`/admin/users/${id}`) + return data } /** @@ -96,9 +96,9 @@ export async function updateBalance( const { data } = await apiClient.post(`/admin/users/${id}/balance`, { balance, operation, - notes: notes || '', - }); - return data; + notes: notes || '' + }) + return data } /** @@ -108,7 +108,7 @@ export async function updateBalance( * @returns Updated user */ export async function updateConcurrency(id: number, concurrency: number): Promise { - return update(id, { concurrency }); + return update(id, { concurrency }) } /** @@ -118,7 +118,7 @@ export async function updateConcurrency(id: number, concurrency: number): Promis * @returns Updated user */ export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise { - return update(id, { status }); + return update(id, { status }) } /** @@ -127,8 +127,8 @@ export async function toggleStatus(id: number, status: 'active' | 'disabled'): P * @returns List of user's API keys */ export async function getUserApiKeys(id: number): Promise> { - const { data } = await apiClient.get>(`/admin/users/${id}/api-keys`); - return data; + const { data } = await apiClient.get>(`/admin/users/${id}/api-keys`) + return data } /** @@ -141,18 +141,18 @@ export async function getUserUsageStats( id: number, period: string = 'month' ): Promise<{ - total_requests: number; - total_cost: number; - total_tokens: number; + total_requests: number + total_cost: number + total_tokens: number }> { const { data } = await apiClient.get<{ - total_requests: number; - total_cost: number; - total_tokens: number; + total_requests: number + total_cost: number + total_tokens: number }>(`/admin/users/${id}/usage`, { - params: { period }, - }); - return data; + params: { period } + }) + return data } export const usersAPI = { @@ -165,7 +165,7 @@ export const usersAPI = { updateConcurrency, toggleStatus, getUserApiKeys, - getUserUsageStats, -}; + getUserUsageStats +} -export default usersAPI; +export default usersAPI diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 9d8c1597..ccac8a77 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -3,29 +3,37 @@ * Handles user login, registration, and logout operations */ -import { apiClient } from './client'; -import type { LoginRequest, RegisterRequest, AuthResponse, User, SendVerifyCodeRequest, SendVerifyCodeResponse, PublicSettings } from '@/types'; +import { apiClient } from './client' +import type { + LoginRequest, + RegisterRequest, + AuthResponse, + User, + SendVerifyCodeRequest, + SendVerifyCodeResponse, + PublicSettings +} from '@/types' /** * Store authentication token in localStorage */ export function setAuthToken(token: string): void { - localStorage.setItem('auth_token', token); + localStorage.setItem('auth_token', token) } /** * Get authentication token from localStorage */ export function getAuthToken(): string | null { - return localStorage.getItem('auth_token'); + return localStorage.getItem('auth_token') } /** * Clear authentication token from localStorage */ export function clearAuthToken(): void { - localStorage.removeItem('auth_token'); - localStorage.removeItem('auth_user'); + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') } /** @@ -34,13 +42,13 @@ export function clearAuthToken(): void { * @returns Authentication response with token and user data */ export async function login(credentials: LoginRequest): Promise { - const { data } = await apiClient.post('/auth/login', credentials); + const { data } = await apiClient.post('/auth/login', credentials) // Store token and user data - setAuthToken(data.access_token); - localStorage.setItem('auth_user', JSON.stringify(data.user)); + setAuthToken(data.access_token) + localStorage.setItem('auth_user', JSON.stringify(data.user)) - return data; + return data } /** @@ -49,13 +57,13 @@ export async function login(credentials: LoginRequest): Promise { * @returns Authentication response with token and user data */ export async function register(userData: RegisterRequest): Promise { - const { data } = await apiClient.post('/auth/register', userData); + const { data } = await apiClient.post('/auth/register', userData) // Store token and user data - setAuthToken(data.access_token); - localStorage.setItem('auth_user', JSON.stringify(data.user)); + setAuthToken(data.access_token) + localStorage.setItem('auth_user', JSON.stringify(data.user)) - return data; + return data } /** @@ -63,8 +71,8 @@ export async function register(userData: RegisterRequest): Promise * @returns User profile data */ export async function getCurrentUser(): Promise { - const { data } = await apiClient.get('/auth/me'); - return data; + const { data } = await apiClient.get('/auth/me') + return data } /** @@ -72,7 +80,7 @@ export async function getCurrentUser(): Promise { * Clears authentication token and user data from localStorage */ export function logout(): void { - clearAuthToken(); + clearAuthToken() // Optionally redirect to login page // window.location.href = '/login'; } @@ -82,7 +90,7 @@ export function logout(): void { * @returns True if user has valid token */ export function isAuthenticated(): boolean { - return getAuthToken() !== null; + return getAuthToken() !== null } /** @@ -90,8 +98,8 @@ export function isAuthenticated(): boolean { * @returns Public settings including registration and Turnstile config */ export async function getPublicSettings(): Promise { - const { data } = await apiClient.get('/settings/public'); - return data; + const { data } = await apiClient.get('/settings/public') + return data } /** @@ -99,9 +107,11 @@ export async function getPublicSettings(): Promise { * @param request - Email and optional Turnstile token * @returns Response with countdown seconds */ -export async function sendVerifyCode(request: SendVerifyCodeRequest): Promise { - const { data } = await apiClient.post('/auth/send-verify-code', request); - return data; +export async function sendVerifyCode( + request: SendVerifyCodeRequest +): Promise { + const { data } = await apiClient.post('/auth/send-verify-code', request) + return data } export const authAPI = { @@ -114,7 +124,7 @@ export const authAPI = { getAuthToken, clearAuthToken, getPublicSettings, - sendVerifyCode, -}; + sendVerifyCode +} -export default authAPI; +export default authAPI diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4847b22a..3aac41a6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3,70 +3,70 @@ * Base client with interceptors for authentication and error handling */ -import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; -import type { ApiResponse } from '@/types'; +import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios' +import type { ApiResponse } from '@/types' // ==================== Axios Instance Configuration ==================== -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'; +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1' export const apiClient: AxiosInstance = axios.create({ baseURL: API_BASE_URL, timeout: 30000, headers: { - 'Content-Type': 'application/json', - }, -}); + 'Content-Type': 'application/json' + } +}) // ==================== Request Interceptor ==================== apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Attach token from localStorage - const token = localStorage.getItem('auth_token'); + const token = localStorage.getItem('auth_token') if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}`; + config.headers.Authorization = `Bearer ${token}` } - return config; + return config }, (error) => { - return Promise.reject(error); + return Promise.reject(error) } -); +) // ==================== Response Interceptor ==================== apiClient.interceptors.response.use( (response) => { // Unwrap standard API response format { code, message, data } - const apiResponse = response.data as ApiResponse; + const apiResponse = response.data as ApiResponse if (apiResponse && typeof apiResponse === 'object' && 'code' in apiResponse) { if (apiResponse.code === 0) { // Success - return the data portion - response.data = apiResponse.data; + response.data = apiResponse.data } else { // API error return Promise.reject({ status: response.status, code: apiResponse.code, - message: apiResponse.message || 'Unknown error', - }); + message: apiResponse.message || 'Unknown error' + }) } } - return response; + return response }, (error: AxiosError>) => { // Handle common errors if (error.response) { - const { status, data } = error.response; + const { status, data } = error.response // 401: Unauthorized - clear token and redirect to login if (status === 401) { - localStorage.removeItem('auth_token'); - localStorage.removeItem('auth_user'); + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') // Only redirect if not already on login page if (!window.location.pathname.includes('/login')) { - window.location.href = '/login'; + window.location.href = '/login' } } @@ -74,16 +74,16 @@ apiClient.interceptors.response.use( return Promise.reject({ status, code: data?.code, - message: data?.message || error.message, - }); + message: data?.message || error.message + }) } // Network error return Promise.reject({ status: 0, - message: 'Network error. Please check your connection.', - }); + message: 'Network error. Please check your connection.' + }) } -); +) -export default apiClient; +export default apiClient diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts index 490c03cd..0f366d51 100644 --- a/frontend/src/api/groups.ts +++ b/frontend/src/api/groups.ts @@ -3,8 +3,8 @@ * Handles group-related operations for regular users */ -import { apiClient } from './client'; -import type { Group } from '@/types'; +import { apiClient } from './client' +import type { Group } from '@/types' /** * Get available groups that the current user can bind to API keys @@ -14,12 +14,12 @@ import type { Group } from '@/types'; * @returns List of available groups */ export async function getAvailable(): Promise { - const { data } = await apiClient.get('/groups/available'); - return data; + const { data } = await apiClient.get('/groups/available') + return data } export const userGroupsAPI = { - getAvailable, -}; + getAvailable +} -export default userGroupsAPI; +export default userGroupsAPI diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index fd3bc414..50b14c4c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -4,20 +4,20 @@ */ // Re-export the HTTP client -export { apiClient } from './client'; +export { apiClient } from './client' // Auth API -export { authAPI } from './auth'; +export { authAPI } from './auth' // User APIs -export { keysAPI } from './keys'; -export { usageAPI } from './usage'; -export { userAPI } from './user'; -export { redeemAPI, type RedeemHistoryItem } from './redeem'; -export { userGroupsAPI } from './groups'; +export { keysAPI } from './keys' +export { usageAPI } from './usage' +export { userAPI } from './user' +export { redeemAPI, type RedeemHistoryItem } from './redeem' +export { userGroupsAPI } from './groups' // Admin APIs -export { adminAPI } from './admin'; +export { adminAPI } from './admin' // Default export -export { default } from './client'; +export { default } from './client' diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts index 754c06a0..5bedbf2c 100644 --- a/frontend/src/api/keys.ts +++ b/frontend/src/api/keys.ts @@ -3,13 +3,8 @@ * Handles CRUD operations for user API keys */ -import { apiClient } from './client'; -import type { - ApiKey, - CreateApiKeyRequest, - UpdateApiKeyRequest, - PaginatedResponse, -} from '@/types'; +import { apiClient } from './client' +import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedResponse } from '@/types' /** * List all API keys for current user @@ -17,11 +12,14 @@ import type { * @param pageSize - Items per page (default: 10) * @returns Paginated list of API keys */ -export async function list(page: number = 1, pageSize: number = 10): Promise> { +export async function list( + page: number = 1, + pageSize: number = 10 +): Promise> { const { data } = await apiClient.get>('/keys', { - params: { page, page_size: pageSize }, - }); - return data; + params: { page, page_size: pageSize } + }) + return data } /** @@ -30,8 +28,8 @@ export async function list(page: number = 1, pageSize: number = 10): Promise { - const { data } = await apiClient.get(`/keys/${id}`); - return data; + const { data } = await apiClient.get(`/keys/${id}`) + return data } /** @@ -41,17 +39,21 @@ export async function getById(id: number): Promise { * @param customKey - Optional custom key value * @returns Created API key */ -export async function create(name: string, groupId?: number | null, customKey?: string): Promise { - const payload: CreateApiKeyRequest = { name }; +export async function create( + name: string, + groupId?: number | null, + customKey?: string +): Promise { + const payload: CreateApiKeyRequest = { name } if (groupId !== undefined) { - payload.group_id = groupId; + payload.group_id = groupId } if (customKey) { - payload.custom_key = customKey; + payload.custom_key = customKey } - const { data } = await apiClient.post('/keys', payload); - return data; + const { data } = await apiClient.post('/keys', payload) + return data } /** @@ -61,8 +63,8 @@ export async function create(name: string, groupId?: number | null, customKey?: * @returns Updated API key */ export async function update(id: number, updates: UpdateApiKeyRequest): Promise { - const { data } = await apiClient.put(`/keys/${id}`, updates); - return data; + const { data } = await apiClient.put(`/keys/${id}`, updates) + return data } /** @@ -71,8 +73,8 @@ export async function update(id: number, updates: UpdateApiKeyRequest): Promise< * @returns Success confirmation */ export async function deleteKey(id: number): Promise<{ message: string }> { - const { data } = await apiClient.delete<{ message: string }>(`/keys/${id}`); - return data; + const { data } = await apiClient.delete<{ message: string }>(`/keys/${id}`) + return data } /** @@ -81,11 +83,8 @@ export async function deleteKey(id: number): Promise<{ message: string }> { * @param status - New status * @returns Updated API key */ -export async function toggleStatus( - id: number, - status: 'active' | 'inactive' -): Promise { - return update(id, { status }); +export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise { + return update(id, { status }) } export const keysAPI = { @@ -94,7 +93,7 @@ export const keysAPI = { create, update, delete: deleteKey, - toggleStatus, -}; + toggleStatus +} -export default keysAPI; +export default keysAPI diff --git a/frontend/src/api/redeem.ts b/frontend/src/api/redeem.ts index 2b127f1e..9e1c7d94 100644 --- a/frontend/src/api/redeem.ts +++ b/frontend/src/api/redeem.ts @@ -3,24 +3,24 @@ * Handles redeem code redemption for users */ -import { apiClient } from './client'; -import type { RedeemCodeRequest } from '@/types'; +import { apiClient } from './client' +import type { RedeemCodeRequest } from '@/types' export interface RedeemHistoryItem { - id: number; - code: string; - type: string; - value: number; - status: string; - used_at: string; - created_at: string; + id: number + code: string + type: string + value: number + status: string + used_at: string + created_at: string // 订阅类型专用字段 - group_id?: number; - validity_days?: number; + group_id?: number + validity_days?: number group?: { - id: number; - name: string; - }; + id: number + name: string + } } /** @@ -29,23 +29,23 @@ export interface RedeemHistoryItem { * @returns Redemption result with updated balance or concurrency */ export async function redeem(code: string): Promise<{ - message: string; - type: string; - value: number; - new_balance?: number; - new_concurrency?: number; + message: string + type: string + value: number + new_balance?: number + new_concurrency?: number }> { - const payload: RedeemCodeRequest = { code }; + const payload: RedeemCodeRequest = { code } const { data } = await apiClient.post<{ - message: string; - type: string; - value: number; - new_balance?: number; - new_concurrency?: number; - }>('/redeem', payload); + message: string + type: string + value: number + new_balance?: number + new_concurrency?: number + }>('/redeem', payload) - return data; + return data } /** @@ -53,13 +53,13 @@ export async function redeem(code: string): Promise<{ * @returns List of redeemed codes */ export async function getHistory(): Promise { - const { data } = await apiClient.get('/redeem/history'); - return data; + const { data } = await apiClient.get('/redeem/history') + return data } export const redeemAPI = { redeem, - getHistory, -}; + getHistory +} -export default redeemAPI; +export default redeemAPI diff --git a/frontend/src/api/setup.ts b/frontend/src/api/setup.ts index ecf7a84f..8b744590 100644 --- a/frontend/src/api/setup.ts +++ b/frontend/src/api/setup.ts @@ -1,87 +1,87 @@ /** * Setup API endpoints */ -import axios from 'axios'; +import axios from 'axios' // Create a separate client for setup endpoints (not under /api/v1) const setupClient = axios.create({ baseURL: '', timeout: 30000, headers: { - 'Content-Type': 'application/json', - }, -}); + 'Content-Type': 'application/json' + } +}) export interface SetupStatus { - needs_setup: boolean; - step: string; + needs_setup: boolean + step: string } export interface DatabaseConfig { - host: string; - port: number; - user: string; - password: string; - dbname: string; - sslmode: string; + host: string + port: number + user: string + password: string + dbname: string + sslmode: string } export interface RedisConfig { - host: string; - port: number; - password: string; - db: number; + host: string + port: number + password: string + db: number } export interface AdminConfig { - email: string; - password: string; + email: string + password: string } export interface ServerConfig { - host: string; - port: number; - mode: string; + host: string + port: number + mode: string } export interface InstallRequest { - database: DatabaseConfig; - redis: RedisConfig; - admin: AdminConfig; - server: ServerConfig; + database: DatabaseConfig + redis: RedisConfig + admin: AdminConfig + server: ServerConfig } export interface InstallResponse { - message: string; - restart: boolean; + message: string + restart: boolean } /** * Get setup status */ export async function getSetupStatus(): Promise { - const response = await setupClient.get('/setup/status'); - return response.data.data; + const response = await setupClient.get('/setup/status') + return response.data.data } /** * Test database connection */ export async function testDatabase(config: DatabaseConfig): Promise { - await setupClient.post('/setup/test-db', config); + await setupClient.post('/setup/test-db', config) } /** * Test Redis connection */ export async function testRedis(config: RedisConfig): Promise { - await setupClient.post('/setup/test-redis', config); + await setupClient.post('/setup/test-redis', config) } /** * Perform installation */ export async function install(config: InstallRequest): Promise { - const response = await setupClient.post('/setup/install', config); - return response.data.data; + const response = await setupClient.post('/setup/install', config) + return response.data.data } diff --git a/frontend/src/api/subscriptions.ts b/frontend/src/api/subscriptions.ts index 3725aa7a..a614a425 100644 --- a/frontend/src/api/subscriptions.ts +++ b/frontend/src/api/subscriptions.ts @@ -3,64 +3,68 @@ * API for regular users to view their own subscriptions and progress */ -import { apiClient } from './client'; -import type { UserSubscription, SubscriptionProgress } from '@/types'; +import { apiClient } from './client' +import type { UserSubscription, SubscriptionProgress } from '@/types' /** * Subscription summary for user dashboard */ export interface SubscriptionSummary { - active_count: number; + active_count: number subscriptions: Array<{ - id: number; - group_name: string; - status: string; - daily_progress: number | null; - weekly_progress: number | null; - monthly_progress: number | null; - expires_at: string | null; - days_remaining: number | null; - }>; + id: number + group_name: string + status: string + daily_progress: number | null + weekly_progress: number | null + monthly_progress: number | null + expires_at: string | null + days_remaining: number | null + }> } /** * Get list of current user's subscriptions */ export async function getMySubscriptions(): Promise { - const response = await apiClient.get('/subscriptions'); - return response.data; + const response = await apiClient.get('/subscriptions') + return response.data } /** * Get current user's active subscriptions */ export async function getActiveSubscriptions(): Promise { - const response = await apiClient.get('/subscriptions/active'); - return response.data; + const response = await apiClient.get('/subscriptions/active') + return response.data } /** * Get progress for all user's active subscriptions */ export async function getSubscriptionsProgress(): Promise { - const response = await apiClient.get('/subscriptions/progress'); - return response.data; + const response = await apiClient.get('/subscriptions/progress') + return response.data } /** * Get subscription summary for dashboard display */ export async function getSubscriptionSummary(): Promise { - const response = await apiClient.get('/subscriptions/summary'); - return response.data; + const response = await apiClient.get('/subscriptions/summary') + return response.data } /** * Get progress for a specific subscription */ -export async function getSubscriptionProgress(subscriptionId: number): Promise { - const response = await apiClient.get(`/subscriptions/${subscriptionId}/progress`); - return response.data; +export async function getSubscriptionProgress( + subscriptionId: number +): Promise { + const response = await apiClient.get( + `/subscriptions/${subscriptionId}/progress` + ) + return response.data } export default { @@ -68,5 +72,5 @@ export default { getActiveSubscriptions, getSubscriptionsProgress, getSubscriptionSummary, - getSubscriptionProgress, -}; + getSubscriptionProgress +} diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 409c3565..20581603 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -3,59 +3,59 @@ * Handles usage logs and statistics retrieval */ -import { apiClient } from './client'; +import { apiClient } from './client' import type { UsageLog, UsageQueryParams, UsageStatsResponse, PaginatedResponse, TrendDataPoint, - ModelStat, -} from '@/types'; + ModelStat +} from '@/types' // ==================== Dashboard Types ==================== export interface UserDashboardStats { - total_api_keys: number; - active_api_keys: number; - total_requests: number; - total_input_tokens: number; - total_output_tokens: number; - total_cache_creation_tokens: number; - total_cache_read_tokens: number; - total_tokens: number; - total_cost: number; // 标准计费 - total_actual_cost: number; // 实际扣除 - today_requests: number; - today_input_tokens: number; - today_output_tokens: number; - today_cache_creation_tokens: number; - today_cache_read_tokens: number; - today_tokens: number; - today_cost: number; // 今日标准计费 - today_actual_cost: number; // 今日实际扣除 - average_duration_ms: number; - rpm: number; // 近5分钟平均每分钟请求数 - tpm: number; // 近5分钟平均每分钟Token数 + total_api_keys: number + active_api_keys: number + total_requests: number + total_input_tokens: number + total_output_tokens: number + total_cache_creation_tokens: number + total_cache_read_tokens: number + total_tokens: number + total_cost: number // 标准计费 + total_actual_cost: number // 实际扣除 + today_requests: number + today_input_tokens: number + today_output_tokens: number + today_cache_creation_tokens: number + today_cache_read_tokens: number + today_tokens: number + today_cost: number // 今日标准计费 + today_actual_cost: number // 今日实际扣除 + average_duration_ms: number + rpm: number // 近5分钟平均每分钟请求数 + tpm: number // 近5分钟平均每分钟Token数 } export interface TrendParams { - start_date?: string; - end_date?: string; - granularity?: 'day' | 'hour'; + start_date?: string + end_date?: string + granularity?: 'day' | 'hour' } export interface TrendResponse { - trend: TrendDataPoint[]; - start_date: string; - end_date: string; - granularity: string; + trend: TrendDataPoint[] + start_date: string + end_date: string + granularity: string } export interface ModelStatsResponse { - models: ModelStat[]; - start_date: string; - end_date: string; + models: ModelStat[] + start_date: string + end_date: string } /** @@ -72,17 +72,17 @@ export async function list( ): Promise> { const params: UsageQueryParams = { page, - page_size: pageSize, - }; + page_size: pageSize + } if (apiKeyId !== undefined) { - params.api_key_id = apiKeyId; + params.api_key_id = apiKeyId } const { data } = await apiClient.get>('/usage', { - params, - }); - return data; + params + }) + return data } /** @@ -92,9 +92,9 @@ export async function list( */ export async function query(params: UsageQueryParams): Promise> { const { data } = await apiClient.get>('/usage', { - params, - }); - return data; + params + }) + return data } /** @@ -107,16 +107,16 @@ export async function getStats( period: string = 'today', apiKeyId?: number ): Promise { - const params: Record = { period }; + const params: Record = { period } if (apiKeyId !== undefined) { - params.api_key_id = apiKeyId; + params.api_key_id = apiKeyId } const { data } = await apiClient.get('/usage/stats', { - params, - }); - return data; + params + }) + return data } /** @@ -133,17 +133,17 @@ export async function getStatsByDateRange( ): Promise { const params: Record = { start_date: startDate, - end_date: endDate, - }; + end_date: endDate + } if (apiKeyId !== undefined) { - params.api_key_id = apiKeyId; + params.api_key_id = apiKeyId } const { data } = await apiClient.get('/usage/stats', { - params, - }); - return data; + params + }) + return data } /** @@ -162,17 +162,17 @@ export async function getByDateRange( start_date: startDate, end_date: endDate, page: 1, - page_size: 100, - }; + page_size: 100 + } if (apiKeyId !== undefined) { - params.api_key_id = apiKeyId; + params.api_key_id = apiKeyId } const { data } = await apiClient.get>('/usage', { - params, - }); - return data; + params + }) + return data } /** @@ -181,8 +181,8 @@ export async function getByDateRange( * @returns Usage log details */ export async function getById(id: number): Promise { - const { data } = await apiClient.get(`/usage/${id}`); - return data; + const { data } = await apiClient.get(`/usage/${id}`) + return data } // ==================== Dashboard API ==================== @@ -192,8 +192,8 @@ export async function getById(id: number): Promise { * @returns Dashboard statistics for current user */ export async function getDashboardStats(): Promise { - const { data } = await apiClient.get('/usage/dashboard/stats'); - return data; + const { data } = await apiClient.get('/usage/dashboard/stats') + return data } /** @@ -202,8 +202,8 @@ export async function getDashboardStats(): Promise { * @returns Usage trend data for current user */ export async function getDashboardTrend(params?: TrendParams): Promise { - const { data } = await apiClient.get('/usage/dashboard/trend', { params }); - return data; + const { data } = await apiClient.get('/usage/dashboard/trend', { params }) + return data } /** @@ -211,19 +211,22 @@ export async function getDashboardTrend(params?: TrendParams): Promise { - const { data } = await apiClient.get('/usage/dashboard/models', { params }); - return data; +export async function getDashboardModels(params?: { + start_date?: string + end_date?: string +}): Promise { + const { data } = await apiClient.get('/usage/dashboard/models', { params }) + return data } export interface BatchApiKeyUsageStats { - api_key_id: number; - today_actual_cost: number; - total_actual_cost: number; + api_key_id: number + today_actual_cost: number + total_actual_cost: number } export interface BatchApiKeysUsageResponse { - stats: Record; + stats: Record } /** @@ -231,11 +234,16 @@ export interface BatchApiKeysUsageResponse { * @param apiKeyIds - Array of API key IDs * @returns Usage stats map keyed by API key ID */ -export async function getDashboardApiKeysUsage(apiKeyIds: number[]): Promise { - const { data } = await apiClient.post('/usage/dashboard/api-keys-usage', { - api_key_ids: apiKeyIds, - }); - return data; +export async function getDashboardApiKeysUsage( + apiKeyIds: number[] +): Promise { + const { data } = await apiClient.post( + '/usage/dashboard/api-keys-usage', + { + api_key_ids: apiKeyIds + } + ) + return data } export const usageAPI = { @@ -249,7 +257,7 @@ export const usageAPI = { getDashboardStats, getDashboardTrend, getDashboardModels, - getDashboardApiKeysUsage, -}; + getDashboardApiKeysUsage +} -export default usageAPI; +export default usageAPI diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index db994e73..d34ce20e 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -3,16 +3,16 @@ * Handles user profile management and password changes */ -import { apiClient } from './client'; -import type { User, ChangePasswordRequest } from '@/types'; +import { apiClient } from './client' +import type { User, ChangePasswordRequest } from '@/types' /** * Get current user profile * @returns User profile data */ export async function getProfile(): Promise { - const { data } = await apiClient.get('/user/profile'); - return data; + const { data } = await apiClient.get('/user/profile') + return data } /** @@ -21,11 +21,11 @@ export async function getProfile(): Promise { * @returns Updated user profile data */ export async function updateProfile(profile: { - username?: string; - wechat?: string; + username?: string + wechat?: string }): Promise { - const { data } = await apiClient.put('/user', profile); - return data; + const { data } = await apiClient.put('/user', profile) + return data } /** @@ -39,17 +39,17 @@ export async function changePassword( ): Promise<{ message: string }> { const payload: ChangePasswordRequest = { old_password: oldPassword, - new_password: newPassword, - }; + new_password: newPassword + } - const { data } = await apiClient.put<{ message: string }>('/user/password', payload); - return data; + const { data } = await apiClient.put<{ message: string }>('/user/password', payload) + return data } export const userAPI = { getProfile, updateProfile, - changePassword, -}; + changePassword +} -export default userAPI; +export default userAPI diff --git a/frontend/src/components/TurnstileWidget.vue b/frontend/src/components/TurnstileWidget.vue index e64a132a..2f0022bf 100644 --- a/frontend/src/components/TurnstileWidget.vue +++ b/frontend/src/components/TurnstileWidget.vue @@ -5,158 +5,164 @@ diff --git a/frontend/src/components/common/PlatformIcon.vue b/frontend/src/components/common/PlatformIcon.vue index 4d46f27d..7ac3f812 100644 --- a/frontend/src/components/common/PlatformIcon.vue +++ b/frontend/src/components/common/PlatformIcon.vue @@ -1,15 +1,25 @@ diff --git a/frontend/src/components/common/PlatformTypeBadge.vue b/frontend/src/components/common/PlatformTypeBadge.vue index d7ed6714..403d898e 100644 --- a/frontend/src/components/common/PlatformTypeBadge.vue +++ b/frontend/src/components/common/PlatformTypeBadge.vue @@ -1,33 +1,56 @@ diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 30b642eb..8c59b1e2 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -6,15 +6,31 @@

-
- - +
+ +
-

{{ t('usage.totalRequests') }}

-

{{ usageStats?.total_requests?.toLocaleString() || '0' }}

-

{{ t('usage.inSelectedRange') }}

+

+ {{ t('usage.totalRequests') }} +

+

+ {{ usageStats?.total_requests?.toLocaleString() || '0' }} +

+

+ {{ t('usage.inSelectedRange') }} +

@@ -22,15 +38,32 @@
-
- - +
+ +
-

{{ t('usage.totalTokens') }}

-

{{ formatTokens(usageStats?.total_tokens || 0) }}

-

{{ t('usage.in') }}: {{ formatTokens(usageStats?.total_input_tokens || 0) }} / {{ t('usage.out') }}: {{ formatTokens(usageStats?.total_output_tokens || 0) }}

+

+ {{ t('usage.totalTokens') }} +

+

+ {{ formatTokens(usageStats?.total_tokens || 0) }} +

+

+ {{ t('usage.in') }}: {{ formatTokens(usageStats?.total_input_tokens || 0) }} / + {{ t('usage.out') }}: {{ formatTokens(usageStats?.total_output_tokens || 0) }} +

@@ -38,16 +71,31 @@
-
- - +
+ +
-

{{ t('usage.totalCost') }}

-

${{ (usageStats?.total_actual_cost || 0).toFixed(4) }}

+

+ {{ t('usage.totalCost') }} +

+

+ ${{ (usageStats?.total_actual_cost || 0).toFixed(4) }} +

- ${{ (usageStats?.total_cost || 0).toFixed(4) }} {{ t('usage.standardCost') }} + ${{ (usageStats?.total_cost || 0).toFixed(4) }} + {{ t('usage.standardCost') }}

@@ -56,14 +104,28 @@
-
- - +
+ +
-

{{ t('usage.avgDuration') }}

-

{{ formatDuration(usageStats?.average_duration_ms || 0) }}

+

+ {{ t('usage.avgDuration') }} +

+

+ {{ formatDuration(usageStats?.average_duration_ms || 0) }} +

{{ t('usage.perRequest') }}

@@ -75,7 +137,9 @@
- {{ t('admin.dashboard.granularity') }}: + {{ t('admin.dashboard.granularity') }}: