| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693 |
- package service
- import (
- "context"
- "crypto/rand"
- "encoding/hex"
- "fmt"
- "log/slog"
- "net/http"
- "strings"
- "sync"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/imroc/req/v3"
- )
- // ErrSparkShadowResetNotSupported is returned when ResetCredit is called on a
- // spark shadow account. Shadow accounts do not hold credentials of their own;
- // the caller must reset the parent account directly. It is a structured
- // infraerrors value so the handler maps it to 409 Conflict (not a bare 500);
- // errors.Is still matches it by identity since ResetCredit returns this var.
- var ErrSparkShadowResetNotSupported = infraerrors.New(http.StatusConflict, "SPARK_SHADOW_RESET_NOT_SUPPORTED", "spark shadow account does not support credit reset; reset the parent account")
- // Endpoints used by the OpenAI/ChatGPT/Codex quota query and reset feature.
- const (
- chatGPTUsageURL = "https://chatgpt.com/backend-api/wham/usage"
- chatGPTRateLimitCreditsURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
- chatGPTRateLimitResetURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"
- openaiQuotaUpstreamTimeout = 20 * time.Second
- openaiQuotaCodexBeta = "codex-1"
- openaiQuotaCodexOriginator = "Codex Desktop"
- openaiQuotaCodexLanguageTag = "zh-CN"
- openaiQuotaSecFetchSite = "none"
- openaiQuotaSecFetchMode = "no-cors"
- openaiQuotaSecFetchDest = "empty"
- openaiQuotaResetCreditsKey = "codex_reset_credit_snapshot"
- )
- // 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"`
- }
- // OpenAIRateLimitResetCreditDetail is the sanitized metadata surfaced for one
- // available reset credit. Do not add upstream ids or tokens here.
- type OpenAIRateLimitResetCreditDetail struct {
- ExpiresAt string `json:"expires_at,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"`
- Credits []OpenAIRateLimitResetCreditDetail `json:"credits,omitempty"`
- }
- // 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"`
- autoResetCandidates []openAIAutoResetCreditCandidate
- }
- // 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
- agentIdentityTaskMu sync.Mutex
- agentIdentityWS agentIdentityWSConnectionInvalidator
- }
- // 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, fedRAMP, 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()
- agentIdentity := s.isAgentIdentityAccount(ctx, accountID)
- var payload OpenAIQuotaUsage
- for recovered := false; ; {
- quotaHeaders, expectedTaskID, headerErr := s.buildCodexQuotaHeaders(callCtx, accountID, accessToken, chatGPTAccountID, fedRAMP)
- if headerErr != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "failed to build upstream authentication: %v", headerErr)
- }
- resp, err := client.R().
- SetContext(callCtx).
- SetHeaders(quotaHeaders).
- 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() {
- if agentIdentity && !recovered && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, []byte(resp.String())) {
- recovered = true
- if err := s.recoverAgentIdentityTask(ctx, accountID, expectedTaskID); err != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "agent identity task recovery failed: %v", err)
- }
- continue
- }
- status := resp.StatusCode
- if isOpenAIAutoResetContext(ctx) {
- slog.Warn("openai_quota_query_failed", "account_id", accountID, "status", status, "source", "auto_reset")
- return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_UPSTREAM_ERROR", "upstream returned %d", status)
- }
- body := truncate(s.redactQuotaErrorBody(ctx, accountID, 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)
- }
- break
- }
- payload.FetchedAt = time.Now().Unix()
- details := s.queryResetCreditDetails(callCtx, client, accessToken, chatGPTAccountID, fedRAMP, accountID)
- if details != nil {
- payload.autoResetCandidates = details.AutoResetCandidates
- hasDetailCount := details.AvailableCount != nil
- if payload.RateLimitResetCredits == nil {
- payload.RateLimitResetCredits = &OpenAIRateLimitResetCredits{}
- }
- if details.CreditListPresent {
- payload.RateLimitResetCredits.Credits = details.Credits
- }
- switch {
- case hasDetailCount:
- payload.RateLimitResetCredits.AvailableCount = *details.AvailableCount
- case details.CreditListPresent:
- payload.RateLimitResetCredits.AvailableCount = details.AvailableCreditCount
- }
- }
- return &payload, nil
- }
- // CacheResetCreditsSnapshot persists a complete reset-credit snapshot after an
- // explicit UI refresh. The snapshot is written to the account that was queried
- // (for a spark shadow that is the shadow row, even though the credits belong to
- // its parent) because it is a per-row display cache: each row caches exactly
- // what its own card renders, and shadows cannot consume credits anyway.
- //
- // Missing expiration details leave the old cache intact:
- // a snapshot claiming N>0 available credits without their expiration timestamps
- // cannot be aged out by readers, so it would keep showing (and offering to
- // consume) credits that already expired. Callers must treat this rejection as a
- // partial success — the upstream read itself is still valid.
- func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error {
- return s.cacheResetCreditsSnapshot(ctx, accountID, credits, nil)
- }
- // CachePostResetSnapshot persists the credits and usage windows observed after a reset.
- func (s *OpenAIQuotaService) CachePostResetSnapshot(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage) error {
- if usage == nil {
- return s.cacheResetCreditsSnapshot(ctx, accountID, nil, nil)
- }
- return s.cacheResetCreditsSnapshot(
- ctx,
- accountID,
- usage.RateLimitResetCredits,
- buildOpenAIAutoResetUsageUpdates(usage, time.Now()),
- )
- }
- func (s *OpenAIQuotaService) cacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits, updates map[string]any) error {
- if credits == nil || (credits.AvailableCount > 0 && len(credits.Credits) == 0) {
- return infraerrors.New(
- http.StatusBadGateway,
- "OPENAI_QUOTA_RESET_CREDITS_REFRESH_FAILED",
- "failed to refresh reset-credit expiration details; cached data was preserved",
- )
- }
- if updates == nil {
- updates = make(map[string]any, 1)
- }
- updates[openaiQuotaResetCreditsKey] = credits
- if err := s.accountRepo.UpdateExtra(ctx, accountID, updates); err != nil {
- return infraerrors.New(
- http.StatusInternalServerError,
- "OPENAI_QUOTA_CACHE_WRITE_FAILED",
- "failed to cache reset-credit details",
- ).WithCause(err)
- }
- return nil
- }
- func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client *req.Client, accessToken, chatGPTAccountID string, fedRAMP bool, accountID int64) *openAIRateLimitResetCreditDetails {
- quotaHeaders, _, headerErr := s.buildCodexQuotaHeaders(ctx, accountID, accessToken, chatGPTAccountID, fedRAMP)
- if headerErr != nil {
- slog.Warn("openai_quota_reset_credit_details_auth_failed", "account_id", accountID, "error", headerErr)
- return nil
- }
- resp, err := client.R().
- SetContext(ctx).
- SetHeaders(quotaHeaders).
- Get(chatGPTRateLimitCreditsURL)
- if err != nil {
- slog.Warn("openai_quota_reset_credit_details_failed", "account_id", accountID, "error", err)
- return nil
- }
- if !resp.IsSuccessState() {
- slog.Warn("openai_quota_reset_credit_details_failed", "account_id", accountID, "status", resp.StatusCode)
- return nil
- }
- details, err := parseOpenAIRateLimitResetCreditDetails(resp.Bytes())
- if err != nil {
- slog.Warn("openai_quota_reset_credit_details_parse_failed", "account_id", accountID, "error", err)
- if details.AvailableCount == nil {
- return nil
- }
- }
- if details.AvailableCount == nil && !details.CreditListPresent {
- return nil
- }
- return &details
- }
- // 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) {
- redeemRequestID, err := generateRedeemRequestID()
- if err != nil {
- return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_QUOTA_REDEEM_ID_FAILED", "failed to generate redeem id: %v", err)
- }
- return s.resetCredit(ctx, accountID, "", redeemRequestID, false)
- }
- // ResetCreditTargeted 使用固定卡 ID 与兑换 ID执行自动消费。调用方必须在重试时
- // 复用同一组参数;本方法不会回退到不带 credit_id 的旧消费方式。
- func (s *OpenAIQuotaService) ResetCreditTargeted(ctx context.Context, accountID int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) {
- creditID = strings.TrimSpace(creditID)
- redeemRequestID = strings.TrimSpace(redeemRequestID)
- if creditID == "" || redeemRequestID == "" {
- return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_TARGETED_RESET_INVALID", "credit_id and redeem_request_id are required")
- }
- return s.resetCredit(ctx, accountID, creditID, redeemRequestID, true)
- }
- func (s *OpenAIQuotaService) resetCredit(ctx context.Context, accountID int64, creditID, redeemRequestID string, targeted bool) (*OpenAIQuotaResetResult, error) {
- // Shadow guard: resetting credits via a shadow account would silently
- // operate on the parent's quota; that is surprising and unwanted. Callers
- // must reset the parent account directly.
- //
- // Fail-closed: if the account cannot be loaded (transient DB error), we
- // must NOT fall through to prepareUpstreamCall. That function resolves a
- // shadow to its parent and would perform a parent-level reset — exactly
- // what this guard must prevent. Return the load error instead.
- if s.accountRepo != nil {
- acc, loadErr := s.accountRepo.GetByID(ctx, accountID)
- if loadErr != nil {
- return nil, infraerrors.Newf(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", loadErr)
- }
- if acc.IsShadow() {
- return nil, ErrSparkShadowResetNotSupported
- }
- }
- accessToken, chatGPTAccountID, proxyURL, fedRAMP, 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()
- agentIdentity := s.isAgentIdentityAccount(ctx, accountID)
- var payload OpenAIQuotaResetResult
- for recovered := false; ; {
- headers, expectedTaskID, headerErr := s.buildCodexQuotaHeaders(callCtx, accountID, accessToken, chatGPTAccountID, fedRAMP)
- if headerErr != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "failed to build upstream authentication: %v", headerErr)
- }
- headers["content-type"] = "application/json"
- body := map[string]string{"redeem_request_id": redeemRequestID}
- if targeted {
- body["credit_id"] = creditID
- }
- resp, err := client.R().
- SetContext(callCtx).
- SetHeaders(headers).
- SetBody(body).
- 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() {
- if agentIdentity && !recovered && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, []byte(resp.String())) {
- recovered = true
- if err := s.recoverAgentIdentityTask(ctx, accountID, expectedTaskID); err != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "agent identity task recovery failed: %v", err)
- }
- continue
- }
- status := resp.StatusCode
- if targeted {
- slog.Warn("openai_quota_targeted_reset_failed", "account_id", accountID, "status", status)
- return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d", status)
- }
- body := truncate(s.redactQuotaErrorBody(callCtx, accountID, 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)
- }
- break
- }
- 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, fedRAMP bool, err error) {
- if s == nil || s.accountRepo == nil || s.privacyClientFactory == nil {
- return "", "", "", false, 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 "", "", "", false, infraerrors.Newf(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", err)
- }
- if account == nil {
- return "", "", "", false, infraerrors.New(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found")
- }
- if account.Platform != PlatformOpenAI {
- return "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_PLATFORM", "account is not an OpenAI account")
- }
- if account.Type != AccountTypeOAuth {
- return "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_TYPE", "account is not an OAuth account")
- }
- // Spark shadow accounts do not hold their own credentials; resolve to the
- // parent account so that chatgpt_account_id / access_token / proxy all come
- // from the parent. This must happen BEFORE the chatgpt_account_id check.
- if account.IsShadow() {
- resolved, rerr := resolveCredentialAccount(ctx, s.accountRepo, account)
- if rerr != nil {
- return "", "", "", false, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_SHADOW_RESOLVE_FAILED", "failed to resolve shadow account: %v", rerr)
- }
- account = resolved
- }
- 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 "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_MISSING_ACCOUNT_ID", "chatgpt_account_id is missing; please re-authorize this account")
- }
- if !account.IsOpenAIAgentIdentity() {
- if s.tokenProvider == nil {
- return "", "", "", false, infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_NOT_CONFIGURED", "openai quota token provider is not configured")
- }
- accessToken, err = s.tokenProvider.GetAccessToken(ctx, account)
- if err != nil {
- return "", "", "", false, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "failed to acquire access token: %v", err)
- }
- if strings.TrimSpace(accessToken) == "" {
- return "", "", "", false, infraerrors.New(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "access token is empty")
- }
- }
- fedRAMP = account.IsChatGPTAccountFedRAMP()
- // 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, fedRAMP, nil
- }
- func (s *OpenAIQuotaService) recoverAgentIdentityTask(ctx context.Context, accountID int64, expectedTaskID string) error {
- if s == nil || s.accountRepo == nil {
- return fmt.Errorf("account repository is unavailable")
- }
- account, err := s.accountRepo.GetByID(ctx, accountID)
- if err != nil || account == nil {
- return fmt.Errorf("account is unavailable")
- }
- if account.IsShadow() {
- account, err = resolveCredentialAccount(ctx, s.accountRepo, account)
- if err != nil || account == nil {
- return fmt.Errorf("credential account is unavailable")
- }
- }
- if !account.IsOpenAIAgentIdentity() {
- return nil
- }
- return ensureAgentIdentityTaskForAccount(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, account, expectedTaskID)
- }
- func (s *OpenAIQuotaService) isAgentIdentityAccount(ctx context.Context, accountID int64) bool {
- if s == nil || s.accountRepo == nil {
- return false
- }
- account, err := s.accountRepo.GetByID(ctx, accountID)
- if err != nil || account == nil {
- return false
- }
- if account.IsShadow() {
- account, err = resolveCredentialAccount(ctx, s.accountRepo, account)
- if err != nil || account == nil {
- return false
- }
- }
- return account.IsOpenAIAgentIdentity()
- }
- func (s *OpenAIQuotaService) buildCodexQuotaHeaders(ctx context.Context, accountID int64, accessToken, chatGPTAccountID string, fedRAMP bool) (map[string]string, string, error) {
- headers := buildCodexCommonHeaders(accessToken, chatGPTAccountID, fedRAMP)
- if s == nil || s.accountRepo == nil {
- return headers, "", nil
- }
- account, err := s.accountRepo.GetByID(ctx, accountID)
- if err != nil || account == nil {
- if strings.TrimSpace(accessToken) == "" {
- return nil, "", fmt.Errorf("agent identity account credentials are unavailable")
- }
- return headers, "", nil
- }
- if account.IsShadow() {
- if resolved, resolveErr := resolveCredentialAccount(ctx, s.accountRepo, account); resolveErr == nil && resolved != nil {
- account = resolved
- } else if strings.TrimSpace(accessToken) == "" {
- return nil, "", fmt.Errorf("agent identity shadow credentials are unavailable")
- }
- }
- if !account.IsOpenAIAgentIdentity() {
- return headers, "", nil
- }
- if err := ensureAgentIdentityTaskForAccount(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, account, ""); err != nil {
- return nil, "", err
- }
- key, err := agentIdentityKeyFromAccount(account)
- if err != nil {
- return nil, "", err
- }
- assertion, err := buildAgentAssertion(key, time.Now())
- if err != nil {
- return nil, "", err
- }
- headers["authorization"] = assertion
- return headers, key.taskID, nil
- }
- func (s *OpenAIQuotaService) redactQuotaErrorBody(ctx context.Context, accountID int64, body string) string {
- if s == nil || s.accountRepo == nil {
- return body
- }
- account, err := s.accountRepo.GetByID(ctx, accountID)
- if err != nil || account == nil {
- return body
- }
- return string(redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, account, []byte(body)))
- }
- // buildCodexCommonHeaders sets the request headers expected by the chatgpt.com
- // backend so calls succeed past Cloudflare/WASM checks.
- func buildCodexCommonHeaders(accessToken, chatGPTAccountID string, fedRAMP bool) map[string]string {
- headers := map[string]string{
- "authorization": "Bearer " + accessToken,
- "chatgpt-account-id": chatGPTAccountID,
- "openai-beta": openaiQuotaCodexBeta,
- "oai-language": openaiQuotaCodexLanguageTag,
- "originator": openaiQuotaCodexOriginator,
- "accept": "application/json",
- "sec-fetch-site": openaiQuotaSecFetchSite,
- "sec-fetch-mode": openaiQuotaSecFetchMode,
- "sec-fetch-dest": openaiQuotaSecFetchDest,
- "priority": "u=4, i",
- }
- if fedRAMP {
- headers["x-openai-fedramp"] = "true"
- }
- return headers
- }
- // 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
- }
- // buildCodexSparkWindowExtraUpdates extracts Codex Spark usage windows from the
- // /wham/usage response body's additional_rate_limits, matching the entry with
- // MeteredFeature == "codex_bengalfox". It produces plain codex_* keys (NOT the
- // Method-Z "codex_spark_" prefix) so that a spark shadow account's extra map
- // is populated with the same key names used by the scheduling / frontend layers.
- // Returns nil when no codex_bengalfox entry is present or when the RateLimit
- // yields no window data.
- func buildCodexSparkWindowExtraUpdates(usage *OpenAIQuotaUsage, now time.Time) map[string]any {
- if usage == nil {
- return nil
- }
- var spark *OpenAIRateLimit
- for i := range usage.AdditionalRateLimits {
- a := usage.AdditionalRateLimits[i]
- if a.MeteredFeature == "codex_bengalfox" {
- spark = a.RateLimit
- break
- }
- }
- if spark == nil {
- return nil
- }
- // Reuse OpenAICodexUsageSnapshot / Normalize to map primary/secondary windows
- // to canonical 5h/7d buckets (same logic as probeOpenAICodexSnapshot).
- snap := &OpenAICodexUsageSnapshot{}
- if w := spark.PrimaryWindow; w != nil {
- p := w.UsedPercent
- snap.PrimaryUsedPercent = &p
- ra := int(w.ResetAfterSeconds)
- snap.PrimaryResetAfterSeconds = &ra
- wm := int(w.LimitWindowSeconds / 60)
- snap.PrimaryWindowMinutes = &wm
- }
- if w := spark.SecondaryWindow; w != nil {
- p := w.UsedPercent
- snap.SecondaryUsedPercent = &p
- ra := int(w.ResetAfterSeconds)
- snap.SecondaryResetAfterSeconds = &ra
- wm := int(w.LimitWindowSeconds / 60)
- snap.SecondaryWindowMinutes = &wm
- }
- normalized := snap.Normalize()
- if normalized == nil {
- return nil
- }
- updates := make(map[string]any)
- if normalized.Used5hPercent != nil {
- updates["codex_5h_used_percent"] = *normalized.Used5hPercent
- }
- if normalized.Reset5hSeconds != nil {
- updates["codex_5h_reset_after_seconds"] = *normalized.Reset5hSeconds
- }
- if normalized.Window5hMinutes != nil {
- updates["codex_5h_window_minutes"] = *normalized.Window5hMinutes
- }
- if normalized.Used7dPercent != nil {
- updates["codex_7d_used_percent"] = *normalized.Used7dPercent
- }
- if normalized.Reset7dSeconds != nil {
- updates["codex_7d_reset_after_seconds"] = *normalized.Reset7dSeconds
- }
- if normalized.Window7dMinutes != nil {
- updates["codex_7d_window_minutes"] = *normalized.Window7dMinutes
- }
- if r := codexResetAtRFC3339(now, normalized.Reset5hSeconds); r != nil {
- updates["codex_5h_reset_at"] = *r
- }
- if r := codexResetAtRFC3339(now, normalized.Reset7dSeconds); r != nil {
- updates["codex_7d_reset_at"] = *r
- }
- if len(updates) == 0 {
- return nil
- }
- updates["codex_usage_updated_at"] = now.Format(time.RFC3339)
- return updates
- }
- // 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
- }
- }
|