openai_oauth_handler.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  1. package admin
  2. import (
  3. "context"
  4. "log/slog"
  5. "net/http"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/handler/dto"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/gin-gonic/gin"
  14. )
  15. // OpenAIOAuthHandler handles OpenAI OAuth-related operations
  16. type OpenAIOAuthHandler struct {
  17. openaiOAuthService *service.OpenAIOAuthService
  18. adminService service.AdminService
  19. quotaService openAIQuotaService
  20. rateLimitService openAIAccountStateRecoverer
  21. }
  22. type openAIQuotaService interface {
  23. QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error)
  24. CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error
  25. CachePostResetSnapshot(ctx context.Context, accountID int64, usage *service.OpenAIQuotaUsage) error
  26. ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error)
  27. }
  28. type openAIAccountStateRecoverer interface {
  29. RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error)
  30. }
  31. // openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the
  32. // (non-refundable) reset credit has already been consumed upstream. The whole
  33. // request must stay comfortably inside the panel HTTP client timeout, otherwise
  34. // the browser aborts a mutation that already succeeded and the operator retries
  35. // it — spending a second credit.
  36. const openAIQuotaResetPostProcessTimeout = 8 * time.Second
  37. type openAIQuotaResetResponse struct {
  38. service.OpenAIQuotaResetResult
  39. Quota *service.OpenAIQuotaUsage `json:"quota,omitempty"`
  40. Account *dto.Account `json:"account,omitempty"`
  41. CacheRefreshed bool `json:"cache_refreshed"`
  42. AccountStateRecovered bool `json:"account_state_recovered"`
  43. WarningCode string `json:"warning_code,omitempty"`
  44. }
  45. // openAIQuotaRefreshResponse is the reset-credit-persisting variant of the quota
  46. // query. The usage payload is embedded so the shape stays identical to the plain
  47. // query; cache_persisted reports whether the snapshot write succeeded, because a
  48. // failed display-cache write must never discard a successful upstream read.
  49. type openAIQuotaRefreshResponse struct {
  50. service.OpenAIQuotaUsage
  51. CachePersisted bool `json:"cache_persisted"`
  52. }
  53. // openAIQuotaResetPostProcessContext detaches the post-reset bookkeeping from the
  54. // client connection. The credit is already spent at that point, so account-state
  55. // recovery must complete even if the operator closes the tab (mirrors
  56. // systemUpdateContext, added for the same reason in #4504).
  57. func openAIQuotaResetPostProcessContext(ctx context.Context) (context.Context, context.CancelFunc) {
  58. base := context.Background()
  59. if ctx != nil {
  60. base = context.WithoutCancel(ctx)
  61. }
  62. return context.WithTimeout(base, openAIQuotaResetPostProcessTimeout)
  63. }
  64. func oauthPlatformFromPath(c *gin.Context) string {
  65. return service.PlatformOpenAI
  66. }
  67. // NewOpenAIOAuthHandler creates a new OpenAI OAuth handler
  68. func NewOpenAIOAuthHandler(
  69. openaiOAuthService *service.OpenAIOAuthService,
  70. adminService service.AdminService,
  71. quotaService *service.OpenAIQuotaService,
  72. rateLimitService *service.RateLimitService,
  73. ) *OpenAIOAuthHandler {
  74. h := &OpenAIOAuthHandler{
  75. openaiOAuthService: openaiOAuthService,
  76. adminService: adminService,
  77. }
  78. // Assign through explicit nil checks: storing a nil *Service in an interface
  79. // field yields a non-nil interface, which would silently defeat the
  80. // `== nil` capability guards below and panic instead of returning 400.
  81. if quotaService != nil {
  82. h.quotaService = quotaService
  83. }
  84. if rateLimitService != nil {
  85. h.rateLimitService = rateLimitService
  86. }
  87. return h
  88. }
  89. // OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL
  90. type OpenAIGenerateAuthURLRequest struct {
  91. ProxyID *int64 `json:"proxy_id"`
  92. RedirectURI string `json:"redirect_uri"`
  93. }
  94. // GenerateAuthURL generates OpenAI OAuth authorization URL
  95. // POST /api/v1/admin/openai/generate-auth-url
  96. func (h *OpenAIOAuthHandler) GenerateAuthURL(c *gin.Context) {
  97. var req OpenAIGenerateAuthURLRequest
  98. if err := c.ShouldBindJSON(&req); err != nil {
  99. // Allow empty body
  100. req = OpenAIGenerateAuthURLRequest{}
  101. }
  102. result, err := h.openaiOAuthService.GenerateAuthURL(
  103. c.Request.Context(),
  104. req.ProxyID,
  105. req.RedirectURI,
  106. oauthPlatformFromPath(c),
  107. )
  108. if err != nil {
  109. response.ErrorFrom(c, err)
  110. return
  111. }
  112. response.Success(c, result)
  113. }
  114. // OpenAIExchangeCodeRequest represents the request for exchanging OpenAI auth code
  115. type OpenAIExchangeCodeRequest struct {
  116. SessionID string `json:"session_id" binding:"required"`
  117. Code string `json:"code" binding:"required"`
  118. State string `json:"state" binding:"required"`
  119. RedirectURI string `json:"redirect_uri"`
  120. ProxyID *int64 `json:"proxy_id"`
  121. }
  122. // ExchangeCode exchanges OpenAI authorization code for tokens
  123. // POST /api/v1/admin/openai/exchange-code
  124. func (h *OpenAIOAuthHandler) ExchangeCode(c *gin.Context) {
  125. var req OpenAIExchangeCodeRequest
  126. if err := c.ShouldBindJSON(&req); err != nil {
  127. response.BadRequest(c, "Invalid request: "+err.Error())
  128. return
  129. }
  130. tokenInfo, err := h.openaiOAuthService.ExchangeCode(c.Request.Context(), &service.OpenAIExchangeCodeInput{
  131. SessionID: req.SessionID,
  132. Code: req.Code,
  133. State: req.State,
  134. RedirectURI: req.RedirectURI,
  135. ProxyID: req.ProxyID,
  136. })
  137. if err != nil {
  138. response.ErrorFrom(c, err)
  139. return
  140. }
  141. response.Success(c, tokenInfo)
  142. }
  143. // OpenAIRefreshTokenRequest represents the request for refreshing OpenAI token
  144. type OpenAIRefreshTokenRequest struct {
  145. RefreshToken string `json:"refresh_token"`
  146. RT string `json:"rt"`
  147. ClientID string `json:"client_id"`
  148. ProxyID *int64 `json:"proxy_id"`
  149. }
  150. type OpenAICodexPATCreateRequest struct {
  151. AccessToken string `json:"access_token" binding:"required"`
  152. Name string `json:"name"`
  153. Notes *string `json:"notes"`
  154. GroupIDs []int64 `json:"group_ids"`
  155. ProxyID *int64 `json:"proxy_id"`
  156. Concurrency *int `json:"concurrency"`
  157. Priority *int `json:"priority"`
  158. RateMultiplier *float64 `json:"rate_multiplier"`
  159. LoadFactor *int `json:"load_factor"`
  160. ExpiresAt *int64 `json:"expires_at"`
  161. AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
  162. CredentialExtras map[string]any `json:"credential_extras"`
  163. Extra map[string]any `json:"extra"`
  164. SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
  165. ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
  166. }
  167. // RefreshToken refreshes an OpenAI OAuth token
  168. // POST /api/v1/admin/openai/refresh-token
  169. func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) {
  170. var req OpenAIRefreshTokenRequest
  171. if err := c.ShouldBindJSON(&req); err != nil {
  172. response.BadRequest(c, "Invalid request: "+err.Error())
  173. return
  174. }
  175. refreshToken := strings.TrimSpace(req.RefreshToken)
  176. if refreshToken == "" {
  177. refreshToken = strings.TrimSpace(req.RT)
  178. }
  179. if refreshToken == "" {
  180. response.BadRequest(c, "refresh_token is required")
  181. return
  182. }
  183. var proxyURL string
  184. if req.ProxyID != nil {
  185. proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
  186. if err == nil && proxy != nil {
  187. proxyURL = proxy.URL()
  188. }
  189. }
  190. // 未指定 client_id 时,根据请求路径平台自动设置默认值,避免 repository 层盲猜
  191. clientID := strings.TrimSpace(req.ClientID)
  192. if clientID == "" {
  193. platform := oauthPlatformFromPath(c)
  194. clientID, _ = openai.OAuthClientConfigByPlatform(platform)
  195. }
  196. tokenInfo, err := h.openaiOAuthService.RefreshTokenWithClientID(c.Request.Context(), refreshToken, proxyURL, clientID)
  197. if err != nil {
  198. response.ErrorFrom(c, err)
  199. return
  200. }
  201. response.Success(c, tokenInfo)
  202. }
  203. // RefreshAccountToken refreshes token for a specific OpenAI account
  204. // POST /api/v1/admin/openai/accounts/:id/refresh
  205. func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
  206. accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  207. if err != nil {
  208. response.BadRequest(c, "Invalid account ID")
  209. return
  210. }
  211. // Get account
  212. account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
  213. if err != nil {
  214. response.ErrorFrom(c, err)
  215. return
  216. }
  217. platform := oauthPlatformFromPath(c)
  218. if account.Platform != platform {
  219. response.BadRequest(c, "Account platform does not match OAuth endpoint")
  220. return
  221. }
  222. // Only refresh OAuth-based accounts
  223. if !account.IsOAuth() {
  224. response.BadRequest(c, "Cannot refresh non-OAuth account credentials")
  225. return
  226. }
  227. // spark 影子账号凭据透传母账号、自身恒空,刷新无意义;在调用上游前早拒,避免先打上游
  228. // 再被凭据写守卫拦下的无谓副作用(外审第6轮)。
  229. if account.IsCredentialShadow() {
  230. response.BadRequest(c, "Cannot refresh spark shadow account; its credentials are managed by the parent account")
  231. return
  232. }
  233. // Use OpenAI OAuth service to refresh token
  234. tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account)
  235. if err != nil {
  236. response.ErrorFrom(c, err)
  237. return
  238. }
  239. // Build new credentials from token info
  240. newCredentials := h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
  241. // Preserve non-token settings from existing credentials
  242. for k, v := range account.Credentials {
  243. if _, exists := newCredentials[k]; !exists {
  244. newCredentials[k] = v
  245. }
  246. }
  247. newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
  248. updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
  249. Credentials: newCredentials,
  250. })
  251. if err != nil {
  252. response.ErrorFrom(c, err)
  253. return
  254. }
  255. response.Success(c, dto.AccountFromService(updatedAccount))
  256. }
  257. // CreateAccountFromOAuth creates a new OpenAI OAuth account from token info
  258. // POST /api/v1/admin/openai/create-from-oauth
  259. func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
  260. var req struct {
  261. SessionID string `json:"session_id" binding:"required"`
  262. Code string `json:"code" binding:"required"`
  263. State string `json:"state" binding:"required"`
  264. RedirectURI string `json:"redirect_uri"`
  265. ProxyID *int64 `json:"proxy_id"`
  266. Name string `json:"name"`
  267. Concurrency int `json:"concurrency"`
  268. Priority int `json:"priority"`
  269. GroupIDs []int64 `json:"group_ids"`
  270. }
  271. if err := c.ShouldBindJSON(&req); err != nil {
  272. response.BadRequest(c, "Invalid request: "+err.Error())
  273. return
  274. }
  275. // Exchange code for tokens
  276. tokenInfo, err := h.openaiOAuthService.ExchangeCode(c.Request.Context(), &service.OpenAIExchangeCodeInput{
  277. SessionID: req.SessionID,
  278. Code: req.Code,
  279. State: req.State,
  280. RedirectURI: req.RedirectURI,
  281. ProxyID: req.ProxyID,
  282. })
  283. if err != nil {
  284. response.ErrorFrom(c, err)
  285. return
  286. }
  287. // Build credentials from token info
  288. credentials := h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
  289. platform := oauthPlatformFromPath(c)
  290. // Use email as default name if not provided
  291. name := req.Name
  292. if name == "" && tokenInfo.Email != "" {
  293. name = tokenInfo.Email
  294. }
  295. if name == "" {
  296. name = "OpenAI OAuth Account"
  297. }
  298. // Create account
  299. account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
  300. Name: name,
  301. Platform: platform,
  302. Type: "oauth",
  303. Credentials: credentials,
  304. Extra: nil,
  305. ProxyID: req.ProxyID,
  306. Concurrency: req.Concurrency,
  307. Priority: req.Priority,
  308. GroupIDs: req.GroupIDs,
  309. })
  310. if err != nil {
  311. response.ErrorFrom(c, err)
  312. return
  313. }
  314. response.Success(c, dto.AccountFromService(account))
  315. }
  316. // CreateAccountFromCodexPAT creates an OpenAI OAuth account from a Codex at-* personal access token.
  317. // POST /api/v1/admin/openai/create-from-codex-pat
  318. func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) {
  319. var req OpenAICodexPATCreateRequest
  320. if err := c.ShouldBindJSON(&req); err != nil {
  321. response.BadRequest(c, "Invalid request: "+err.Error())
  322. return
  323. }
  324. if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil {
  325. response.ErrorFrom(c, err)
  326. return
  327. }
  328. if req.Concurrency != nil && *req.Concurrency < 0 {
  329. response.BadRequest(c, "concurrency must be >= 0")
  330. return
  331. }
  332. if req.Priority != nil && *req.Priority < 0 {
  333. response.BadRequest(c, "priority must be >= 0")
  334. return
  335. }
  336. if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
  337. response.BadRequest(c, "rate_multiplier must be >= 0")
  338. return
  339. }
  340. if req.LoadFactor != nil && *req.LoadFactor > 10000 {
  341. response.BadRequest(c, "load_factor must be <= 10000")
  342. return
  343. }
  344. var proxyURL string
  345. if req.ProxyID != nil {
  346. proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
  347. if err != nil {
  348. response.ErrorFrom(c, err)
  349. return
  350. }
  351. if proxy != nil {
  352. proxyURL = proxy.URL()
  353. }
  354. }
  355. tokenInfo, err := h.openaiOAuthService.ValidateCodexPersonalAccessToken(c.Request.Context(), req.AccessToken, proxyURL)
  356. if err != nil {
  357. response.ErrorFrom(c, err)
  358. return
  359. }
  360. credentials := mergeCodexImportMap(
  361. h.openaiOAuthService.BuildAccountCredentials(tokenInfo),
  362. sanitizeCodexImportCredentialExtras(req.CredentialExtras),
  363. )
  364. extra := mergeCodexImportMap(req.Extra, map[string]any{
  365. "import_source": "codex_personal_access_token",
  366. "auth_provider": "codex_personal_access_token",
  367. "imported_at": time.Now().UTC().Format(time.RFC3339),
  368. "access_token_sha256": codexTokenFingerprint(req.AccessToken),
  369. })
  370. concurrency := 3
  371. if req.Concurrency != nil {
  372. concurrency = *req.Concurrency
  373. }
  374. priority := 50
  375. if req.Priority != nil {
  376. priority = *req.Priority
  377. }
  378. skipDefaultGroupBind := false
  379. if req.SkipDefaultGroupBind != nil {
  380. skipDefaultGroupBind = *req.SkipDefaultGroupBind
  381. }
  382. account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
  383. Name: buildOpenAICodexPATAccountName(req.Name, tokenInfo),
  384. Notes: req.Notes,
  385. Platform: service.PlatformOpenAI,
  386. Type: service.AccountTypeOAuth,
  387. Credentials: credentials,
  388. Extra: extra,
  389. ProxyID: req.ProxyID,
  390. Concurrency: concurrency,
  391. Priority: priority,
  392. RateMultiplier: req.RateMultiplier,
  393. LoadFactor: req.LoadFactor,
  394. GroupIDs: req.GroupIDs,
  395. ExpiresAt: req.ExpiresAt,
  396. AutoPauseOnExpired: req.AutoPauseOnExpired,
  397. SkipDefaultGroupBind: skipDefaultGroupBind,
  398. SkipMixedChannelCheck: req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk,
  399. })
  400. if err != nil {
  401. response.ErrorFrom(c, err)
  402. return
  403. }
  404. response.Success(c, dto.AccountFromService(account))
  405. }
  406. func buildOpenAICodexPATAccountName(name string, tokenInfo *service.OpenAITokenInfo) string {
  407. name = strings.TrimSpace(name)
  408. if name != "" {
  409. return name
  410. }
  411. if tokenInfo != nil {
  412. for _, candidate := range []string{tokenInfo.Email, tokenInfo.ChatGPTAccountID, tokenInfo.ChatGPTUserID} {
  413. if candidate = strings.TrimSpace(candidate); candidate != "" {
  414. return candidate
  415. }
  416. }
  417. }
  418. return "Codex PAT Account"
  419. }
  420. // QueryQuota queries the rate-limit / quota usage for an OpenAI account.
  421. // GET /api/v1/admin/openai/accounts/:id/quota
  422. func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
  423. accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  424. if err != nil {
  425. response.BadRequest(c, "Invalid account ID")
  426. return
  427. }
  428. if h.quotaService == nil {
  429. response.BadRequest(c, "openai quota service is not enabled")
  430. return
  431. }
  432. usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
  433. if err != nil {
  434. response.ErrorFrom(c, err)
  435. return
  436. }
  437. service.NotifyOpenAIAutoResetCredit(accountID)
  438. response.Success(c, usage)
  439. }
  440. // RefreshQuota queries the rate-limit / quota usage AND persists the reset-credit
  441. // snapshot so the card can be rehydrated without an upstream round-trip.
  442. // POST /api/v1/admin/openai/accounts/:id/quota/refresh
  443. //
  444. // It is a POST (not a GET with a side-effect flag) because it writes account
  445. // state: the audit middleware only records mutating verbs, so a persisting GET
  446. // would mutate the database without an audit trail.
  447. func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) {
  448. accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  449. if err != nil {
  450. response.BadRequest(c, "Invalid account ID")
  451. return
  452. }
  453. if h.quotaService == nil {
  454. response.BadRequest(c, "openai quota service is not enabled")
  455. return
  456. }
  457. usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
  458. if err != nil {
  459. response.ErrorFrom(c, err)
  460. return
  461. }
  462. if usage == nil {
  463. response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result")
  464. return
  465. }
  466. service.NotifyOpenAIAutoResetCredit(accountID)
  467. refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage}
  468. // A failed snapshot write leaves the previous cache intact — report it as a
  469. // partial success instead of discarding the usage payload we just fetched,
  470. // which would leave the card without a credit count at all.
  471. if err := h.quotaService.CacheResetCreditsSnapshot(c.Request.Context(), accountID, usage.RateLimitResetCredits); err != nil {
  472. slog.Warn("openai_quota_reset_credit_cache_persist_failed", "account_id", accountID, "error", err)
  473. response.Success(c, refreshResponse)
  474. return
  475. }
  476. refreshResponse.CachePersisted = true
  477. response.Success(c, refreshResponse)
  478. }
  479. // CreateShadowRequest is the request body for CreateShadow.
  480. type CreateShadowRequest struct {
  481. Name string `json:"name"`
  482. Priority int `json:"priority"`
  483. Concurrency int `json:"concurrency"`
  484. GroupIDs []int64 `json:"group_ids"`
  485. }
  486. // CreateShadow creates a spark-dimension shadow account for a parent OpenAI OAuth account.
  487. // POST /api/v1/admin/accounts/:id/shadow
  488. func (h *OpenAIOAuthHandler) CreateShadow(c *gin.Context) {
  489. parentID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  490. if err != nil {
  491. response.BadRequest(c, "Invalid account ID")
  492. return
  493. }
  494. var req CreateShadowRequest
  495. if err := c.ShouldBindJSON(&req); err != nil {
  496. response.BadRequest(c, "Invalid request: "+err.Error())
  497. return
  498. }
  499. shadow, err := h.adminService.CreateShadow(c.Request.Context(), parentID, service.ShadowOptions{
  500. Name: req.Name,
  501. Priority: req.Priority,
  502. Concurrency: req.Concurrency,
  503. GroupIDs: req.GroupIDs,
  504. })
  505. if err != nil {
  506. response.ErrorFrom(c, err)
  507. return
  508. }
  509. response.Success(c, dto.AccountFromServiceShallow(shadow))
  510. }
  511. // ResetQuota consumes one rate-limit reset credit for an OpenAI account.
  512. // POST /api/v1/admin/openai/accounts/:id/reset-quota
  513. func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
  514. accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  515. if err != nil {
  516. response.BadRequest(c, "Invalid account ID")
  517. return
  518. }
  519. if h.quotaService == nil {
  520. response.BadRequest(c, "openai quota service is not enabled")
  521. return
  522. }
  523. result, err := h.quotaService.ResetCredit(c.Request.Context(), accountID)
  524. if err != nil {
  525. response.ErrorFrom(c, err)
  526. return
  527. }
  528. if result == nil {
  529. response.Error(c, http.StatusInternalServerError, "openai quota reset returned an empty result")
  530. return
  531. }
  532. resetResponse := openAIQuotaResetResponse{OpenAIQuotaResetResult: *result}
  533. postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context())
  534. defer cancelPost()
  535. postResult := service.RunOpenAIQuotaResetPostProcess(
  536. postCtx,
  537. accountID,
  538. h.quotaService,
  539. h.rateLimitService,
  540. h.adminService.GetAccount,
  541. )
  542. resetResponse.Quota = postResult.Quota
  543. resetResponse.CacheRefreshed = postResult.CacheRefreshed
  544. resetResponse.AccountStateRecovered = postResult.AccountStateRecovered
  545. resetResponse.WarningCode = postResult.WarningCode
  546. if postResult.Account != nil {
  547. resetResponse.Account = dto.AccountFromService(postResult.Account)
  548. }
  549. response.Success(c, resetResponse)
  550. }