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
654 lines
22 KiB
Go
654 lines
22 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// OpenAIOAuthHandler handles OpenAI OAuth-related operations
|
|
type OpenAIOAuthHandler struct {
|
|
openaiOAuthService *service.OpenAIOAuthService
|
|
adminService service.AdminService
|
|
quotaService openAIQuotaService
|
|
rateLimitService openAIAccountStateRecoverer
|
|
}
|
|
|
|
type openAIQuotaService interface {
|
|
QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error)
|
|
CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error
|
|
ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error)
|
|
}
|
|
|
|
type openAIAccountStateRecoverer interface {
|
|
RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error)
|
|
}
|
|
|
|
const (
|
|
openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed"
|
|
openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed"
|
|
openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed"
|
|
)
|
|
|
|
// openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the
|
|
// (non-refundable) reset credit has already been consumed upstream. The whole
|
|
// request must stay comfortably inside the panel HTTP client timeout, otherwise
|
|
// the browser aborts a mutation that already succeeded and the operator retries
|
|
// it — spending a second credit.
|
|
const openAIQuotaResetPostProcessTimeout = 8 * time.Second
|
|
|
|
type openAIQuotaResetResponse struct {
|
|
service.OpenAIQuotaResetResult
|
|
Quota *service.OpenAIQuotaUsage `json:"quota,omitempty"`
|
|
Account *dto.Account `json:"account,omitempty"`
|
|
CacheRefreshed bool `json:"cache_refreshed"`
|
|
AccountStateRecovered bool `json:"account_state_recovered"`
|
|
WarningCode string `json:"warning_code,omitempty"`
|
|
}
|
|
|
|
// openAIQuotaRefreshResponse is the reset-credit-persisting variant of the quota
|
|
// query. The usage payload is embedded so the shape stays identical to the plain
|
|
// query; cache_persisted reports whether the snapshot write succeeded, because a
|
|
// failed display-cache write must never discard a successful upstream read.
|
|
type openAIQuotaRefreshResponse struct {
|
|
service.OpenAIQuotaUsage
|
|
CachePersisted bool `json:"cache_persisted"`
|
|
}
|
|
|
|
// openAIQuotaResetPostProcessContext detaches the post-reset bookkeeping from the
|
|
// client connection. The credit is already spent at that point, so account-state
|
|
// recovery must complete even if the operator closes the tab (mirrors
|
|
// systemUpdateContext, added for the same reason in #4504).
|
|
func openAIQuotaResetPostProcessContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
base := context.Background()
|
|
if ctx != nil {
|
|
base = context.WithoutCancel(ctx)
|
|
}
|
|
return context.WithTimeout(base, openAIQuotaResetPostProcessTimeout)
|
|
}
|
|
|
|
func oauthPlatformFromPath(c *gin.Context) string {
|
|
return service.PlatformOpenAI
|
|
}
|
|
|
|
// NewOpenAIOAuthHandler creates a new OpenAI OAuth handler
|
|
func NewOpenAIOAuthHandler(
|
|
openaiOAuthService *service.OpenAIOAuthService,
|
|
adminService service.AdminService,
|
|
quotaService *service.OpenAIQuotaService,
|
|
rateLimitService *service.RateLimitService,
|
|
) *OpenAIOAuthHandler {
|
|
h := &OpenAIOAuthHandler{
|
|
openaiOAuthService: openaiOAuthService,
|
|
adminService: adminService,
|
|
}
|
|
// Assign through explicit nil checks: storing a nil *Service in an interface
|
|
// field yields a non-nil interface, which would silently defeat the
|
|
// `== nil` capability guards below and panic instead of returning 400.
|
|
if quotaService != nil {
|
|
h.quotaService = quotaService
|
|
}
|
|
if rateLimitService != nil {
|
|
h.rateLimitService = rateLimitService
|
|
}
|
|
return h
|
|
}
|
|
|
|
// OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL
|
|
type OpenAIGenerateAuthURLRequest struct {
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
}
|
|
|
|
// GenerateAuthURL generates OpenAI OAuth authorization URL
|
|
// POST /api/v1/admin/openai/generate-auth-url
|
|
func (h *OpenAIOAuthHandler) GenerateAuthURL(c *gin.Context) {
|
|
var req OpenAIGenerateAuthURLRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
// Allow empty body
|
|
req = OpenAIGenerateAuthURLRequest{}
|
|
}
|
|
|
|
result, err := h.openaiOAuthService.GenerateAuthURL(
|
|
c.Request.Context(),
|
|
req.ProxyID,
|
|
req.RedirectURI,
|
|
oauthPlatformFromPath(c),
|
|
)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, result)
|
|
}
|
|
|
|
// OpenAIExchangeCodeRequest represents the request for exchanging OpenAI auth code
|
|
type OpenAIExchangeCodeRequest struct {
|
|
SessionID string `json:"session_id" binding:"required"`
|
|
Code string `json:"code" binding:"required"`
|
|
State string `json:"state" binding:"required"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
}
|
|
|
|
// ExchangeCode exchanges OpenAI authorization code for tokens
|
|
// POST /api/v1/admin/openai/exchange-code
|
|
func (h *OpenAIOAuthHandler) ExchangeCode(c *gin.Context) {
|
|
var req OpenAIExchangeCodeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
|
|
tokenInfo, err := h.openaiOAuthService.ExchangeCode(c.Request.Context(), &service.OpenAIExchangeCodeInput{
|
|
SessionID: req.SessionID,
|
|
Code: req.Code,
|
|
State: req.State,
|
|
RedirectURI: req.RedirectURI,
|
|
ProxyID: req.ProxyID,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, tokenInfo)
|
|
}
|
|
|
|
// OpenAIRefreshTokenRequest represents the request for refreshing OpenAI token
|
|
type OpenAIRefreshTokenRequest struct {
|
|
RefreshToken string `json:"refresh_token"`
|
|
RT string `json:"rt"`
|
|
ClientID string `json:"client_id"`
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
}
|
|
|
|
type OpenAICodexPATCreateRequest struct {
|
|
AccessToken string `json:"access_token" binding:"required"`
|
|
Name string `json:"name"`
|
|
Notes *string `json:"notes"`
|
|
GroupIDs []int64 `json:"group_ids"`
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
Concurrency *int `json:"concurrency"`
|
|
Priority *int `json:"priority"`
|
|
RateMultiplier *float64 `json:"rate_multiplier"`
|
|
LoadFactor *int `json:"load_factor"`
|
|
ExpiresAt *int64 `json:"expires_at"`
|
|
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
|
CredentialExtras map[string]any `json:"credential_extras"`
|
|
Extra map[string]any `json:"extra"`
|
|
SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
|
|
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
|
|
}
|
|
|
|
// RefreshToken refreshes an OpenAI OAuth token
|
|
// POST /api/v1/admin/openai/refresh-token
|
|
func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) {
|
|
var req OpenAIRefreshTokenRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
refreshToken := strings.TrimSpace(req.RefreshToken)
|
|
if refreshToken == "" {
|
|
refreshToken = strings.TrimSpace(req.RT)
|
|
}
|
|
if refreshToken == "" {
|
|
response.BadRequest(c, "refresh_token is required")
|
|
return
|
|
}
|
|
|
|
var proxyURL string
|
|
if req.ProxyID != nil {
|
|
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
|
|
if err == nil && proxy != nil {
|
|
proxyURL = proxy.URL()
|
|
}
|
|
}
|
|
|
|
// 未指定 client_id 时,根据请求路径平台自动设置默认值,避免 repository 层盲猜
|
|
clientID := strings.TrimSpace(req.ClientID)
|
|
if clientID == "" {
|
|
platform := oauthPlatformFromPath(c)
|
|
clientID, _ = openai.OAuthClientConfigByPlatform(platform)
|
|
}
|
|
|
|
tokenInfo, err := h.openaiOAuthService.RefreshTokenWithClientID(c.Request.Context(), refreshToken, proxyURL, clientID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, tokenInfo)
|
|
}
|
|
|
|
// RefreshAccountToken refreshes token for a specific OpenAI account
|
|
// POST /api/v1/admin/openai/accounts/:id/refresh
|
|
func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid account ID")
|
|
return
|
|
}
|
|
|
|
// Get account
|
|
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
platform := oauthPlatformFromPath(c)
|
|
if account.Platform != platform {
|
|
response.BadRequest(c, "Account platform does not match OAuth endpoint")
|
|
return
|
|
}
|
|
|
|
// Only refresh OAuth-based accounts
|
|
if !account.IsOAuth() {
|
|
response.BadRequest(c, "Cannot refresh non-OAuth account credentials")
|
|
return
|
|
}
|
|
|
|
// spark 影子账号凭据透传母账号、自身恒空,刷新无意义;在调用上游前早拒,避免先打上游
|
|
// 再被凭据写守卫拦下的无谓副作用(外审第6轮)。
|
|
if account.IsCredentialShadow() {
|
|
response.BadRequest(c, "Cannot refresh spark shadow account; its credentials are managed by the parent account")
|
|
return
|
|
}
|
|
|
|
// Use OpenAI OAuth service to refresh token
|
|
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
// Build new credentials from token info
|
|
newCredentials := h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
|
|
|
// Preserve non-token settings from existing credentials
|
|
for k, v := range account.Credentials {
|
|
if _, exists := newCredentials[k]; !exists {
|
|
newCredentials[k] = v
|
|
}
|
|
}
|
|
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
|
|
|
|
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
|
Credentials: newCredentials,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, dto.AccountFromService(updatedAccount))
|
|
}
|
|
|
|
// CreateAccountFromOAuth creates a new OpenAI OAuth account from token info
|
|
// POST /api/v1/admin/openai/create-from-oauth
|
|
func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
|
var req struct {
|
|
SessionID string `json:"session_id" binding:"required"`
|
|
Code string `json:"code" binding:"required"`
|
|
State string `json:"state" binding:"required"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
Name string `json:"name"`
|
|
Concurrency int `json:"concurrency"`
|
|
Priority int `json:"priority"`
|
|
GroupIDs []int64 `json:"group_ids"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Exchange code for tokens
|
|
tokenInfo, err := h.openaiOAuthService.ExchangeCode(c.Request.Context(), &service.OpenAIExchangeCodeInput{
|
|
SessionID: req.SessionID,
|
|
Code: req.Code,
|
|
State: req.State,
|
|
RedirectURI: req.RedirectURI,
|
|
ProxyID: req.ProxyID,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
// Build credentials from token info
|
|
credentials := h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
|
|
|
platform := oauthPlatformFromPath(c)
|
|
|
|
// Use email as default name if not provided
|
|
name := req.Name
|
|
if name == "" && tokenInfo.Email != "" {
|
|
name = tokenInfo.Email
|
|
}
|
|
if name == "" {
|
|
name = "OpenAI OAuth Account"
|
|
}
|
|
|
|
// Create account
|
|
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
|
Name: name,
|
|
Platform: platform,
|
|
Type: "oauth",
|
|
Credentials: credentials,
|
|
Extra: nil,
|
|
ProxyID: req.ProxyID,
|
|
Concurrency: req.Concurrency,
|
|
Priority: req.Priority,
|
|
GroupIDs: req.GroupIDs,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, dto.AccountFromService(account))
|
|
}
|
|
|
|
// CreateAccountFromCodexPAT creates an OpenAI OAuth account from a Codex at-* personal access token.
|
|
// POST /api/v1/admin/openai/create-from-codex-pat
|
|
func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) {
|
|
var req OpenAICodexPATCreateRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
if req.Concurrency != nil && *req.Concurrency < 0 {
|
|
response.BadRequest(c, "concurrency must be >= 0")
|
|
return
|
|
}
|
|
if req.Priority != nil && *req.Priority < 0 {
|
|
response.BadRequest(c, "priority must be >= 0")
|
|
return
|
|
}
|
|
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
|
|
response.BadRequest(c, "rate_multiplier must be >= 0")
|
|
return
|
|
}
|
|
if req.LoadFactor != nil && *req.LoadFactor > 10000 {
|
|
response.BadRequest(c, "load_factor must be <= 10000")
|
|
return
|
|
}
|
|
|
|
var proxyURL string
|
|
if req.ProxyID != nil {
|
|
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
if proxy != nil {
|
|
proxyURL = proxy.URL()
|
|
}
|
|
}
|
|
|
|
tokenInfo, err := h.openaiOAuthService.ValidateCodexPersonalAccessToken(c.Request.Context(), req.AccessToken, proxyURL)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
credentials := mergeCodexImportMap(
|
|
h.openaiOAuthService.BuildAccountCredentials(tokenInfo),
|
|
sanitizeCodexImportCredentialExtras(req.CredentialExtras),
|
|
)
|
|
extra := mergeCodexImportMap(req.Extra, map[string]any{
|
|
"import_source": "codex_personal_access_token",
|
|
"auth_provider": "codex_personal_access_token",
|
|
"imported_at": time.Now().UTC().Format(time.RFC3339),
|
|
"access_token_sha256": codexTokenFingerprint(req.AccessToken),
|
|
})
|
|
|
|
concurrency := 3
|
|
if req.Concurrency != nil {
|
|
concurrency = *req.Concurrency
|
|
}
|
|
priority := 50
|
|
if req.Priority != nil {
|
|
priority = *req.Priority
|
|
}
|
|
skipDefaultGroupBind := false
|
|
if req.SkipDefaultGroupBind != nil {
|
|
skipDefaultGroupBind = *req.SkipDefaultGroupBind
|
|
}
|
|
|
|
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
|
Name: buildOpenAICodexPATAccountName(req.Name, tokenInfo),
|
|
Notes: req.Notes,
|
|
Platform: service.PlatformOpenAI,
|
|
Type: service.AccountTypeOAuth,
|
|
Credentials: credentials,
|
|
Extra: extra,
|
|
ProxyID: req.ProxyID,
|
|
Concurrency: concurrency,
|
|
Priority: priority,
|
|
RateMultiplier: req.RateMultiplier,
|
|
LoadFactor: req.LoadFactor,
|
|
GroupIDs: req.GroupIDs,
|
|
ExpiresAt: req.ExpiresAt,
|
|
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
|
SkipDefaultGroupBind: skipDefaultGroupBind,
|
|
SkipMixedChannelCheck: req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, dto.AccountFromService(account))
|
|
}
|
|
|
|
func buildOpenAICodexPATAccountName(name string, tokenInfo *service.OpenAITokenInfo) string {
|
|
name = strings.TrimSpace(name)
|
|
if name != "" {
|
|
return name
|
|
}
|
|
if tokenInfo != nil {
|
|
for _, candidate := range []string{tokenInfo.Email, tokenInfo.ChatGPTAccountID, tokenInfo.ChatGPTUserID} {
|
|
if candidate = strings.TrimSpace(candidate); candidate != "" {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
return "Codex PAT Account"
|
|
}
|
|
|
|
// QueryQuota queries the rate-limit / quota usage for an OpenAI account.
|
|
// GET /api/v1/admin/openai/accounts/:id/quota
|
|
func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid account ID")
|
|
return
|
|
}
|
|
if h.quotaService == nil {
|
|
response.BadRequest(c, "openai quota service is not enabled")
|
|
return
|
|
}
|
|
|
|
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, usage)
|
|
}
|
|
|
|
// RefreshQuota queries the rate-limit / quota usage AND persists the reset-credit
|
|
// snapshot so the card can be rehydrated without an upstream round-trip.
|
|
// POST /api/v1/admin/openai/accounts/:id/quota/refresh
|
|
//
|
|
// It is a POST (not a GET with a side-effect flag) because it writes account
|
|
// state: the audit middleware only records mutating verbs, so a persisting GET
|
|
// would mutate the database without an audit trail.
|
|
func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) {
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid account ID")
|
|
return
|
|
}
|
|
if h.quotaService == nil {
|
|
response.BadRequest(c, "openai quota service is not enabled")
|
|
return
|
|
}
|
|
|
|
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
if usage == nil {
|
|
response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result")
|
|
return
|
|
}
|
|
|
|
refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage}
|
|
// A failed snapshot write leaves the previous cache intact — report it as a
|
|
// partial success instead of discarding the usage payload we just fetched,
|
|
// which would leave the card without a credit count at all.
|
|
if err := h.quotaService.CacheResetCreditsSnapshot(c.Request.Context(), accountID, usage.RateLimitResetCredits); err != nil {
|
|
slog.Warn("openai_quota_reset_credit_cache_persist_failed", "account_id", accountID, "error", err)
|
|
response.Success(c, refreshResponse)
|
|
return
|
|
}
|
|
refreshResponse.CachePersisted = true
|
|
response.Success(c, refreshResponse)
|
|
}
|
|
|
|
// CreateShadowRequest is the request body for CreateShadow.
|
|
type CreateShadowRequest struct {
|
|
Name string `json:"name"`
|
|
Priority int `json:"priority"`
|
|
Concurrency int `json:"concurrency"`
|
|
GroupIDs []int64 `json:"group_ids"`
|
|
}
|
|
|
|
// CreateShadow creates a spark-dimension shadow account for a parent OpenAI OAuth account.
|
|
// POST /api/v1/admin/accounts/:id/shadow
|
|
func (h *OpenAIOAuthHandler) CreateShadow(c *gin.Context) {
|
|
parentID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid account ID")
|
|
return
|
|
}
|
|
|
|
var req CreateShadowRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
|
|
shadow, err := h.adminService.CreateShadow(c.Request.Context(), parentID, service.ShadowOptions{
|
|
Name: req.Name,
|
|
Priority: req.Priority,
|
|
Concurrency: req.Concurrency,
|
|
GroupIDs: req.GroupIDs,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, dto.AccountFromServiceShallow(shadow))
|
|
}
|
|
|
|
// ResetQuota consumes one rate-limit reset credit for an OpenAI account.
|
|
// POST /api/v1/admin/openai/accounts/:id/reset-quota
|
|
func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid account ID")
|
|
return
|
|
}
|
|
if h.quotaService == nil {
|
|
response.BadRequest(c, "openai quota service is not enabled")
|
|
return
|
|
}
|
|
result, err := h.quotaService.ResetCredit(c.Request.Context(), accountID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
if result == nil {
|
|
response.Error(c, http.StatusInternalServerError, "openai quota reset returned an empty result")
|
|
return
|
|
}
|
|
|
|
resetResponse := openAIQuotaResetResponse{OpenAIQuotaResetResult: *result}
|
|
postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context())
|
|
defer cancelPost()
|
|
|
|
// Step 1 — unblocking the account is the whole point of consuming a credit
|
|
// (#3672 / #3740), so it runs FIRST and is never gated on the display cache.
|
|
// Recovery is DB-only and leaves the manual `schedulable` switch untouched.
|
|
if h.rateLimitService == nil {
|
|
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
|
|
response.Success(c, resetResponse)
|
|
return
|
|
}
|
|
if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{
|
|
InvalidateToken: true,
|
|
}); err != nil {
|
|
// Recovery failures are almost always storage-level; the remaining steps
|
|
// share that dependency, so stop here instead of compounding the failure.
|
|
slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err)
|
|
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
|
|
response.Success(c, resetResponse)
|
|
return
|
|
}
|
|
resetResponse.AccountStateRecovered = true
|
|
|
|
// Step 2 — refresh the reset-credit display cache. A failure here is reported
|
|
// but must not hide the recovered account row produced by step 3.
|
|
usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID)
|
|
switch {
|
|
case usageErr != nil || usage == nil:
|
|
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr)
|
|
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
|
|
default:
|
|
if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil {
|
|
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err)
|
|
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
|
|
} else {
|
|
resetResponse.Quota = usage
|
|
resetResponse.CacheRefreshed = true
|
|
}
|
|
}
|
|
|
|
// Step 3 — hand back the post-recovery account row so the list drops the
|
|
// stale rate-limit badge without waiting for the next poll.
|
|
account, err := h.adminService.GetAccount(postCtx, accountID)
|
|
if err != nil {
|
|
slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err)
|
|
if resetResponse.WarningCode == "" {
|
|
resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed
|
|
}
|
|
response.Success(c, resetResponse)
|
|
return
|
|
}
|
|
resetResponse.Account = dto.AccountFromService(account)
|
|
response.Success(c, resetResponse)
|
|
}
|