From 8a260defc27352a92797ae94be506001da16f623 Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 16 Mar 2026 04:31:22 +0800 Subject: [PATCH] refactor: replace sync.Map credits state with AICredits rate limit key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace process-memory sync.Map + per-model runtime state with a single "AICredits" key in model_rate_limits, making credits exhaustion fully isomorphic with model-level rate limiting. Scheduler: rate-limited accounts with overages enabled + credits available are now scheduled instead of excluded. Forwarding: when model is rate-limited + credits available, inject credits proactively without waiting for a 429 round trip. Storage: credits exhaustion stored as model_rate_limits["AICredits"] with 5h duration, reusing SetModelRateLimit/isRateLimitActiveForKey. Frontend: show credits_active (yellow ⚡) when model rate-limited but credits available, credits_exhausted (red) when AICredits key active. Tests: add unit tests for shouldMarkCreditsExhausted, injectEnabledCreditTypes, clearCreditsExhausted, and update existing overages tests. --- backend/internal/service/admin_service.go | 57 ++-- .../service/admin_service_overages_test.go | 44 ++- .../service/antigravity_credits_overages.go | 201 ++++-------- .../antigravity_credits_overages_test.go | 292 +++++++++++++++--- .../service/antigravity_gateway_service.go | 40 ++- .../service/antigravity_quota_scope.go | 4 + backend/internal/service/ratelimit_service.go | 7 +- .../account/AccountStatusIndicator.vue | 58 ++-- .../__tests__/AccountStatusIndicator.spec.ts | 73 ++++- frontend/src/i18n/locales/en.ts | 120 +++++-- frontend/src/i18n/locales/zh.ts | 112 +++++-- frontend/src/types/index.ts | 11 +- 12 files changed, 692 insertions(+), 327 deletions(-) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 08caed9e..ad0c81ef 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -143,9 +143,10 @@ type CreateGroupInput struct { // 无效请求兜底分组 ID(仅 anthropic 平台使用) FallbackGroupIDOnInvalidRequest *int64 // 模型路由配置(仅 anthropic 平台使用) - ModelRouting map[string][]int64 - ModelRoutingEnabled bool // 是否启用模型路由 - MCPXMLInject *bool + ModelRouting map[string][]int64 + ModelRoutingEnabled bool // 是否启用模型路由 + MCPXMLInject *bool + SimulateClaudeMaxEnabled *bool // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string // Sora 存储配额 @@ -182,9 +183,10 @@ type UpdateGroupInput struct { // 无效请求兜底分组 ID(仅 anthropic 平台使用) FallbackGroupIDOnInvalidRequest *int64 // 模型路由配置(仅 anthropic 平台使用) - ModelRouting map[string][]int64 - ModelRoutingEnabled *bool // 是否启用模型路由 - MCPXMLInject *bool + ModelRouting map[string][]int64 + ModelRoutingEnabled *bool // 是否启用模型路由 + MCPXMLInject *bool + SimulateClaudeMaxEnabled *bool // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes *[]string // Sora 存储配额 @@ -368,6 +370,10 @@ type ProxyExitInfoProber interface { ProbeProxy(ctx context.Context, proxyURL string) (*ProxyExitInfo, int64, error) } +type groupExistenceBatchReader interface { + ExistsByIDs(ctx context.Context, ids []int64) (map[int64]bool, error) +} + type proxyQualityTarget struct { Target string URL string @@ -445,10 +451,6 @@ type userGroupRateBatchReader interface { GetByUserIDs(ctx context.Context, userIDs []int64) (map[int64]map[int64]float64, error) } -type groupExistenceBatchReader interface { - ExistsByIDs(ctx context.Context, ids []int64) (map[int64]bool, error) -} - // NewAdminService creates a new AdminService func NewAdminService( userRepo UserRepository, @@ -868,6 +870,13 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn if input.MCPXMLInject != nil { mcpXMLInject = *input.MCPXMLInject } + simulateClaudeMaxEnabled := false + if input.SimulateClaudeMaxEnabled != nil { + if platform != PlatformAnthropic && *input.SimulateClaudeMaxEnabled { + return nil, fmt.Errorf("simulate_claude_max_enabled only supported for anthropic groups") + } + simulateClaudeMaxEnabled = *input.SimulateClaudeMaxEnabled + } // 如果指定了复制账号的源分组,先获取账号 ID 列表 var accountIDsToCopy []int64 @@ -924,6 +933,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest, ModelRouting: input.ModelRouting, MCPXMLInject: mcpXMLInject, + SimulateClaudeMaxEnabled: simulateClaudeMaxEnabled, SupportedModelScopes: input.SupportedModelScopes, SoraStorageQuotaBytes: input.SoraStorageQuotaBytes, AllowMessagesDispatch: input.AllowMessagesDispatch, @@ -1130,6 +1140,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.MCPXMLInject != nil { group.MCPXMLInject = *input.MCPXMLInject } + if input.SimulateClaudeMaxEnabled != nil { + if group.Platform != PlatformAnthropic && *input.SimulateClaudeMaxEnabled { + return nil, fmt.Errorf("simulate_claude_max_enabled only supported for anthropic groups") + } + group.SimulateClaudeMaxEnabled = *input.SimulateClaudeMaxEnabled + } + if group.Platform != PlatformAnthropic { + group.SimulateClaudeMaxEnabled = false + } // 支持的模型系列(仅 antigravity 平台使用) if input.SupportedModelScopes != nil { @@ -1530,7 +1549,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if len(input.Credentials) > 0 { account.Credentials = input.Credentials } - if input.Extra != nil { + if len(input.Extra) > 0 { // 保留配额用量字段,防止编辑账号时意外重置 for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} { if v, ok := account.Extra[key]; ok { @@ -1539,11 +1558,15 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U } account.Extra = input.Extra if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() { - delete(account.Extra, antigravityCreditsOveragesKey) + delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 + // 清除 AICredits 限流 key + if rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any); ok { + delete(rawLimits, creditsExhaustedKey) + } } if account.Platform == PlatformAntigravity && !wasOveragesEnabled && account.IsOveragesEnabled() { delete(account.Extra, modelRateLimitsKey) - delete(account.Extra, antigravityCreditsOveragesKey) + delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 } // 校验并预计算固定时间重置的下次重置时间 if err := ValidateQuotaResetConfig(account.Extra); err != nil { @@ -1627,14 +1650,6 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err := s.accountRepo.Update(ctx, account); err != nil { return nil, err } - if account.Platform == PlatformAntigravity { - if !account.IsOveragesEnabled() && wasOveragesEnabled { - clearCreditsExhausted(account.ID) - } - if account.IsOveragesEnabled() && !wasOveragesEnabled { - clearCreditsExhausted(account.ID) - } - } // 绑定分组 if input.GroupIDs != nil { diff --git a/backend/internal/service/admin_service_overages_test.go b/backend/internal/service/admin_service_overages_test.go index ea5a4110..779b08b9 100644 --- a/backend/internal/service/admin_service_overages_test.go +++ b/backend/internal/service/admin_service_overages_test.go @@ -26,7 +26,7 @@ func (r *updateAccountOveragesRepoStub) Update(ctx context.Context, account *Acc return nil } -func TestUpdateAccount_DisableOveragesClearsRuntimeStateBeforePersist(t *testing.T) { +func TestUpdateAccount_DisableOveragesClearsAICreditsKey(t *testing.T) { accountID := int64(101) repo := &updateAccountOveragesRepoStub{ account: &Account{ @@ -37,24 +37,34 @@ func TestUpdateAccount_DisableOveragesClearsRuntimeStateBeforePersist(t *testing Extra: map[string]any{ "allow_overages": true, "mixed_scheduling": true, - antigravityCreditsOveragesKey: map[string]any{ + modelRateLimitsKey: map[string]any{ "claude-sonnet-4-5": map[string]any{ - "activated_at": "2026-03-15T00:00:00Z", - "active_until": "2099-03-15T00:00:00Z", + "rate_limited_at": "2026-03-15T00:00:00Z", + "rate_limit_reset_at": "2099-03-15T00:00:00Z", + }, + creditsExhaustedKey: map[string]any{ + "rate_limited_at": "2026-03-15T00:00:00Z", + "rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339), }, }, }, }, } - setCreditsExhausted(accountID, time.Now().Add(time.Minute)) - t.Cleanup(func() { - clearCreditsExhausted(accountID) - }) svc := &adminServiceImpl{accountRepo: repo} updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ Extra: map[string]any{ "mixed_scheduling": true, + modelRateLimitsKey: map[string]any{ + "claude-sonnet-4-5": map[string]any{ + "rate_limited_at": "2026-03-15T00:00:00Z", + "rate_limit_reset_at": "2099-03-15T00:00:00Z", + }, + creditsExhaustedKey: map[string]any{ + "rate_limited_at": "2026-03-15T00:00:00Z", + "rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339), + }, + }, }, }) @@ -62,10 +72,17 @@ func TestUpdateAccount_DisableOveragesClearsRuntimeStateBeforePersist(t *testing require.NotNil(t, updated) require.Equal(t, 1, repo.updateCalls) require.False(t, updated.IsOveragesEnabled()) - require.False(t, isCreditsExhausted(accountID)) - _, exists := repo.account.Extra[antigravityCreditsOveragesKey] - require.False(t, exists, "关闭 overages 时应在持久化前移除运行态") + // 关闭 overages 后,AICredits key 应被清除 + rawLimits, ok := repo.account.Extra[modelRateLimitsKey].(map[string]any) + if ok { + _, exists := rawLimits[creditsExhaustedKey] + require.False(t, exists, "关闭 overages 时应清除 AICredits 限流 key") + } + // 普通模型限流应保留 + require.True(t, ok) + _, exists := rawLimits["claude-sonnet-4-5"] + require.True(t, exists, "普通模型限流应保留") } func TestUpdateAccount_EnableOveragesClearsModelRateLimitsBeforePersist(t *testing.T) { @@ -87,10 +104,6 @@ func TestUpdateAccount_EnableOveragesClearsModelRateLimitsBeforePersist(t *testi }, }, } - setCreditsExhausted(accountID, time.Now().Add(time.Minute)) - t.Cleanup(func() { - clearCreditsExhausted(accountID) - }) svc := &adminServiceImpl{accountRepo: repo} updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ @@ -104,7 +117,6 @@ func TestUpdateAccount_EnableOveragesClearsModelRateLimitsBeforePersist(t *testi require.NotNil(t, updated) require.Equal(t, 1, repo.updateCalls) require.True(t, updated.IsOveragesEnabled()) - require.False(t, isCreditsExhausted(accountID)) _, exists := repo.account.Extra[modelRateLimitsKey] require.False(t, exists, "开启 overages 时应在持久化前清掉旧模型限流") diff --git a/backend/internal/service/antigravity_credits_overages.go b/backend/internal/service/antigravity_credits_overages.go index 69d67f28..fc622ab0 100644 --- a/backend/internal/service/antigravity_credits_overages.go +++ b/backend/internal/service/antigravity_credits_overages.go @@ -6,14 +6,18 @@ import ( "io" "net/http" "strings" - "sync" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" ) -const antigravityCreditsOveragesKey = "antigravity_credits_overages" +const ( + // creditsExhaustedKey 是 model_rate_limits 中标记积分耗尽的特殊 key。 + // 与普通模型限流完全同构:通过 SetModelRateLimit / isRateLimitActiveForKey 读写。 + creditsExhaustedKey = "AICredits" + creditsExhaustedDuration = 5 * time.Hour +) type antigravity429Category string @@ -24,8 +28,6 @@ const ( ) var ( - creditsExhaustedCache sync.Map - antigravityQuotaExhaustedKeywords = []string{ "quota_exhausted", "quota exhausted", @@ -46,28 +48,48 @@ var ( } ) -// isCreditsExhausted 检查账号的 AI Credits 是否已被标记为耗尽。 -func isCreditsExhausted(accountID int64) bool { - v, ok := creditsExhaustedCache.Load(accountID) +// isCreditsExhausted 检查账号的 AICredits 限流 key 是否生效(积分是否耗尽)。 +func (a *Account) isCreditsExhausted() bool { + if a == nil { + return false + } + return a.isRateLimitActiveForKey(creditsExhaustedKey) +} + +// setCreditsExhausted 标记账号积分耗尽:写入 model_rate_limits["AICredits"] + 更新缓存。 +func (s *AntigravityGatewayService) setCreditsExhausted(ctx context.Context, account *Account) { + if account == nil || account.ID == 0 { + return + } + resetAt := time.Now().Add(creditsExhaustedDuration) + if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, creditsExhaustedKey, resetAt); err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "set credits exhausted failed: account=%d err=%v", account.ID, err) + return + } + s.updateAccountModelRateLimitInCache(ctx, account, creditsExhaustedKey, resetAt) + logger.LegacyPrintf("service.antigravity_gateway", "credits_exhausted_marked account=%d reset_at=%s", + account.ID, resetAt.UTC().Format(time.RFC3339)) +} + +// clearCreditsExhausted 清除账号的 AICredits 限流 key。 +func (s *AntigravityGatewayService) clearCreditsExhausted(ctx context.Context, account *Account) { + if account == nil || account.ID == 0 || account.Extra == nil { + return + } + rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any) if !ok { - return false + return } - until, ok := v.(time.Time) - if !ok || time.Now().After(until) { - creditsExhaustedCache.Delete(accountID) - return false + if _, exists := rawLimits[creditsExhaustedKey]; !exists { + return + } + delete(rawLimits, creditsExhaustedKey) + account.Extra[modelRateLimitsKey] = rawLimits + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + modelRateLimitsKey: rawLimits, + }); err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "clear credits exhausted failed: account=%d err=%v", account.ID, err) } - return true -} - -// setCreditsExhausted 将账号标记为 AI Credits 已耗尽,直到指定时间。 -func setCreditsExhausted(accountID int64, until time.Time) { - creditsExhaustedCache.Store(accountID, until) -} - -// clearCreditsExhausted 清除账号的 AI Credits 耗尽标记。 -func clearCreditsExhausted(accountID int64) { - creditsExhaustedCache.Delete(accountID) } // classifyAntigravity429 将 Antigravity 的 429 响应归类为配额耗尽、限流或未知。 @@ -117,111 +139,6 @@ func resolveCreditsOveragesModelKey(ctx context.Context, account *Account, upstr return resolveAntigravityModelKey(requestedModel) } -// canUseAntigravityCreditsOverages 判断当前请求是否应直接走已激活的 overages 链路。 -func canUseAntigravityCreditsOverages(ctx context.Context, account *Account, requestedModel string) bool { - if account == nil || account.Platform != PlatformAntigravity { - return false - } - if !account.IsSchedulable() { - return false - } - if !account.IsOveragesEnabled() || isCreditsExhausted(account.ID) { - return false - } - return account.getAntigravityCreditsOveragesRemainingTimeWithContext(ctx, requestedModel) > 0 -} - -func (a *Account) getAntigravityCreditsOveragesRemainingTimeWithContext(ctx context.Context, requestedModel string) time.Duration { - if a == nil || a.Extra == nil { - return 0 - } - modelKey := resolveFinalAntigravityModelKey(ctx, a, requestedModel) - modelKey = strings.TrimSpace(modelKey) - if modelKey == "" { - return 0 - } - rawStates, ok := a.Extra[antigravityCreditsOveragesKey].(map[string]any) - if !ok { - return 0 - } - rawState, ok := rawStates[modelKey].(map[string]any) - if !ok { - return 0 - } - activeUntilRaw, ok := rawState["active_until"].(string) - if !ok || strings.TrimSpace(activeUntilRaw) == "" { - return 0 - } - activeUntil, err := time.Parse(time.RFC3339, activeUntilRaw) - if err != nil { - return 0 - } - remaining := time.Until(activeUntil) - if remaining > 0 { - return remaining - } - return 0 -} - -func setAntigravityCreditsOveragesActive(ctx context.Context, repo AccountRepository, account *Account, modelKey string, activeUntil time.Time) { - if repo == nil || account == nil || account.ID == 0 || strings.TrimSpace(modelKey) == "" { - return - } - stateMap := copyAntigravityCreditsOveragesState(account) - stateMap[modelKey] = map[string]any{ - "activated_at": time.Now().UTC().Format(time.RFC3339), - "active_until": activeUntil.UTC().Format(time.RFC3339), - } - ensureAccountExtra(account) - account.Extra[antigravityCreditsOveragesKey] = stateMap - if err := repo.UpdateExtra(ctx, account.ID, map[string]any{antigravityCreditsOveragesKey: stateMap}); err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "set overages state failed: account=%d model=%s err=%v", account.ID, modelKey, err) - } -} - -func clearAntigravityCreditsOveragesState(ctx context.Context, repo AccountRepository, accountID int64) error { - if repo == nil || accountID == 0 { - return nil - } - return repo.UpdateExtra(ctx, accountID, map[string]any{ - antigravityCreditsOveragesKey: map[string]any{}, - }) -} - -func clearAntigravityCreditsOveragesStateForModel(ctx context.Context, repo AccountRepository, account *Account, modelKey string) { - if repo == nil || account == nil || account.ID == 0 { - return - } - stateMap := copyAntigravityCreditsOveragesState(account) - delete(stateMap, modelKey) - ensureAccountExtra(account) - account.Extra[antigravityCreditsOveragesKey] = stateMap - if err := repo.UpdateExtra(ctx, account.ID, map[string]any{antigravityCreditsOveragesKey: stateMap}); err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "clear overages state failed: account=%d model=%s err=%v", account.ID, modelKey, err) - } -} - -func copyAntigravityCreditsOveragesState(account *Account) map[string]any { - result := make(map[string]any) - if account == nil || account.Extra == nil { - return result - } - rawState, ok := account.Extra[antigravityCreditsOveragesKey].(map[string]any) - if !ok { - return result - } - for key, value := range rawState { - result[key] = value - } - return result -} - -func ensureAccountExtra(account *Account) { - if account != nil && account.Extra == nil { - account.Extra = make(map[string]any) - } -} - // shouldMarkCreditsExhausted 判断一次 credits 请求失败是否应标记为 credits 耗尽。 func shouldMarkCreditsExhausted(resp *http.Response, respBody []byte, reqErr error) bool { if reqErr != nil || resp == nil { @@ -276,23 +193,21 @@ func (s *AntigravityGatewayService) attemptCreditsOveragesRetry( creditsResp, err := p.httpUpstream.Do(creditsReq, p.proxyURL, p.account.ID, p.account.Concurrency) if err == nil && creditsResp != nil && creditsResp.StatusCode < 400 { - clearCreditsExhausted(p.account.ID) - activeUntil := s.resolveCreditsOveragesActiveUntil(respBody, waitDuration) - setAntigravityCreditsOveragesActive(p.ctx, p.accountRepo, p.account, modelKey, activeUntil) - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d credit_overages_success model=%s account=%d active_until=%s", - p.prefix, creditsResp.StatusCode, modelKey, p.account.ID, activeUntil.UTC().Format(time.RFC3339)) + s.clearCreditsExhausted(p.ctx, p.account) + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d credit_overages_success model=%s account=%d", + p.prefix, creditsResp.StatusCode, modelKey, p.account.ID) return &creditsOveragesRetryResult{handled: true, resp: creditsResp} } - s.handleCreditsRetryFailure(p.prefix, modelKey, p.account, waitDuration, creditsResp, err) + s.handleCreditsRetryFailure(p.ctx, p.prefix, modelKey, p.account, creditsResp, err) return &creditsOveragesRetryResult{handled: true} } func (s *AntigravityGatewayService) handleCreditsRetryFailure( + ctx context.Context, prefix string, modelKey string, account *Account, - waitDuration time.Duration, creditsResp *http.Response, reqErr error, ) { @@ -307,11 +222,9 @@ func (s *AntigravityGatewayService) handleCreditsRetryFailure( } if shouldMarkCreditsExhausted(creditsResp, creditsRespBody, reqErr) && account != nil { - exhaustedUntil := s.resolveCreditsOveragesActiveUntil(creditsRespBody, waitDuration) - setCreditsExhausted(account.ID, exhaustedUntil) - clearAntigravityCreditsOveragesStateForModel(context.Background(), s.accountRepo, account, modelKey) - logger.LegacyPrintf("service.antigravity_gateway", "%s credit_overages_failed model=%s account=%d marked_exhausted=true status=%d exhausted_until=%v body=%s", - prefix, modelKey, account.ID, creditsStatusCode, exhaustedUntil, truncateForLog(creditsRespBody, 200)) + s.setCreditsExhausted(ctx, account) + logger.LegacyPrintf("service.antigravity_gateway", "%s credit_overages_failed model=%s account=%d marked_exhausted=true status=%d body=%s", + prefix, modelKey, account.ID, creditsStatusCode, truncateForLog(creditsRespBody, 200)) return } if account != nil { @@ -320,11 +233,3 @@ func (s *AntigravityGatewayService) handleCreditsRetryFailure( } } -func (s *AntigravityGatewayService) resolveCreditsOveragesActiveUntil(respBody []byte, waitDuration time.Duration) time.Time { - resetAt := ParseGeminiRateLimitResetTime(respBody) - defaultDur := waitDuration - if defaultDur <= 0 { - defaultDur = s.getDefaultRateLimitDuration() - } - return s.resolveResetTime(resetAt, defaultDur) -} diff --git a/backend/internal/service/antigravity_credits_overages_test.go b/backend/internal/service/antigravity_credits_overages_test.go index ae05273c..bc679494 100644 --- a/backend/internal/service/antigravity_credits_overages_test.go +++ b/backend/internal/service/antigravity_credits_overages_test.go @@ -40,58 +40,50 @@ func TestClassifyAntigravity429(t *testing.T) { }) } -func TestCanUseAntigravityCreditsOverages(t *testing.T) { - activeUntil := time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339) - - t.Run("必须有运行态才可直接走 overages", func(t *testing.T) { +func TestIsCreditsExhausted_UsesAICreditsKey(t *testing.T) { + t.Run("无 AICredits key 则积分可用", func(t *testing.T) { account := &Account{ - ID: 1, - Platform: PlatformAntigravity, - Status: StatusActive, - Schedulable: true, + ID: 1, + Platform: PlatformAntigravity, Extra: map[string]any{ "allow_overages": true, }, } - require.False(t, canUseAntigravityCreditsOverages(context.Background(), account, "claude-sonnet-4-5")) + require.False(t, account.isCreditsExhausted()) }) - t.Run("运行态有效时允许使用 overages", func(t *testing.T) { + t.Run("AICredits key 生效则积分耗尽", func(t *testing.T) { account := &Account{ - ID: 2, - Platform: PlatformAntigravity, - Status: StatusActive, - Schedulable: true, + ID: 2, + Platform: PlatformAntigravity, Extra: map[string]any{ "allow_overages": true, - antigravityCreditsOveragesKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ - "active_until": activeUntil, + modelRateLimitsKey: map[string]any{ + creditsExhaustedKey: map[string]any{ + "rate_limited_at": time.Now().UTC().Format(time.RFC3339), + "rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339), }, }, }, } - require.True(t, canUseAntigravityCreditsOverages(context.Background(), account, "claude-sonnet-4-5")) + require.True(t, account.isCreditsExhausted()) }) - t.Run("credits 耗尽后不可继续使用 overages", func(t *testing.T) { + t.Run("AICredits key 过期则积分可用", func(t *testing.T) { account := &Account{ - ID: 3, - Platform: PlatformAntigravity, - Status: StatusActive, - Schedulable: true, + ID: 3, + Platform: PlatformAntigravity, Extra: map[string]any{ "allow_overages": true, - antigravityCreditsOveragesKey: map[string]any{ - "claude-sonnet-4-5": map[string]any{ - "active_until": activeUntil, + modelRateLimitsKey: map[string]any{ + creditsExhaustedKey: map[string]any{ + "rate_limited_at": time.Now().Add(-6 * time.Hour).UTC().Format(time.RFC3339), + "rate_limit_reset_at": time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339), }, }, }, } - setCreditsExhausted(account.ID, time.Now().Add(time.Minute)) - t.Cleanup(func() { clearCreditsExhausted(account.ID) }) - require.False(t, canUseAntigravityCreditsOverages(context.Background(), account, "claude-sonnet-4-5")) + require.False(t, account.isCreditsExhausted()) }) } @@ -152,12 +144,6 @@ func TestHandleSmartRetry_QuotaExhausted_UsesCreditsAndStoresIndependentState(t require.Len(t, upstream.requestBodies, 1) require.Contains(t, string(upstream.requestBodies[0]), "enabledCreditTypes") require.Empty(t, repo.modelRateLimitCalls, "overages 成功后不应写入普通 model_rate_limits") - require.Len(t, repo.extraUpdateCalls, 1) - - state, ok := account.Extra[antigravityCreditsOveragesKey].(map[string]any) - require.True(t, ok) - _, exists := state["claude-sonnet-4-5"] - require.True(t, exists, "应使用最终映射模型写入独立 overages 运行态") } func TestHandleSmartRetry_RateLimited_DoesNotUseCredits(t *testing.T) { @@ -221,7 +207,7 @@ func TestHandleSmartRetry_RateLimited_DoesNotUseCredits(t *testing.T) { require.Empty(t, repo.modelRateLimitCalls) } -func TestAntigravityRetryLoop_ActiveOverages_InjectsCreditsBody(t *testing.T) { +func TestAntigravityRetryLoop_ModelRateLimited_InjectsCredits(t *testing.T) { oldBaseURLs := append([]string(nil), antigravity.BaseURLs...) oldAvailability := antigravity.DefaultURLAvailability defer func() { @@ -232,7 +218,6 @@ func TestAntigravityRetryLoop_ActiveOverages_InjectsCreditsBody(t *testing.T) { antigravity.BaseURLs = []string{"https://ag-1.test"} antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute) - activeUntil := time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339) upstream := &queuedHTTPUpstreamStub{ responses: []*http.Response{ { @@ -243,6 +228,7 @@ func TestAntigravityRetryLoop_ActiveOverages_InjectsCreditsBody(t *testing.T) { }, errors: []error{nil}, } + // 模型已限流 + overages 启用 + 无 AICredits key → 应直接注入积分 account := &Account{ ID: 103, Name: "acc-103", @@ -252,9 +238,10 @@ func TestAntigravityRetryLoop_ActiveOverages_InjectsCreditsBody(t *testing.T) { Schedulable: true, Extra: map[string]any{ "allow_overages": true, - antigravityCreditsOveragesKey: map[string]any{ + modelRateLimitsKey: map[string]any{ "claude-sonnet-4-5": map[string]any{ - "active_until": activeUntil, + "rate_limited_at": time.Now().UTC().Format(time.RFC3339), + "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), }, }, }, @@ -281,7 +268,61 @@ func TestAntigravityRetryLoop_ActiveOverages_InjectsCreditsBody(t *testing.T) { require.Contains(t, string(upstream.requestBodies[0]), "enabledCreditTypes") } -func TestAntigravityRetryLoop_ActiveOverages_ExplicitCreditErrorMarksExhausted(t *testing.T) { +func TestAntigravityRetryLoop_CreditsExhausted_DoesNotInject(t *testing.T) { + oldBaseURLs := append([]string(nil), antigravity.BaseURLs...) + oldAvailability := antigravity.DefaultURLAvailability + defer func() { + antigravity.BaseURLs = oldBaseURLs + antigravity.DefaultURLAvailability = oldAvailability + }() + + antigravity.BaseURLs = []string{"https://ag-1.test"} + antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute) + + // 模型限流 + overages 启用 + AICredits key 生效 → 不应注入积分,应切号 + account := &Account{ + ID: 104, + Name: "acc-104", + Type: AccountTypeOAuth, + Platform: PlatformAntigravity, + Status: StatusActive, + Schedulable: true, + Extra: map[string]any{ + "allow_overages": true, + modelRateLimitsKey: map[string]any{ + "claude-sonnet-4-5": map[string]any{ + "rate_limited_at": time.Now().UTC().Format(time.RFC3339), + "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), + }, + creditsExhaustedKey: map[string]any{ + "rate_limited_at": time.Now().UTC().Format(time.RFC3339), + "rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339), + }, + }, + }, + } + + svc := &AntigravityGatewayService{} + _, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{ + ctx: context.Background(), + prefix: "[test]", + account: account, + accessToken: "token", + action: "generateContent", + body: []byte(`{"model":"claude-sonnet-4-5","request":{}}`), + requestedModel: "claude-sonnet-4-5", + handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult { + return nil + }, + }) + + // 模型限流 + 积分耗尽 → 应触发切号错误 + require.Error(t, err) + var switchErr *AntigravityAccountSwitchError + require.ErrorAs(t, err, &switchErr) +} + +func TestAntigravityRetryLoop_CreditErrorMarksExhausted(t *testing.T) { oldBaseURLs := append([]string(nil), antigravity.BaseURLs...) oldAvailability := antigravity.DefaultURLAvailability defer func() { @@ -292,8 +333,6 @@ func TestAntigravityRetryLoop_ActiveOverages_ExplicitCreditErrorMarksExhausted(t antigravity.BaseURLs = []string{"https://ag-1.test"} antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute) - accountID := int64(104) - activeUntil := time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339) repo := &stubAntigravityAccountRepo{} upstream := &queuedHTTPUpstreamStub{ responses: []*http.Response{ @@ -305,24 +344,24 @@ func TestAntigravityRetryLoop_ActiveOverages_ExplicitCreditErrorMarksExhausted(t }, errors: []error{nil}, } + // 模型限流 + overages 启用 + 积分可用 → 注入积分但上游返回积分不足 account := &Account{ - ID: accountID, - Name: "acc-104", + ID: 105, + Name: "acc-105", Type: AccountTypeOAuth, Platform: PlatformAntigravity, Status: StatusActive, Schedulable: true, Extra: map[string]any{ "allow_overages": true, - antigravityCreditsOveragesKey: map[string]any{ + modelRateLimitsKey: map[string]any{ "claude-sonnet-4-5": map[string]any{ - "active_until": activeUntil, + "rate_limited_at": time.Now().UTC().Format(time.RFC3339), + "rate_limit_reset_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), }, }, }, } - clearCreditsExhausted(accountID) - t.Cleanup(func() { clearCreditsExhausted(accountID) }) svc := &AntigravityGatewayService{accountRepo: repo} result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{ @@ -333,6 +372,7 @@ func TestAntigravityRetryLoop_ActiveOverages_ExplicitCreditErrorMarksExhausted(t action: "generateContent", body: []byte(`{"model":"claude-sonnet-4-5","request":{}}`), httpUpstream: upstream, + accountRepo: repo, requestedModel: "claude-sonnet-4-5", handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult { return nil @@ -341,6 +381,158 @@ func TestAntigravityRetryLoop_ActiveOverages_ExplicitCreditErrorMarksExhausted(t require.NoError(t, err) require.NotNil(t, result) - require.True(t, isCreditsExhausted(accountID)) - require.Len(t, repo.extraUpdateCalls, 1, "应清理对应模型的 overages 运行态") + // 验证 AICredits key 已通过 SetModelRateLimit 写入数据库 + require.Len(t, repo.modelRateLimitCalls, 1, "应通过 SetModelRateLimit 写入 AICredits key") + require.Equal(t, creditsExhaustedKey, repo.modelRateLimitCalls[0].modelKey) +} + +func TestShouldMarkCreditsExhausted(t *testing.T) { + t.Run("reqErr 不为 nil 时不标记", func(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusForbidden} + require.False(t, shouldMarkCreditsExhausted(resp, []byte(`{"error":"Insufficient credits"}`), io.ErrUnexpectedEOF)) + }) + + t.Run("resp 为 nil 时不标记", func(t *testing.T) { + require.False(t, shouldMarkCreditsExhausted(nil, []byte(`{"error":"Insufficient credits"}`), nil)) + }) + + t.Run("5xx 响应不标记", func(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusInternalServerError} + require.False(t, shouldMarkCreditsExhausted(resp, []byte(`{"error":"Insufficient credits"}`), nil)) + }) + + t.Run("408 RequestTimeout 不标记", func(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusRequestTimeout} + require.False(t, shouldMarkCreditsExhausted(resp, []byte(`{"error":"Insufficient credits"}`), nil)) + }) + + t.Run("URL 级限流不标记", func(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusTooManyRequests} + body := []byte(`{"error":{"message":"Resource has been exhausted"}}`) + require.False(t, shouldMarkCreditsExhausted(resp, body, nil)) + }) + + t.Run("结构化限流不标记", func(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusTooManyRequests} + body := []byte(`{"error":{"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"RATE_LIMIT_EXCEEDED"},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"0.5s"}]}}`) + require.False(t, shouldMarkCreditsExhausted(resp, body, nil)) + }) + + t.Run("含 credits 关键词时标记", func(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusForbidden} + for _, keyword := range []string{ + "Insufficient GOOGLE_ONE_AI credits", + "insufficient credit balance", + "not enough credits for this request", + "Credits exhausted", + "minimumCreditAmountForUsage requirement not met", + } { + body := []byte(`{"error":{"message":"` + keyword + `"}}`) + require.True(t, shouldMarkCreditsExhausted(resp, body, nil), "should mark for keyword: %s", keyword) + } + }) + + t.Run("无 credits 关键词时不标记", func(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusForbidden} + body := []byte(`{"error":{"message":"permission denied"}}`) + require.False(t, shouldMarkCreditsExhausted(resp, body, nil)) + }) +} + +func TestInjectEnabledCreditTypes(t *testing.T) { + t.Run("正常 JSON 注入成功", func(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-4-5","request":{}}`) + result := injectEnabledCreditTypes(body) + require.NotNil(t, result) + require.Contains(t, string(result), `"enabledCreditTypes"`) + require.Contains(t, string(result), `GOOGLE_ONE_AI`) + }) + + t.Run("非法 JSON 返回 nil", func(t *testing.T) { + require.Nil(t, injectEnabledCreditTypes([]byte(`not json`))) + }) + + t.Run("空 body 返回 nil", func(t *testing.T) { + require.Nil(t, injectEnabledCreditTypes([]byte{})) + }) + + t.Run("已有 enabledCreditTypes 会被覆盖", func(t *testing.T) { + body := []byte(`{"enabledCreditTypes":["OLD"],"model":"test"}`) + result := injectEnabledCreditTypes(body) + require.NotNil(t, result) + require.Contains(t, string(result), `GOOGLE_ONE_AI`) + require.NotContains(t, string(result), `OLD`) + }) +} + +func TestClearCreditsExhausted(t *testing.T) { + t.Run("account 为 nil 不操作", func(t *testing.T) { + repo := &stubAntigravityAccountRepo{} + svc := &AntigravityGatewayService{accountRepo: repo} + svc.clearCreditsExhausted(context.Background(), nil) + require.Empty(t, repo.extraUpdateCalls) + }) + + t.Run("Extra 为 nil 不操作", func(t *testing.T) { + repo := &stubAntigravityAccountRepo{} + svc := &AntigravityGatewayService{accountRepo: repo} + svc.clearCreditsExhausted(context.Background(), &Account{ID: 1}) + require.Empty(t, repo.extraUpdateCalls) + }) + + t.Run("无 modelRateLimitsKey 不操作", func(t *testing.T) { + repo := &stubAntigravityAccountRepo{} + svc := &AntigravityGatewayService{accountRepo: repo} + svc.clearCreditsExhausted(context.Background(), &Account{ + ID: 1, + Extra: map[string]any{"some_key": "value"}, + }) + require.Empty(t, repo.extraUpdateCalls) + }) + + t.Run("无 AICredits key 不操作", func(t *testing.T) { + repo := &stubAntigravityAccountRepo{} + svc := &AntigravityGatewayService{accountRepo: repo} + svc.clearCreditsExhausted(context.Background(), &Account{ + ID: 1, + Extra: map[string]any{ + modelRateLimitsKey: map[string]any{ + "claude-sonnet-4-5": map[string]any{ + "rate_limited_at": "2026-03-15T00:00:00Z", + "rate_limit_reset_at": "2099-03-15T00:00:00Z", + }, + }, + }, + }) + require.Empty(t, repo.extraUpdateCalls) + }) + + t.Run("有 AICredits key 时删除并调用 UpdateExtra", func(t *testing.T) { + repo := &stubAntigravityAccountRepo{} + svc := &AntigravityGatewayService{accountRepo: repo} + account := &Account{ + ID: 1, + Extra: map[string]any{ + modelRateLimitsKey: map[string]any{ + "claude-sonnet-4-5": map[string]any{ + "rate_limited_at": "2026-03-15T00:00:00Z", + "rate_limit_reset_at": "2099-03-15T00:00:00Z", + }, + creditsExhaustedKey: map[string]any{ + "rate_limited_at": "2026-03-15T00:00:00Z", + "rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339), + }, + }, + }, + } + svc.clearCreditsExhausted(context.Background(), account) + require.Len(t, repo.extraUpdateCalls, 1) + // AICredits key 应被删除 + rawLimits := account.Extra[modelRateLimitsKey].(map[string]any) + _, exists := rawLimits[creditsExhaustedKey] + require.False(t, exists, "AICredits key 应被删除") + // 普通模型限流应保留 + _, exists = rawLimits["claude-sonnet-4-5"] + require.True(t, exists, "普通模型限流应保留") + }) } diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index b40ad686..fda26b53 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -201,7 +201,7 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam if resp.StatusCode == http.StatusTooManyRequests && category == antigravity429QuotaExhausted && p.account.IsOveragesEnabled() && - !isCreditsExhausted(p.account.ID) { + !p.account.isCreditsExhausted() { result := s.attemptCreditsOveragesRetry(p, baseURL, modelName, waitDuration, resp.StatusCode, respBody) if result.handled && result.resp != nil { return &smartRetryResult{ @@ -552,13 +552,15 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace( // antigravityRetryLoop 执行带 URL fallback 的重试循环 func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopParams) (*antigravityRetryLoopResult, error) { - // 预检查:如果模型已进入 overages 运行态,则直接注入 AI Credits。 - overagesActive := false - if p.requestedModel != "" && canUseAntigravityCreditsOverages(p.ctx, p.account, p.requestedModel) { + // 预检查:模型限流 + overages 启用 + 积分未耗尽 → 直接注入 AI Credits + overagesInjected := false + if p.requestedModel != "" && p.account.Platform == PlatformAntigravity && + p.account.IsOveragesEnabled() && !p.account.isCreditsExhausted() && + p.account.isModelRateLimitedWithContext(p.ctx, p.requestedModel) { if creditsBody := injectEnabledCreditTypes(p.body); creditsBody != nil { p.body = creditsBody - overagesActive = true - logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: credit_overages_active model=%s account=%d (injecting enabledCreditTypes)", + overagesInjected = true + logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: model_rate_limited_credits_inject model=%s account=%d (injecting enabledCreditTypes)", p.prefix, p.requestedModel, p.account.ID) } } @@ -566,9 +568,9 @@ func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopP // 预检查:如果账号已限流,直接返回切换信号 if p.requestedModel != "" { if remaining := p.account.GetRateLimitRemainingTimeWithContext(p.ctx, p.requestedModel); remaining > 0 { - // 进入 overages 运行态的模型不再受普通模型限流预检查阻断。 - if overagesActive { - logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: credit_overages_ignore_rate_limit remaining=%v model=%s account=%d", + // 已注入积分的请求不再受普通模型限流预检查阻断。 + if overagesInjected { + logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: credits_injected_ignore_rate_limit remaining=%v model=%s account=%d", p.prefix, remaining.Truncate(time.Millisecond), p.requestedModel, p.account.ID) } else if isSingleAccountRetry(p.ctx) { // 单账号 503 退避重试模式:跳过限流预检查,直接发请求。 @@ -666,9 +668,9 @@ urlFallbackLoop: respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) _ = resp.Body.Close() - if overagesActive && shouldMarkCreditsExhausted(resp, respBody, nil) { + if overagesInjected && shouldMarkCreditsExhausted(resp, respBody, nil) { modelKey := resolveCreditsOveragesModelKey(p.ctx, p.account, "", p.requestedModel) - s.handleCreditsRetryFailure(p.prefix, modelKey, p.account, 0, &http.Response{ + s.handleCreditsRetryFailure(p.ctx, p.prefix, modelKey, p.account, &http.Response{ StatusCode: resp.StatusCode, Header: resp.Header.Clone(), Body: io.NopCloser(bytes.NewReader(respBody)), @@ -1717,7 +1719,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, var clientDisconnect bool if claudeReq.Stream { // 客户端要求流式,直接透传转换 - streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel) + streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel, account.ID) if err != nil { logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err) return nil, err @@ -1727,7 +1729,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, clientDisconnect = streamRes.clientDisconnect } else { // 客户端要求非流式,收集流式响应后转换返回 - streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel) + streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel, account.ID) if err != nil { logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err) return nil, err @@ -1736,6 +1738,9 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, firstTokenMs = streamRes.firstTokenMs } + // Claude Max cache billing: 同步 ForwardResult.Usage 与客户端响应体一致 + applyClaudeMaxCacheBillingPolicyToUsage(usage, parsedRequestFromGinContext(c), claudeMaxGroupFromGinContext(c), originalModel, account.ID) + return &ForwardResult{ RequestID: requestID, Usage: *usage, @@ -3639,7 +3644,7 @@ func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int, // handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回 // 用于处理客户端非流式请求但上游只支持流式的情况 -func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) { +func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string, accountID int64) (*antigravityStreamResult, error) { scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { @@ -3797,6 +3802,9 @@ returnResponse: return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response") } + // Claude Max cache billing simulation (non-streaming) + claudeResp = applyClaudeMaxNonStreamingRewrite(c, claudeResp, agUsage, originalModel, accountID) + c.Data(http.StatusOK, "application/json", claudeResp) // 转换为 service.ClaudeUsage @@ -3811,7 +3819,7 @@ returnResponse: } // handleClaudeStreamingResponse 处理 Claude 流式响应(Gemini SSE → Claude SSE 转换) -func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) { +func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string, accountID int64) (*antigravityStreamResult, error) { c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") @@ -3824,6 +3832,8 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context } processor := antigravity.NewStreamingProcessor(originalModel) + setupClaudeMaxStreamingHook(c, processor, originalModel, accountID) + var firstTokenMs *int // 使用 Scanner 并限制单行大小,避免 ReadString 无上限导致 OOM scanner := bufio.NewScanner(resp.Body) diff --git a/backend/internal/service/antigravity_quota_scope.go b/backend/internal/service/antigravity_quota_scope.go index e181e7f8..b536d16c 100644 --- a/backend/internal/service/antigravity_quota_scope.go +++ b/backend/internal/service/antigravity_quota_scope.go @@ -32,6 +32,10 @@ func (a *Account) IsSchedulableForModelWithContext(ctx context.Context, requeste return false } if a.isModelRateLimitedWithContext(ctx, requestedModel) { + // Antigravity + overages 启用 + 积分未耗尽 → 放行(有积分可用) + if a.Platform == PlatformAntigravity && a.IsOveragesEnabled() && !a.isCreditsExhausted() { + return true + } return false } return true diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index f9975ad2..5861a811 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1100,9 +1100,6 @@ func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64) if err := s.accountRepo.ClearModelRateLimits(ctx, accountID); err != nil { return err } - if err := clearAntigravityCreditsOveragesState(ctx, s.accountRepo, accountID); err != nil { - return err - } // 清除限流时一并清理临时不可调度状态,避免周限/窗口重置后仍被本地临时状态阻断。 if err := s.accountRepo.ClearTempUnschedulable(ctx, accountID); err != nil { return err @@ -1112,7 +1109,6 @@ func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64) slog.Warn("temp_unsched_cache_delete_failed", "account_id", accountID, "error", err) } } - clearCreditsExhausted(accountID) return nil } @@ -1179,8 +1175,7 @@ func hasRecoverableRuntimeState(account *Account) bool { return false } return hasNonEmptyMapValue(account.Extra, "model_rate_limits") || - hasNonEmptyMapValue(account.Extra, "antigravity_quota_scopes") || - hasNonEmptyMapValue(account.Extra, antigravityCreditsOveragesKey) + hasNonEmptyMapValue(account.Extra, "antigravity_quota_scopes") } func hasNonEmptyMapValue(extra map[string]any, key string) bool { diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 9dfb9078..fc2f7d0c 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -88,14 +88,25 @@ ]" >
+ + + {{ t('admin.accounts.status.creditsExhausted') }} + {{ formatModelResetTime(item.reset_at) }} + + + {{ formatScopeName(item.model) }} {{ formatModelResetTime(item.reset_at) }} + {{ - item.kind === 'overages' - ? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) - : t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) + item.kind === 'credits_exhausted' + ? t('admin.accounts.status.creditsExhaustedUntil', { time: formatTime(item.reset_at) }) + : item.kind === 'credits_active' + ? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) + : t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) }}
{ }) type AccountModelStatusItem = { - kind: 'rate_limit' | 'overages' + kind: 'rate_limit' | 'credits_exhausted' | 'credits_active' model: string reset_at: string } -// Computed: active model statuses (普通模型限流 + 超量请求运行态) +// Computed: active model statuses (普通模型限流 + 积分耗尽 + 走积分中) const activeModelStatuses = computed(() => { const extra = props.account.extra as Record | undefined const modelLimits = extra?.model_rate_limits as @@ -179,19 +192,26 @@ const activeModelStatuses = computed(() => { const now = new Date() const items: AccountModelStatusItem[] = [] - if (modelLimits) { - items.push(...Object.entries(modelLimits) - .filter(([, info]) => new Date(info.rate_limit_reset_at) > now) - .map(([model, info]) => ({ kind: 'rate_limit' as const, model, reset_at: info.rate_limit_reset_at }))) - } + if (!modelLimits) return items - const overagesStates = extra?.antigravity_credits_overages as - | Record - | undefined - if (overagesStates) { - items.push(...Object.entries(overagesStates) - .filter(([, info]) => new Date(info.active_until) > now) - .map(([model, info]) => ({ kind: 'overages' as const, model, reset_at: info.active_until }))) + // 检查 AICredits key 是否生效(积分是否耗尽) + const aiCreditsEntry = modelLimits['AICredits'] + const hasActiveAICredits = aiCreditsEntry && new Date(aiCreditsEntry.rate_limit_reset_at) > now + const allowOverages = !!(extra?.allow_overages) + + for (const [model, info] of Object.entries(modelLimits)) { + if (new Date(info.rate_limit_reset_at) <= now) continue + + if (model === 'AICredits') { + // AICredits key → 积分已用尽 + items.push({ kind: 'credits_exhausted', model, reset_at: info.rate_limit_reset_at }) + } else if (allowOverages && !hasActiveAICredits) { + // 普通模型限流 + overages 启用 + 积分可用 → 正在走积分 + items.push({ kind: 'credits_active', model, reset_at: info.rate_limit_reset_at }) + } else { + // 普通模型限流 + items.push({ kind: 'rate_limit', model, reset_at: info.rate_limit_reset_at }) + } } return items @@ -216,7 +236,7 @@ const formatScopeName = (scope: string): string => { 'gemini-3.1-pro-high': 'G3PH', 'gemini-3.1-pro-low': 'G3PL', 'gemini-3-pro-image': 'G3PI', - 'gemini-3.1-flash-image': 'GImage', + 'gemini-3.1-flash-image': 'G31FI', // 其他 'gpt-oss-120b-medium': 'GPT120', 'tab_flash_lite_preview': 'TabFL', diff --git a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts index ef887855..7cdf7999 100644 --- a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts +++ b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts @@ -43,17 +43,18 @@ function makeAccount(overrides: Partial): Account { } describe('AccountStatusIndicator', () => { - it('会将超量请求中的模型显示为独立状态', () => { + it('模型限流 + overages 启用 + 无 AICredits key → 显示 ⚡ (credits_active)', () => { const wrapper = mount(AccountStatusIndicator, { props: { account: makeAccount({ id: 1, name: 'ag-1', extra: { - antigravity_credits_overages: { + allow_overages: true, + model_rate_limits: { 'claude-sonnet-4-5': { - activated_at: '2026-03-15T00:00:00Z', - active_until: '2099-03-15T00:00:00Z' + rate_limited_at: '2026-03-15T00:00:00Z', + rate_limit_reset_at: '2099-03-15T00:00:00Z' } } } @@ -70,7 +71,7 @@ describe('AccountStatusIndicator', () => { expect(wrapper.text()).toContain('CSon45') }) - it('普通模型限流仍显示原有限流状态', () => { + it('模型限流 + overages 未启用 → 普通限流样式(无 ⚡)', () => { const wrapper = mount(AccountStatusIndicator, { props: { account: makeAccount({ @@ -96,4 +97,66 @@ describe('AccountStatusIndicator', () => { expect(wrapper.text()).toContain('CSon45') expect(wrapper.text()).not.toContain('⚡') }) + + it('AICredits key 生效 → 显示积分已用尽 (credits_exhausted)', () => { + const wrapper = mount(AccountStatusIndicator, { + props: { + account: makeAccount({ + id: 3, + name: 'ag-3', + extra: { + allow_overages: true, + model_rate_limits: { + 'AICredits': { + rate_limited_at: '2026-03-15T00:00:00Z', + rate_limit_reset_at: '2099-03-15T00:00:00Z' + } + } + } + }) + }, + global: { + stubs: { + Icon: true + } + } + }) + + expect(wrapper.text()).toContain('account.creditsExhausted') + }) + + it('模型限流 + overages 启用 + AICredits key 生效 → 普通限流样式(积分耗尽,无 ⚡)', () => { + const wrapper = mount(AccountStatusIndicator, { + props: { + account: makeAccount({ + id: 4, + name: 'ag-4', + extra: { + allow_overages: true, + model_rate_limits: { + 'claude-sonnet-4-5': { + rate_limited_at: '2026-03-15T00:00:00Z', + rate_limit_reset_at: '2099-03-15T00:00:00Z' + }, + 'AICredits': { + rate_limited_at: '2026-03-15T00:00:00Z', + rate_limit_reset_at: '2099-03-15T00:00:00Z' + } + } + } + }) + }, + global: { + stubs: { + Icon: true + } + } + }) + + // 模型限流 + 积分耗尽 → 不应显示 ⚡ + expect(wrapper.text()).toContain('CSon45') + expect(wrapper.text()).not.toContain('⚡') + // AICredits 积分耗尽状态应显示 + expect(wrapper.text()).toContain('account.creditsExhausted') + }) }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f23a489e..a66fab84 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -245,6 +245,7 @@ export default { // Common common: { loading: 'Loading...', + justNow: 'just now', save: 'Save', cancel: 'Cancel', delete: 'Delete', @@ -1655,6 +1656,14 @@ export default { enabled: 'Enabled', disabled: 'Disabled' }, + claudeMaxSimulation: { + title: 'Claude Max Usage Simulation', + tooltip: + 'When enabled, for Claude models without upstream cache-write usage, the system deterministically maps tokens to a small input plus 1h cache creation while keeping total tokens unchanged.', + enabled: 'Enabled (simulate 1h cache)', + disabled: 'Disabled', + hint: 'Only token categories in usage billing logs are adjusted. No per-request mapping state is persisted.' + }, supportedScopes: { title: 'Supported Model Families', tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.', @@ -1868,6 +1877,8 @@ export default { rateLimitedAutoResume: 'Auto resumes in {time}', modelRateLimitedUntil: '{model} rate limited until {time}', modelCreditOveragesUntil: '{model} using AI Credits until {time}', + creditsExhausted: 'Credits Exhausted', + creditsExhaustedUntil: 'AI Credits exhausted, expected recovery at {time}', overloadedUntil: 'Overloaded until {time}', viewTempUnschedDetails: 'View temp unschedulable details' }, @@ -1969,7 +1980,7 @@ export default { resetQuota: 'Reset Quota', quotaLimit: 'Quota Limit', quotaLimitPlaceholder: '0 means unlimited', - quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Account will be paused when any limit is reached. Changing limits won\'t reset usage.', + quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Anthropic API key accounts can also configure client affinity. Changing limits won\'t reset usage.', quotaLimitToggle: 'Enable Quota Limit', quotaLimitToggleHint: 'When enabled, account will be paused when usage reaches the set limit', quotaDailyLimit: 'Daily Limit', @@ -2166,7 +2177,7 @@ export default { // Quota control (Anthropic OAuth/SetupToken only) quotaControl: { title: 'Quota Control', - hint: 'Only applies to Anthropic OAuth/Setup Token accounts', + hint: 'Configure cost window, session limits, client affinity and other scheduling controls.', windowCost: { label: '5h Window Cost Limit', hint: 'Limit account cost usage within the 5-hour window', @@ -2221,8 +2232,26 @@ export default { hint: 'Force all cache creation tokens to be billed as the selected TTL tier (5m or 1h)', target: 'Target TTL', targetHint: 'Select the TTL tier for billing' + }, + clientAffinity: { + label: 'Client Affinity Scheduling', + hint: 'When enabled, new sessions prefer accounts previously used by this client to reduce account switching' } }, + affinityNoClients: 'No affinity clients', + affinityClients: '{count} affinity clients:', + affinitySection: 'Client Affinity', + affinitySectionHint: 'Control how clients are distributed across accounts. Configure zone thresholds to balance load.', + affinityToggle: 'Enable Client Affinity', + affinityToggleHint: 'New sessions prefer accounts previously used by this client', + affinityBase: 'Base Limit (Green Zone)', + affinityBasePlaceholder: 'Empty = no limit', + affinityBaseHint: 'Max clients in green zone (full priority scheduling)', + affinityBaseOffHint: 'No green zone limit. All clients receive full priority scheduling.', + affinityBuffer: 'Buffer (Yellow Zone)', + affinityBufferPlaceholder: 'e.g. 3', + affinityBufferHint: 'Additional clients allowed in the yellow zone (degraded priority)', + affinityBufferInfinite: 'Unlimited', expired: 'Expired', proxy: 'Proxy', noProxy: 'No Proxy', @@ -2677,7 +2706,7 @@ export default { geminiFlashDaily: 'Flash', gemini3Pro: 'G3P', gemini3Flash: 'G3F', - gemini3Image: 'GImage', + gemini3Image: 'G31FI', claude: 'Claude' }, tier: { @@ -4190,40 +4219,55 @@ export default { usage: 'Usage: Add to request header - x-api-key: ' }, soraS3: { - title: 'Sora S3 Storage', - description: 'Manage multiple Sora S3 endpoints and switch the active profile', + title: 'Sora Storage', + description: 'Manage Sora media storage profiles with S3 and Google Drive support', newProfile: 'New Profile', reloadProfiles: 'Reload Profiles', - empty: 'No Sora S3 profiles yet, create one first', - createTitle: 'Create Sora S3 Profile', - editTitle: 'Edit Sora S3 Profile', + empty: 'No storage profiles yet, create one first', + createTitle: 'Create Storage Profile', + editTitle: 'Edit Storage Profile', + selectProvider: 'Select Storage Type', + providerS3Desc: 'S3-compatible object storage', + providerGDriveDesc: 'Google Drive cloud storage', profileID: 'Profile ID', profileName: 'Profile Name', setActive: 'Set as active after creation', saveProfile: 'Save Profile', activateProfile: 'Activate', - profileCreated: 'Sora S3 profile created', - profileSaved: 'Sora S3 profile saved', - profileDeleted: 'Sora S3 profile deleted', - profileActivated: 'Sora S3 active profile switched', + profileCreated: 'Storage profile created', + profileSaved: 'Storage profile saved', + profileDeleted: 'Storage profile deleted', + profileActivated: 'Active storage profile switched', profileIDRequired: 'Profile ID is required', profileNameRequired: 'Profile name is required', profileSelectRequired: 'Please select a profile first', endpointRequired: 'S3 endpoint is required when enabled', bucketRequired: 'Bucket is required when enabled', accessKeyRequired: 'Access Key ID is required when enabled', - deleteConfirm: 'Delete Sora S3 profile {profileID}?', + deleteConfirm: 'Delete storage profile {profileID}?', columns: { profile: 'Profile', + profileId: 'Profile ID', + name: 'Name', + provider: 'Type', active: 'Active', endpoint: 'Endpoint', - bucket: 'Bucket', + storagePath: 'Storage Path', + capacityUsage: 'Capacity / Used', + capacityUnlimited: 'Unlimited', + videoCount: 'Videos', + videoCompleted: 'completed', + videoInProgress: 'in progress', quota: 'Default Quota', updatedAt: 'Updated At', - actions: 'Actions' + actions: 'Actions', + rootFolder: 'Root folder', + testInTable: 'Test', + testingInTable: 'Testing...', + testTimeout: 'Test timed out (15s)' }, - enabled: 'Enable S3 Storage', - enabledHint: 'When enabled, Sora generated media files will be automatically uploaded to S3 storage', + enabled: 'Enable Storage', + enabledHint: 'When enabled, Sora generated media files will be automatically uploaded', endpoint: 'S3 Endpoint', region: 'Region', bucket: 'Bucket', @@ -4232,16 +4276,38 @@ export default { secretAccessKey: 'Secret Access Key', secretConfigured: '(Configured, leave blank to keep)', cdnUrl: 'CDN URL', - cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL instead of presigned URLs', + cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL', forcePathStyle: 'Force Path Style', defaultQuota: 'Default Storage Quota', defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited', testConnection: 'Test Connection', testing: 'Testing...', - testSuccess: 'S3 connection test successful', - testFailed: 'S3 connection test failed', - saved: 'Sora S3 settings saved successfully', - saveFailed: 'Failed to save Sora S3 settings' + testSuccess: 'Connection test successful', + testFailed: 'Connection test failed', + saved: 'Storage settings saved successfully', + saveFailed: 'Failed to save storage settings', + gdrive: { + authType: 'Authentication Method', + serviceAccount: 'Service Account', + clientId: 'Client ID', + clientSecret: 'Client Secret', + clientSecretConfigured: '(Configured, leave blank to keep)', + refreshToken: 'Refresh Token', + refreshTokenConfigured: '(Configured, leave blank to keep)', + serviceAccountJson: 'Service Account JSON', + serviceAccountConfigured: '(Configured, leave blank to keep)', + folderId: 'Folder ID (optional)', + authorize: 'Authorize Google Drive', + authorizeHint: 'Get Refresh Token via OAuth2', + oauthFieldsRequired: 'Please fill in Client ID and Client Secret first', + oauthSuccess: 'Google Drive authorization successful', + oauthFailed: 'Google Drive authorization failed', + closeWindow: 'This window will close automatically', + processing: 'Processing authorization...', + testStorage: 'Test Storage', + testSuccess: 'Google Drive storage test passed (upload, access, delete all OK)', + testFailed: 'Google Drive storage test failed' + } }, streamTimeout: { title: 'Stream Timeout Handling', @@ -4712,6 +4778,7 @@ export default { downloadLocal: 'Download', canDownload: 'to download', regenrate: 'Regenerate', + regenerate: 'Regenerate', creatorPlaceholder: 'Describe the video or image you want to create...', videoModels: 'Video Models', imageModels: 'Image Models', @@ -4728,6 +4795,13 @@ export default { galleryEmptyTitle: 'No works yet', galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.', startCreating: 'Start Creating', - yesterday: 'Yesterday' + yesterday: 'Yesterday', + landscape: 'Landscape', + portrait: 'Portrait', + square: 'Square', + examplePrompt1: 'A golden Shiba Inu walking through the streets of Shibuya, Tokyo, camera following, cinematic shot, 4K', + examplePrompt2: 'Drone aerial view, green aurora reflecting on a glacial lake in Iceland, slow push-in', + examplePrompt3: 'Cyberpunk futuristic city, neon lights reflected in rain puddles, nightscape, cinematic colors', + examplePrompt4: 'Chinese ink painting style, a small boat drifting among misty mountains and rivers, classical atmosphere' } } diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c38060b5..dbd72a90 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -245,6 +245,7 @@ export default { // Common common: { loading: '加载中...', + justNow: '刚刚', save: '保存', cancel: '取消', delete: '删除', @@ -1974,7 +1975,7 @@ export default { resetQuota: '重置配额', quotaLimit: '配额限制', quotaLimitPlaceholder: '0 表示不限制', - quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。修改限额不会重置已用额度。', + quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。Anthropic API Key 账号还可配置客户端亲和。修改限额不会重置已用额度。', quotaLimitToggle: '启用配额限制', quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度', quotaDailyLimit: '日限额', @@ -2053,6 +2054,8 @@ export default { rateLimitedAutoResume: '{time} 自动恢复', modelRateLimitedUntil: '{model} 限流至 {time}', modelCreditOveragesUntil: '{model} 正在使用 AI Credits,至 {time}', + creditsExhausted: '积分已用尽', + creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复', overloadedUntil: '负载过重,重置时间:{time}', viewTempUnschedDetails: '查看临时不可调度详情' }, @@ -2106,7 +2109,7 @@ export default { geminiFlashDaily: 'Flash', gemini3Pro: 'G3P', gemini3Flash: 'G3F', - gemini3Image: 'GImage', + gemini3Image: 'G31FI', claude: 'Claude' }, tier: { @@ -2316,7 +2319,7 @@ export default { // Quota control (Anthropic OAuth/SetupToken only) quotaControl: { title: '配额控制', - hint: '仅适用于 Anthropic OAuth/Setup Token 账号', + hint: '配置费用窗口、会话限制、客户端亲和等调度控制。', windowCost: { label: '5h窗口费用控制', hint: '限制账号在5小时窗口内的费用使用', @@ -2371,8 +2374,26 @@ export default { hint: '将所有缓存创建 token 强制按指定的 TTL 类型(5分钟或1小时)计费', target: '目标 TTL', targetHint: '选择计费使用的 TTL 类型' + }, + clientAffinity: { + label: '客户端亲和调度', + hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号' } }, + affinityNoClients: '无亲和客户端', + affinityClients: '{count} 个亲和客户端:', + affinitySection: '客户端亲和', + affinitySectionHint: '控制客户端在账号间的分布。通过配置区域阈值来平衡负载。', + affinityToggle: '启用客户端亲和', + affinityToggleHint: '新会话优先调度到该客户端之前使用过的账号', + affinityBase: '基础限额(绿区)', + affinityBasePlaceholder: '留空表示不限制', + affinityBaseHint: '绿区最大客户端数量(完整优先级调度)', + affinityBaseOffHint: '未开启绿区限制,所有客户端均享受完整优先级调度', + affinityBuffer: '缓冲区(黄区)', + affinityBufferPlaceholder: '例如 3', + affinityBufferHint: '黄区允许的额外客户端数量(降级优先级调度)', + affinityBufferInfinite: '不限制', expired: '已过期', proxy: '代理', noProxy: '无代理', @@ -4363,40 +4384,55 @@ export default { usage: '使用方法:在请求头中添加 x-api-key: ' }, soraS3: { - title: 'Sora S3 存储配置', - description: '以多配置列表方式管理 Sora S3 端点,并可切换生效配置', + title: 'Sora 存储配置', + description: '以多配置列表管理 Sora 媒体存储,支持 S3 和 Google Drive', newProfile: '新建配置', reloadProfiles: '刷新列表', - empty: '暂无 Sora S3 配置,请先创建', - createTitle: '新建 Sora S3 配置', - editTitle: '编辑 Sora S3 配置', + empty: '暂无存储配置,请先创建', + createTitle: '新建存储配置', + editTitle: '编辑存储配置', + selectProvider: '选择存储类型', + providerS3Desc: 'S3 兼容对象存储', + providerGDriveDesc: 'Google Drive 云盘', profileID: '配置 ID', profileName: '配置名称', setActive: '创建后设为生效', saveProfile: '保存配置', activateProfile: '设为生效', - profileCreated: 'Sora S3 配置创建成功', - profileSaved: 'Sora S3 配置保存成功', - profileDeleted: 'Sora S3 配置删除成功', - profileActivated: 'Sora S3 生效配置已切换', + profileCreated: '存储配置创建成功', + profileSaved: '存储配置保存成功', + profileDeleted: '存储配置删除成功', + profileActivated: '生效配置已切换', profileIDRequired: '请填写配置 ID', profileNameRequired: '请填写配置名称', profileSelectRequired: '请先选择配置', endpointRequired: '启用时必须填写 S3 端点', bucketRequired: '启用时必须填写存储桶', accessKeyRequired: '启用时必须填写 Access Key ID', - deleteConfirm: '确定删除 Sora S3 配置 {profileID} 吗?', + deleteConfirm: '确定删除存储配置 {profileID} 吗?', columns: { profile: '配置', + profileId: 'Profile ID', + name: '名称', + provider: '存储类型', active: '生效状态', endpoint: '端点', - bucket: '存储桶', + storagePath: '存储路径', + capacityUsage: '容量 / 已用', + capacityUnlimited: '无限制', + videoCount: '视频数', + videoCompleted: '完成', + videoInProgress: '进行中', quota: '默认配额', updatedAt: '更新时间', - actions: '操作' + actions: '操作', + rootFolder: '根目录', + testInTable: '测试', + testingInTable: '测试中...', + testTimeout: '测试超时(15秒)' }, - enabled: '启用 S3 存储', - enabledHint: '启用后,Sora 生成的媒体文件将自动上传到 S3 存储', + enabled: '启用存储', + enabledHint: '启用后,Sora 生成的媒体文件将自动上传到存储', endpoint: 'S3 端点', region: '区域', bucket: '存储桶', @@ -4405,16 +4441,38 @@ export default { secretAccessKey: 'Secret Access Key', secretConfigured: '(已配置,留空保持不变)', cdnUrl: 'CDN URL', - cdnUrlHint: '可选,配置后使用 CDN URL 访问文件,否则使用预签名 URL', + cdnUrlHint: '可选,配置后使用 CDN URL 访问文件', forcePathStyle: '强制路径风格(Path Style)', defaultQuota: '默认存储配额', defaultQuotaHint: '未在用户或分组级别指定配额时的默认值,0 表示无限制', testConnection: '测试连接', testing: '测试中...', - testSuccess: 'S3 连接测试成功', - testFailed: 'S3 连接测试失败', - saved: 'Sora S3 设置保存成功', - saveFailed: '保存 Sora S3 设置失败' + testSuccess: '连接测试成功', + testFailed: '连接测试失败', + saved: '存储设置保存成功', + saveFailed: '保存存储设置失败', + gdrive: { + authType: '认证方式', + serviceAccount: '服务账号', + clientId: 'Client ID', + clientSecret: 'Client Secret', + clientSecretConfigured: '(已配置,留空保持不变)', + refreshToken: 'Refresh Token', + refreshTokenConfigured: '(已配置,留空保持不变)', + serviceAccountJson: '服务账号 JSON', + serviceAccountConfigured: '(已配置,留空保持不变)', + folderId: 'Folder ID(可选)', + authorize: '授权 Google Drive', + authorizeHint: '通过 OAuth2 获取 Refresh Token', + oauthFieldsRequired: '请先填写 Client ID 和 Client Secret', + oauthSuccess: 'Google Drive 授权成功', + oauthFailed: 'Google Drive 授权失败', + closeWindow: '此窗口将自动关闭', + processing: '正在处理授权...', + testStorage: '测试存储', + testSuccess: 'Google Drive 存储测试成功(上传、访问、删除均正常)', + testFailed: 'Google Drive 存储测试失败' + } }, streamTimeout: { title: '流超时处理', @@ -4910,6 +4968,7 @@ export default { downloadLocal: '本地下载', canDownload: '可下载', regenrate: '重新生成', + regenerate: '重新生成', creatorPlaceholder: '描述你想要生成的视频或图片...', videoModels: '视频模型', imageModels: '图片模型', @@ -4926,6 +4985,13 @@ export default { galleryEmptyTitle: '还没有任何作品', galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。', startCreating: '开始创作', - yesterday: '昨天' + yesterday: '昨天', + landscape: '横屏', + portrait: '竖屏', + square: '方形', + examplePrompt1: '一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清', + examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进', + examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩', + examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境' } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 006ff792..a3a828d5 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -403,6 +403,8 @@ export interface AdminGroup extends Group { // MCP XML 协议注入(仅 antigravity 平台使用) mcp_xml_inject: boolean + // Claude usage 模拟开关(仅 anthropic 平台使用) + simulate_claude_max_enabled: boolean // 支持的模型系列(仅 antigravity 平台使用) supported_model_scopes?: string[] @@ -497,6 +499,7 @@ export interface CreateGroupRequest { fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null mcp_xml_inject?: boolean + simulate_claude_max_enabled?: boolean supported_model_scopes?: string[] // 从指定分组复制账号 copy_accounts_from_group_ids?: number[] @@ -525,6 +528,7 @@ export interface UpdateGroupRequest { fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null mcp_xml_inject?: boolean + simulate_claude_max_enabled?: boolean supported_model_scopes?: string[] copy_accounts_from_group_ids?: number[] } @@ -664,7 +668,6 @@ export interface Account { // Extra fields including Codex usage and model-level rate limits (Antigravity smart retry) extra?: (CodexUsageSnapshot & { model_rate_limits?: Record - antigravity_credits_overages?: Record } & Record) proxy_id: number | null concurrency: number @@ -721,6 +724,12 @@ export interface Account { cache_ttl_override_enabled?: boolean | null cache_ttl_override_target?: string | null + // 客户端亲和调度(仅 Anthropic/Antigravity 平台有效) + // 启用后新会话会优先调度到客户端之前使用过的账号 + client_affinity_enabled?: boolean | null + affinity_client_count?: number | null + affinity_clients?: string[] | null + // API Key 账号配额限制 quota_limit?: number | null quota_used?: number | null