From c53f754f758a166cfc65e87d3afb9ff9ed7d6801 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Sun, 17 May 2026 16:34:10 +0800 Subject: [PATCH 01/37] fix(oauth): fall back to system proxy when OAuth session has no proxy_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When adding accounts via OAuth flow, if no proxy_url is provided in either the generate-auth-url step or the exchange-code step, the request to auth.openai.com goes direct — which fails from geo-blocked regions (HK, etc.) with 403 or Cloudflare 502. Add a fallback to `h.store.GetProxyURL()` (the system default proxy) in both `ExchangeOAuthCode` and `OAuthCallback` handlers, so OAuth token exchange always goes through a working proxy chain. Fixes the issue where the admin UI's "Add Account via OAuth" button returns a Cloudflare 502 HTML page instead of a proper error. Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/oauth.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/admin/oauth.go b/admin/oauth.go index c5cf25e0..4df3532b 100644 --- a/admin/oauth.go +++ b/admin/oauth.go @@ -224,6 +224,9 @@ func (h *Handler) ExchangeOAuthCode(c *gin.Context) { if trimmed := strings.TrimSpace(req.ProxyURL); trimmed != "" { proxyURL = trimmed } + if proxyURL == "" { + proxyURL = h.store.GetProxyURL() + } // Resin 临时身份用于 OAuth 兑换(新账号尚无 DBID) resinTempID := "oauth-" + req.SessionID @@ -391,8 +394,12 @@ func (h *Handler) OAuthCallback(c *gin.Context) { sess.CallbackAt = time.Now() // 执行 code exchange(Resin 临时身份) + proxyURL := sess.ProxyURL + if proxyURL == "" { + proxyURL = h.store.GetProxyURL() + } resinTempID := "oauth-" + sessionID - tokenResp, accountInfo, err := doOAuthCodeExchange(c.Request.Context(), code, sess.CodeVerifier, sess.RedirectURI, sess.ProxyURL, resinTempID) + tokenResp, accountInfo, err := doOAuthCodeExchange(c.Request.Context(), code, sess.CodeVerifier, sess.RedirectURI, proxyURL, resinTempID) if err != nil { sess.ExchangeResult = &oauthExchangeResult{ Success: false, @@ -429,7 +436,7 @@ func (h *Handler) OAuthCallback(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() - id, err := h.db.InsertAccount(ctx, name, tokenResp.RefreshToken, sess.ProxyURL) + id, err := h.db.InsertAccount(ctx, name, tokenResp.RefreshToken, proxyURL) if err != nil { sess.ExchangeResult = &oauthExchangeResult{ Success: false, @@ -453,7 +460,7 @@ func (h *Handler) OAuthCallback(c *gin.Context) { go proxy.InheritLease(resinTempID, fmt.Sprintf("%d", id)) } - newAcc := accountFromCredentialSeed(id, sess.ProxyURL, seed) + newAcc := accountFromCredentialSeed(id, proxyURL, seed) h.store.AddAccount(newAcc) email := "" From bdb6c19dfba7e98ef7cdf5d6b5ffcfccd437b376 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Tue, 19 May 2026 22:49:22 +0800 Subject: [PATCH 02/37] fix(billing): correct GPT-5.5 pricing to $5.00/$30.00/M standard --- database/billing.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/database/billing.go b/database/billing.go index d2a9c557..47a778fd 100644 --- a/database/billing.go +++ b/database/billing.go @@ -34,12 +34,12 @@ var ( modelPricingRules = []modelPricingRule{ // Codex/GPT-5 系列,参考 sub2api 的动态定价优先、fallback 兜底策略。 {model: "gpt-5.5", pricing: ModelPricing{ - InputPricePerMToken: 2.5, - InputPricePerMTokenPriority: 5.0, - OutputPricePerMToken: 15.0, - OutputPricePerMTokenPriority: 30.0, - CacheReadPricePerMToken: 0.25, - CacheReadPricePerMTokenPriority: 0.5, + InputPricePerMToken: 5.0, + InputPricePerMTokenPriority: 12.5, + OutputPricePerMToken: 30.0, + OutputPricePerMTokenPriority: 75.0, + CacheReadPricePerMToken: 0.5, + CacheReadPricePerMTokenPriority: 1.25, }}, {model: "gpt-5.4-mini", pricing: ModelPricing{InputPricePerMToken: 0.75, OutputPricePerMToken: 4.5, CacheReadPricePerMToken: 0.075}}, {model: "gpt-5.4-nano", pricing: ModelPricing{InputPricePerMToken: 0.2, OutputPricePerMToken: 1.25, CacheReadPricePerMToken: 0.02}}, From 0514b1905479c00c4067561cd4510a645ca58a8d Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Tue, 19 May 2026 23:06:39 +0800 Subject: [PATCH 03/37] fix(docs): bind SQLite compose ports to 127.0.0.1 by default --- .env.example | 2 ++ .env.sqlite.example | 2 ++ README.md | 1 + docker-compose.sqlite.local.yml | 4 +++- docker-compose.sqlite.yml | 4 +++- 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 7d4c9ab2..a44a57df 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ # ============================================================ # HTTP 服务端口 +# Note: the standard compose files bind to all interfaces (0.0.0.0). +# Set BIND_HOST=127.0.0.1 in .env to restrict to localhost only. CODEX_PORT=8080 # 监听地址(默认 0.0.0.0,兼容 Docker 端口映射 / 反向代理 / 公网部署) diff --git a/.env.sqlite.example b/.env.sqlite.example index 1276fc7e..42788b20 100644 --- a/.env.sqlite.example +++ b/.env.sqlite.example @@ -4,6 +4,8 @@ # ============================================================ # HTTP 服务端口 +# Note: the SQLite compose files bind to 127.0.0.1 by default for security. +# Override with BIND_HOST=0.0.0.0 in .env if you need external access. CODEX_PORT=8080 # 监听地址(默认 0.0.0.0,兼容 Docker 端口映射 / 反向代理 / 公网部署) diff --git a/README.md b/README.md index eb088d46..7bfcce33 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ Notes: - Standard and SQLite modes both read `.env`. - Before switching deployment modes, replace `.env` with the matching example file. - The SQLite lightweight mode runs a single `codex2api` container and stores data at `/data/codex2api.db`. +- **SQLite compose files bind to `127.0.0.1` by default for security.** To expose the SQLite service on all interfaces, set `BIND_HOST=0.0.0.0` in `.env` or override the port binding in the compose file. The standard compose files bind to `0.0.0.0` by default. - The image studio library is stored under `/data/images`; Docker configurations persist `/data`. - `docker compose down` does not delete named volumes by default. Data is removed only by commands such as `docker compose down -v`, `docker volume rm`, or `docker volume prune`. diff --git a/docker-compose.sqlite.local.yml b/docker-compose.sqlite.local.yml index cac25167..52207fd7 100644 --- a/docker-compose.sqlite.local.yml +++ b/docker-compose.sqlite.local.yml @@ -5,7 +5,9 @@ services: build: . container_name: codex2api-sqlite-local ports: - - "${BIND_HOST:-0.0.0.0}:${CODEX_PORT:-8080}:${CODEX_PORT:-8080}" + # Bind to localhost only by default for security. + # To expose on all interfaces, set BIND_HOST=0.0.0.0 in .env or override here. + - "127.0.0.1:${CODEX_PORT:-8080}:${CODEX_PORT:-8080}" env_file: - .env volumes: diff --git a/docker-compose.sqlite.yml b/docker-compose.sqlite.yml index b20a791d..2dc5071d 100644 --- a/docker-compose.sqlite.yml +++ b/docker-compose.sqlite.yml @@ -5,7 +5,9 @@ services: image: ghcr.io/james-6-23/codex2api:latest container_name: codex2api-sqlite ports: - - "${BIND_HOST:-0.0.0.0}:${CODEX_PORT:-8080}:${CODEX_PORT:-8080}" + # Bind to localhost only by default for security. + # To expose on all interfaces, set BIND_HOST=0.0.0.0 in .env or override here. + - "127.0.0.1:${CODEX_PORT:-8080}:${CODEX_PORT:-8080}" env_file: - .env volumes: From fd7b5a21932a39a67f6efc9a62939f3570254e90 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Tue, 19 May 2026 23:18:49 +0800 Subject: [PATCH 04/37] feat: credit quota, scheduler_mode, image_studio improvements Credit quota (#141): - Add credit_enabled / credit_skip_usage_window to accounts - DB migrations, Account struct, admin API endpoint - AccountUsageModal toggle switches with i18n - Credit accounts skip usage window checks in scheduler Scheduler mode (#133): - scheduler_mode: round_robin (default) / remaining_quota - remaining_quota sorts by usage percentage ascending - FastScheduler, Store, Settings API, frontend dropdown - All test calls updated Image studio (#135, #136): - admin/image_studio.go: add image-to-image generation mode - proxy/images.go: improve error handling and retry logic - proxy/admin_images.go: add image-to-image endpoint - Frontend: add image-to-image tab in ImageStudio Docker security (#134): - SQLite compose files bind to 127.0.0.1 by default Co-Authored-By: Claude --- admin/handler.go | 45 +++++ admin/image_studio.go | 180 +++++++++++++++++- auth/fast_scheduler.go | 85 +++++++-- auth/fast_scheduler_test.go | 30 +-- auth/store.go | 61 +++++- database/postgres.go | 38 +++- database/sqlite.go | 6 +- frontend/src/api.ts | 2 + frontend/src/components/AccountUsageModal.tsx | 66 +++++++ frontend/src/locales/en.json | 17 +- frontend/src/locales/zh.json | 17 +- frontend/src/pages/ImageStudio.tsx | 2 +- frontend/src/pages/Settings.tsx | 12 ++ frontend/src/types.ts | 4 + proxy/admin_images.go | 35 ++++ proxy/images.go | 50 ++++- 16 files changed, 597 insertions(+), 53 deletions(-) diff --git a/admin/handler.go b/admin/handler.go index c6bdb835..d3dd16e6 100644 --- a/admin/handler.go +++ b/admin/handler.go @@ -242,6 +242,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { api.PATCH("/image-prompts/:id", h.UpdateImagePromptTemplate) api.DELETE("/image-prompts/:id", h.DeleteImagePromptTemplate) api.POST("/images/jobs", h.CreateImageGenerationJob) + api.POST("/images/edit-jobs", h.CreateImageEditJob) api.GET("/images/jobs", h.ListImageGenerationJobs) api.GET("/images/jobs/:id", h.GetImageGenerationJob) api.DELETE("/images/jobs/:id", h.DeleteImageGenerationJob) @@ -638,6 +639,40 @@ type updateAccountSchedulerReq struct { } // UpdateAccountScheduler 更新账号调度配置。 +// UpdateAccountCredit 更新账号信用设置 +func (h *Handler) UpdateAccountCredit(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + writeError(c, http.StatusBadRequest, "无效的账号 ID") + return + } + + var req struct { + CreditEnabled *bool `json:"credit_enabled"` + CreditSkipUsageWindow *bool `json:"credit_skip_usage_window"` + } + if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + + creditEnabled := false + if req.CreditEnabled != nil { + creditEnabled = *req.CreditEnabled + } + creditSkipUsageWindow := false + if req.CreditSkipUsageWindow != nil { + creditSkipUsageWindow = *req.CreditSkipUsageWindow + } + + if err := h.store.UpdateAccountCredit(id, creditEnabled, creditSkipUsageWindow); err != nil { + writeError(c, http.StatusInternalServerError, "更新信用设置失败: "+err.Error()) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "信用设置已更新", "credit_enabled": creditEnabled, "credit_skip_usage_window": creditSkipUsageWindow}) +} + func (h *Handler) UpdateAccountScheduler(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -3496,6 +3531,7 @@ type settingsResponse struct { AutoCleanExpired bool `json:"auto_clean_expired"` ProxyPoolEnabled bool `json:"proxy_pool_enabled"` FastSchedulerEnabled bool `json:"fast_scheduler_enabled"` + SchedulerMode string `json:"scheduler_mode"` MaxRetries int `json:"max_retries"` MaxRateLimitRetries int `json:"max_rate_limit_retries"` AllowRemoteMigration bool `json:"allow_remote_migration"` @@ -3554,6 +3590,7 @@ type updateSettingsReq struct { AutoCleanExpired *bool `json:"auto_clean_expired"` ProxyPoolEnabled *bool `json:"proxy_pool_enabled"` FastSchedulerEnabled *bool `json:"fast_scheduler_enabled"` + SchedulerMode *string `json:"scheduler_mode"` MaxRetries *int `json:"max_retries"` MaxRateLimitRetries *int `json:"max_rate_limit_retries"` AllowRemoteMigration *bool `json:"allow_remote_migration"` @@ -3694,6 +3731,7 @@ func (h *Handler) GetSettings(c *gin.Context) { AutoCleanExpired: h.store.GetAutoCleanExpired(), ProxyPoolEnabled: h.store.GetProxyPoolEnabled(), FastSchedulerEnabled: h.store.FastSchedulerEnabled(), + SchedulerMode: h.store.GetSchedulerMode(), MaxRetries: h.store.GetMaxRetries(), MaxRateLimitRetries: h.store.GetMaxRateLimitRetries(), AllowRemoteMigration: h.store.GetAllowRemoteMigration() && adminAuthSource != "disabled", @@ -3926,6 +3964,11 @@ func (h *Handler) UpdateSettings(c *gin.Context) { log.Printf("设置已更新: fast_scheduler_enabled = %t", *req.FastSchedulerEnabled) } + if req.SchedulerMode != nil { + h.store.SetSchedulerMode(*req.SchedulerMode) + log.Printf("设置已更新: scheduler_mode = %s", *req.SchedulerMode) + } + if req.MaxRetries != nil { v := *req.MaxRetries if v < 0 { @@ -4167,6 +4210,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) { AutoCleanExpired: h.store.GetAutoCleanExpired(), ProxyPoolEnabled: h.store.GetProxyPoolEnabled(), FastSchedulerEnabled: h.store.FastSchedulerEnabled(), + SchedulerMode: h.store.GetSchedulerMode(), MaxRetries: h.store.GetMaxRetries(), MaxRateLimitRetries: h.store.GetMaxRateLimitRetries(), AllowRemoteMigration: h.store.GetAllowRemoteMigration() && hasAdminSecret, @@ -4230,6 +4274,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) { AutoCleanExpired: h.store.GetAutoCleanExpired(), ProxyPoolEnabled: h.store.GetProxyPoolEnabled(), FastSchedulerEnabled: h.store.FastSchedulerEnabled(), + SchedulerMode: h.store.GetSchedulerMode(), MaxRetries: h.store.GetMaxRetries(), MaxRateLimitRetries: h.store.GetMaxRateLimitRetries(), AllowRemoteMigration: h.store.GetAllowRemoteMigration() && adminAuthSource != "disabled", diff --git a/admin/image_studio.go b/admin/image_studio.go index 47186da1..f2eb4e55 100644 --- a/admin/image_studio.go +++ b/admin/image_studio.go @@ -63,7 +63,8 @@ type imageGenerationJobPayload struct { Style string `json:"style"` Upscale string `json:"upscale"` APIKeyID int64 `json:"api_key_id"` - TemplateID int64 `json:"template_id"` + TemplateID int64 `json:"template_id"` + InputImages []string `json:"input_images"` } type imageJobResponse struct { @@ -322,6 +323,85 @@ func (h *Handler) CreateImageGenerationJob(c *gin.Context) { c.JSON(http.StatusOK, imageJobResponse{Job: job}) } +func (h *Handler) CreateImageEditJob(c *gin.Context) { + var req imageGenerationJobPayload + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求体无效") + return + } + req.Prompt = strings.TrimSpace(req.Prompt) + if req.Prompt == "" { + writeError(c, http.StatusBadRequest, "提示词不能为空") + return + } + if len(req.InputImages) == 0 { + writeError(c, http.StatusBadRequest, "图生图需要上传参考图片") + return + } + if len([]rune(req.Prompt)) > 8000 { + writeError(c, http.StatusBadRequest, "提示词不能超过 8000 个字符") + return + } + req.Model = normalizeImageStudioModel(req.Model) + if req.Model == "" { + req.Model = "gpt-image-2" + } + req.Size = normalizeOptionalImageParam(req.Size) + req.Quality = normalizeOptionalImageParam(req.Quality) + req.OutputFormat = normalizeOptionalImageParam(req.OutputFormat) + if req.OutputFormat == "" { + req.OutputFormat = "png" + } + req.Background = normalizeOptionalImageParam(req.Background) + req.Style = normalizeOptionalImageParam(req.Style) + req.Upscale = imageproc.NormalizeUpscale(req.Upscale) + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + apiKey, err := h.resolveImageJobAPIKey(ctx, req.APIKeyID) + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + paramsJSON, _ := json.Marshal(req) + keyID, keyName, keyMasked := imageJobAPIKeyMeta(apiKey) + if h.inspectImageStudioPromptFilter(c, proxy.AppendImageStyleToPrompt(req.Prompt, req.Style), req.Model, keyID, keyName, keyMasked) { + return + } + jobID, err := h.db.InsertImageGenerationJob(ctx, database.ImageGenerationJobInput{ + Prompt: req.Prompt, + ParamsJSON: string(paramsJSON), + APIKeyID: keyID, + APIKeyName: keyName, + APIKeyMasked: keyMasked, + }) + if err != nil { + writeInternalError(c, err) + return + } + if req.TemplateID > 0 { + _ = h.db.IncrementImagePromptTemplateUsage(ctx, req.TemplateID) + } + job, err := h.db.GetImageGenerationJob(ctx, jobID) + if err != nil { + writeInternalError(c, err) + return + } + log.Printf("[image-studio] job=%d queued mode=edit model=%s size=%s quality=%s format=%s image_count=%d api_key=%s template=%d prompt=%q", + jobID, + imageLogValue(req.Model), + imageLogValue(req.Size), + imageLogValue(req.Quality), + imageLogValue(req.OutputFormat), + len(req.InputImages), + imageLogAPIKeyLabel(keyID, keyName, keyMasked), + req.TemplateID, + imageLogPromptPreview(req.Prompt), + ) + go h.runImageEditJob(jobID, req, apiKey) + c.JSON(http.StatusOK, imageJobResponse{Job: job}) +} + func (h *Handler) ListImageGenerationJobs(c *gin.Context) { page, pageSize := paginationParams(c, 20) ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) @@ -790,6 +870,104 @@ func (h *Handler) runImageGenerationJob(jobID int64, req imageGenerationJobPaylo ) } +func buildAdminImageEditRequest(req imageGenerationJobPayload) ([]byte, error) { + if len(req.InputImages) == 0 { + return nil, fmt.Errorf("input_images is required for image edit") + } + body := map[string]any{ + "model": req.Model, + "prompt": proxy.AppendImageStyleToPrompt(req.Prompt, req.Style), + "response_format": "b64_json", + } + images := make([]map[string]string, len(req.InputImages)) + for i, img := range req.InputImages { + images[i] = map[string]string{"image_url": img} + } + body["images"] = images + if req.Size != "" && req.Size != "auto" { + body["size"] = req.Size + } + if req.Quality != "" && req.Quality != "auto" { + body["quality"] = req.Quality + } + if req.OutputFormat != "" { + body["output_format"] = req.OutputFormat + } + if req.Background != "" && req.Background != "auto" { + body["background"] = req.Background + } + return json.Marshal(body) +} + +func (h *Handler) runImageEditJob(jobID int64, req imageGenerationJobPayload, apiKey *database.APIKeyRow) { + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) + defer cancel() + start := time.Now() + if err := h.db.MarkImageJobRunning(ctx, jobID); err != nil { + logImageJobError(jobID, err) + return + } + log.Printf("[image-studio] job=%d started mode=edit model=%s image_count=%d prompt_chars=%d", + jobID, + imageLogValue(req.Model), + len(req.InputImages), + len([]rune(req.Prompt)), + ) + + rawBody, err := buildAdminImageEditRequest(req) + if err != nil { + durationMs := int(time.Since(start).Milliseconds()) + log.Printf("[image-studio] job=%d failed mode=edit duration=%s stage=build_request error=%s", jobID, imageLogDuration(durationMs), security.SanitizeLog(err.Error())) + _ = h.db.MarkImageJobFailed(context.Background(), jobID, err.Error(), durationMs) + return + } + + imageProxy := h.imageProxy + if imageProxy == nil { + imageProxy = proxy.NewHandler(h.store, h.db, nil, nil) + } + responseJSON, upstreamStatus, err := imageProxy.GenerateImageEditForAdmin(ctx, rawBody, apiKey) + durationMs := int(time.Since(start).Milliseconds()) + if err != nil { + log.Printf("[image-studio] job=%d failed mode=edit duration=%s upstream_status=%d error=%s", + jobID, + imageLogDuration(durationMs), + upstreamStatus, + security.SanitizeLog(err.Error()), + ) + _ = h.db.MarkImageJobFailed(context.Background(), jobID, err.Error(), durationMs) + return + } + log.Printf("[image-studio] job=%d upstream completed mode=edit duration=%s upstream_status=%d response_bytes=%d", + jobID, + imageLogDuration(durationMs), + upstreamStatus, + len(responseJSON), + ) + + assets, err := h.saveImageJobAssets(context.Background(), jobID, req, responseJSON) + if err != nil { + log.Printf("[image-studio] job=%d failed mode=edit duration=%s stage=save_assets error=%s", jobID, imageLogDuration(durationMs), security.SanitizeLog(err.Error())) + _ = h.db.MarkImageJobFailed(context.Background(), jobID, err.Error(), durationMs) + return + } + if len(assets) == 0 { + log.Printf("[image-studio] job=%d failed mode=edit duration=%s stage=save_assets error=%s", jobID, imageLogDuration(durationMs), "上游未返回图片") + _ = h.db.MarkImageJobFailed(context.Background(), jobID, "上游未返回图片", durationMs) + return + } + if err := h.db.MarkImageJobSucceeded(context.Background(), jobID, durationMs); err != nil { + logImageJobError(jobID, err) + } + log.Printf("[image-studio] job=%d succeeded mode=edit duration=%s assets=%d total_bytes=%d first_size=%s", + jobID, + imageLogDuration(durationMs), + len(assets), + imageAssetsTotalBytes(assets), + imageLogFirstAssetSize(assets), + ) +} + func buildAdminImageGenerationRequest(req imageGenerationJobPayload) ([]byte, error) { body := map[string]any{ "model": req.Model, diff --git a/auth/fast_scheduler.go b/auth/fast_scheduler.go index 992644c8..b17ffffc 100644 --- a/auth/fast_scheduler.go +++ b/auth/fast_scheduler.go @@ -33,26 +33,31 @@ type fastSchedulerPosition struct { // 调度策略:按健康层级分桶,桶内按调度分排序后 round-robin。 // 验证过的账号只作为同分 tie-breaker,避免历史请求量盖过额度快重置优先级。 type FastScheduler struct { - mu sync.RWMutex - baseLimit int64 - buckets map[AccountHealthTier][]fastSchedulerEntry - positions map[int64]fastSchedulerPosition - cursors [3]atomic.Uint64 - groupCheck func(apiKeyID int64, account *Account) bool + mu sync.RWMutex + baseLimit int64 + schedulerMode string + buckets map[AccountHealthTier][]fastSchedulerEntry + positions map[int64]fastSchedulerPosition + cursors [3]atomic.Uint64 + groupCheck func(apiKeyID int64, account *Account) bool } -func NewFastScheduler(baseLimit int64) *FastScheduler { +func NewFastScheduler(baseLimit int64, schedulerMode string) *FastScheduler { if baseLimit <= 0 { baseLimit = 1 } + if schedulerMode == "" { + schedulerMode = "round_robin" + } return &FastScheduler{ - baseLimit: baseLimit, + baseLimit: baseLimit, + schedulerMode: schedulerMode, buckets: map[AccountHealthTier][]fastSchedulerEntry{ HealthTierHealthy: nil, HealthTierWarm: nil, HealthTierRisky: nil, }, - positions: make(map[int64]fastSchedulerPosition), + positions: map[int64]fastSchedulerPosition{}, } } @@ -65,13 +70,34 @@ func (s *FastScheduler) SetGroupCheck(check func(apiKeyID int64, account *Accoun s.mu.Unlock() } +func (s *FastScheduler) SetSchedulerMode(mode string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if mode == "" { + mode = "round_robin" + } + s.schedulerMode = mode +} + +func (s *FastScheduler) SchedulerMode() string { + if s == nil { + return "round_robin" + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.schedulerMode +} + // BuildFastScheduler 用当前 Store 快照构建一个独立 scheduler。 // 该方法不会影响现有生产流量路径,只用于 POC/benchmark/灰度验证。 func (s *Store) BuildFastScheduler() *FastScheduler { if s == nil { - return NewFastScheduler(1) + return NewFastScheduler(1, "round_robin") } - scheduler := NewFastScheduler(atomic.LoadInt64(&s.maxConcurrency)) + scheduler := NewFastScheduler(atomic.LoadInt64(&s.maxConcurrency), s.GetSchedulerMode()) s.mu.RLock() accounts := make([]*Account, len(s.accounts)) @@ -193,6 +219,7 @@ func (s *FastScheduler) AcquireExcludingWithFilter(apiKeyID int64, exclude map[i defer s.mu.Unlock() baseLimit := s.baseLimit + var zeroCursor atomic.Uint64 for { changed := false for tierIdx, tier := range fastSchedulerTierOrder { @@ -201,7 +228,11 @@ func (s *FastScheduler) AcquireExcludingWithFilter(apiKeyID int64, exclude map[i continue } - acc, stale := s.scanRangeLocked(tier, 0, len(bucket), &s.cursors[tierIdx], baseLimit, now, apiKeyID, exclude, filter) + cursor := &s.cursors[tierIdx] + if s.schedulerMode == "remaining_quota" { + cursor = &zeroCursor + } + acc, stale := s.scanRangeLocked(tier, 0, len(bucket), cursor, baseLimit, now, apiKeyID, exclude, filter) if acc != nil { return acc } @@ -301,15 +332,29 @@ func (s *FastScheduler) insertLocked(acc *Account, now time.Time) { dispatchScore: dispatchScore, proven: proven, }) - sort.SliceStable(entries, func(i, j int) bool { - if entries[i].dispatchScore == entries[j].dispatchScore { - if entries[i].proven != entries[j].proven { - return entries[i].proven + if s.schedulerMode == "remaining_quota" { + sort.SliceStable(entries, func(i, j int) bool { + usageI := entries[i].acc.usagePercentForScheduling() + usageJ := entries[j].acc.usagePercentForScheduling() + if usageI == usageJ { + if entries[i].proven != entries[j].proven { + return entries[i].proven + } + return entries[i].dbID < entries[j].dbID } - return entries[i].dbID < entries[j].dbID - } - return entries[i].dispatchScore > entries[j].dispatchScore - }) + return usageI < usageJ + }) + } else { + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].dispatchScore == entries[j].dispatchScore { + if entries[i].proven != entries[j].proven { + return entries[i].proven + } + return entries[i].dbID < entries[j].dbID + } + return entries[i].dispatchScore > entries[j].dispatchScore + }) + } s.buckets[tier] = entries s.rebuildPositionsLocked(tier) } diff --git a/auth/fast_scheduler_test.go b/auth/fast_scheduler_test.go index 915f5f2d..5b122366 100644 --- a/auth/fast_scheduler_test.go +++ b/auth/fast_scheduler_test.go @@ -24,7 +24,7 @@ func TestFastSchedulerAcquirePrefersHealthyTier(t *testing.T) { warm := newFastSchedulerTestAccount(1, HealthTierWarm, 90, 2) healthy := newFastSchedulerTestAccount(2, HealthTierHealthy, 80, 2) - scheduler := NewFastScheduler(2) + scheduler := NewFastScheduler(2, "round_robin") scheduler.Rebuild([]*Account{warm, healthy}) got := scheduler.Acquire() @@ -43,7 +43,7 @@ func TestFastSchedulerSkipsDispatchPausedAccount(t *testing.T) { atomic.StoreInt32(&paused.DispatchPaused, 1) fallback := newFastSchedulerTestAccount(2, HealthTierHealthy, 80, 2) - scheduler := NewFastScheduler(2) + scheduler := NewFastScheduler(2, "round_robin") scheduler.Rebuild([]*Account{paused, fallback}) got := scheduler.Acquire() @@ -62,7 +62,7 @@ func TestFastSchedulerSkipsErrorAccount(t *testing.T) { errored.Status = StatusError fallback := newFastSchedulerTestAccount(2, HealthTierHealthy, 80, 2) - scheduler := NewFastScheduler(2) + scheduler := NewFastScheduler(2, "round_robin") scheduler.Rebuild([]*Account{errored, fallback}) got := scheduler.Acquire() @@ -79,7 +79,7 @@ func TestFastSchedulerSkipsErrorAccount(t *testing.T) { func TestFastSchedulerRespectsConcurrencyLimit(t *testing.T) { acc := newFastSchedulerTestAccount(1, HealthTierHealthy, 100, 1) - scheduler := NewFastScheduler(1) + scheduler := NewFastScheduler(1, "round_robin") scheduler.Rebuild([]*Account{acc}) first := scheduler.Acquire() @@ -105,7 +105,7 @@ func TestFastSchedulerRoundRobinWithinTier(t *testing.T) { a2 := newFastSchedulerTestAccount(2, HealthTierHealthy, 100, 4) a3 := newFastSchedulerTestAccount(3, HealthTierHealthy, 100, 4) - scheduler := NewFastScheduler(4) + scheduler := NewFastScheduler(4, "round_robin") scheduler.Rebuild([]*Account{a1, a2, a3}) var got []int64 @@ -287,7 +287,7 @@ func TestFastSchedulerAcquireExcludingRespectsAPIKeyWhitelist(t *testing.T) { restricted.SetAllowedAPIKeyIDs([]int64{2}) fallback := newFastSchedulerTestAccount(2, HealthTierHealthy, 80, 1) - scheduler := NewFastScheduler(1) + scheduler := NewFastScheduler(1, "round_robin") scheduler.Rebuild([]*Account{restricted, fallback}) got := scheduler.AcquireExcluding(1, nil) @@ -307,7 +307,7 @@ func TestFastSchedulerAcquireExcludingWithFilterRespectsPlanFilter(t *testing.T) pro := newFastSchedulerTestAccount(2, HealthTierHealthy, 80, 1) pro.PlanType = "pro" - scheduler := NewFastScheduler(1) + scheduler := NewFastScheduler(1, "round_robin") scheduler.Rebuild([]*Account{plus, pro}) got := scheduler.AcquireExcludingWithFilter(0, nil, func(acc *Account) bool { @@ -325,7 +325,7 @@ func TestFastSchedulerAcquireExcludingWithFilterRespectsPlanFilter(t *testing.T) func TestFastSchedulerUpdateMovesAccountBetweenBuckets(t *testing.T) { acc := newFastSchedulerTestAccount(1, HealthTierHealthy, 100, 2) - scheduler := NewFastScheduler(2) + scheduler := NewFastScheduler(2, "round_robin") scheduler.Rebuild([]*Account{acc}) sizes := scheduler.BucketSizes() @@ -358,7 +358,7 @@ func TestFastSchedulerUpdateMovesAccountBetweenBuckets(t *testing.T) { func TestFastSchedulerSkipsStaleBucketEntryWithoutUpdate(t *testing.T) { acc := newFastSchedulerTestAccount(1, HealthTierHealthy, 100, 1) - scheduler := NewFastScheduler(1) + scheduler := NewFastScheduler(1, "round_robin") scheduler.Rebuild([]*Account{acc}) acc.SetCooldownUntil(time.Now().Add(5*time.Minute), "rate_limited") @@ -392,7 +392,7 @@ func TestFastSchedulerDispatchScoreOutranksProvenHistory(t *testing.T) { proven := newFastSchedulerTestAccount(2, HealthTierHealthy, 100, 1) atomic.StoreInt64(&proven.TotalRequests, 11) - scheduler := NewFastScheduler(1) + scheduler := NewFastScheduler(1, "round_robin") scheduler.Rebuild([]*Account{highScore, proven}) got := scheduler.Acquire() @@ -413,7 +413,7 @@ func TestFastSchedulerProvenHistoryBreaksDispatchScoreTies(t *testing.T) { proven := newFastSchedulerTestAccount(2, HealthTierHealthy, 100, 1) atomic.StoreInt64(&proven.TotalRequests, 11) - scheduler := NewFastScheduler(1) + scheduler := NewFastScheduler(1, "round_robin") scheduler.Rebuild([]*Account{unproven, proven}) got := scheduler.Acquire() @@ -442,7 +442,7 @@ func TestFastSchedulerPrefersPremium7dResetSoonOverProvenAccount(t *testing.T) { soon.UsagePercent7dValid = true soon.Reset7dAt = now.Add(36 * time.Hour) - scheduler := NewFastScheduler(1) + scheduler := NewFastScheduler(1, "round_robin") scheduler.Rebuild([]*Account{later, soon}) got := scheduler.Acquire() @@ -470,7 +470,7 @@ func TestFastSchedulerPrefersPremium5hResetSoonWithinTier(t *testing.T) { soon.UsagePercent5hValid = true soon.Reset5hAt = now.Add(30 * time.Minute) - scheduler := NewFastScheduler(1) + scheduler := NewFastScheduler(1, "round_robin") scheduler.Rebuild([]*Account{later, soon}) got := scheduler.Acquire() @@ -600,7 +600,7 @@ func TestFastSchedulerPremium5hRateLimitIsFencedAndRecoversAfterReset(t *testing Reset5hAt: time.Now().Add(30 * time.Minute), } - scheduler := NewFastScheduler(4) + scheduler := NewFastScheduler(4, "round_robin") scheduler.Rebuild([]*Account{acc}) sizes := scheduler.BucketSizes() @@ -784,7 +784,7 @@ func TestFastSchedulerRelease(t *testing.T) { acc := newFastSchedulerTestAccount(1, HealthTierHealthy, 100, 2) atomic.StoreInt64(&acc.ActiveRequests, 1) - scheduler := NewFastScheduler(2) + scheduler := NewFastScheduler(2, "round_robin") scheduler.Release(acc) if got := atomic.LoadInt64(&acc.ActiveRequests); got != 0 { diff --git a/auth/store.go b/auth/store.go index 4420b744..a59cd2cf 100644 --- a/auth/store.go +++ b/auth/store.go @@ -110,6 +110,8 @@ type Account struct { // per-account 调度配置(nil = 跟随默认) ScoreBiasOverride *int64 BaseConcurrencyOverride *int64 + CreditEnabled bool // 信用账号标记 + CreditSkipUsageWindow bool // 跳过用量窗口惩罚 AllowedAPIKeyIDs []int64 allowedAPIKeySet map[int64]struct{} Tags []string @@ -617,7 +619,7 @@ func (a *Account) schedulerBreakdownLocked(now time.Time) SchedulerBreakdown { } } - if a.UsagePercent7dValid && strings.EqualFold(a.PlanType, "free") { + if !a.CreditSkipUsageWindow && a.UsagePercent7dValid && strings.EqualFold(a.PlanType, "free") { switch { case a.UsagePercent7d >= 100: breakdown.UsagePenalty7d = 40 @@ -801,7 +803,7 @@ func (a *Account) recomputeSchedulerLocked(baseLimit int64) { if !a.LastUnauthorizedAt.IsZero() && now.Sub(a.LastUnauthorizedAt) < 24*time.Hour && tier == HealthTierHealthy { tier = HealthTierWarm } - if a.UsagePercent7dValid && strings.EqualFold(a.PlanType, "free") { + if !a.CreditSkipUsageWindow && a.UsagePercent7dValid && strings.EqualFold(a.PlanType, "free") { switch { case a.UsagePercent7d >= 95: tier = HealthTierRisky @@ -1014,6 +1016,16 @@ func (a *Account) GetUsagePercent7d() (float64, bool) { return a.UsagePercent7d, a.UsagePercent7dValid } +// usagePercentForScheduling 返回调度排序用的用量百分比(7d 窗口有效则返回,否则 0)。 +func (a *Account) usagePercentForScheduling() float64 { + a.mu.RLock() + defer a.mu.RUnlock() + if a.UsagePercent7dValid { + return a.UsagePercent7d + } + return 0 +} + // SetUsageSnapshot5h 更新 5h 用量快照 func (a *Account) SetUsageSnapshot5h(pct float64, resetAt time.Time) { a.mu.Lock() @@ -1491,6 +1503,7 @@ type Store struct { allowRemoteMigration atomic.Bool // 是否允许远程迁移拉取账号 modelMapping atomic.Value // 模型映射 JSON 字符串 + schedulerMode atomic.Value // string: "round_robin" or "remaining_quota" promptFilterConfig atomic.Value // promptfilter.Config sessionMu sync.RWMutex sessionBindings map[string]sessionAffinity @@ -1874,15 +1887,15 @@ func NewStore(db *database.DB, tc cache.TokenCache, settings *database.SystemSet } atomic.StoreInt64(&s.maxRateLimitRetries, rateLimitRetries) s.allowRemoteMigration.Store(settings.AllowRemoteMigration) + s.schedulerMode.Store(settings.SchedulerMode) if settings.ModelMapping != "" { - s.modelMapping.Store(settings.ModelMapping) + s.SetPromptFilterConfig(promptFilterConfigFromSettings(settings)) } - s.SetPromptFilterConfig(promptFilterConfigFromSettings(settings)) // 环境变量优先,否则读数据库设置 fastEnabled := fastSchedulerEnabledFromEnv() || settings.FastSchedulerEnabled s.fastSchedulerEnabled.Store(fastEnabled) if fastEnabled { - s.fastScheduler.Store(NewFastScheduler(int64(settings.MaxConcurrency))) + s.fastScheduler.Store(NewFastScheduler(int64(settings.MaxConcurrency), s.GetSchedulerMode())) log.Printf("快速调度器已启用(请求热路径将优先走本地内存调度器)") } @@ -2281,6 +2294,8 @@ func (s *Store) loadFromDB(ctx context.Context) error { if !row.Enabled { atomic.StoreInt32(&account.DispatchPaused, 1) } + account.CreditEnabled = row.CreditEnabled + account.CreditSkipUsageWindow = row.CreditSkipUsageWindow if row.Status == "error" { account.Status = StatusError account.ErrorMsg = row.ErrorMessage @@ -2938,6 +2953,22 @@ func (s *Store) GetModelMapping() string { return "{}" } +// GetSchedulerMode 获取当前调度模式 +func (s *Store) GetSchedulerMode() string { + if v, ok := s.schedulerMode.Load().(string); ok { + return v + } + return "round_robin" +} + +// SetSchedulerMode 设置调度模式并传播到 FastScheduler +func (s *Store) SetSchedulerMode(mode string) { + s.schedulerMode.Store(mode) + if scheduler := s.getFastScheduler(); scheduler != nil { + scheduler.SetSchedulerMode(mode) + } +} + func promptFilterConfigFromSettings(settings *database.SystemSettings) promptfilter.Config { cfg := promptfilter.DefaultConfig() if settings == nil { @@ -3070,6 +3101,26 @@ func (s *Store) ApplyAccountGroups(dbID int64, groupIDs []int64) bool { return true } +// UpdateAccountCredit 更新账号信用设置 +func (s *Store) UpdateAccountCredit(dbID int64, creditEnabled, creditSkipUsageWindow bool) error { + acc := s.FindByID(dbID) + if acc == nil { + return fmt.Errorf("账号 %d 不存在", dbID) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.db.UpdateAccountCredit(ctx, dbID, creditEnabled, creditSkipUsageWindow); err != nil { + return err + } + acc.mu.Lock() + acc.CreditEnabled = creditEnabled + acc.CreditSkipUsageWindow = creditSkipUsageWindow + acc.recomputeSchedulerLocked(atomic.LoadInt64(&s.maxConcurrency)) + acc.mu.Unlock() + s.fastSchedulerUpdate(acc) + return nil +} + func (s *Store) ApplyAccountGroupMemberships(memberships map[int64][]int64) { for _, acc := range s.Accounts() { acc.mu.Lock() diff --git a/database/postgres.go b/database/postgres.go index f34811c4..4488fff4 100644 --- a/database/postgres.go +++ b/database/postgres.go @@ -30,6 +30,8 @@ type AccountRow struct { ErrorMessage string Enabled bool Locked bool + CreditEnabled bool + CreditSkipUsageWindow bool ScoreBiasOverride sql.NullInt64 BaseConcurrencyOverride sql.NullInt64 Tags []string @@ -481,6 +483,8 @@ func (db *DB) migrate(ctx context.Context) error { ALTER TABLE accounts ADD COLUMN IF NOT EXISTS today_used_count INT DEFAULT 0; ALTER TABLE accounts ADD COLUMN IF NOT EXISTS image_quota_reset_at TIMESTAMPTZ NULL; ALTER TABLE accounts ADD COLUMN IF NOT EXISTS tags JSONB DEFAULT '[]'::jsonb; + ALTER TABLE accounts ADD COLUMN IF NOT EXISTS credit_enabled BOOLEAN DEFAULT FALSE; + ALTER TABLE accounts ADD COLUMN IF NOT EXISTS credit_skip_usage_window BOOLEAN DEFAULT FALSE; CREATE TABLE IF NOT EXISTS account_groups ( id SERIAL PRIMARY KEY, @@ -597,7 +601,8 @@ func (db *DB) migrate(ctx context.Context) error { auto_clean_rate_limited BOOLEAN DEFAULT FALSE, background_refresh_interval_minutes INT DEFAULT 2, usage_probe_max_age_minutes INT DEFAULT 10, - recovery_probe_interval_minutes INT DEFAULT 30 + recovery_probe_interval_minutes INT DEFAULT 30, + scheduler_mode VARCHAR(20) DEFAULT 'round_robin' ); CREATE TABLE IF NOT EXISTS account_model_cooldowns ( account_id BIGINT NOT NULL, @@ -627,6 +632,7 @@ func (db *DB) migrate(ctx context.Context) error { ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS background_refresh_interval_minutes INT DEFAULT 2; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS usage_probe_max_age_minutes INT DEFAULT 10; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS recovery_probe_interval_minutes INT DEFAULT 30; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS scheduler_mode VARCHAR(20) DEFAULT 'round_robin'; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS resin_url TEXT DEFAULT ''; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS resin_platform_name TEXT DEFAULT ''; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS prompt_filter_enabled BOOLEAN DEFAULT FALSE; @@ -1093,6 +1099,7 @@ type SystemSettings struct { BackgroundRefreshIntervalMinutes int UsageProbeMaxAgeMinutes int RecoveryProbeIntervalMinutes int + SchedulerMode string ResinURL string // Resin 代理池地址(含 Token),例如 http://127.0.0.1:2260/my-token ResinPlatformName string // Resin 平台标识,例如 codex2api PromptFilterEnabled bool @@ -1132,6 +1139,7 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) { COALESCE(background_refresh_interval_minutes, 2), COALESCE(usage_probe_max_age_minutes, 10), COALESCE(recovery_probe_interval_minutes, 30), + COALESCE(scheduler_mode, 'round_robin'), COALESCE(resin_url, ''), COALESCE(resin_platform_name, ''), COALESCE(prompt_filter_enabled, false), @@ -1159,6 +1167,7 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) { &s.ProxyPoolEnabled, &s.FastSchedulerEnabled, &s.MaxRetries, &s.MaxRateLimitRetries, &s.AllowRemoteMigration, &s.AutoCleanError, &s.AutoCleanExpired, &s.ModelMapping, &s.BackgroundRefreshIntervalMinutes, &s.UsageProbeMaxAgeMinutes, &s.RecoveryProbeIntervalMinutes, + &s.SchedulerMode, &s.ResinURL, &s.ResinPlatformName, &s.PromptFilterEnabled, &s.PromptFilterMode, &s.PromptFilterThreshold, &s.PromptFilterStrictThreshold, &s.PromptFilterLogMatches, &s.PromptFilterMaxTextLength, &s.PromptFilterSensitiveWords, @@ -1188,9 +1197,10 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error prompt_filter_sensitive_words, prompt_filter_custom_patterns, prompt_filter_disabled_patterns, client_compat_mode, codex_min_cli_version, usage_log_mode, usage_log_batch_size, usage_log_flush_interval_seconds, stream_flush_policy, stream_flush_interval_ms, - image_storage_config + image_storage_config, + scheduler_mode ) - VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44) ON CONFLICT (id) DO UPDATE SET site_name = EXCLUDED.site_name, site_logo = EXCLUDED.site_logo, @@ -1234,7 +1244,8 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error usage_log_flush_interval_seconds = EXCLUDED.usage_log_flush_interval_seconds, stream_flush_policy = EXCLUDED.stream_flush_policy, stream_flush_interval_ms = EXCLUDED.stream_flush_interval_ms, - image_storage_config = EXCLUDED.image_storage_config + image_storage_config = EXCLUDED.image_storage_config, + scheduler_mode = EXCLUDED.scheduler_mode `, NormalizeSiteName(s.SiteName), strings.TrimSpace(s.SiteLogo), s.MaxConcurrency, s.GlobalRPM, s.TestModel, s.TestConcurrency, s.ProxyURL, s.PgMaxConns, s.RedisPoolSize, s.AutoCleanUnauthorized, s.AutoCleanRateLimited, s.AdminSecret, s.AutoCleanFullUsage, s.ProxyPoolEnabled, @@ -1245,7 +1256,7 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error s.PromptFilterSensitiveWords, s.PromptFilterCustomPatterns, s.PromptFilterDisabledPatterns, s.ClientCompatMode, s.CodexMinCLIVersion, s.UsageLogMode, s.UsageLogBatchSize, s.UsageLogFlushIntervalSeconds, s.StreamFlushPolicy, s.StreamFlushIntervalMS, - s.ImageStorageConfig) + s.ImageStorageConfig, s.SchedulerMode) return err } @@ -2933,7 +2944,7 @@ func (db *DB) GetAccountTimeRangeUsage(ctx context.Context, since time.Time) (ma // ListActive 获取所有未删除账号。 func (db *DB) ListActive(ctx context.Context) ([]*AccountRow, error) { query := ` - SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), score_bias_override, base_concurrency_override, COALESCE(tags, '[]'), created_at, updated_at + SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), COALESCE(credit_enabled, false), COALESCE(credit_skip_usage_window, false), score_bias_override, base_concurrency_override, COALESCE(tags, '[]'), created_at, updated_at FROM accounts WHERE status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted' ORDER BY id @@ -2966,6 +2977,8 @@ func (db *DB) ListActive(ctx context.Context) ([]*AccountRow, error) { &a.Enabled, &a.Locked, &a.ScoreBiasOverride, + &a.CreditEnabled, + &a.CreditSkipUsageWindow, &a.BaseConcurrencyOverride, &tagsRaw, &createdAtRaw, @@ -3074,7 +3087,7 @@ func (db *DB) ClearExpiredModelCooldowns(ctx context.Context) error { // GetAccountByID 获取未删除账号的完整数据库行。 func (db *DB) GetAccountByID(ctx context.Context, id int64) (*AccountRow, error) { query := ` - SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), score_bias_override, base_concurrency_override, COALESCE(tags, '[]'), created_at, updated_at + SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), COALESCE(credit_enabled, false), COALESCE(credit_skip_usage_window, false), score_bias_override, base_concurrency_override, COALESCE(tags, '[]'), created_at, updated_at FROM accounts WHERE id = $1 AND status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted' LIMIT 1 @@ -3099,6 +3112,8 @@ func (db *DB) GetAccountByID(ctx context.Context, id int64) (*AccountRow, error) &a.Enabled, &a.Locked, &a.ScoreBiasOverride, + &a.CreditEnabled, + &a.CreditSkipUsageWindow, &a.BaseConcurrencyOverride, &tagsRaw, &createdAtRaw, @@ -3340,6 +3355,15 @@ func (db *DB) SetAccountLocked(ctx context.Context, id int64, locked bool) error return err } +// UpdateAccountCredit 更新账号的信用设置(credit_enabled / credit_skip_usage_window) +func (db *DB) UpdateAccountCredit(ctx context.Context, id int64, creditEnabled, creditSkipUsageWindow bool) error { + _, err := db.conn.ExecContext(ctx, + `UPDATE accounts SET credit_enabled = $1, credit_skip_usage_window = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`, + creditEnabled, creditSkipUsageWindow, id, + ) + return err +} + // UpdateCredentials 原子合并更新账号的 credentials(JSONB || 运算符,不覆盖已有字段) // 解决并发刷新时一个进程覆盖另一个进程写入的字段的问题 func (db *DB) UpdateCredentials(ctx context.Context, id int64, credentials map[string]interface{}) error { diff --git a/database/sqlite.go b/database/sqlite.go index 5609e81f..a10aad78 100644 --- a/database/sqlite.go +++ b/database/sqlite.go @@ -138,7 +138,8 @@ func (db *DB) migrateSQLite(ctx context.Context) error { usage_log_flush_interval_seconds INTEGER DEFAULT 5, stream_flush_policy TEXT DEFAULT 'immediate', stream_flush_interval_ms INTEGER DEFAULT 20, - image_storage_config TEXT DEFAULT '{}' + image_storage_config TEXT DEFAULT '{}', + scheduler_mode TEXT DEFAULT 'round_robin' );`, `CREATE TABLE IF NOT EXISTS model_registry ( id TEXT PRIMARY KEY, @@ -330,8 +331,11 @@ func (db *DB) migrateSQLite(ctx context.Context) error { {"system_settings", "stream_flush_policy", "TEXT DEFAULT 'immediate'"}, {"system_settings", "stream_flush_interval_ms", "INTEGER DEFAULT 20"}, {"system_settings", "image_storage_config", "TEXT DEFAULT '{}'"}, + {"system_settings", "scheduler_mode", "TEXT DEFAULT 'round_robin'"}, {"accounts", "enabled", "INTEGER DEFAULT 1"}, {"accounts", "locked", "INTEGER DEFAULT 0"}, + {"accounts", "credit_enabled", "INTEGER DEFAULT 0"}, + {"accounts", "credit_skip_usage_window", "INTEGER DEFAULT 0"}, {"accounts", "image_quota_remaining", "INTEGER NULL"}, {"accounts", "image_quota_total", "INTEGER NULL"}, {"accounts", "today_used_count", "INTEGER DEFAULT 0"}, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e77229c3..9a9cb85c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -336,6 +336,8 @@ export const api = { request(`/image-prompts/${id}`, { method: 'DELETE' }), createImageJob: (data: CreateImageJobPayload) => request('/images/jobs', { method: 'POST', body: JSON.stringify(data) }), + createImageEditJob: (data: CreateImageJobPayload) => + request('/images/edit-jobs', { method: 'POST', body: JSON.stringify(data) }), getImageJobs: (params: { page?: number; pageSize?: number } = {}) => { const sp = new URLSearchParams() if (params.page) sp.set('page', String(params.page)) diff --git a/frontend/src/components/AccountUsageModal.tsx b/frontend/src/components/AccountUsageModal.tsx index abb3aed4..3ef64698 100644 --- a/frontend/src/components/AccountUsageModal.tsx +++ b/frontend/src/components/AccountUsageModal.tsx @@ -19,6 +19,11 @@ export default function AccountUsageModal({ account, onClose }: Props) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [creditEnabled, setCreditEnabled] = useState(account.credit_enabled ?? false) + const [creditSkipWindow, setCreditSkipWindow] = useState(account.credit_skip_usage_window ?? false) + const [savingCredit, setSavingCredit] = useState(false) + const [creditError, setCreditError] = useState(null) + const load = useCallback(async () => { setLoading(true) setError(null) @@ -39,6 +44,25 @@ export default function AccountUsageModal({ account, onClose }: Props) { : (account.email || account.name || `#${account.id}`) const title = t('accounts.usageDetailTitle') + ' — ' + accountLabel + const handleCreditToggle = async (field: 'credit_enabled' | 'credit_skip_usage_window', value: boolean) => { + setCreditError(null) + const newEnabled = field === 'credit_enabled' ? value : creditEnabled + const newSkip = field === 'credit_skip_usage_window' ? value : creditSkipWindow + setSavingCredit(true) + try { + await api.updateAccountCredit(account.id, { + credit_enabled: newEnabled, + credit_skip_usage_window: newSkip, + }) + if (field === 'credit_enabled') setCreditEnabled(value) + if (field === 'credit_skip_usage_window') setCreditSkipWindow(value) + } catch (err) { + setCreditError(getErrorMessage(err)) + } finally { + setSavingCredit(false) + } + } + return ( {loading ? ( @@ -101,6 +125,48 @@ export default function AccountUsageModal({ account, onClose }: Props) { )} + + {/* 信用设置 */} +
+

{t('accounts.creditSettings')}

+ {creditError && ( +
{creditError}
+ )} +
+
+

{t('accounts.creditEnabled')}

+

{t('accounts.creditEnabledHint')}

+
+ +
+ {creditEnabled && ( +
+
+

{t('accounts.creditSkipWindow')}

+

{t('accounts.creditSkipWindowHint')}

+
+ +
+ )} +
) } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 3dce35bd..34ca0846 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -550,7 +550,12 @@ "batchMetaDesc": "The following tags and groups will be saved to {{count}} selected accounts. Leaving a field empty clears it.", "batchMetaDone": "Batch metadata save complete: {{success}} succeeded, {{fail}} failed", "batchMetaFailed": "Batch metadata save failed: {{error}}", - "testAutoReset": "Test passed, account status has been automatically restored to normal" + "testAutoReset": "Test passed, account status has been automatically restored to normal", + "creditSettings": "Credit Settings", + "creditEnabled": "Credit Enabled", + "creditEnabledHint": "Mark this account as having extra credits", + "creditSkipWindow": "Skip Usage Window Penalty", + "creditSkipWindowHint": "Bypass 7d/5h usage window penalties during scheduling" }, "status": { "active": "Available", @@ -938,6 +943,10 @@ "timezoneAuto": "Auto (Browser Timezone)", "fastSchedulerEnabled": "Fast Scheduler", "fastSchedulerEnabledDesc": "Uses an in-memory fast scheduling algorithm, significantly reducing dispatch latency under high concurrency. Recommended for large account pools (100+).", + "schedulerMode": "Scheduler Mode", + "schedulerModeDesc": "Round Robin distributes requests evenly across accounts. Remaining Quota prefers accounts with lower usage percentage.", + "schedulerModeRoundRobin": "Round Robin", + "schedulerModeRemainingQuota": "Remaining Quota", "allowRemoteMigration": "Allow Remote Migration", "allowRemoteMigrationDesc": "When enabled, other codex2api instances can pull healthy accounts from this instance.", "allowRemoteMigrationRequiresSecret": "Set an admin secret first. Remote migration cannot be enabled without admin authentication.", @@ -1373,6 +1382,12 @@ "deleteAssetTitle": "Delete gallery image", "assetDeleted": "Image deleted", "assetDeletedInHistory": "Image has been deleted", + "imageToImage": "Image to Image", + "textToImage": "Text to Image", + "inputImage": "Reference Image", + "inputImageHint": "Upload one or more reference images; AI will modify based on these images", + "inputImageRequired": "Reference image is required for image-to-image mode", + "removeImage": "Remove image", "loadFailed": "Failed to load image studio", "status": { "queued": "Queued", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 055c2297..fa9a93b2 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -550,7 +550,12 @@ "batchMetaDesc": "将为 {{count}} 个选中账号统一保存以下标签和分组。留空会清空对应字段。", "batchMetaDone": "批量保存完成:成功 {{success}},失败 {{fail}}", "batchMetaFailed": "批量保存失败:{{error}}", - "testAutoReset": "测试成功,账号状态已自动恢复为正常" + "testAutoReset": "测试成功,账号状态已自动恢复为正常", + "creditSettings": "信用设置", + "creditEnabled": "启用信用", + "creditEnabledHint": "标记该账号拥有额外信用", + "creditSkipWindow": "跳过用量窗口惩罚", + "creditSkipWindowHint": "调度时不使用 7d/5h 用量窗口降权" }, "status": { "active": "可用", @@ -938,6 +943,10 @@ "timezoneAuto": "自动(浏览器时区)", "fastSchedulerEnabled": "快速调度器", "fastSchedulerEnabledDesc": "启用后,账号选择将使用内存快速调度算法,大幅降低高并发场景下的调度延迟。适合大号池(100+ 账号)场景。", + "schedulerMode": "调度模式", + "schedulerModeDesc": "轮询模式在所有账号间平均分配请求。剩余配额模式优先使用用量较低的账号。", + "schedulerModeRoundRobin": "轮询", + "schedulerModeRemainingQuota": "剩余配额", "allowRemoteMigration": "允许远程迁移", "allowRemoteMigrationDesc": "开启后,其他 codex2api 实例可通过导出接口拉取本实例的健康账号。", "allowRemoteMigrationRequiresSecret": "请先设置管理密钥,未设置密码时不能启用远程迁移。", @@ -1373,6 +1382,12 @@ "deleteAssetTitle": "删除图库图片", "assetDeleted": "图片已删除", "assetDeletedInHistory": "图片已经被删除", + "imageToImage": "图生图", + "textToImage": "文生图", + "inputImage": "参考图片", + "inputImageHint": "上传一张或多张参考图片,AI 将基于这些图片进行修改", + "inputImageRequired": "图生图模式需要上传参考图片", + "removeImage": "移除图片", "loadFailed": "加载生图工作台失败", "status": { "queued": "排队中", diff --git a/frontend/src/pages/ImageStudio.tsx b/frontend/src/pages/ImageStudio.tsx index 2480017b..fcb528cc 100644 --- a/frontend/src/pages/ImageStudio.tsx +++ b/frontend/src/pages/ImageStudio.tsx @@ -23,7 +23,7 @@ import { import { Input } from '@/components/ui/input' import { Select } from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { Copy, Download, Eye, Image as ImageIcon, Loader2, Pencil, Play, Plus, RefreshCcw, Save, Search, Sparkles, Star, Trash2, X } from 'lucide-react' +import { Copy, Download, Eye, Image as ImageIcon, Loader2, Pencil, Play, Plus, RefreshCcw, Save, Search, Sparkles, Star, Trash2, Upload, X } from 'lucide-react' const IMAGE_VIEWS = ['studio', 'prompts', 'gallery', 'history'] as const type ImageView = typeof IMAGE_VIEWS[number] diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index b686ac30..86a36a1e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -346,6 +346,10 @@ export default function Settings() { { label: t('common.disabled'), value: 'false' }, { label: t('common.enabled'), value: 'true' }, ] + const schedulerModeOptions = [ + { label: t('settings.schedulerModeRoundRobin'), value: 'round_robin' }, + { label: t('settings.schedulerModeRemainingQuota'), value: 'remaining_quota' }, + ] const clientCompatOptions = [ { label: t('settings.clientCompatPreserve'), value: 'preserve' }, { label: t('settings.clientCompatAuto'), value: 'auto' }, @@ -385,6 +389,7 @@ export default function Settings() { auto_clean_full_usage: false, proxy_pool_enabled: false, fast_scheduler_enabled: false, + scheduler_mode: 'round_robin', max_retries: 2, max_rate_limit_retries: 1, allow_remote_migration: false, @@ -718,6 +723,13 @@ export default function Settings() { options={booleanOptions} /> + + + + + {inputImageDataURLs.length > 0 ? ( +
+ {inputImageDataURLs.map((dataURL, index) => ( +
+ {`Input + +
+ ))} +
+ ) : ( + + )} + + )} +