openai_quota_service.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. package service
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "encoding/hex"
  6. "fmt"
  7. "log/slog"
  8. "net/http"
  9. "strings"
  10. "sync"
  11. "time"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. "github.com/imroc/req/v3"
  14. )
  15. // ErrSparkShadowResetNotSupported is returned when ResetCredit is called on a
  16. // spark shadow account. Shadow accounts do not hold credentials of their own;
  17. // the caller must reset the parent account directly. It is a structured
  18. // infraerrors value so the handler maps it to 409 Conflict (not a bare 500);
  19. // errors.Is still matches it by identity since ResetCredit returns this var.
  20. var ErrSparkShadowResetNotSupported = infraerrors.New(http.StatusConflict, "SPARK_SHADOW_RESET_NOT_SUPPORTED", "spark shadow account does not support credit reset; reset the parent account")
  21. // Endpoints used by the OpenAI/ChatGPT/Codex quota query and reset feature.
  22. const (
  23. chatGPTUsageURL = "https://chatgpt.com/backend-api/wham/usage"
  24. chatGPTRateLimitCreditsURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
  25. chatGPTRateLimitResetURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"
  26. openaiQuotaUpstreamTimeout = 20 * time.Second
  27. openaiQuotaCodexBeta = "codex-1"
  28. openaiQuotaCodexOriginator = "Codex Desktop"
  29. openaiQuotaCodexLanguageTag = "zh-CN"
  30. openaiQuotaSecFetchSite = "none"
  31. openaiQuotaSecFetchMode = "no-cors"
  32. openaiQuotaSecFetchDest = "empty"
  33. openaiQuotaResetCreditsKey = "codex_reset_credit_snapshot"
  34. )
  35. // OpenAIRateLimitWindow describes a single rate-limit window returned by
  36. // /wham/usage. The upstream returns an explicit `null` window when the slot
  37. // is unused, so consumers should treat a nil pointer as "no data".
  38. type OpenAIRateLimitWindow struct {
  39. UsedPercent float64 `json:"used_percent"`
  40. LimitWindowSeconds int64 `json:"limit_window_seconds"`
  41. ResetAfterSeconds int64 `json:"reset_after_seconds"`
  42. ResetAt int64 `json:"reset_at"`
  43. }
  44. // OpenAIRateLimit is a rate-limit envelope (primary + optional secondary window).
  45. type OpenAIRateLimit struct {
  46. Allowed bool `json:"allowed"`
  47. LimitReached bool `json:"limit_reached"`
  48. PrimaryWindow *OpenAIRateLimitWindow `json:"primary_window,omitempty"`
  49. SecondaryWindow *OpenAIRateLimitWindow `json:"secondary_window,omitempty"`
  50. }
  51. // OpenAIAdditionalRateLimit describes a per-feature rate limit (e.g. Codex Spark).
  52. type OpenAIAdditionalRateLimit struct {
  53. LimitName string `json:"limit_name"`
  54. MeteredFeature string `json:"metered_feature"`
  55. RateLimit *OpenAIRateLimit `json:"rate_limit,omitempty"`
  56. }
  57. // OpenAIRateLimitResetCreditDetail is the sanitized metadata surfaced for one
  58. // available reset credit. Do not add upstream ids or tokens here.
  59. type OpenAIRateLimitResetCreditDetail struct {
  60. ExpiresAt string `json:"expires_at,omitempty"`
  61. }
  62. // OpenAIRateLimitResetCredits captures the "available_count" surfaced for the
  63. // rate_limit_reset_credit grant type, which the reset action consumes.
  64. type OpenAIRateLimitResetCredits struct {
  65. AvailableCount int `json:"available_count"`
  66. Credits []OpenAIRateLimitResetCreditDetail `json:"credits,omitempty"`
  67. }
  68. // OpenAIQuotaUsage is the typed projection of /wham/usage we expose to the UI.
  69. // Fields not relevant to the quota card are intentionally omitted to keep the
  70. // surface narrow; full upstream payload preservation is unnecessary.
  71. type OpenAIQuotaUsage struct {
  72. UserID string `json:"user_id,omitempty"`
  73. AccountID string `json:"account_id,omitempty"`
  74. Email string `json:"email,omitempty"`
  75. PlanType string `json:"plan_type,omitempty"`
  76. RateLimit *OpenAIRateLimit `json:"rate_limit,omitempty"`
  77. AdditionalRateLimits []OpenAIAdditionalRateLimit `json:"additional_rate_limits,omitempty"`
  78. RateLimitResetCredits *OpenAIRateLimitResetCredits `json:"rate_limit_reset_credits,omitempty"`
  79. FetchedAt int64 `json:"fetched_at"`
  80. autoResetCandidates []openAIAutoResetCreditCandidate
  81. }
  82. // OpenAIQuotaResetCredit captures the redeemed credit metadata returned by the
  83. // reset endpoint.
  84. type OpenAIQuotaResetCredit struct {
  85. ID string `json:"id,omitempty"`
  86. ResetType string `json:"reset_type,omitempty"`
  87. Status string `json:"status,omitempty"`
  88. GrantedAt string `json:"granted_at,omitempty"`
  89. ExpiresAt string `json:"expires_at,omitempty"`
  90. RedeemStartedAt string `json:"redeem_started_at,omitempty"`
  91. RedeemedAt string `json:"redeemed_at,omitempty"`
  92. }
  93. // OpenAIQuotaResetResult is the typed projection of /wham/rate-limit-reset-credits/consume.
  94. // The inner Credit also carries `redeemed_at` (RFC3339 string); we deliberately do
  95. // NOT add a top-level redeemed_at to avoid ambiguity with the nested field.
  96. type OpenAIQuotaResetResult struct {
  97. Code string `json:"code"`
  98. Credit *OpenAIQuotaResetCredit `json:"credit,omitempty"`
  99. WindowsReset int `json:"windows_reset"`
  100. }
  101. // OpenAIQuotaService queries and consumes ChatGPT/Codex rate-limit reset credits
  102. // for OpenAI OAuth accounts. It reuses the privacy client factory so all calls
  103. // flow through the impersonated HTTP client (Cloudflare-friendly TLS fingerprint).
  104. type OpenAIQuotaService struct {
  105. accountRepo AccountRepository
  106. proxyRepo ProxyRepository
  107. tokenProvider *OpenAITokenProvider
  108. privacyClientFactory PrivacyClientFactory
  109. agentIdentityTaskMu sync.Mutex
  110. agentIdentityWS agentIdentityWSConnectionInvalidator
  111. }
  112. // NewOpenAIQuotaService constructs a quota service. token provider is required —
  113. // it ensures we always invoke upstream with a valid (refreshed-if-needed)
  114. // access_token, sharing the same refresh/locking machinery used by the gateway.
  115. func NewOpenAIQuotaService(
  116. accountRepo AccountRepository,
  117. proxyRepo ProxyRepository,
  118. tokenProvider *OpenAITokenProvider,
  119. privacyClientFactory PrivacyClientFactory,
  120. ) *OpenAIQuotaService {
  121. return &OpenAIQuotaService{
  122. accountRepo: accountRepo,
  123. proxyRepo: proxyRepo,
  124. tokenProvider: tokenProvider,
  125. privacyClientFactory: privacyClientFactory,
  126. }
  127. }
  128. // QueryUsage fetches the latest rate-limit/usage snapshot for the given OpenAI
  129. // OAuth account. Returns infraerrors so the handler layer can map them to
  130. // stable error codes / HTTP statuses.
  131. func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error) {
  132. accessToken, chatGPTAccountID, proxyURL, fedRAMP, err := s.prepareUpstreamCall(ctx, accountID)
  133. if err != nil {
  134. return nil, err
  135. }
  136. client, err := s.privacyClientFactory(proxyURL)
  137. if err != nil {
  138. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_CLIENT_ERROR", "failed to build upstream client: %v", err)
  139. }
  140. callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout)
  141. defer cancel()
  142. agentIdentity := s.isAgentIdentityAccount(ctx, accountID)
  143. var payload OpenAIQuotaUsage
  144. for recovered := false; ; {
  145. quotaHeaders, expectedTaskID, headerErr := s.buildCodexQuotaHeaders(callCtx, accountID, accessToken, chatGPTAccountID, fedRAMP)
  146. if headerErr != nil {
  147. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "failed to build upstream authentication: %v", headerErr)
  148. }
  149. resp, err := client.R().
  150. SetContext(callCtx).
  151. SetHeaders(quotaHeaders).
  152. SetSuccessResult(&payload).
  153. Get(chatGPTUsageURL)
  154. if err != nil {
  155. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_REQUEST_FAILED", "upstream request failed: %v", err)
  156. }
  157. if !resp.IsSuccessState() {
  158. if agentIdentity && !recovered && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, []byte(resp.String())) {
  159. recovered = true
  160. if err := s.recoverAgentIdentityTask(ctx, accountID, expectedTaskID); err != nil {
  161. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "agent identity task recovery failed: %v", err)
  162. }
  163. continue
  164. }
  165. status := resp.StatusCode
  166. if isOpenAIAutoResetContext(ctx) {
  167. slog.Warn("openai_quota_query_failed", "account_id", accountID, "status", status, "source", "auto_reset")
  168. return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_UPSTREAM_ERROR", "upstream returned %d", status)
  169. }
  170. body := truncate(s.redactQuotaErrorBody(ctx, accountID, resp.String()), 240)
  171. slog.Warn("openai_quota_query_failed", "account_id", accountID, "status", status, "body", body)
  172. return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_UPSTREAM_ERROR", "upstream returned %d: %s", status, body)
  173. }
  174. break
  175. }
  176. payload.FetchedAt = time.Now().Unix()
  177. details := s.queryResetCreditDetails(callCtx, client, accessToken, chatGPTAccountID, fedRAMP, accountID)
  178. if details != nil {
  179. payload.autoResetCandidates = details.AutoResetCandidates
  180. hasDetailCount := details.AvailableCount != nil
  181. if payload.RateLimitResetCredits == nil {
  182. payload.RateLimitResetCredits = &OpenAIRateLimitResetCredits{}
  183. }
  184. if details.CreditListPresent {
  185. payload.RateLimitResetCredits.Credits = details.Credits
  186. }
  187. switch {
  188. case hasDetailCount:
  189. payload.RateLimitResetCredits.AvailableCount = *details.AvailableCount
  190. case details.CreditListPresent:
  191. payload.RateLimitResetCredits.AvailableCount = details.AvailableCreditCount
  192. }
  193. }
  194. return &payload, nil
  195. }
  196. // CacheResetCreditsSnapshot persists a complete reset-credit snapshot after an
  197. // explicit UI refresh. The snapshot is written to the account that was queried
  198. // (for a spark shadow that is the shadow row, even though the credits belong to
  199. // its parent) because it is a per-row display cache: each row caches exactly
  200. // what its own card renders, and shadows cannot consume credits anyway.
  201. //
  202. // Missing expiration details leave the old cache intact:
  203. // a snapshot claiming N>0 available credits without their expiration timestamps
  204. // cannot be aged out by readers, so it would keep showing (and offering to
  205. // consume) credits that already expired. Callers must treat this rejection as a
  206. // partial success — the upstream read itself is still valid.
  207. func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error {
  208. return s.cacheResetCreditsSnapshot(ctx, accountID, credits, nil)
  209. }
  210. // CachePostResetSnapshot persists the credits and usage windows observed after a reset.
  211. func (s *OpenAIQuotaService) CachePostResetSnapshot(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage) error {
  212. if usage == nil {
  213. return s.cacheResetCreditsSnapshot(ctx, accountID, nil, nil)
  214. }
  215. return s.cacheResetCreditsSnapshot(
  216. ctx,
  217. accountID,
  218. usage.RateLimitResetCredits,
  219. buildOpenAIAutoResetUsageUpdates(usage, time.Now()),
  220. )
  221. }
  222. func (s *OpenAIQuotaService) cacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits, updates map[string]any) error {
  223. if credits == nil || (credits.AvailableCount > 0 && len(credits.Credits) == 0) {
  224. return infraerrors.New(
  225. http.StatusBadGateway,
  226. "OPENAI_QUOTA_RESET_CREDITS_REFRESH_FAILED",
  227. "failed to refresh reset-credit expiration details; cached data was preserved",
  228. )
  229. }
  230. if updates == nil {
  231. updates = make(map[string]any, 1)
  232. }
  233. updates[openaiQuotaResetCreditsKey] = credits
  234. if err := s.accountRepo.UpdateExtra(ctx, accountID, updates); err != nil {
  235. return infraerrors.New(
  236. http.StatusInternalServerError,
  237. "OPENAI_QUOTA_CACHE_WRITE_FAILED",
  238. "failed to cache reset-credit details",
  239. ).WithCause(err)
  240. }
  241. return nil
  242. }
  243. func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client *req.Client, accessToken, chatGPTAccountID string, fedRAMP bool, accountID int64) *openAIRateLimitResetCreditDetails {
  244. quotaHeaders, _, headerErr := s.buildCodexQuotaHeaders(ctx, accountID, accessToken, chatGPTAccountID, fedRAMP)
  245. if headerErr != nil {
  246. slog.Warn("openai_quota_reset_credit_details_auth_failed", "account_id", accountID, "error", headerErr)
  247. return nil
  248. }
  249. resp, err := client.R().
  250. SetContext(ctx).
  251. SetHeaders(quotaHeaders).
  252. Get(chatGPTRateLimitCreditsURL)
  253. if err != nil {
  254. slog.Warn("openai_quota_reset_credit_details_failed", "account_id", accountID, "error", err)
  255. return nil
  256. }
  257. if !resp.IsSuccessState() {
  258. slog.Warn("openai_quota_reset_credit_details_failed", "account_id", accountID, "status", resp.StatusCode)
  259. return nil
  260. }
  261. details, err := parseOpenAIRateLimitResetCreditDetails(resp.Bytes())
  262. if err != nil {
  263. slog.Warn("openai_quota_reset_credit_details_parse_failed", "account_id", accountID, "error", err)
  264. if details.AvailableCount == nil {
  265. return nil
  266. }
  267. }
  268. if details.AvailableCount == nil && !details.CreditListPresent {
  269. return nil
  270. }
  271. return &details
  272. }
  273. // ResetCredit consumes one rate_limit_reset_credit for the given OpenAI account.
  274. // The redeem_request_id is auto-generated (uuid-like) — upstream uses it for
  275. // idempotency. Returns the consumed credit metadata so the UI can refresh.
  276. func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) (*OpenAIQuotaResetResult, error) {
  277. redeemRequestID, err := generateRedeemRequestID()
  278. if err != nil {
  279. return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_QUOTA_REDEEM_ID_FAILED", "failed to generate redeem id: %v", err)
  280. }
  281. return s.resetCredit(ctx, accountID, "", redeemRequestID, false)
  282. }
  283. // ResetCreditTargeted 使用固定卡 ID 与兑换 ID执行自动消费。调用方必须在重试时
  284. // 复用同一组参数;本方法不会回退到不带 credit_id 的旧消费方式。
  285. func (s *OpenAIQuotaService) ResetCreditTargeted(ctx context.Context, accountID int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) {
  286. creditID = strings.TrimSpace(creditID)
  287. redeemRequestID = strings.TrimSpace(redeemRequestID)
  288. if creditID == "" || redeemRequestID == "" {
  289. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_TARGETED_RESET_INVALID", "credit_id and redeem_request_id are required")
  290. }
  291. return s.resetCredit(ctx, accountID, creditID, redeemRequestID, true)
  292. }
  293. func (s *OpenAIQuotaService) resetCredit(ctx context.Context, accountID int64, creditID, redeemRequestID string, targeted bool) (*OpenAIQuotaResetResult, error) {
  294. // Shadow guard: resetting credits via a shadow account would silently
  295. // operate on the parent's quota; that is surprising and unwanted. Callers
  296. // must reset the parent account directly.
  297. //
  298. // Fail-closed: if the account cannot be loaded (transient DB error), we
  299. // must NOT fall through to prepareUpstreamCall. That function resolves a
  300. // shadow to its parent and would perform a parent-level reset — exactly
  301. // what this guard must prevent. Return the load error instead.
  302. if s.accountRepo != nil {
  303. acc, loadErr := s.accountRepo.GetByID(ctx, accountID)
  304. if loadErr != nil {
  305. return nil, infraerrors.Newf(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", loadErr)
  306. }
  307. if acc.IsShadow() {
  308. return nil, ErrSparkShadowResetNotSupported
  309. }
  310. }
  311. accessToken, chatGPTAccountID, proxyURL, fedRAMP, err := s.prepareUpstreamCall(ctx, accountID)
  312. if err != nil {
  313. return nil, err
  314. }
  315. client, err := s.privacyClientFactory(proxyURL)
  316. if err != nil {
  317. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_CLIENT_ERROR", "failed to build upstream client: %v", err)
  318. }
  319. callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout)
  320. defer cancel()
  321. agentIdentity := s.isAgentIdentityAccount(ctx, accountID)
  322. var payload OpenAIQuotaResetResult
  323. for recovered := false; ; {
  324. headers, expectedTaskID, headerErr := s.buildCodexQuotaHeaders(callCtx, accountID, accessToken, chatGPTAccountID, fedRAMP)
  325. if headerErr != nil {
  326. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "failed to build upstream authentication: %v", headerErr)
  327. }
  328. headers["content-type"] = "application/json"
  329. body := map[string]string{"redeem_request_id": redeemRequestID}
  330. if targeted {
  331. body["credit_id"] = creditID
  332. }
  333. resp, err := client.R().
  334. SetContext(callCtx).
  335. SetHeaders(headers).
  336. SetBody(body).
  337. SetSuccessResult(&payload).
  338. Post(chatGPTRateLimitResetURL)
  339. if err != nil {
  340. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_RESET_REQUEST_FAILED", "upstream request failed: %v", err)
  341. }
  342. if !resp.IsSuccessState() {
  343. if agentIdentity && !recovered && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, []byte(resp.String())) {
  344. recovered = true
  345. if err := s.recoverAgentIdentityTask(ctx, accountID, expectedTaskID); err != nil {
  346. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "agent identity task recovery failed: %v", err)
  347. }
  348. continue
  349. }
  350. status := resp.StatusCode
  351. if targeted {
  352. slog.Warn("openai_quota_targeted_reset_failed", "account_id", accountID, "status", status)
  353. return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d", status)
  354. }
  355. body := truncate(s.redactQuotaErrorBody(callCtx, accountID, resp.String()), 240)
  356. slog.Warn("openai_quota_reset_failed", "account_id", accountID, "status", status, "body", body)
  357. return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d: %s", status, body)
  358. }
  359. break
  360. }
  361. slog.Info("openai_quota_reset_success",
  362. "account_id", accountID,
  363. "code", payload.Code,
  364. "windows_reset", payload.WindowsReset,
  365. )
  366. return &payload, nil
  367. }
  368. // prepareUpstreamCall loads the account, validates it, obtains a fresh access
  369. // token via the shared TokenProvider, and resolves the chatgpt-account-id and
  370. // proxy URL. Centralized so QueryUsage / ResetCredit share validation.
  371. func (s *OpenAIQuotaService) prepareUpstreamCall(ctx context.Context, accountID int64) (accessToken, chatGPTAccountID, proxyURL string, fedRAMP bool, err error) {
  372. if s == nil || s.accountRepo == nil || s.privacyClientFactory == nil {
  373. return "", "", "", false, infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_NOT_CONFIGURED", "openai quota service is not configured")
  374. }
  375. account, err := s.accountRepo.GetByID(ctx, accountID)
  376. if err != nil {
  377. return "", "", "", false, infraerrors.Newf(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", err)
  378. }
  379. if account == nil {
  380. return "", "", "", false, infraerrors.New(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found")
  381. }
  382. if account.Platform != PlatformOpenAI {
  383. return "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_PLATFORM", "account is not an OpenAI account")
  384. }
  385. if account.Type != AccountTypeOAuth {
  386. return "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_INVALID_TYPE", "account is not an OAuth account")
  387. }
  388. // Spark shadow accounts do not hold their own credentials; resolve to the
  389. // parent account so that chatgpt_account_id / access_token / proxy all come
  390. // from the parent. This must happen BEFORE the chatgpt_account_id check.
  391. if account.IsShadow() {
  392. resolved, rerr := resolveCredentialAccount(ctx, s.accountRepo, account)
  393. if rerr != nil {
  394. return "", "", "", false, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_SHADOW_RESOLVE_FAILED", "failed to resolve shadow account: %v", rerr)
  395. }
  396. account = resolved
  397. }
  398. chatGPTAccountID = strings.TrimSpace(account.GetCredential("chatgpt_account_id"))
  399. if chatGPTAccountID == "" {
  400. // Fall back to organization_id — some legacy accounts only persisted poid.
  401. chatGPTAccountID = strings.TrimSpace(account.GetCredential("organization_id"))
  402. }
  403. if chatGPTAccountID == "" {
  404. return "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_MISSING_ACCOUNT_ID", "chatgpt_account_id is missing; please re-authorize this account")
  405. }
  406. if !account.IsOpenAIAgentIdentity() {
  407. if s.tokenProvider == nil {
  408. return "", "", "", false, infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_NOT_CONFIGURED", "openai quota token provider is not configured")
  409. }
  410. accessToken, err = s.tokenProvider.GetAccessToken(ctx, account)
  411. if err != nil {
  412. return "", "", "", false, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "failed to acquire access token: %v", err)
  413. }
  414. if strings.TrimSpace(accessToken) == "" {
  415. return "", "", "", false, infraerrors.New(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "access token is empty")
  416. }
  417. }
  418. fedRAMP = account.IsChatGPTAccountFedRAMP()
  419. // account.Proxy is eager-loaded by accountRepo.GetByID (see
  420. // repository.accountsToService), so we can read the proxy URL directly
  421. // instead of round-tripping the DB again. Fall back to proxyRepo only
  422. // when Proxy isn't pre-populated (defensive — e.g. callers that built
  423. // the Account by hand).
  424. if account.ProxyID != nil {
  425. switch {
  426. case account.Proxy != nil:
  427. proxyURL = account.Proxy.URL()
  428. case s.proxyRepo != nil:
  429. if proxy, perr := s.proxyRepo.GetByID(ctx, *account.ProxyID); perr == nil && proxy != nil {
  430. proxyURL = proxy.URL()
  431. }
  432. }
  433. }
  434. return accessToken, chatGPTAccountID, proxyURL, fedRAMP, nil
  435. }
  436. func (s *OpenAIQuotaService) recoverAgentIdentityTask(ctx context.Context, accountID int64, expectedTaskID string) error {
  437. if s == nil || s.accountRepo == nil {
  438. return fmt.Errorf("account repository is unavailable")
  439. }
  440. account, err := s.accountRepo.GetByID(ctx, accountID)
  441. if err != nil || account == nil {
  442. return fmt.Errorf("account is unavailable")
  443. }
  444. if account.IsShadow() {
  445. account, err = resolveCredentialAccount(ctx, s.accountRepo, account)
  446. if err != nil || account == nil {
  447. return fmt.Errorf("credential account is unavailable")
  448. }
  449. }
  450. if !account.IsOpenAIAgentIdentity() {
  451. return nil
  452. }
  453. return ensureAgentIdentityTaskForAccount(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, account, expectedTaskID)
  454. }
  455. func (s *OpenAIQuotaService) isAgentIdentityAccount(ctx context.Context, accountID int64) bool {
  456. if s == nil || s.accountRepo == nil {
  457. return false
  458. }
  459. account, err := s.accountRepo.GetByID(ctx, accountID)
  460. if err != nil || account == nil {
  461. return false
  462. }
  463. if account.IsShadow() {
  464. account, err = resolveCredentialAccount(ctx, s.accountRepo, account)
  465. if err != nil || account == nil {
  466. return false
  467. }
  468. }
  469. return account.IsOpenAIAgentIdentity()
  470. }
  471. func (s *OpenAIQuotaService) buildCodexQuotaHeaders(ctx context.Context, accountID int64, accessToken, chatGPTAccountID string, fedRAMP bool) (map[string]string, string, error) {
  472. headers := buildCodexCommonHeaders(accessToken, chatGPTAccountID, fedRAMP)
  473. if s == nil || s.accountRepo == nil {
  474. return headers, "", nil
  475. }
  476. account, err := s.accountRepo.GetByID(ctx, accountID)
  477. if err != nil || account == nil {
  478. if strings.TrimSpace(accessToken) == "" {
  479. return nil, "", fmt.Errorf("agent identity account credentials are unavailable")
  480. }
  481. return headers, "", nil
  482. }
  483. if account.IsShadow() {
  484. if resolved, resolveErr := resolveCredentialAccount(ctx, s.accountRepo, account); resolveErr == nil && resolved != nil {
  485. account = resolved
  486. } else if strings.TrimSpace(accessToken) == "" {
  487. return nil, "", fmt.Errorf("agent identity shadow credentials are unavailable")
  488. }
  489. }
  490. if !account.IsOpenAIAgentIdentity() {
  491. return headers, "", nil
  492. }
  493. if err := ensureAgentIdentityTaskForAccount(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, account, ""); err != nil {
  494. return nil, "", err
  495. }
  496. key, err := agentIdentityKeyFromAccount(account)
  497. if err != nil {
  498. return nil, "", err
  499. }
  500. assertion, err := buildAgentAssertion(key, time.Now())
  501. if err != nil {
  502. return nil, "", err
  503. }
  504. headers["authorization"] = assertion
  505. return headers, key.taskID, nil
  506. }
  507. func (s *OpenAIQuotaService) redactQuotaErrorBody(ctx context.Context, accountID int64, body string) string {
  508. if s == nil || s.accountRepo == nil {
  509. return body
  510. }
  511. account, err := s.accountRepo.GetByID(ctx, accountID)
  512. if err != nil || account == nil {
  513. return body
  514. }
  515. return string(redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, account, []byte(body)))
  516. }
  517. // buildCodexCommonHeaders sets the request headers expected by the chatgpt.com
  518. // backend so calls succeed past Cloudflare/WASM checks.
  519. func buildCodexCommonHeaders(accessToken, chatGPTAccountID string, fedRAMP bool) map[string]string {
  520. headers := map[string]string{
  521. "authorization": "Bearer " + accessToken,
  522. "chatgpt-account-id": chatGPTAccountID,
  523. "openai-beta": openaiQuotaCodexBeta,
  524. "oai-language": openaiQuotaCodexLanguageTag,
  525. "originator": openaiQuotaCodexOriginator,
  526. "accept": "application/json",
  527. "sec-fetch-site": openaiQuotaSecFetchSite,
  528. "sec-fetch-mode": openaiQuotaSecFetchMode,
  529. "sec-fetch-dest": openaiQuotaSecFetchDest,
  530. "priority": "u=4, i",
  531. }
  532. if fedRAMP {
  533. headers["x-openai-fedramp"] = "true"
  534. }
  535. return headers
  536. }
  537. // generateRedeemRequestID produces a UUID-v4-shaped string without pulling in a
  538. // new dependency. ChatGPT uses this as an idempotency key for the consume call.
  539. func generateRedeemRequestID() (string, error) {
  540. b := make([]byte, 16)
  541. if _, err := rand.Read(b); err != nil {
  542. return "", err
  543. }
  544. // Set version (4) and variant (RFC 4122) bits.
  545. b[6] = (b[6] & 0x0f) | 0x40
  546. b[8] = (b[8] & 0x3f) | 0x80
  547. hexStr := hex.EncodeToString(b)
  548. return fmt.Sprintf("%s-%s-%s-%s-%s", hexStr[0:8], hexStr[8:12], hexStr[12:16], hexStr[16:20], hexStr[20:]), nil
  549. }
  550. // buildCodexSparkWindowExtraUpdates extracts Codex Spark usage windows from the
  551. // /wham/usage response body's additional_rate_limits, matching the entry with
  552. // MeteredFeature == "codex_bengalfox". It produces plain codex_* keys (NOT the
  553. // Method-Z "codex_spark_" prefix) so that a spark shadow account's extra map
  554. // is populated with the same key names used by the scheduling / frontend layers.
  555. // Returns nil when no codex_bengalfox entry is present or when the RateLimit
  556. // yields no window data.
  557. func buildCodexSparkWindowExtraUpdates(usage *OpenAIQuotaUsage, now time.Time) map[string]any {
  558. if usage == nil {
  559. return nil
  560. }
  561. var spark *OpenAIRateLimit
  562. for i := range usage.AdditionalRateLimits {
  563. a := usage.AdditionalRateLimits[i]
  564. if a.MeteredFeature == "codex_bengalfox" {
  565. spark = a.RateLimit
  566. break
  567. }
  568. }
  569. if spark == nil {
  570. return nil
  571. }
  572. // Reuse OpenAICodexUsageSnapshot / Normalize to map primary/secondary windows
  573. // to canonical 5h/7d buckets (same logic as probeOpenAICodexSnapshot).
  574. snap := &OpenAICodexUsageSnapshot{}
  575. if w := spark.PrimaryWindow; w != nil {
  576. p := w.UsedPercent
  577. snap.PrimaryUsedPercent = &p
  578. ra := int(w.ResetAfterSeconds)
  579. snap.PrimaryResetAfterSeconds = &ra
  580. wm := int(w.LimitWindowSeconds / 60)
  581. snap.PrimaryWindowMinutes = &wm
  582. }
  583. if w := spark.SecondaryWindow; w != nil {
  584. p := w.UsedPercent
  585. snap.SecondaryUsedPercent = &p
  586. ra := int(w.ResetAfterSeconds)
  587. snap.SecondaryResetAfterSeconds = &ra
  588. wm := int(w.LimitWindowSeconds / 60)
  589. snap.SecondaryWindowMinutes = &wm
  590. }
  591. normalized := snap.Normalize()
  592. if normalized == nil {
  593. return nil
  594. }
  595. updates := make(map[string]any)
  596. if normalized.Used5hPercent != nil {
  597. updates["codex_5h_used_percent"] = *normalized.Used5hPercent
  598. }
  599. if normalized.Reset5hSeconds != nil {
  600. updates["codex_5h_reset_after_seconds"] = *normalized.Reset5hSeconds
  601. }
  602. if normalized.Window5hMinutes != nil {
  603. updates["codex_5h_window_minutes"] = *normalized.Window5hMinutes
  604. }
  605. if normalized.Used7dPercent != nil {
  606. updates["codex_7d_used_percent"] = *normalized.Used7dPercent
  607. }
  608. if normalized.Reset7dSeconds != nil {
  609. updates["codex_7d_reset_after_seconds"] = *normalized.Reset7dSeconds
  610. }
  611. if normalized.Window7dMinutes != nil {
  612. updates["codex_7d_window_minutes"] = *normalized.Window7dMinutes
  613. }
  614. if r := codexResetAtRFC3339(now, normalized.Reset5hSeconds); r != nil {
  615. updates["codex_5h_reset_at"] = *r
  616. }
  617. if r := codexResetAtRFC3339(now, normalized.Reset7dSeconds); r != nil {
  618. updates["codex_7d_reset_at"] = *r
  619. }
  620. if len(updates) == 0 {
  621. return nil
  622. }
  623. updates["codex_usage_updated_at"] = now.Format(time.RFC3339)
  624. return updates
  625. }
  626. // mapUpstreamStatus collapses upstream HTTP statuses into a stable set we
  627. // surface from the admin handler. 4xx upstream errors are surfaced as 502
  628. // (BadGateway) so callers can distinguish "your input is bad" (400) from
  629. // "upstream said no" (502); 401/403 are bubbled directly to hint at re-auth.
  630. func mapUpstreamStatus(status int) int {
  631. switch {
  632. case status == http.StatusUnauthorized || status == http.StatusForbidden:
  633. return status
  634. case status == http.StatusTooManyRequests:
  635. return http.StatusTooManyRequests
  636. case status >= 400 && status < 500:
  637. return http.StatusBadGateway
  638. case status >= 500:
  639. return http.StatusBadGateway
  640. default:
  641. return http.StatusBadGateway
  642. }
  643. }