Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
647 lines
26 KiB
Go
647 lines
26 KiB
Go
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"`
|
|
}
|
|
|
|
// 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
|
|
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 {
|
|
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 {
|
|
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 err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
|
|
openaiQuotaResetCreditsKey: credits,
|
|
}); 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) {
|
|
// 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
|
|
}
|
|
|
|
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()
|
|
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"
|
|
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() {
|
|
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
|
|
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
|
|
}
|
|
}
|