mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-10 17:02:32 +00:00
背景:gpt-5.3-codex-spark 使用独立于 codex 全局(5h/7d)的配额窗口(数据源是 /wham/usage 响应体的 codex_bengalfox,而非 codex 全局用的 x-codex-* 响应头),且 只能挂在已完成 OAuth 授权的 OpenAI 账号下复用其登录态,不能作为独立账号单独接入。 为此新增“链接型影子账号”(spark shadow account):影子账号本身不持有任何凭据, 通过 parent_account_id 指向母账号,凭据/token/代理透传自母账号并共享母账号的刷新 周期,仅在配额维度(quota_dimension=spark)和用量窗口上与母账号完全独立调度、互不 连坐。 实现: - 数据模型:migration 154(+154a)给 accounts 表加 parent_account_id / quota_dimension 列 + 4 条约束(维度合法 / parent⟺非 global 维度一致 / 禁自指 / FK)+ 2 个 CONCURRENTLY 索引(母账号索引 + 每母账号至多一个影子的唯一索引)。 - 创建:POST /api/v1/admin/accounts/:id/shadow(CreateShadow)—— 一母一影(唯一 索引兜底并发竞态),继承母账号 proxy/分组/并发/优先级(显式传参可覆盖),默认 model_mapping 恒等映射到 spark(拒绝非 spark 模型),母账号必须是真实的 OpenAI OAuth 账号(非影子)。 - 凭据透传:resolveCredentialAccount 把影子解析回母账号,GetAccessToken / 请求头 / WS 三条路径统一走此函数;影子自身 Credentials 恒为空(仅允许写 model_mapping), 凭据写入的汇聚点 persistAccountCredentials 对影子早返 no-op,防止误写。 - 调度:parentHealthyForShadow 只看母账号是否仍是 OpenAI OAuth + 凭据/传输是否 可用(active、token 未过期、未处于 401/刷新失败/传输故障导致的临时不可调度冷却), 刻意不看母账号的 global 限流窗口——两条 429 道互不连坐。 - 用量:影子的 codex_5h/7d 走 OpenAIQuotaService.QueryUsage(/wham/usage 的 codex_bengalfox),与母账号走的 WSv2 探测(/responses 头)完全独立的数据源、 刷新节流与 staleness 判定。 - 备份:ExportData 显式排除影子账号(影子不持凭据,通用凭据型导入强制 credentials 非空、无法表达父子链接),按 skipped_shadows 计数提示前端。 - 前端:账号操作菜单新增“创建 Spark 影子”入口,影子行展示回填的母账号信息 (邮箱 / plan / 隐私模式 / 订阅到期 / chatgpt_account_id),批量操作自动跳过 影子账号。 说明:migrations 目录用完整文件名(而非纯数字前缀)标识迁移,故本次新增的 154_account_spark_shadow.sql / 154a_..._notx.sql 与已有的 154_add_ops_system_logs_api_key_id.sql 按序号共存,与目录里 145/151 已有的 先例一致。 测试:新增约 20 个测试文件,覆盖 handler(CreateShadow 校验 / 母账号信息回填)、 repository(影子 round-trip / 一母一影唯一索引 / 迁移 schema)、service(凭据 透传三路径 / 调度母健康门 / 用量窗口来源与刷新节流 / CRS 母账号不变量 / 各类 早返与 fail-closed 场景)及前端组件(账号列表 / 操作菜单 / 用量重置)。 验证(镜像 CI;golangci-lint 首次全量分析耗时过长被跳过,其余全部实测): - gofmt -l:干净 - go build ./... / go vet ./...:通过 - go test ./... -count=1:全绿(全部包 ok,含 internal/service、 internal/repository、migrations) - go test -tags integration ./internal/repository/... ./internal/service/... (真实 Postgres,testcontainers):全绿,含迁移幂等性 (TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate)与影子相关全部用例 - pnpm lint:check / pnpm typecheck / pnpm build(真实 vite 构建)/ pnpm vitest run:全绿(124 文件 760 用例) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
196 lines
6.1 KiB
Go
196 lines
6.1 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
openAIAccountStateUpdateTimeout = 5 * time.Second
|
|
openAIOAuth429FallbackCooldown = 5 * time.Second
|
|
openAIStopSchedulingBridgeCooldown = 2 * time.Minute
|
|
openAIOAuth429StormWindow = 10 * time.Second
|
|
openAIOAuth429StormThreshold = 20
|
|
openAIOAuth429StormMaxAccountSwitches = 1
|
|
)
|
|
|
|
func openAIAccountStateContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
base := context.Background()
|
|
if ctx != nil {
|
|
base = context.WithoutCancel(ctx)
|
|
}
|
|
return context.WithTimeout(base, openAIAccountStateUpdateTimeout)
|
|
}
|
|
|
|
func isOpenAIOAuthAccount(account *Account) bool {
|
|
return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth
|
|
}
|
|
|
|
func isGrokOAuthAccount(account *Account) bool {
|
|
return account != nil && account.Platform == PlatformGrok && account.Type == AccountTypeOAuth
|
|
}
|
|
|
|
func isOpenAIAccount(account *Account) bool {
|
|
return account != nil && (account.Platform == PlatformOpenAI || account.Platform == PlatformGrok)
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte, requestedModel ...string) bool {
|
|
stateCtx, cancel := openAIAccountStateContext(ctx)
|
|
defer cancel()
|
|
|
|
if account != nil && account.Platform == PlatformOpenAI && isOpenAIContextWindowError("", responseBody) {
|
|
return false
|
|
}
|
|
|
|
if isOpenAIImageRateLimitError(statusCode, responseBody) {
|
|
if s != nil && s.rateLimitService != nil {
|
|
_ = s.rateLimitService.HandleOpenAIImageRateLimit(stateCtx, account, statusCode, headers, responseBody)
|
|
}
|
|
return false
|
|
}
|
|
|
|
if statusCode == http.StatusTooManyRequests {
|
|
s.markOpenAIOAuth429RateLimited(stateCtx, account, headers, responseBody)
|
|
}
|
|
if s == nil || account == nil || s.rateLimitService == nil {
|
|
return false
|
|
}
|
|
if len(requestedModel) > 0 && s.rateLimitService.HandleUpstreamModelNotFound(stateCtx, account, requestedModel[0], statusCode, responseBody) {
|
|
return true
|
|
}
|
|
shouldDisable := s.rateLimitService.HandleUpstreamError(stateCtx, account, statusCode, headers, responseBody)
|
|
if shouldDisable {
|
|
s.BlockAccountScheduling(account, time.Time{}, "upstream_disable")
|
|
}
|
|
return shouldDisable
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context, account *Account, headers http.Header, responseBody []byte) {
|
|
if s == nil || !isOpenAIOAuthAccount(account) {
|
|
return
|
|
}
|
|
// Spark 影子:不按 /responses 429 的 global x-codex-* 信号做内存运行时熔断(同 handle429,外审第8轮 P1)。
|
|
// 同时避免把 spark 的 429 计入全局 429 storm 计数(recordOpenAIOAuth429),否则会误伤母账号 failover 决策。
|
|
if account.IsShadow() {
|
|
return
|
|
}
|
|
s.recordOpenAIOAuth429()
|
|
|
|
cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown)
|
|
if s.rateLimitService != nil {
|
|
if resetAt := s.rateLimitService.calculateOpenAI429ResetTime(headers); resetAt != nil && resetAt.After(time.Now()) {
|
|
cooldownUntil = *resetAt
|
|
} else if resetUnix := parseOpenAIRateLimitResetTime(responseBody); resetUnix != nil {
|
|
if resetAt := time.Unix(*resetUnix, 0); resetAt.After(time.Now()) {
|
|
cooldownUntil = resetAt
|
|
}
|
|
} else if cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account); ok && cooldown > 0 {
|
|
cooldownUntil = time.Now().Add(cooldown)
|
|
}
|
|
}
|
|
s.BlockAccountScheduling(account, cooldownUntil, "429")
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) BlockAccountScheduling(account *Account, until time.Time, reason string) {
|
|
if s == nil || !isOpenAIAccount(account) {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
blockUntil := until
|
|
if blockUntil.IsZero() || !blockUntil.After(now) {
|
|
blockUntil = now.Add(openAIStopSchedulingBridgeCooldown)
|
|
}
|
|
|
|
for {
|
|
current, loaded := s.openaiAccountRuntimeBlockUntil.Load(account.ID)
|
|
if !loaded {
|
|
actual, stored := s.openaiAccountRuntimeBlockUntil.LoadOrStore(account.ID, blockUntil)
|
|
if !stored {
|
|
return
|
|
}
|
|
current = actual
|
|
}
|
|
|
|
currentUntil, ok := current.(time.Time)
|
|
if !ok || currentUntil.IsZero() {
|
|
if s.openaiAccountRuntimeBlockUntil.CompareAndSwap(account.ID, current, blockUntil) {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
if currentUntil.After(blockUntil) {
|
|
return
|
|
}
|
|
if s.openaiAccountRuntimeBlockUntil.CompareAndSwap(account.ID, current, blockUntil) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) ClearAccountSchedulingBlock(accountID int64) {
|
|
if s == nil || accountID <= 0 {
|
|
return
|
|
}
|
|
s.openaiAccountRuntimeBlockUntil.Delete(accountID)
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) isOpenAIAccountRuntimeBlocked(account *Account) bool {
|
|
if s == nil || !isOpenAIAccount(account) {
|
|
return false
|
|
}
|
|
value, ok := s.openaiAccountRuntimeBlockUntil.Load(account.ID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
cooldownUntil, ok := value.(time.Time)
|
|
if !ok || cooldownUntil.IsZero() {
|
|
s.openaiAccountRuntimeBlockUntil.Delete(account.ID)
|
|
return false
|
|
}
|
|
if time.Now().Before(cooldownUntil) {
|
|
return true
|
|
}
|
|
s.openaiAccountRuntimeBlockUntil.Delete(account.ID)
|
|
return false
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) recordOpenAIOAuth429() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
windowStart := s.openaiOAuth429WindowStartUnixNano.Load()
|
|
if windowStart == 0 || now.Sub(time.Unix(0, windowStart)) >= openAIOAuth429StormWindow {
|
|
if s.openaiOAuth429WindowStartUnixNano.CompareAndSwap(windowStart, now.UnixNano()) {
|
|
s.openaiOAuth429WindowCount.Store(1)
|
|
return
|
|
}
|
|
}
|
|
s.openaiOAuth429WindowCount.Add(1)
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) isOpenAIOAuth429Storm() bool {
|
|
if s == nil {
|
|
return false
|
|
}
|
|
windowStart := s.openaiOAuth429WindowStartUnixNano.Load()
|
|
if windowStart == 0 || time.Since(time.Unix(0, windowStart)) >= openAIOAuth429StormWindow {
|
|
return false
|
|
}
|
|
return s.openaiOAuth429WindowCount.Load() >= openAIOAuth429StormThreshold
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) ShouldStopOpenAIOAuth429Failover(account *Account, statusCode int, failedSwitches int) bool {
|
|
if statusCode != http.StatusTooManyRequests || failedSwitches < openAIOAuth429StormMaxAccountSwitches {
|
|
return false
|
|
}
|
|
if isGrokOAuthAccount(account) {
|
|
return true
|
|
}
|
|
if !isOpenAIOAuthAccount(account) {
|
|
return false
|
|
}
|
|
return s.isOpenAIOAuth429Storm()
|
|
}
|