mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 22:31:42 +08:00
feat(openai-quota): query + reset rate-limit credits for OpenAI accounts
Adds an admin-side action that mirrors the Codex Desktop "rate-limit reset" flow against chatgpt.com upstream for OpenAI OAuth accounts. Backend - OpenAIQuotaService.QueryUsage / ResetCredit hit /wham/usage and /wham/rate-limit-reset-credits/consume with the Codex Desktop header set, reusing OpenAITokenProvider for refreshed tokens and PrivacyClientFactory for the impersonated Chrome TLS fingerprint. - Honors the account's configured proxy by reading the eager-loaded account.Proxy directly (falls back to proxyRepo only when missing). - GET /api/v1/admin/openai/accounts/:id/quota POST /api/v1/admin/openai/accounts/:id/reset-quota - Wire DI for the new service + handler dependency. Frontend - OpenAIQuotaResetCell renders a single action row in AccountUsageCell's OpenAI section: the existing local "查询" (active sampling) is injected via #pre-actions, alongside a "次数 N" button that doubles as the upstream query trigger and the available-credit indicator, and a "重置" button that consumes one credit. - No duplicate 5h/7d window display; the local UsageProgressBar owns those bars to avoid confusion.
This commit is contained in:
@@ -191,7 +191,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
|
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
|
||||||
backupHandler := admin.NewBackupHandler(backupService, userService)
|
backupHandler := admin.NewBackupHandler(backupService, userService)
|
||||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
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)
|
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||||
proxyHandler := admin.NewProxyHandler(adminService)
|
proxyHandler := admin.NewProxyHandler(adminService)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
type OpenAIOAuthHandler struct {
|
type OpenAIOAuthHandler struct {
|
||||||
openaiOAuthService *service.OpenAIOAuthService
|
openaiOAuthService *service.OpenAIOAuthService
|
||||||
adminService service.AdminService
|
adminService service.AdminService
|
||||||
|
quotaService *service.OpenAIQuotaService
|
||||||
}
|
}
|
||||||
|
|
||||||
func oauthPlatformFromPath(c *gin.Context) string {
|
func oauthPlatformFromPath(c *gin.Context) string {
|
||||||
@@ -23,10 +24,15 @@ func oauthPlatformFromPath(c *gin.Context) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewOpenAIOAuthHandler creates a new OpenAI OAuth handler
|
// 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{
|
return &OpenAIOAuthHandler{
|
||||||
openaiOAuthService: openaiOAuthService,
|
openaiOAuthService: openaiOAuthService,
|
||||||
adminService: adminService,
|
adminService: adminService,
|
||||||
|
quotaService: quotaService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,3 +268,43 @@ func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, dto.AccountFromService(account))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -362,6 +362,8 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
openai.POST("/refresh-token", h.Admin.OpenAIOAuth.RefreshToken)
|
openai.POST("/refresh-token", h.Admin.OpenAIOAuth.RefreshToken)
|
||||||
openai.POST("/accounts/:id/refresh", h.Admin.OpenAIOAuth.RefreshAccountToken)
|
openai.POST("/accounts/:id/refresh", h.Admin.OpenAIOAuth.RefreshAccountToken)
|
||||||
openai.POST("/create-from-oauth", h.Admin.OpenAIOAuth.CreateAccountFromOAuth)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -112,6 +112,18 @@ func ProvideOpenAITokenProvider(
|
|||||||
return p
|
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
|
// ProvideGeminiTokenProvider creates GeminiTokenProvider with OAuthRefreshAPI injection
|
||||||
func ProvideGeminiTokenProvider(
|
func ProvideGeminiTokenProvider(
|
||||||
accountRepo AccountRepository,
|
accountRepo AccountRepository,
|
||||||
@@ -533,6 +545,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewGeminiMessagesCompatService,
|
NewGeminiMessagesCompatService,
|
||||||
ProvideAntigravityTokenProvider,
|
ProvideAntigravityTokenProvider,
|
||||||
ProvideOpenAITokenProvider,
|
ProvideOpenAITokenProvider,
|
||||||
|
ProvideOpenAIQuotaService,
|
||||||
ProvideClaudeTokenProvider,
|
ProvideClaudeTokenProvider,
|
||||||
NewAntigravityGatewayService,
|
NewAntigravityGatewayService,
|
||||||
ProvideRateLimitService,
|
ProvideRateLimitService,
|
||||||
|
|||||||
@@ -705,6 +705,76 @@ export async function setPrivacy(id: number): Promise<Account> {
|
|||||||
return data
|
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<OpenAIQuotaUsage> {
|
||||||
|
const { data } = await apiClient.get<OpenAIQuotaUsage>(`/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<OpenAIQuotaResetResult> {
|
||||||
|
const { data } = await apiClient.post<OpenAIQuotaResetResult>(`/admin/openai/accounts/${id}/reset-quota`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export const accountsAPI = {
|
export const accountsAPI = {
|
||||||
list,
|
list,
|
||||||
listWithEtag,
|
listWithEtag,
|
||||||
@@ -746,7 +816,9 @@ export const accountsAPI = {
|
|||||||
batchClearError,
|
batchClearError,
|
||||||
batchRefresh,
|
batchRefresh,
|
||||||
setPrivacy,
|
setPrivacy,
|
||||||
revertProxyFallback
|
revertProxyFallback,
|
||||||
|
queryOpenAIQuota,
|
||||||
|
resetOpenAIQuota
|
||||||
}
|
}
|
||||||
|
|
||||||
export default accountsAPI
|
export default accountsAPI
|
||||||
|
|||||||
@@ -126,30 +126,37 @@
|
|||||||
:show-now-when-idle="true"
|
:show-now-when-idle="true"
|
||||||
color="emerald"
|
color="emerald"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-1.5 mt-0.5">
|
<!--
|
||||||
<button
|
Upstream codex /wham/usage quota query + reset. The local active-sampling
|
||||||
type="button"
|
refresh button is rendered via the pre-actions slot so the user sees a
|
||||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[9px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors"
|
single row of related buttons instead of two stacked rows.
|
||||||
:disabled="activeQueryLoading"
|
-->
|
||||||
@click="loadActiveUsage"
|
<OpenAIQuotaResetCell :account="account">
|
||||||
>
|
<template #pre-actions>
|
||||||
<svg
|
<button
|
||||||
class="h-2.5 w-2.5"
|
type="button"
|
||||||
:class="{ 'animate-spin': activeQueryLoading }"
|
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
fill="none"
|
:disabled="activeQueryLoading"
|
||||||
stroke="currentColor"
|
@click="loadActiveUsage"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
stroke-linecap="round"
|
class="h-2.5 w-2.5"
|
||||||
stroke-linejoin="round"
|
:class="{ 'animate-spin': activeQueryLoading }"
|
||||||
stroke-width="2"
|
fill="none"
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
stroke="currentColor"
|
||||||
/>
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
>
|
||||||
{{ t('admin.accounts.usageWindow.activeQuery') }}
|
<path
|
||||||
</button>
|
stroke-linecap="round"
|
||||||
</div>
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.usageWindow.activeQuery') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</OpenAIQuotaResetCell>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="loading" class="space-y-1.5">
|
<div v-else-if="loading" class="space-y-1.5">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -163,7 +170,11 @@
|
|||||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-gray-400">-</div>
|
<div v-else>
|
||||||
|
<div class="text-xs text-gray-400">-</div>
|
||||||
|
<!-- Always allow on-demand upstream quota query, even before local data exists. -->
|
||||||
|
<OpenAIQuotaResetCell :account="account" class="mt-1" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Antigravity OAuth accounts: fetch usage from API -->
|
<!-- Antigravity OAuth accounts: fetch usage from API -->
|
||||||
@@ -503,6 +514,7 @@ import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
|
|||||||
import { formatCompactNumber } from '@/utils/format'
|
import { formatCompactNumber } from '@/utils/format'
|
||||||
import UsageProgressBar from './UsageProgressBar.vue'
|
import UsageProgressBar from './UsageProgressBar.vue'
|
||||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||||
|
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
|
||||||
|
|
||||||
// Module-level cache shared across all AccountUsageCell instances
|
// Module-level cache shared across all AccountUsageCell instances
|
||||||
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="space-y-1">
|
||||||
|
<!--
|
||||||
|
Unified action row. Parents that already render their own "local query"
|
||||||
|
affordance (e.g. AccountUsageCell's active-sampling refresh) pass it in
|
||||||
|
via the #pre-actions slot so the user sees a single row of related
|
||||||
|
buttons rather than two near-duplicate "查询" rows.
|
||||||
|
|
||||||
|
The 5h / 7d window bars are deliberately NOT rendered here — the local
|
||||||
|
active-sampling display (UsageProgressBar in AccountUsageCell) already
|
||||||
|
owns that real estate. This cell is purely about the rate-limit reset
|
||||||
|
credit: query its count, consume one if needed.
|
||||||
|
-->
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<slot name="pre-actions" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30"
|
||||||
|
:disabled="loading || resetting"
|
||||||
|
:title="countButtonTitle"
|
||||||
|
@click="handleQuery"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-2.5 w-2.5"
|
||||||
|
:class="{ 'animate-spin': loading }"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.openaiQuotaReset.count') }}<span v-if="data"> {{ availableResetCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-orange-600 transition-colors hover:bg-orange-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-orange-400 dark:hover:bg-orange-900/30"
|
||||||
|
:disabled="resetting || loading || !canReset"
|
||||||
|
:title="resetButtonTitle"
|
||||||
|
@click="handleReset"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-2.5 w-2.5"
|
||||||
|
:class="{ 'animate-spin': resetting }"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M20 12a8 8 0 11-2.343-5.657L20 8m0 0V4m0 4h-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.openaiQuotaReset.reset') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error / success feedback -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="text-[10px] text-red-600 dark:text-red-400"
|
||||||
|
:title="error"
|
||||||
|
>
|
||||||
|
{{ truncatedError }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="resetMessage"
|
||||||
|
class="text-[10px] text-emerald-600 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
{{ resetMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { Account } from '@/types'
|
||||||
|
import {
|
||||||
|
queryOpenAIQuota,
|
||||||
|
resetOpenAIQuota,
|
||||||
|
type OpenAIQuotaUsage,
|
||||||
|
type OpenAIQuotaResetResult
|
||||||
|
} from '@/api/admin/accounts'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
account: Account
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Visible only for OpenAI OAuth accounts.
|
||||||
|
const visible = computed(() => props.account.platform === 'openai' && props.account.type === 'oauth')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const resetting = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const data = ref<OpenAIQuotaUsage | null>(null)
|
||||||
|
const resetMessage = ref<string | null>(null)
|
||||||
|
|
||||||
|
const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0)
|
||||||
|
const canReset = computed(() => availableResetCount.value > 0)
|
||||||
|
|
||||||
|
const resetButtonTitle = computed(() => {
|
||||||
|
if (!data.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
|
||||||
|
if (!canReset.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNoCredits')
|
||||||
|
return t('admin.accounts.openaiQuotaReset.resetTooltipReady')
|
||||||
|
})
|
||||||
|
|
||||||
|
// "次数" button doubles as the upstream-query trigger and the count display.
|
||||||
|
// Tooltip differs between "click to load" (no data yet) and "click to refresh".
|
||||||
|
const countButtonTitle = computed(() => {
|
||||||
|
if (!data.value) return t('admin.accounts.openaiQuotaReset.countTooltipLoad')
|
||||||
|
return t('admin.accounts.openaiQuotaReset.countTooltipRefresh')
|
||||||
|
})
|
||||||
|
|
||||||
|
const truncatedError = computed(() => {
|
||||||
|
if (!error.value) return ''
|
||||||
|
return error.value.length > 80 ? `${error.value.slice(0, 80)}…` : error.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const extractErrorMessage = (e: unknown): string => {
|
||||||
|
// The project's axios response interceptor (api/client.ts) flattens server
|
||||||
|
// errors into { status, code, message, reason, ... } and re-rejects them, so
|
||||||
|
// the message lives at the top level rather than under .response.data. Fall
|
||||||
|
// back to the raw axios shape for the cancellation/network branches that
|
||||||
|
// bypass the flattening, and finally to the generic i18n string.
|
||||||
|
const err = e as {
|
||||||
|
message?: string
|
||||||
|
reason?: string
|
||||||
|
response?: { data?: { message?: string; error?: string } }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
err?.message ||
|
||||||
|
err?.reason ||
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.response?.data?.error ||
|
||||||
|
t('common.error')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = async () => {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
resetMessage.value = null
|
||||||
|
try {
|
||||||
|
data.value = await queryOpenAIQuota(props.account.id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = extractErrorMessage(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (resetting.value) return
|
||||||
|
if (!canReset.value) {
|
||||||
|
error.value = t('admin.accounts.openaiQuotaReset.noCreditsAvailable')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetting.value = true
|
||||||
|
error.value = null
|
||||||
|
resetMessage.value = null
|
||||||
|
try {
|
||||||
|
const result: OpenAIQuotaResetResult = await resetOpenAIQuota(props.account.id)
|
||||||
|
// Refresh the reset-credit count so the badge reflects the consumed credit.
|
||||||
|
// handleQuery clears resetMessage on entry, so the success toast is set
|
||||||
|
// AFTER it resolves.
|
||||||
|
await handleQuery()
|
||||||
|
resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', {
|
||||||
|
windows: result.windows_reset
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
error.value = extractErrorMessage(e)
|
||||||
|
} finally {
|
||||||
|
resetting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.account.id,
|
||||||
|
() => {
|
||||||
|
// Account row may be reused across paginated lists; reset local state.
|
||||||
|
data.value = null
|
||||||
|
error.value = null
|
||||||
|
resetMessage.value = null
|
||||||
|
loading.value = false
|
||||||
|
resetting.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -4105,6 +4105,17 @@ export default {
|
|||||||
passiveSampled: 'Passive',
|
passiveSampled: 'Passive',
|
||||||
activeQuery: 'Query'
|
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: {
|
tier: {
|
||||||
free: 'Free',
|
free: 'Free',
|
||||||
pro: 'Pro',
|
pro: 'Pro',
|
||||||
|
|||||||
@@ -3393,6 +3393,17 @@ export default {
|
|||||||
passiveSampled: '被动采样',
|
passiveSampled: '被动采样',
|
||||||
activeQuery: '查询'
|
activeQuery: '查询'
|
||||||
},
|
},
|
||||||
|
openaiQuotaReset: {
|
||||||
|
count: '次数',
|
||||||
|
reset: '重置',
|
||||||
|
countTooltipLoad: '点击查询剩余重置次数',
|
||||||
|
countTooltipRefresh: '点击刷新剩余重置次数',
|
||||||
|
resetTooltipReady: '消耗 1 次重置次数以立即恢复当前窗口',
|
||||||
|
resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数',
|
||||||
|
resetTooltipNoCredits: '没有可用的重置次数',
|
||||||
|
noCreditsAvailable: '没有可用的重置次数',
|
||||||
|
resetSuccess: '已重置 {windows} 个窗口'
|
||||||
|
},
|
||||||
tier: {
|
tier: {
|
||||||
free: 'Free',
|
free: 'Free',
|
||||||
pro: 'Pro',
|
pro: 'Pro',
|
||||||
|
|||||||
Reference in New Issue
Block a user