diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 879d56d22f..74b01286f6 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -191,7 +191,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper) backupHandler := admin.NewBackupHandler(backupService, userService) oAuthHandler := admin.NewOAuthHandler(oAuthService) - openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) + openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory) + openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService) geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) proxyHandler := admin.NewProxyHandler(adminService) diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index cc0c933792..89010f6982 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -16,6 +16,7 @@ import ( type OpenAIOAuthHandler struct { openaiOAuthService *service.OpenAIOAuthService adminService service.AdminService + quotaService *service.OpenAIQuotaService } func oauthPlatformFromPath(c *gin.Context) string { @@ -23,10 +24,15 @@ func oauthPlatformFromPath(c *gin.Context) string { } // NewOpenAIOAuthHandler creates a new OpenAI OAuth handler -func NewOpenAIOAuthHandler(openaiOAuthService *service.OpenAIOAuthService, adminService service.AdminService) *OpenAIOAuthHandler { +func NewOpenAIOAuthHandler( + openaiOAuthService *service.OpenAIOAuthService, + adminService service.AdminService, + quotaService *service.OpenAIQuotaService, +) *OpenAIOAuthHandler { return &OpenAIOAuthHandler{ openaiOAuthService: openaiOAuthService, adminService: adminService, + quotaService: quotaService, } } @@ -262,3 +268,43 @@ func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) { response.Success(c, dto.AccountFromService(account)) } + +// QueryQuota queries the rate-limit / quota usage for an OpenAI account. +// GET /api/v1/admin/openai/accounts/:id/quota +func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + if h.quotaService == nil { + response.BadRequest(c, "openai quota service is not enabled") + return + } + usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, usage) +} + +// ResetQuota consumes one rate-limit reset credit for an OpenAI account. +// POST /api/v1/admin/openai/accounts/:id/reset-quota +func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + if h.quotaService == nil { + response.BadRequest(c, "openai quota service is not enabled") + return + } + result, err := h.quotaService.ResetCredit(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index e6afa1436c..24c52acb9e 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -362,6 +362,8 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { openai.POST("/refresh-token", h.Admin.OpenAIOAuth.RefreshToken) openai.POST("/accounts/:id/refresh", h.Admin.OpenAIOAuth.RefreshAccountToken) openai.POST("/create-from-oauth", h.Admin.OpenAIOAuth.CreateAccountFromOAuth) + openai.GET("/accounts/:id/quota", h.Admin.OpenAIOAuth.QueryQuota) + openai.POST("/accounts/:id/reset-quota", h.Admin.OpenAIOAuth.ResetQuota) } } diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go new file mode 100644 index 0000000000..7d68d18386 --- /dev/null +++ b/backend/internal/service/openai_quota_service.go @@ -0,0 +1,314 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// Endpoints used by the OpenAI/ChatGPT/Codex quota query and reset feature. +const ( + chatGPTUsageURL = "https://chatgpt.com/backend-api/wham/usage" + chatGPTRateLimitResetURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume" + openaiQuotaUpstreamTimeout = 20 * time.Second + openaiQuotaCodexOriginator = "Codex Desktop" + openaiQuotaCodexLanguageTag = "zh-CN" + openaiQuotaSecFetchSite = "none" + openaiQuotaSecFetchMode = "no-cors" + openaiQuotaSecFetchDest = "empty" +) + +// OpenAIRateLimitWindow describes a single rate-limit window returned by +// /wham/usage. The upstream returns an explicit `null` window when the slot +// is unused, so consumers should treat a nil pointer as "no data". +type OpenAIRateLimitWindow struct { + UsedPercent float64 `json:"used_percent"` + LimitWindowSeconds int64 `json:"limit_window_seconds"` + ResetAfterSeconds int64 `json:"reset_after_seconds"` + ResetAt int64 `json:"reset_at"` +} + +// OpenAIRateLimit is a rate-limit envelope (primary + optional secondary window). +type OpenAIRateLimit struct { + Allowed bool `json:"allowed"` + LimitReached bool `json:"limit_reached"` + PrimaryWindow *OpenAIRateLimitWindow `json:"primary_window,omitempty"` + SecondaryWindow *OpenAIRateLimitWindow `json:"secondary_window,omitempty"` +} + +// OpenAIAdditionalRateLimit describes a per-feature rate limit (e.g. Codex Spark). +type OpenAIAdditionalRateLimit struct { + LimitName string `json:"limit_name"` + MeteredFeature string `json:"metered_feature"` + RateLimit *OpenAIRateLimit `json:"rate_limit,omitempty"` +} + +// OpenAIRateLimitResetCredits captures the "available_count" surfaced for the +// rate_limit_reset_credit grant type, which the reset action consumes. +type OpenAIRateLimitResetCredits struct { + AvailableCount int `json:"available_count"` +} + +// OpenAIQuotaUsage is the typed projection of /wham/usage we expose to the UI. +// Fields not relevant to the quota card are intentionally omitted to keep the +// surface narrow; full upstream payload preservation is unnecessary. +type OpenAIQuotaUsage struct { + UserID string `json:"user_id,omitempty"` + AccountID string `json:"account_id,omitempty"` + Email string `json:"email,omitempty"` + PlanType string `json:"plan_type,omitempty"` + RateLimit *OpenAIRateLimit `json:"rate_limit,omitempty"` + AdditionalRateLimits []OpenAIAdditionalRateLimit `json:"additional_rate_limits,omitempty"` + RateLimitResetCredits *OpenAIRateLimitResetCredits `json:"rate_limit_reset_credits,omitempty"` + FetchedAt int64 `json:"fetched_at"` +} + +// OpenAIQuotaResetCredit captures the redeemed credit metadata returned by the +// reset endpoint. +type OpenAIQuotaResetCredit struct { + ID string `json:"id,omitempty"` + ResetType string `json:"reset_type,omitempty"` + Status string `json:"status,omitempty"` + GrantedAt string `json:"granted_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + RedeemStartedAt string `json:"redeem_started_at,omitempty"` + RedeemedAt string `json:"redeemed_at,omitempty"` +} + +// OpenAIQuotaResetResult is the typed projection of /wham/rate-limit-reset-credits/consume. +// The inner Credit also carries `redeemed_at` (RFC3339 string); we deliberately do +// NOT add a top-level redeemed_at to avoid ambiguity with the nested field. +type OpenAIQuotaResetResult struct { + Code string `json:"code"` + Credit *OpenAIQuotaResetCredit `json:"credit,omitempty"` + WindowsReset int `json:"windows_reset"` +} + +// OpenAIQuotaService queries and consumes ChatGPT/Codex rate-limit reset credits +// for OpenAI OAuth accounts. It reuses the privacy client factory so all calls +// flow through the impersonated HTTP client (Cloudflare-friendly TLS fingerprint). +type OpenAIQuotaService struct { + accountRepo AccountRepository + proxyRepo ProxyRepository + tokenProvider *OpenAITokenProvider + privacyClientFactory PrivacyClientFactory +} + +// NewOpenAIQuotaService constructs a quota service. token provider is required — +// it ensures we always invoke upstream with a valid (refreshed-if-needed) +// access_token, sharing the same refresh/locking machinery used by the gateway. +func NewOpenAIQuotaService( + accountRepo AccountRepository, + proxyRepo ProxyRepository, + tokenProvider *OpenAITokenProvider, + privacyClientFactory PrivacyClientFactory, +) *OpenAIQuotaService { + return &OpenAIQuotaService{ + accountRepo: accountRepo, + proxyRepo: proxyRepo, + tokenProvider: tokenProvider, + privacyClientFactory: privacyClientFactory, + } +} + +// QueryUsage fetches the latest rate-limit/usage snapshot for the given OpenAI +// OAuth account. Returns infraerrors so the handler layer can map them to +// stable error codes / HTTP statuses. +func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error) { + accessToken, chatGPTAccountID, proxyURL, err := s.prepareUpstreamCall(ctx, accountID) + if err != nil { + return nil, err + } + + client, err := s.privacyClientFactory(proxyURL) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_CLIENT_ERROR", "failed to build upstream client: %v", err) + } + + callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout) + defer cancel() + + var payload OpenAIQuotaUsage + resp, err := client.R(). + SetContext(callCtx). + SetHeaders(buildCodexCommonHeaders(accessToken, chatGPTAccountID)). + SetSuccessResult(&payload). + Get(chatGPTUsageURL) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_REQUEST_FAILED", "upstream request failed: %v", err) + } + if !resp.IsSuccessState() { + status := resp.StatusCode + body := truncate(resp.String(), 240) + slog.Warn("openai_quota_query_failed", "account_id", accountID, "status", status, "body", body) + return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_UPSTREAM_ERROR", "upstream returned %d: %s", status, body) + } + + payload.FetchedAt = time.Now().Unix() + return &payload, nil +} + +// ResetCredit consumes one rate_limit_reset_credit for the given OpenAI account. +// The redeem_request_id is auto-generated (uuid-like) — upstream uses it for +// idempotency. Returns the consumed credit metadata so the UI can refresh. +func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) (*OpenAIQuotaResetResult, error) { + accessToken, chatGPTAccountID, proxyURL, err := s.prepareUpstreamCall(ctx, accountID) + if err != nil { + return nil, err + } + + redeemRequestID, err := generateRedeemRequestID() + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_QUOTA_REDEEM_ID_FAILED", "failed to generate redeem id: %v", err) + } + + client, err := s.privacyClientFactory(proxyURL) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_CLIENT_ERROR", "failed to build upstream client: %v", err) + } + + callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout) + defer cancel() + + headers := buildCodexCommonHeaders(accessToken, chatGPTAccountID) + headers["content-type"] = "application/json" + + var payload OpenAIQuotaResetResult + resp, err := client.R(). + SetContext(callCtx). + SetHeaders(headers). + SetBody(map[string]string{"redeem_request_id": redeemRequestID}). + SetSuccessResult(&payload). + Post(chatGPTRateLimitResetURL) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_RESET_REQUEST_FAILED", "upstream request failed: %v", err) + } + if !resp.IsSuccessState() { + status := resp.StatusCode + body := truncate(resp.String(), 240) + slog.Warn("openai_quota_reset_failed", "account_id", accountID, "status", status, "body", body) + return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d: %s", status, body) + } + + slog.Info("openai_quota_reset_success", + "account_id", accountID, + "code", payload.Code, + "windows_reset", payload.WindowsReset, + ) + return &payload, nil +} + +// prepareUpstreamCall loads the account, validates it, obtains a fresh access +// token via the shared TokenProvider, and resolves the chatgpt-account-id and +// proxy URL. Centralized so QueryUsage / ResetCredit share validation. +func (s *OpenAIQuotaService) prepareUpstreamCall(ctx context.Context, accountID int64) (accessToken, chatGPTAccountID, proxyURL string, err error) { + if s == nil || s.accountRepo == nil || s.tokenProvider == nil || s.privacyClientFactory == nil { + return "", "", "", infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_NOT_CONFIGURED", "openai quota service is not configured") + } + + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return "", "", "", infraerrors.Newf(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", err) + } + if account == nil { + return "", "", "", infraerrors.New(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found") + } + if account.Platform != PlatformOpenAI { + return "", "", "", infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_PLATFORM", "account is not an OpenAI account") + } + if account.Type != AccountTypeOAuth { + return "", "", "", infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_TYPE", "account is not an OAuth account") + } + + chatGPTAccountID = strings.TrimSpace(account.GetCredential("chatgpt_account_id")) + if chatGPTAccountID == "" { + // Fall back to organization_id — some legacy accounts only persisted poid. + chatGPTAccountID = strings.TrimSpace(account.GetCredential("organization_id")) + } + if chatGPTAccountID == "" { + return "", "", "", infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_MISSING_ACCOUNT_ID", "chatgpt_account_id is missing; please re-authorize this account") + } + + accessToken, err = s.tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return "", "", "", infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "failed to acquire access token: %v", err) + } + if strings.TrimSpace(accessToken) == "" { + return "", "", "", infraerrors.New(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "access token is empty") + } + + // account.Proxy is eager-loaded by accountRepo.GetByID (see + // repository.accountsToService), so we can read the proxy URL directly + // instead of round-tripping the DB again. Fall back to proxyRepo only + // when Proxy isn't pre-populated (defensive — e.g. callers that built + // the Account by hand). + if account.ProxyID != nil { + switch { + case account.Proxy != nil: + proxyURL = account.Proxy.URL() + case s.proxyRepo != nil: + if proxy, perr := s.proxyRepo.GetByID(ctx, *account.ProxyID); perr == nil && proxy != nil { + proxyURL = proxy.URL() + } + } + } + + return accessToken, chatGPTAccountID, proxyURL, nil +} + +// buildCodexCommonHeaders sets the request headers expected by the chatgpt.com +// backend so calls succeed past Cloudflare/WASM checks. +func buildCodexCommonHeaders(accessToken, chatGPTAccountID string) map[string]string { + return map[string]string{ + "authorization": "Bearer " + accessToken, + "chatgpt-account-id": chatGPTAccountID, + "oai-language": openaiQuotaCodexLanguageTag, + "originator": openaiQuotaCodexOriginator, + "accept": "application/json", + "sec-fetch-site": openaiQuotaSecFetchSite, + "sec-fetch-mode": openaiQuotaSecFetchMode, + "sec-fetch-dest": openaiQuotaSecFetchDest, + "priority": "u=4, i", + } +} + +// generateRedeemRequestID produces a UUID-v4-shaped string without pulling in a +// new dependency. ChatGPT uses this as an idempotency key for the consume call. +func generateRedeemRequestID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + // Set version (4) and variant (RFC 4122) bits. + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + hexStr := hex.EncodeToString(b) + return fmt.Sprintf("%s-%s-%s-%s-%s", hexStr[0:8], hexStr[8:12], hexStr[12:16], hexStr[16:20], hexStr[20:]), nil +} + +// mapUpstreamStatus collapses upstream HTTP statuses into a stable set we +// surface from the admin handler. 4xx upstream errors are surfaced as 502 +// (BadGateway) so callers can distinguish "your input is bad" (400) from +// "upstream said no" (502); 401/403 are bubbled directly to hint at re-auth. +func mapUpstreamStatus(status int) int { + switch { + case status == http.StatusUnauthorized || status == http.StatusForbidden: + return status + case status == http.StatusTooManyRequests: + return http.StatusTooManyRequests + case status >= 400 && status < 500: + return http.StatusBadGateway + case status >= 500: + return http.StatusBadGateway + default: + return http.StatusBadGateway + } +} + diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index f8721ba031..bc0cf46f35 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -112,6 +112,18 @@ func ProvideOpenAITokenProvider( return p } +// ProvideOpenAIQuotaService wires the OpenAI quota query/reset service. +// It depends on the OpenAI token provider for refreshed access tokens and the +// privacy client factory for the impersonated upstream HTTP client. +func ProvideOpenAIQuotaService( + accountRepo AccountRepository, + proxyRepo ProxyRepository, + tokenProvider *OpenAITokenProvider, + privacyClientFactory PrivacyClientFactory, +) *OpenAIQuotaService { + return NewOpenAIQuotaService(accountRepo, proxyRepo, tokenProvider, privacyClientFactory) +} + // ProvideGeminiTokenProvider creates GeminiTokenProvider with OAuthRefreshAPI injection func ProvideGeminiTokenProvider( accountRepo AccountRepository, @@ -533,6 +545,7 @@ var ProviderSet = wire.NewSet( NewGeminiMessagesCompatService, ProvideAntigravityTokenProvider, ProvideOpenAITokenProvider, + ProvideOpenAIQuotaService, ProvideClaudeTokenProvider, NewAntigravityGatewayService, ProvideRateLimitService, diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index f800b940ba..c297943d91 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -705,6 +705,76 @@ export async function setPrivacy(id: number): Promise { return data } +/** + * OpenAI / Codex rate-limit reset feature: query and reset upstream usage. + */ +export interface OpenAIRateLimitWindow { + used_percent: number + limit_window_seconds: number + reset_after_seconds: number + reset_at: number +} + +export interface OpenAIRateLimit { + allowed: boolean + limit_reached: boolean + primary_window?: OpenAIRateLimitWindow | null + secondary_window?: OpenAIRateLimitWindow | null +} + +export interface OpenAIAdditionalRateLimit { + limit_name: string + metered_feature: string + rate_limit?: OpenAIRateLimit | null +} + +export interface OpenAIRateLimitResetCredits { + available_count: number +} + +export interface OpenAIQuotaUsage { + user_id?: string + account_id?: string + email?: string + plan_type?: string + rate_limit?: OpenAIRateLimit | null + additional_rate_limits?: OpenAIAdditionalRateLimit[] + rate_limit_reset_credits?: OpenAIRateLimitResetCredits | null + fetched_at: number +} + +export interface OpenAIQuotaResetCredit { + id?: string + reset_type?: string + status?: string + granted_at?: string + expires_at?: string + redeem_started_at?: string + redeemed_at?: string +} + +export interface OpenAIQuotaResetResult { + code: string + credit?: OpenAIQuotaResetCredit | null + windows_reset: number +} + +/** + * Query OpenAI/Codex rate-limit usage for an OAuth account. + */ +export async function queryOpenAIQuota(id: number): Promise { + const { data } = await apiClient.get(`/admin/openai/accounts/${id}/quota`) + return data +} + +/** + * Consume one rate-limit-reset credit for an OpenAI/Codex OAuth account. + */ +export async function resetOpenAIQuota(id: number): Promise { + const { data } = await apiClient.post(`/admin/openai/accounts/${id}/reset-quota`) + return data +} + export const accountsAPI = { list, listWithEtag, @@ -746,7 +816,9 @@ export const accountsAPI = { batchClearError, batchRefresh, setPrivacy, - revertProxyFallback + revertProxyFallback, + queryOpenAIQuota, + resetOpenAIQuota } export default accountsAPI diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 561f90d0e0..156b4e2497 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -126,30 +126,37 @@ :show-now-when-idle="true" color="emerald" /> -
- -
+ + + + {{ t('admin.accounts.usageWindow.activeQuery') }} + + +
@@ -163,7 +170,11 @@
-
-
+
+
-
+ + +
@@ -503,6 +514,7 @@ import { enqueueUsageRequest } from '@/utils/usageLoadQueue' import { formatCompactNumber } from '@/utils/format' import UsageProgressBar from './UsageProgressBar.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue' +import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue' // Module-level cache shared across all AccountUsageCell instances const _usageCache = new Map() diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue new file mode 100644 index 0000000000..5d3cf64108 --- /dev/null +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -0,0 +1,200 @@ + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f3ea5ccbe8..1d54722d08 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4105,6 +4105,17 @@ export default { passiveSampled: 'Passive', activeQuery: 'Query' }, + openaiQuotaReset: { + count: 'Credits', + reset: 'Reset', + countTooltipLoad: 'Click to load the available reset-credit count', + countTooltipRefresh: 'Click to refresh the available reset-credit count', + resetTooltipReady: 'Consume 1 reset credit to immediately restore the window', + resetTooltipNeedQuery: 'Click Credits first to load the available count', + resetTooltipNoCredits: 'No reset credits available', + noCreditsAvailable: 'No reset credits available', + resetSuccess: 'Reset {windows} window(s)' + }, tier: { free: 'Free', pro: 'Pro', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 4c589b2eee..20eecb98dd 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3393,6 +3393,17 @@ export default { passiveSampled: '被动采样', activeQuery: '查询' }, + openaiQuotaReset: { + count: '次数', + reset: '重置', + countTooltipLoad: '点击查询剩余重置次数', + countTooltipRefresh: '点击刷新剩余重置次数', + resetTooltipReady: '消耗 1 次重置次数以立即恢复当前窗口', + resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数', + resetTooltipNoCredits: '没有可用的重置次数', + noCreditsAvailable: '没有可用的重置次数', + resetSuccess: '已重置 {windows} 个窗口' + }, tier: { free: 'Free', pro: 'Pro',