mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-22 07:34:45 +08:00
refactor: replace sync.Map credits state with AICredits rate limit key
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.
This commit is contained in:
@@ -143,9 +143,10 @@ type CreateGroupInput struct {
|
|||||||
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
|
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
|
||||||
FallbackGroupIDOnInvalidRequest *int64
|
FallbackGroupIDOnInvalidRequest *int64
|
||||||
// 模型路由配置(仅 anthropic 平台使用)
|
// 模型路由配置(仅 anthropic 平台使用)
|
||||||
ModelRouting map[string][]int64
|
ModelRouting map[string][]int64
|
||||||
ModelRoutingEnabled bool // 是否启用模型路由
|
ModelRoutingEnabled bool // 是否启用模型路由
|
||||||
MCPXMLInject *bool
|
MCPXMLInject *bool
|
||||||
|
SimulateClaudeMaxEnabled *bool
|
||||||
// 支持的模型系列(仅 antigravity 平台使用)
|
// 支持的模型系列(仅 antigravity 平台使用)
|
||||||
SupportedModelScopes []string
|
SupportedModelScopes []string
|
||||||
// Sora 存储配额
|
// Sora 存储配额
|
||||||
@@ -182,9 +183,10 @@ type UpdateGroupInput struct {
|
|||||||
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
|
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
|
||||||
FallbackGroupIDOnInvalidRequest *int64
|
FallbackGroupIDOnInvalidRequest *int64
|
||||||
// 模型路由配置(仅 anthropic 平台使用)
|
// 模型路由配置(仅 anthropic 平台使用)
|
||||||
ModelRouting map[string][]int64
|
ModelRouting map[string][]int64
|
||||||
ModelRoutingEnabled *bool // 是否启用模型路由
|
ModelRoutingEnabled *bool // 是否启用模型路由
|
||||||
MCPXMLInject *bool
|
MCPXMLInject *bool
|
||||||
|
SimulateClaudeMaxEnabled *bool
|
||||||
// 支持的模型系列(仅 antigravity 平台使用)
|
// 支持的模型系列(仅 antigravity 平台使用)
|
||||||
SupportedModelScopes *[]string
|
SupportedModelScopes *[]string
|
||||||
// Sora 存储配额
|
// Sora 存储配额
|
||||||
@@ -368,6 +370,10 @@ type ProxyExitInfoProber interface {
|
|||||||
ProbeProxy(ctx context.Context, proxyURL string) (*ProxyExitInfo, int64, error)
|
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 {
|
type proxyQualityTarget struct {
|
||||||
Target string
|
Target string
|
||||||
URL string
|
URL string
|
||||||
@@ -445,10 +451,6 @@ type userGroupRateBatchReader interface {
|
|||||||
GetByUserIDs(ctx context.Context, userIDs []int64) (map[int64]map[int64]float64, error)
|
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
|
// NewAdminService creates a new AdminService
|
||||||
func NewAdminService(
|
func NewAdminService(
|
||||||
userRepo UserRepository,
|
userRepo UserRepository,
|
||||||
@@ -868,6 +870,13 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
|||||||
if input.MCPXMLInject != nil {
|
if input.MCPXMLInject != nil {
|
||||||
mcpXMLInject = *input.MCPXMLInject
|
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 列表
|
// 如果指定了复制账号的源分组,先获取账号 ID 列表
|
||||||
var accountIDsToCopy []int64
|
var accountIDsToCopy []int64
|
||||||
@@ -924,6 +933,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
|||||||
FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest,
|
FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest,
|
||||||
ModelRouting: input.ModelRouting,
|
ModelRouting: input.ModelRouting,
|
||||||
MCPXMLInject: mcpXMLInject,
|
MCPXMLInject: mcpXMLInject,
|
||||||
|
SimulateClaudeMaxEnabled: simulateClaudeMaxEnabled,
|
||||||
SupportedModelScopes: input.SupportedModelScopes,
|
SupportedModelScopes: input.SupportedModelScopes,
|
||||||
SoraStorageQuotaBytes: input.SoraStorageQuotaBytes,
|
SoraStorageQuotaBytes: input.SoraStorageQuotaBytes,
|
||||||
AllowMessagesDispatch: input.AllowMessagesDispatch,
|
AllowMessagesDispatch: input.AllowMessagesDispatch,
|
||||||
@@ -1130,6 +1140,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
|||||||
if input.MCPXMLInject != nil {
|
if input.MCPXMLInject != nil {
|
||||||
group.MCPXMLInject = *input.MCPXMLInject
|
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 平台使用)
|
// 支持的模型系列(仅 antigravity 平台使用)
|
||||||
if input.SupportedModelScopes != nil {
|
if input.SupportedModelScopes != nil {
|
||||||
@@ -1530,7 +1549,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
|||||||
if len(input.Credentials) > 0 {
|
if len(input.Credentials) > 0 {
|
||||||
account.Credentials = input.Credentials
|
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"} {
|
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 {
|
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
|
account.Extra = input.Extra
|
||||||
if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() {
|
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() {
|
if account.Platform == PlatformAntigravity && !wasOveragesEnabled && account.IsOveragesEnabled() {
|
||||||
delete(account.Extra, modelRateLimitsKey)
|
delete(account.Extra, modelRateLimitsKey)
|
||||||
delete(account.Extra, antigravityCreditsOveragesKey)
|
delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
|
||||||
}
|
}
|
||||||
// 校验并预计算固定时间重置的下次重置时间
|
// 校验并预计算固定时间重置的下次重置时间
|
||||||
if err := ValidateQuotaResetConfig(account.Extra); err != nil {
|
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 {
|
if err := s.accountRepo.Update(ctx, account); err != nil {
|
||||||
return nil, err
|
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 {
|
if input.GroupIDs != nil {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func (r *updateAccountOveragesRepoStub) Update(ctx context.Context, account *Acc
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateAccount_DisableOveragesClearsRuntimeStateBeforePersist(t *testing.T) {
|
func TestUpdateAccount_DisableOveragesClearsAICreditsKey(t *testing.T) {
|
||||||
accountID := int64(101)
|
accountID := int64(101)
|
||||||
repo := &updateAccountOveragesRepoStub{
|
repo := &updateAccountOveragesRepoStub{
|
||||||
account: &Account{
|
account: &Account{
|
||||||
@@ -37,24 +37,34 @@ func TestUpdateAccount_DisableOveragesClearsRuntimeStateBeforePersist(t *testing
|
|||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"allow_overages": true,
|
"allow_overages": true,
|
||||||
"mixed_scheduling": true,
|
"mixed_scheduling": true,
|
||||||
antigravityCreditsOveragesKey: map[string]any{
|
modelRateLimitsKey: map[string]any{
|
||||||
"claude-sonnet-4-5": map[string]any{
|
"claude-sonnet-4-5": map[string]any{
|
||||||
"activated_at": "2026-03-15T00:00:00Z",
|
"rate_limited_at": "2026-03-15T00:00:00Z",
|
||||||
"active_until": "2099-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}
|
svc := &adminServiceImpl{accountRepo: repo}
|
||||||
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"mixed_scheduling": true,
|
"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.NotNil(t, updated)
|
||||||
require.Equal(t, 1, repo.updateCalls)
|
require.Equal(t, 1, repo.updateCalls)
|
||||||
require.False(t, updated.IsOveragesEnabled())
|
require.False(t, updated.IsOveragesEnabled())
|
||||||
require.False(t, isCreditsExhausted(accountID))
|
|
||||||
|
|
||||||
_, exists := repo.account.Extra[antigravityCreditsOveragesKey]
|
// 关闭 overages 后,AICredits key 应被清除
|
||||||
require.False(t, exists, "关闭 overages 时应在持久化前移除运行态")
|
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) {
|
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}
|
svc := &adminServiceImpl{accountRepo: repo}
|
||||||
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||||
@@ -104,7 +117,6 @@ func TestUpdateAccount_EnableOveragesClearsModelRateLimitsBeforePersist(t *testi
|
|||||||
require.NotNil(t, updated)
|
require.NotNil(t, updated)
|
||||||
require.Equal(t, 1, repo.updateCalls)
|
require.Equal(t, 1, repo.updateCalls)
|
||||||
require.True(t, updated.IsOveragesEnabled())
|
require.True(t, updated.IsOveragesEnabled())
|
||||||
require.False(t, isCreditsExhausted(accountID))
|
|
||||||
|
|
||||||
_, exists := repo.account.Extra[modelRateLimitsKey]
|
_, exists := repo.account.Extra[modelRateLimitsKey]
|
||||||
require.False(t, exists, "开启 overages 时应在持久化前清掉旧模型限流")
|
require.False(t, exists, "开启 overages 时应在持久化前清掉旧模型限流")
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
"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
|
type antigravity429Category string
|
||||||
|
|
||||||
@@ -24,8 +28,6 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
creditsExhaustedCache sync.Map
|
|
||||||
|
|
||||||
antigravityQuotaExhaustedKeywords = []string{
|
antigravityQuotaExhaustedKeywords = []string{
|
||||||
"quota_exhausted",
|
"quota_exhausted",
|
||||||
"quota exhausted",
|
"quota exhausted",
|
||||||
@@ -46,28 +48,48 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// isCreditsExhausted 检查账号的 AI Credits 是否已被标记为耗尽。
|
// isCreditsExhausted 检查账号的 AICredits 限流 key 是否生效(积分是否耗尽)。
|
||||||
func isCreditsExhausted(accountID int64) bool {
|
func (a *Account) isCreditsExhausted() bool {
|
||||||
v, ok := creditsExhaustedCache.Load(accountID)
|
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 {
|
if !ok {
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
until, ok := v.(time.Time)
|
if _, exists := rawLimits[creditsExhaustedKey]; !exists {
|
||||||
if !ok || time.Now().After(until) {
|
return
|
||||||
creditsExhaustedCache.Delete(accountID)
|
}
|
||||||
return false
|
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 响应归类为配额耗尽、限流或未知。
|
// classifyAntigravity429 将 Antigravity 的 429 响应归类为配额耗尽、限流或未知。
|
||||||
@@ -117,111 +139,6 @@ func resolveCreditsOveragesModelKey(ctx context.Context, account *Account, upstr
|
|||||||
return resolveAntigravityModelKey(requestedModel)
|
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 耗尽。
|
// shouldMarkCreditsExhausted 判断一次 credits 请求失败是否应标记为 credits 耗尽。
|
||||||
func shouldMarkCreditsExhausted(resp *http.Response, respBody []byte, reqErr error) bool {
|
func shouldMarkCreditsExhausted(resp *http.Response, respBody []byte, reqErr error) bool {
|
||||||
if reqErr != nil || resp == nil {
|
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)
|
creditsResp, err := p.httpUpstream.Do(creditsReq, p.proxyURL, p.account.ID, p.account.Concurrency)
|
||||||
if err == nil && creditsResp != nil && creditsResp.StatusCode < 400 {
|
if err == nil && creditsResp != nil && creditsResp.StatusCode < 400 {
|
||||||
clearCreditsExhausted(p.account.ID)
|
s.clearCreditsExhausted(p.ctx, p.account)
|
||||||
activeUntil := s.resolveCreditsOveragesActiveUntil(respBody, waitDuration)
|
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d credit_overages_success model=%s account=%d",
|
||||||
setAntigravityCreditsOveragesActive(p.ctx, p.accountRepo, p.account, modelKey, activeUntil)
|
p.prefix, creditsResp.StatusCode, modelKey, p.account.ID)
|
||||||
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))
|
|
||||||
return &creditsOveragesRetryResult{handled: true, resp: creditsResp}
|
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}
|
return &creditsOveragesRetryResult{handled: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AntigravityGatewayService) handleCreditsRetryFailure(
|
func (s *AntigravityGatewayService) handleCreditsRetryFailure(
|
||||||
|
ctx context.Context,
|
||||||
prefix string,
|
prefix string,
|
||||||
modelKey string,
|
modelKey string,
|
||||||
account *Account,
|
account *Account,
|
||||||
waitDuration time.Duration,
|
|
||||||
creditsResp *http.Response,
|
creditsResp *http.Response,
|
||||||
reqErr error,
|
reqErr error,
|
||||||
) {
|
) {
|
||||||
@@ -307,11 +222,9 @@ func (s *AntigravityGatewayService) handleCreditsRetryFailure(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if shouldMarkCreditsExhausted(creditsResp, creditsRespBody, reqErr) && account != nil {
|
if shouldMarkCreditsExhausted(creditsResp, creditsRespBody, reqErr) && account != nil {
|
||||||
exhaustedUntil := s.resolveCreditsOveragesActiveUntil(creditsRespBody, waitDuration)
|
s.setCreditsExhausted(ctx, account)
|
||||||
setCreditsExhausted(account.ID, exhaustedUntil)
|
logger.LegacyPrintf("service.antigravity_gateway", "%s credit_overages_failed model=%s account=%d marked_exhausted=true status=%d body=%s",
|
||||||
clearAntigravityCreditsOveragesStateForModel(context.Background(), s.accountRepo, account, modelKey)
|
prefix, modelKey, account.ID, creditsStatusCode, truncateForLog(creditsRespBody, 200))
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if account != nil {
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,58 +40,50 @@ func TestClassifyAntigravity429(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCanUseAntigravityCreditsOverages(t *testing.T) {
|
func TestIsCreditsExhausted_UsesAICreditsKey(t *testing.T) {
|
||||||
activeUntil := time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339)
|
t.Run("无 AICredits key 则积分可用", func(t *testing.T) {
|
||||||
|
|
||||||
t.Run("必须有运行态才可直接走 overages", func(t *testing.T) {
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Platform: PlatformAntigravity,
|
Platform: PlatformAntigravity,
|
||||||
Status: StatusActive,
|
|
||||||
Schedulable: true,
|
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"allow_overages": true,
|
"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{
|
account := &Account{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Platform: PlatformAntigravity,
|
Platform: PlatformAntigravity,
|
||||||
Status: StatusActive,
|
|
||||||
Schedulable: true,
|
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"allow_overages": true,
|
"allow_overages": true,
|
||||||
antigravityCreditsOveragesKey: map[string]any{
|
modelRateLimitsKey: map[string]any{
|
||||||
"claude-sonnet-4-5": map[string]any{
|
creditsExhaustedKey: map[string]any{
|
||||||
"active_until": activeUntil,
|
"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{
|
account := &Account{
|
||||||
ID: 3,
|
ID: 3,
|
||||||
Platform: PlatformAntigravity,
|
Platform: PlatformAntigravity,
|
||||||
Status: StatusActive,
|
|
||||||
Schedulable: true,
|
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"allow_overages": true,
|
"allow_overages": true,
|
||||||
antigravityCreditsOveragesKey: map[string]any{
|
modelRateLimitsKey: map[string]any{
|
||||||
"claude-sonnet-4-5": map[string]any{
|
creditsExhaustedKey: map[string]any{
|
||||||
"active_until": activeUntil,
|
"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))
|
require.False(t, account.isCreditsExhausted())
|
||||||
t.Cleanup(func() { clearCreditsExhausted(account.ID) })
|
|
||||||
require.False(t, canUseAntigravityCreditsOverages(context.Background(), account, "claude-sonnet-4-5"))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,12 +144,6 @@ func TestHandleSmartRetry_QuotaExhausted_UsesCreditsAndStoresIndependentState(t
|
|||||||
require.Len(t, upstream.requestBodies, 1)
|
require.Len(t, upstream.requestBodies, 1)
|
||||||
require.Contains(t, string(upstream.requestBodies[0]), "enabledCreditTypes")
|
require.Contains(t, string(upstream.requestBodies[0]), "enabledCreditTypes")
|
||||||
require.Empty(t, repo.modelRateLimitCalls, "overages 成功后不应写入普通 model_rate_limits")
|
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) {
|
func TestHandleSmartRetry_RateLimited_DoesNotUseCredits(t *testing.T) {
|
||||||
@@ -221,7 +207,7 @@ func TestHandleSmartRetry_RateLimited_DoesNotUseCredits(t *testing.T) {
|
|||||||
require.Empty(t, repo.modelRateLimitCalls)
|
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...)
|
oldBaseURLs := append([]string(nil), antigravity.BaseURLs...)
|
||||||
oldAvailability := antigravity.DefaultURLAvailability
|
oldAvailability := antigravity.DefaultURLAvailability
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -232,7 +218,6 @@ func TestAntigravityRetryLoop_ActiveOverages_InjectsCreditsBody(t *testing.T) {
|
|||||||
antigravity.BaseURLs = []string{"https://ag-1.test"}
|
antigravity.BaseURLs = []string{"https://ag-1.test"}
|
||||||
antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
|
antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
|
||||||
|
|
||||||
activeUntil := time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339)
|
|
||||||
upstream := &queuedHTTPUpstreamStub{
|
upstream := &queuedHTTPUpstreamStub{
|
||||||
responses: []*http.Response{
|
responses: []*http.Response{
|
||||||
{
|
{
|
||||||
@@ -243,6 +228,7 @@ func TestAntigravityRetryLoop_ActiveOverages_InjectsCreditsBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
errors: []error{nil},
|
errors: []error{nil},
|
||||||
}
|
}
|
||||||
|
// 模型已限流 + overages 启用 + 无 AICredits key → 应直接注入积分
|
||||||
account := &Account{
|
account := &Account{
|
||||||
ID: 103,
|
ID: 103,
|
||||||
Name: "acc-103",
|
Name: "acc-103",
|
||||||
@@ -252,9 +238,10 @@ func TestAntigravityRetryLoop_ActiveOverages_InjectsCreditsBody(t *testing.T) {
|
|||||||
Schedulable: true,
|
Schedulable: true,
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"allow_overages": true,
|
"allow_overages": true,
|
||||||
antigravityCreditsOveragesKey: map[string]any{
|
modelRateLimitsKey: map[string]any{
|
||||||
"claude-sonnet-4-5": 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")
|
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...)
|
oldBaseURLs := append([]string(nil), antigravity.BaseURLs...)
|
||||||
oldAvailability := antigravity.DefaultURLAvailability
|
oldAvailability := antigravity.DefaultURLAvailability
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -292,8 +333,6 @@ func TestAntigravityRetryLoop_ActiveOverages_ExplicitCreditErrorMarksExhausted(t
|
|||||||
antigravity.BaseURLs = []string{"https://ag-1.test"}
|
antigravity.BaseURLs = []string{"https://ag-1.test"}
|
||||||
antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
|
antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute)
|
||||||
|
|
||||||
accountID := int64(104)
|
|
||||||
activeUntil := time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339)
|
|
||||||
repo := &stubAntigravityAccountRepo{}
|
repo := &stubAntigravityAccountRepo{}
|
||||||
upstream := &queuedHTTPUpstreamStub{
|
upstream := &queuedHTTPUpstreamStub{
|
||||||
responses: []*http.Response{
|
responses: []*http.Response{
|
||||||
@@ -305,24 +344,24 @@ func TestAntigravityRetryLoop_ActiveOverages_ExplicitCreditErrorMarksExhausted(t
|
|||||||
},
|
},
|
||||||
errors: []error{nil},
|
errors: []error{nil},
|
||||||
}
|
}
|
||||||
|
// 模型限流 + overages 启用 + 积分可用 → 注入积分但上游返回积分不足
|
||||||
account := &Account{
|
account := &Account{
|
||||||
ID: accountID,
|
ID: 105,
|
||||||
Name: "acc-104",
|
Name: "acc-105",
|
||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Platform: PlatformAntigravity,
|
Platform: PlatformAntigravity,
|
||||||
Status: StatusActive,
|
Status: StatusActive,
|
||||||
Schedulable: true,
|
Schedulable: true,
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"allow_overages": true,
|
"allow_overages": true,
|
||||||
antigravityCreditsOveragesKey: map[string]any{
|
modelRateLimitsKey: map[string]any{
|
||||||
"claude-sonnet-4-5": 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}
|
svc := &AntigravityGatewayService{accountRepo: repo}
|
||||||
result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
|
result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
|
||||||
@@ -333,6 +372,7 @@ func TestAntigravityRetryLoop_ActiveOverages_ExplicitCreditErrorMarksExhausted(t
|
|||||||
action: "generateContent",
|
action: "generateContent",
|
||||||
body: []byte(`{"model":"claude-sonnet-4-5","request":{}}`),
|
body: []byte(`{"model":"claude-sonnet-4-5","request":{}}`),
|
||||||
httpUpstream: upstream,
|
httpUpstream: upstream,
|
||||||
|
accountRepo: repo,
|
||||||
requestedModel: "claude-sonnet-4-5",
|
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 {
|
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
|
return nil
|
||||||
@@ -341,6 +381,158 @@ func TestAntigravityRetryLoop_ActiveOverages_ExplicitCreditErrorMarksExhausted(t
|
|||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, result)
|
require.NotNil(t, result)
|
||||||
require.True(t, isCreditsExhausted(accountID))
|
// 验证 AICredits key 已通过 SetModelRateLimit 写入数据库
|
||||||
require.Len(t, repo.extraUpdateCalls, 1, "应清理对应模型的 overages 运行态")
|
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, "普通模型限流应保留")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
|
|||||||
if resp.StatusCode == http.StatusTooManyRequests &&
|
if resp.StatusCode == http.StatusTooManyRequests &&
|
||||||
category == antigravity429QuotaExhausted &&
|
category == antigravity429QuotaExhausted &&
|
||||||
p.account.IsOveragesEnabled() &&
|
p.account.IsOveragesEnabled() &&
|
||||||
!isCreditsExhausted(p.account.ID) {
|
!p.account.isCreditsExhausted() {
|
||||||
result := s.attemptCreditsOveragesRetry(p, baseURL, modelName, waitDuration, resp.StatusCode, respBody)
|
result := s.attemptCreditsOveragesRetry(p, baseURL, modelName, waitDuration, resp.StatusCode, respBody)
|
||||||
if result.handled && result.resp != nil {
|
if result.handled && result.resp != nil {
|
||||||
return &smartRetryResult{
|
return &smartRetryResult{
|
||||||
@@ -552,13 +552,15 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace(
|
|||||||
|
|
||||||
// antigravityRetryLoop 执行带 URL fallback 的重试循环
|
// antigravityRetryLoop 执行带 URL fallback 的重试循环
|
||||||
func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopParams) (*antigravityRetryLoopResult, error) {
|
func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopParams) (*antigravityRetryLoopResult, error) {
|
||||||
// 预检查:如果模型已进入 overages 运行态,则直接注入 AI Credits。
|
// 预检查:模型限流 + overages 启用 + 积分未耗尽 → 直接注入 AI Credits
|
||||||
overagesActive := false
|
overagesInjected := false
|
||||||
if p.requestedModel != "" && canUseAntigravityCreditsOverages(p.ctx, p.account, p.requestedModel) {
|
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 {
|
if creditsBody := injectEnabledCreditTypes(p.body); creditsBody != nil {
|
||||||
p.body = creditsBody
|
p.body = creditsBody
|
||||||
overagesActive = true
|
overagesInjected = true
|
||||||
logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: credit_overages_active model=%s account=%d (injecting enabledCreditTypes)",
|
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)
|
p.prefix, p.requestedModel, p.account.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,9 +568,9 @@ func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopP
|
|||||||
// 预检查:如果账号已限流,直接返回切换信号
|
// 预检查:如果账号已限流,直接返回切换信号
|
||||||
if p.requestedModel != "" {
|
if p.requestedModel != "" {
|
||||||
if remaining := p.account.GetRateLimitRemainingTimeWithContext(p.ctx, p.requestedModel); remaining > 0 {
|
if remaining := p.account.GetRateLimitRemainingTimeWithContext(p.ctx, p.requestedModel); remaining > 0 {
|
||||||
// 进入 overages 运行态的模型不再受普通模型限流预检查阻断。
|
// 已注入积分的请求不再受普通模型限流预检查阻断。
|
||||||
if overagesActive {
|
if overagesInjected {
|
||||||
logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: credit_overages_ignore_rate_limit remaining=%v model=%s account=%d",
|
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)
|
p.prefix, remaining.Truncate(time.Millisecond), p.requestedModel, p.account.ID)
|
||||||
} else if isSingleAccountRetry(p.ctx) {
|
} else if isSingleAccountRetry(p.ctx) {
|
||||||
// 单账号 503 退避重试模式:跳过限流预检查,直接发请求。
|
// 单账号 503 退避重试模式:跳过限流预检查,直接发请求。
|
||||||
@@ -666,9 +668,9 @@ urlFallbackLoop:
|
|||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
if overagesActive && shouldMarkCreditsExhausted(resp, respBody, nil) {
|
if overagesInjected && shouldMarkCreditsExhausted(resp, respBody, nil) {
|
||||||
modelKey := resolveCreditsOveragesModelKey(p.ctx, p.account, "", p.requestedModel)
|
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,
|
StatusCode: resp.StatusCode,
|
||||||
Header: resp.Header.Clone(),
|
Header: resp.Header.Clone(),
|
||||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||||
@@ -1717,7 +1719,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
var clientDisconnect bool
|
var clientDisconnect bool
|
||||||
if claudeReq.Stream {
|
if claudeReq.Stream {
|
||||||
// 客户端要求流式,直接透传转换
|
// 客户端要求流式,直接透传转换
|
||||||
streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel)
|
streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel, account.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err)
|
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1727,7 +1729,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
clientDisconnect = streamRes.clientDisconnect
|
clientDisconnect = streamRes.clientDisconnect
|
||||||
} else {
|
} else {
|
||||||
// 客户端要求非流式,收集流式响应后转换返回
|
// 客户端要求非流式,收集流式响应后转换返回
|
||||||
streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel)
|
streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel, account.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err)
|
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1736,6 +1738,9 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
firstTokenMs = streamRes.firstTokenMs
|
firstTokenMs = streamRes.firstTokenMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude Max cache billing: 同步 ForwardResult.Usage 与客户端响应体一致
|
||||||
|
applyClaudeMaxCacheBillingPolicyToUsage(usage, parsedRequestFromGinContext(c), claudeMaxGroupFromGinContext(c), originalModel, account.ID)
|
||||||
|
|
||||||
return &ForwardResult{
|
return &ForwardResult{
|
||||||
RequestID: requestID,
|
RequestID: requestID,
|
||||||
Usage: *usage,
|
Usage: *usage,
|
||||||
@@ -3639,7 +3644,7 @@ func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int,
|
|||||||
|
|
||||||
// handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回
|
// 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)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
maxLineSize := defaultMaxLineSize
|
maxLineSize := defaultMaxLineSize
|
||||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
|
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")
|
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)
|
c.Data(http.StatusOK, "application/json", claudeResp)
|
||||||
|
|
||||||
// 转换为 service.ClaudeUsage
|
// 转换为 service.ClaudeUsage
|
||||||
@@ -3811,7 +3819,7 @@ returnResponse:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleClaudeStreamingResponse 处理 Claude 流式响应(Gemini SSE → Claude SSE 转换)
|
// 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("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Header("Connection", "keep-alive")
|
c.Header("Connection", "keep-alive")
|
||||||
@@ -3824,6 +3832,8 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
|
|||||||
}
|
}
|
||||||
|
|
||||||
processor := antigravity.NewStreamingProcessor(originalModel)
|
processor := antigravity.NewStreamingProcessor(originalModel)
|
||||||
|
setupClaudeMaxStreamingHook(c, processor, originalModel, accountID)
|
||||||
|
|
||||||
var firstTokenMs *int
|
var firstTokenMs *int
|
||||||
// 使用 Scanner 并限制单行大小,避免 ReadString 无上限导致 OOM
|
// 使用 Scanner 并限制单行大小,避免 ReadString 无上限导致 OOM
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ func (a *Account) IsSchedulableForModelWithContext(ctx context.Context, requeste
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if a.isModelRateLimitedWithContext(ctx, requestedModel) {
|
if a.isModelRateLimitedWithContext(ctx, requestedModel) {
|
||||||
|
// Antigravity + overages 启用 + 积分未耗尽 → 放行(有积分可用)
|
||||||
|
if a.Platform == PlatformAntigravity && a.IsOveragesEnabled() && !a.isCreditsExhausted() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1100,9 +1100,6 @@ func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64)
|
|||||||
if err := s.accountRepo.ClearModelRateLimits(ctx, accountID); err != nil {
|
if err := s.accountRepo.ClearModelRateLimits(ctx, accountID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := clearAntigravityCreditsOveragesState(ctx, s.accountRepo, accountID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 清除限流时一并清理临时不可调度状态,避免周限/窗口重置后仍被本地临时状态阻断。
|
// 清除限流时一并清理临时不可调度状态,避免周限/窗口重置后仍被本地临时状态阻断。
|
||||||
if err := s.accountRepo.ClearTempUnschedulable(ctx, accountID); err != nil {
|
if err := s.accountRepo.ClearTempUnschedulable(ctx, accountID); err != nil {
|
||||||
return err
|
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)
|
slog.Warn("temp_unsched_cache_delete_failed", "account_id", accountID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clearCreditsExhausted(accountID)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,8 +1175,7 @@ func hasRecoverableRuntimeState(account *Account) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return hasNonEmptyMapValue(account.Extra, "model_rate_limits") ||
|
return hasNonEmptyMapValue(account.Extra, "model_rate_limits") ||
|
||||||
hasNonEmptyMapValue(account.Extra, "antigravity_quota_scopes") ||
|
hasNonEmptyMapValue(account.Extra, "antigravity_quota_scopes")
|
||||||
hasNonEmptyMapValue(account.Extra, antigravityCreditsOveragesKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasNonEmptyMapValue(extra map[string]any, key string) bool {
|
func hasNonEmptyMapValue(extra map[string]any, key string) bool {
|
||||||
|
|||||||
@@ -88,14 +88,25 @@
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div v-for="item in activeModelStatuses" :key="`${item.kind}-${item.model}`" class="group relative mb-1 break-inside-avoid">
|
<div v-for="item in activeModelStatuses" :key="`${item.kind}-${item.model}`" class="group relative mb-1 break-inside-avoid">
|
||||||
|
<!-- 积分已用尽 -->
|
||||||
<span
|
<span
|
||||||
v-if="item.kind === 'overages'"
|
v-if="item.kind === 'credits_exhausted'"
|
||||||
|
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||||
|
>
|
||||||
|
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||||
|
{{ t('admin.accounts.status.creditsExhausted') }}
|
||||||
|
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
|
||||||
|
</span>
|
||||||
|
<!-- 正在走积分(模型限流但积分可用)-->
|
||||||
|
<span
|
||||||
|
v-else-if="item.kind === 'credits_active'"
|
||||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||||
>
|
>
|
||||||
<span>⚡</span>
|
<span>⚡</span>
|
||||||
{{ formatScopeName(item.model) }}
|
{{ formatScopeName(item.model) }}
|
||||||
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
|
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<!-- 普通模型限流 -->
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||||
@@ -109,9 +120,11 @@
|
|||||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
item.kind === 'overages'
|
item.kind === 'credits_exhausted'
|
||||||
? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
|
? t('admin.accounts.status.creditsExhaustedUntil', { time: formatTime(item.reset_at) })
|
||||||
: t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), 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) })
|
||||||
}}
|
}}
|
||||||
<div
|
<div
|
||||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||||
@@ -165,12 +178,12 @@ const isRateLimited = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
type AccountModelStatusItem = {
|
type AccountModelStatusItem = {
|
||||||
kind: 'rate_limit' | 'overages'
|
kind: 'rate_limit' | 'credits_exhausted' | 'credits_active'
|
||||||
model: string
|
model: string
|
||||||
reset_at: string
|
reset_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed: active model statuses (普通模型限流 + 超量请求运行态)
|
// Computed: active model statuses (普通模型限流 + 积分耗尽 + 走积分中)
|
||||||
const activeModelStatuses = computed<AccountModelStatusItem[]>(() => {
|
const activeModelStatuses = computed<AccountModelStatusItem[]>(() => {
|
||||||
const extra = props.account.extra as Record<string, unknown> | undefined
|
const extra = props.account.extra as Record<string, unknown> | undefined
|
||||||
const modelLimits = extra?.model_rate_limits as
|
const modelLimits = extra?.model_rate_limits as
|
||||||
@@ -179,19 +192,26 @@ const activeModelStatuses = computed<AccountModelStatusItem[]>(() => {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const items: AccountModelStatusItem[] = []
|
const items: AccountModelStatusItem[] = []
|
||||||
|
|
||||||
if (modelLimits) {
|
if (!modelLimits) return items
|
||||||
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 })))
|
|
||||||
}
|
|
||||||
|
|
||||||
const overagesStates = extra?.antigravity_credits_overages as
|
// 检查 AICredits key 是否生效(积分是否耗尽)
|
||||||
| Record<string, { activated_at?: string; active_until: string }>
|
const aiCreditsEntry = modelLimits['AICredits']
|
||||||
| undefined
|
const hasActiveAICredits = aiCreditsEntry && new Date(aiCreditsEntry.rate_limit_reset_at) > now
|
||||||
if (overagesStates) {
|
const allowOverages = !!(extra?.allow_overages)
|
||||||
items.push(...Object.entries(overagesStates)
|
|
||||||
.filter(([, info]) => new Date(info.active_until) > now)
|
for (const [model, info] of Object.entries(modelLimits)) {
|
||||||
.map(([model, info]) => ({ kind: 'overages' as const, model, reset_at: info.active_until })))
|
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
|
return items
|
||||||
@@ -216,7 +236,7 @@ const formatScopeName = (scope: string): string => {
|
|||||||
'gemini-3.1-pro-high': 'G3PH',
|
'gemini-3.1-pro-high': 'G3PH',
|
||||||
'gemini-3.1-pro-low': 'G3PL',
|
'gemini-3.1-pro-low': 'G3PL',
|
||||||
'gemini-3-pro-image': 'G3PI',
|
'gemini-3-pro-image': 'G3PI',
|
||||||
'gemini-3.1-flash-image': 'GImage',
|
'gemini-3.1-flash-image': 'G31FI',
|
||||||
// 其他
|
// 其他
|
||||||
'gpt-oss-120b-medium': 'GPT120',
|
'gpt-oss-120b-medium': 'GPT120',
|
||||||
'tab_flash_lite_preview': 'TabFL',
|
'tab_flash_lite_preview': 'TabFL',
|
||||||
|
|||||||
@@ -43,17 +43,18 @@ function makeAccount(overrides: Partial<Account>): Account {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AccountStatusIndicator', () => {
|
describe('AccountStatusIndicator', () => {
|
||||||
it('会将超量请求中的模型显示为独立状态', () => {
|
it('模型限流 + overages 启用 + 无 AICredits key → 显示 ⚡ (credits_active)', () => {
|
||||||
const wrapper = mount(AccountStatusIndicator, {
|
const wrapper = mount(AccountStatusIndicator, {
|
||||||
props: {
|
props: {
|
||||||
account: makeAccount({
|
account: makeAccount({
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'ag-1',
|
name: 'ag-1',
|
||||||
extra: {
|
extra: {
|
||||||
antigravity_credits_overages: {
|
allow_overages: true,
|
||||||
|
model_rate_limits: {
|
||||||
'claude-sonnet-4-5': {
|
'claude-sonnet-4-5': {
|
||||||
activated_at: '2026-03-15T00:00:00Z',
|
rate_limited_at: '2026-03-15T00:00:00Z',
|
||||||
active_until: '2099-03-15T00:00:00Z'
|
rate_limit_reset_at: '2099-03-15T00:00:00Z'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,7 @@ describe('AccountStatusIndicator', () => {
|
|||||||
expect(wrapper.text()).toContain('CSon45')
|
expect(wrapper.text()).toContain('CSon45')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('普通模型限流仍显示原有限流状态', () => {
|
it('模型限流 + overages 未启用 → 普通限流样式(无 ⚡)', () => {
|
||||||
const wrapper = mount(AccountStatusIndicator, {
|
const wrapper = mount(AccountStatusIndicator, {
|
||||||
props: {
|
props: {
|
||||||
account: makeAccount({
|
account: makeAccount({
|
||||||
@@ -96,4 +97,66 @@ describe('AccountStatusIndicator', () => {
|
|||||||
expect(wrapper.text()).toContain('CSon45')
|
expect(wrapper.text()).toContain('CSon45')
|
||||||
expect(wrapper.text()).not.toContain('⚡')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export default {
|
|||||||
// Common
|
// Common
|
||||||
common: {
|
common: {
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
|
justNow: 'just now',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
@@ -1655,6 +1656,14 @@ export default {
|
|||||||
enabled: 'Enabled',
|
enabled: 'Enabled',
|
||||||
disabled: 'Disabled'
|
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: {
|
supportedScopes: {
|
||||||
title: 'Supported Model Families',
|
title: 'Supported Model Families',
|
||||||
tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.',
|
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}',
|
rateLimitedAutoResume: 'Auto resumes in {time}',
|
||||||
modelRateLimitedUntil: '{model} rate limited until {time}',
|
modelRateLimitedUntil: '{model} rate limited until {time}',
|
||||||
modelCreditOveragesUntil: '{model} using AI Credits until {time}',
|
modelCreditOveragesUntil: '{model} using AI Credits until {time}',
|
||||||
|
creditsExhausted: 'Credits Exhausted',
|
||||||
|
creditsExhaustedUntil: 'AI Credits exhausted, expected recovery at {time}',
|
||||||
overloadedUntil: 'Overloaded until {time}',
|
overloadedUntil: 'Overloaded until {time}',
|
||||||
viewTempUnschedDetails: 'View temp unschedulable details'
|
viewTempUnschedDetails: 'View temp unschedulable details'
|
||||||
},
|
},
|
||||||
@@ -1969,7 +1980,7 @@ export default {
|
|||||||
resetQuota: 'Reset Quota',
|
resetQuota: 'Reset Quota',
|
||||||
quotaLimit: 'Quota Limit',
|
quotaLimit: 'Quota Limit',
|
||||||
quotaLimitPlaceholder: '0 means unlimited',
|
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',
|
quotaLimitToggle: 'Enable Quota Limit',
|
||||||
quotaLimitToggleHint: 'When enabled, account will be paused when usage reaches the set limit',
|
quotaLimitToggleHint: 'When enabled, account will be paused when usage reaches the set limit',
|
||||||
quotaDailyLimit: 'Daily Limit',
|
quotaDailyLimit: 'Daily Limit',
|
||||||
@@ -2166,7 +2177,7 @@ export default {
|
|||||||
// Quota control (Anthropic OAuth/SetupToken only)
|
// Quota control (Anthropic OAuth/SetupToken only)
|
||||||
quotaControl: {
|
quotaControl: {
|
||||||
title: 'Quota Control',
|
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: {
|
windowCost: {
|
||||||
label: '5h Window Cost Limit',
|
label: '5h Window Cost Limit',
|
||||||
hint: 'Limit account cost usage within the 5-hour window',
|
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)',
|
hint: 'Force all cache creation tokens to be billed as the selected TTL tier (5m or 1h)',
|
||||||
target: 'Target TTL',
|
target: 'Target TTL',
|
||||||
targetHint: 'Select the TTL tier for billing'
|
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',
|
expired: 'Expired',
|
||||||
proxy: 'Proxy',
|
proxy: 'Proxy',
|
||||||
noProxy: 'No Proxy',
|
noProxy: 'No Proxy',
|
||||||
@@ -2677,7 +2706,7 @@ export default {
|
|||||||
geminiFlashDaily: 'Flash',
|
geminiFlashDaily: 'Flash',
|
||||||
gemini3Pro: 'G3P',
|
gemini3Pro: 'G3P',
|
||||||
gemini3Flash: 'G3F',
|
gemini3Flash: 'G3F',
|
||||||
gemini3Image: 'GImage',
|
gemini3Image: 'G31FI',
|
||||||
claude: 'Claude'
|
claude: 'Claude'
|
||||||
},
|
},
|
||||||
tier: {
|
tier: {
|
||||||
@@ -4190,40 +4219,55 @@ export default {
|
|||||||
usage: 'Usage: Add to request header - x-api-key: <your-admin-api-key>'
|
usage: 'Usage: Add to request header - x-api-key: <your-admin-api-key>'
|
||||||
},
|
},
|
||||||
soraS3: {
|
soraS3: {
|
||||||
title: 'Sora S3 Storage',
|
title: 'Sora Storage',
|
||||||
description: 'Manage multiple Sora S3 endpoints and switch the active profile',
|
description: 'Manage Sora media storage profiles with S3 and Google Drive support',
|
||||||
newProfile: 'New Profile',
|
newProfile: 'New Profile',
|
||||||
reloadProfiles: 'Reload Profiles',
|
reloadProfiles: 'Reload Profiles',
|
||||||
empty: 'No Sora S3 profiles yet, create one first',
|
empty: 'No storage profiles yet, create one first',
|
||||||
createTitle: 'Create Sora S3 Profile',
|
createTitle: 'Create Storage Profile',
|
||||||
editTitle: 'Edit Sora S3 Profile',
|
editTitle: 'Edit Storage Profile',
|
||||||
|
selectProvider: 'Select Storage Type',
|
||||||
|
providerS3Desc: 'S3-compatible object storage',
|
||||||
|
providerGDriveDesc: 'Google Drive cloud storage',
|
||||||
profileID: 'Profile ID',
|
profileID: 'Profile ID',
|
||||||
profileName: 'Profile Name',
|
profileName: 'Profile Name',
|
||||||
setActive: 'Set as active after creation',
|
setActive: 'Set as active after creation',
|
||||||
saveProfile: 'Save Profile',
|
saveProfile: 'Save Profile',
|
||||||
activateProfile: 'Activate',
|
activateProfile: 'Activate',
|
||||||
profileCreated: 'Sora S3 profile created',
|
profileCreated: 'Storage profile created',
|
||||||
profileSaved: 'Sora S3 profile saved',
|
profileSaved: 'Storage profile saved',
|
||||||
profileDeleted: 'Sora S3 profile deleted',
|
profileDeleted: 'Storage profile deleted',
|
||||||
profileActivated: 'Sora S3 active profile switched',
|
profileActivated: 'Active storage profile switched',
|
||||||
profileIDRequired: 'Profile ID is required',
|
profileIDRequired: 'Profile ID is required',
|
||||||
profileNameRequired: 'Profile name is required',
|
profileNameRequired: 'Profile name is required',
|
||||||
profileSelectRequired: 'Please select a profile first',
|
profileSelectRequired: 'Please select a profile first',
|
||||||
endpointRequired: 'S3 endpoint is required when enabled',
|
endpointRequired: 'S3 endpoint is required when enabled',
|
||||||
bucketRequired: 'Bucket is required when enabled',
|
bucketRequired: 'Bucket is required when enabled',
|
||||||
accessKeyRequired: 'Access Key ID is required when enabled',
|
accessKeyRequired: 'Access Key ID is required when enabled',
|
||||||
deleteConfirm: 'Delete Sora S3 profile {profileID}?',
|
deleteConfirm: 'Delete storage profile {profileID}?',
|
||||||
columns: {
|
columns: {
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
|
profileId: 'Profile ID',
|
||||||
|
name: 'Name',
|
||||||
|
provider: 'Type',
|
||||||
active: 'Active',
|
active: 'Active',
|
||||||
endpoint: 'Endpoint',
|
endpoint: 'Endpoint',
|
||||||
bucket: 'Bucket',
|
storagePath: 'Storage Path',
|
||||||
|
capacityUsage: 'Capacity / Used',
|
||||||
|
capacityUnlimited: 'Unlimited',
|
||||||
|
videoCount: 'Videos',
|
||||||
|
videoCompleted: 'completed',
|
||||||
|
videoInProgress: 'in progress',
|
||||||
quota: 'Default Quota',
|
quota: 'Default Quota',
|
||||||
updatedAt: 'Updated At',
|
updatedAt: 'Updated At',
|
||||||
actions: 'Actions'
|
actions: 'Actions',
|
||||||
|
rootFolder: 'Root folder',
|
||||||
|
testInTable: 'Test',
|
||||||
|
testingInTable: 'Testing...',
|
||||||
|
testTimeout: 'Test timed out (15s)'
|
||||||
},
|
},
|
||||||
enabled: 'Enable S3 Storage',
|
enabled: 'Enable Storage',
|
||||||
enabledHint: 'When enabled, Sora generated media files will be automatically uploaded to S3 storage',
|
enabledHint: 'When enabled, Sora generated media files will be automatically uploaded',
|
||||||
endpoint: 'S3 Endpoint',
|
endpoint: 'S3 Endpoint',
|
||||||
region: 'Region',
|
region: 'Region',
|
||||||
bucket: 'Bucket',
|
bucket: 'Bucket',
|
||||||
@@ -4232,16 +4276,38 @@ export default {
|
|||||||
secretAccessKey: 'Secret Access Key',
|
secretAccessKey: 'Secret Access Key',
|
||||||
secretConfigured: '(Configured, leave blank to keep)',
|
secretConfigured: '(Configured, leave blank to keep)',
|
||||||
cdnUrl: 'CDN URL',
|
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',
|
forcePathStyle: 'Force Path Style',
|
||||||
defaultQuota: 'Default Storage Quota',
|
defaultQuota: 'Default Storage Quota',
|
||||||
defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited',
|
defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited',
|
||||||
testConnection: 'Test Connection',
|
testConnection: 'Test Connection',
|
||||||
testing: 'Testing...',
|
testing: 'Testing...',
|
||||||
testSuccess: 'S3 connection test successful',
|
testSuccess: 'Connection test successful',
|
||||||
testFailed: 'S3 connection test failed',
|
testFailed: 'Connection test failed',
|
||||||
saved: 'Sora S3 settings saved successfully',
|
saved: 'Storage settings saved successfully',
|
||||||
saveFailed: 'Failed to save Sora S3 settings'
|
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: {
|
streamTimeout: {
|
||||||
title: 'Stream Timeout Handling',
|
title: 'Stream Timeout Handling',
|
||||||
@@ -4712,6 +4778,7 @@ export default {
|
|||||||
downloadLocal: 'Download',
|
downloadLocal: 'Download',
|
||||||
canDownload: 'to download',
|
canDownload: 'to download',
|
||||||
regenrate: 'Regenerate',
|
regenrate: 'Regenerate',
|
||||||
|
regenerate: 'Regenerate',
|
||||||
creatorPlaceholder: 'Describe the video or image you want to create...',
|
creatorPlaceholder: 'Describe the video or image you want to create...',
|
||||||
videoModels: 'Video Models',
|
videoModels: 'Video Models',
|
||||||
imageModels: 'Image Models',
|
imageModels: 'Image Models',
|
||||||
@@ -4728,6 +4795,13 @@ export default {
|
|||||||
galleryEmptyTitle: 'No works yet',
|
galleryEmptyTitle: 'No works yet',
|
||||||
galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.',
|
galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.',
|
||||||
startCreating: 'Start Creating',
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export default {
|
|||||||
// Common
|
// Common
|
||||||
common: {
|
common: {
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
|
justNow: '刚刚',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
delete: '删除',
|
delete: '删除',
|
||||||
@@ -1974,7 +1975,7 @@ export default {
|
|||||||
resetQuota: '重置配额',
|
resetQuota: '重置配额',
|
||||||
quotaLimit: '配额限制',
|
quotaLimit: '配额限制',
|
||||||
quotaLimitPlaceholder: '0 表示不限制',
|
quotaLimitPlaceholder: '0 表示不限制',
|
||||||
quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。修改限额不会重置已用额度。',
|
quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。Anthropic API Key 账号还可配置客户端亲和。修改限额不会重置已用额度。',
|
||||||
quotaLimitToggle: '启用配额限制',
|
quotaLimitToggle: '启用配额限制',
|
||||||
quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度',
|
quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度',
|
||||||
quotaDailyLimit: '日限额',
|
quotaDailyLimit: '日限额',
|
||||||
@@ -2053,6 +2054,8 @@ export default {
|
|||||||
rateLimitedAutoResume: '{time} 自动恢复',
|
rateLimitedAutoResume: '{time} 自动恢复',
|
||||||
modelRateLimitedUntil: '{model} 限流至 {time}',
|
modelRateLimitedUntil: '{model} 限流至 {time}',
|
||||||
modelCreditOveragesUntil: '{model} 正在使用 AI Credits,至 {time}',
|
modelCreditOveragesUntil: '{model} 正在使用 AI Credits,至 {time}',
|
||||||
|
creditsExhausted: '积分已用尽',
|
||||||
|
creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复',
|
||||||
overloadedUntil: '负载过重,重置时间:{time}',
|
overloadedUntil: '负载过重,重置时间:{time}',
|
||||||
viewTempUnschedDetails: '查看临时不可调度详情'
|
viewTempUnschedDetails: '查看临时不可调度详情'
|
||||||
},
|
},
|
||||||
@@ -2106,7 +2109,7 @@ export default {
|
|||||||
geminiFlashDaily: 'Flash',
|
geminiFlashDaily: 'Flash',
|
||||||
gemini3Pro: 'G3P',
|
gemini3Pro: 'G3P',
|
||||||
gemini3Flash: 'G3F',
|
gemini3Flash: 'G3F',
|
||||||
gemini3Image: 'GImage',
|
gemini3Image: 'G31FI',
|
||||||
claude: 'Claude'
|
claude: 'Claude'
|
||||||
},
|
},
|
||||||
tier: {
|
tier: {
|
||||||
@@ -2316,7 +2319,7 @@ export default {
|
|||||||
// Quota control (Anthropic OAuth/SetupToken only)
|
// Quota control (Anthropic OAuth/SetupToken only)
|
||||||
quotaControl: {
|
quotaControl: {
|
||||||
title: '配额控制',
|
title: '配额控制',
|
||||||
hint: '仅适用于 Anthropic OAuth/Setup Token 账号',
|
hint: '配置费用窗口、会话限制、客户端亲和等调度控制。',
|
||||||
windowCost: {
|
windowCost: {
|
||||||
label: '5h窗口费用控制',
|
label: '5h窗口费用控制',
|
||||||
hint: '限制账号在5小时窗口内的费用使用',
|
hint: '限制账号在5小时窗口内的费用使用',
|
||||||
@@ -2371,8 +2374,26 @@ export default {
|
|||||||
hint: '将所有缓存创建 token 强制按指定的 TTL 类型(5分钟或1小时)计费',
|
hint: '将所有缓存创建 token 强制按指定的 TTL 类型(5分钟或1小时)计费',
|
||||||
target: '目标 TTL',
|
target: '目标 TTL',
|
||||||
targetHint: '选择计费使用的 TTL 类型'
|
targetHint: '选择计费使用的 TTL 类型'
|
||||||
|
},
|
||||||
|
clientAffinity: {
|
||||||
|
label: '客户端亲和调度',
|
||||||
|
hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
affinityNoClients: '无亲和客户端',
|
||||||
|
affinityClients: '{count} 个亲和客户端:',
|
||||||
|
affinitySection: '客户端亲和',
|
||||||
|
affinitySectionHint: '控制客户端在账号间的分布。通过配置区域阈值来平衡负载。',
|
||||||
|
affinityToggle: '启用客户端亲和',
|
||||||
|
affinityToggleHint: '新会话优先调度到该客户端之前使用过的账号',
|
||||||
|
affinityBase: '基础限额(绿区)',
|
||||||
|
affinityBasePlaceholder: '留空表示不限制',
|
||||||
|
affinityBaseHint: '绿区最大客户端数量(完整优先级调度)',
|
||||||
|
affinityBaseOffHint: '未开启绿区限制,所有客户端均享受完整优先级调度',
|
||||||
|
affinityBuffer: '缓冲区(黄区)',
|
||||||
|
affinityBufferPlaceholder: '例如 3',
|
||||||
|
affinityBufferHint: '黄区允许的额外客户端数量(降级优先级调度)',
|
||||||
|
affinityBufferInfinite: '不限制',
|
||||||
expired: '已过期',
|
expired: '已过期',
|
||||||
proxy: '代理',
|
proxy: '代理',
|
||||||
noProxy: '无代理',
|
noProxy: '无代理',
|
||||||
@@ -4363,40 +4384,55 @@ export default {
|
|||||||
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-api-key>'
|
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-api-key>'
|
||||||
},
|
},
|
||||||
soraS3: {
|
soraS3: {
|
||||||
title: 'Sora S3 存储配置',
|
title: 'Sora 存储配置',
|
||||||
description: '以多配置列表方式管理 Sora S3 端点,并可切换生效配置',
|
description: '以多配置列表管理 Sora 媒体存储,支持 S3 和 Google Drive',
|
||||||
newProfile: '新建配置',
|
newProfile: '新建配置',
|
||||||
reloadProfiles: '刷新列表',
|
reloadProfiles: '刷新列表',
|
||||||
empty: '暂无 Sora S3 配置,请先创建',
|
empty: '暂无存储配置,请先创建',
|
||||||
createTitle: '新建 Sora S3 配置',
|
createTitle: '新建存储配置',
|
||||||
editTitle: '编辑 Sora S3 配置',
|
editTitle: '编辑存储配置',
|
||||||
|
selectProvider: '选择存储类型',
|
||||||
|
providerS3Desc: 'S3 兼容对象存储',
|
||||||
|
providerGDriveDesc: 'Google Drive 云盘',
|
||||||
profileID: '配置 ID',
|
profileID: '配置 ID',
|
||||||
profileName: '配置名称',
|
profileName: '配置名称',
|
||||||
setActive: '创建后设为生效',
|
setActive: '创建后设为生效',
|
||||||
saveProfile: '保存配置',
|
saveProfile: '保存配置',
|
||||||
activateProfile: '设为生效',
|
activateProfile: '设为生效',
|
||||||
profileCreated: 'Sora S3 配置创建成功',
|
profileCreated: '存储配置创建成功',
|
||||||
profileSaved: 'Sora S3 配置保存成功',
|
profileSaved: '存储配置保存成功',
|
||||||
profileDeleted: 'Sora S3 配置删除成功',
|
profileDeleted: '存储配置删除成功',
|
||||||
profileActivated: 'Sora S3 生效配置已切换',
|
profileActivated: '生效配置已切换',
|
||||||
profileIDRequired: '请填写配置 ID',
|
profileIDRequired: '请填写配置 ID',
|
||||||
profileNameRequired: '请填写配置名称',
|
profileNameRequired: '请填写配置名称',
|
||||||
profileSelectRequired: '请先选择配置',
|
profileSelectRequired: '请先选择配置',
|
||||||
endpointRequired: '启用时必须填写 S3 端点',
|
endpointRequired: '启用时必须填写 S3 端点',
|
||||||
bucketRequired: '启用时必须填写存储桶',
|
bucketRequired: '启用时必须填写存储桶',
|
||||||
accessKeyRequired: '启用时必须填写 Access Key ID',
|
accessKeyRequired: '启用时必须填写 Access Key ID',
|
||||||
deleteConfirm: '确定删除 Sora S3 配置 {profileID} 吗?',
|
deleteConfirm: '确定删除存储配置 {profileID} 吗?',
|
||||||
columns: {
|
columns: {
|
||||||
profile: '配置',
|
profile: '配置',
|
||||||
|
profileId: 'Profile ID',
|
||||||
|
name: '名称',
|
||||||
|
provider: '存储类型',
|
||||||
active: '生效状态',
|
active: '生效状态',
|
||||||
endpoint: '端点',
|
endpoint: '端点',
|
||||||
bucket: '存储桶',
|
storagePath: '存储路径',
|
||||||
|
capacityUsage: '容量 / 已用',
|
||||||
|
capacityUnlimited: '无限制',
|
||||||
|
videoCount: '视频数',
|
||||||
|
videoCompleted: '完成',
|
||||||
|
videoInProgress: '进行中',
|
||||||
quota: '默认配额',
|
quota: '默认配额',
|
||||||
updatedAt: '更新时间',
|
updatedAt: '更新时间',
|
||||||
actions: '操作'
|
actions: '操作',
|
||||||
|
rootFolder: '根目录',
|
||||||
|
testInTable: '测试',
|
||||||
|
testingInTable: '测试中...',
|
||||||
|
testTimeout: '测试超时(15秒)'
|
||||||
},
|
},
|
||||||
enabled: '启用 S3 存储',
|
enabled: '启用存储',
|
||||||
enabledHint: '启用后,Sora 生成的媒体文件将自动上传到 S3 存储',
|
enabledHint: '启用后,Sora 生成的媒体文件将自动上传到存储',
|
||||||
endpoint: 'S3 端点',
|
endpoint: 'S3 端点',
|
||||||
region: '区域',
|
region: '区域',
|
||||||
bucket: '存储桶',
|
bucket: '存储桶',
|
||||||
@@ -4405,16 +4441,38 @@ export default {
|
|||||||
secretAccessKey: 'Secret Access Key',
|
secretAccessKey: 'Secret Access Key',
|
||||||
secretConfigured: '(已配置,留空保持不变)',
|
secretConfigured: '(已配置,留空保持不变)',
|
||||||
cdnUrl: 'CDN URL',
|
cdnUrl: 'CDN URL',
|
||||||
cdnUrlHint: '可选,配置后使用 CDN URL 访问文件,否则使用预签名 URL',
|
cdnUrlHint: '可选,配置后使用 CDN URL 访问文件',
|
||||||
forcePathStyle: '强制路径风格(Path Style)',
|
forcePathStyle: '强制路径风格(Path Style)',
|
||||||
defaultQuota: '默认存储配额',
|
defaultQuota: '默认存储配额',
|
||||||
defaultQuotaHint: '未在用户或分组级别指定配额时的默认值,0 表示无限制',
|
defaultQuotaHint: '未在用户或分组级别指定配额时的默认值,0 表示无限制',
|
||||||
testConnection: '测试连接',
|
testConnection: '测试连接',
|
||||||
testing: '测试中...',
|
testing: '测试中...',
|
||||||
testSuccess: 'S3 连接测试成功',
|
testSuccess: '连接测试成功',
|
||||||
testFailed: 'S3 连接测试失败',
|
testFailed: '连接测试失败',
|
||||||
saved: 'Sora S3 设置保存成功',
|
saved: '存储设置保存成功',
|
||||||
saveFailed: '保存 Sora S3 设置失败'
|
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: {
|
streamTimeout: {
|
||||||
title: '流超时处理',
|
title: '流超时处理',
|
||||||
@@ -4910,6 +4968,7 @@ export default {
|
|||||||
downloadLocal: '本地下载',
|
downloadLocal: '本地下载',
|
||||||
canDownload: '可下载',
|
canDownload: '可下载',
|
||||||
regenrate: '重新生成',
|
regenrate: '重新生成',
|
||||||
|
regenerate: '重新生成',
|
||||||
creatorPlaceholder: '描述你想要生成的视频或图片...',
|
creatorPlaceholder: '描述你想要生成的视频或图片...',
|
||||||
videoModels: '视频模型',
|
videoModels: '视频模型',
|
||||||
imageModels: '图片模型',
|
imageModels: '图片模型',
|
||||||
@@ -4926,6 +4985,13 @@ export default {
|
|||||||
galleryEmptyTitle: '还没有任何作品',
|
galleryEmptyTitle: '还没有任何作品',
|
||||||
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
|
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
|
||||||
startCreating: '开始创作',
|
startCreating: '开始创作',
|
||||||
yesterday: '昨天'
|
yesterday: '昨天',
|
||||||
|
landscape: '横屏',
|
||||||
|
portrait: '竖屏',
|
||||||
|
square: '方形',
|
||||||
|
examplePrompt1: '一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
|
||||||
|
examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
|
||||||
|
examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
|
||||||
|
examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,6 +403,8 @@ export interface AdminGroup extends Group {
|
|||||||
|
|
||||||
// MCP XML 协议注入(仅 antigravity 平台使用)
|
// MCP XML 协议注入(仅 antigravity 平台使用)
|
||||||
mcp_xml_inject: boolean
|
mcp_xml_inject: boolean
|
||||||
|
// Claude usage 模拟开关(仅 anthropic 平台使用)
|
||||||
|
simulate_claude_max_enabled: boolean
|
||||||
|
|
||||||
// 支持的模型系列(仅 antigravity 平台使用)
|
// 支持的模型系列(仅 antigravity 平台使用)
|
||||||
supported_model_scopes?: string[]
|
supported_model_scopes?: string[]
|
||||||
@@ -497,6 +499,7 @@ export interface CreateGroupRequest {
|
|||||||
fallback_group_id?: number | null
|
fallback_group_id?: number | null
|
||||||
fallback_group_id_on_invalid_request?: number | null
|
fallback_group_id_on_invalid_request?: number | null
|
||||||
mcp_xml_inject?: boolean
|
mcp_xml_inject?: boolean
|
||||||
|
simulate_claude_max_enabled?: boolean
|
||||||
supported_model_scopes?: string[]
|
supported_model_scopes?: string[]
|
||||||
// 从指定分组复制账号
|
// 从指定分组复制账号
|
||||||
copy_accounts_from_group_ids?: number[]
|
copy_accounts_from_group_ids?: number[]
|
||||||
@@ -525,6 +528,7 @@ export interface UpdateGroupRequest {
|
|||||||
fallback_group_id?: number | null
|
fallback_group_id?: number | null
|
||||||
fallback_group_id_on_invalid_request?: number | null
|
fallback_group_id_on_invalid_request?: number | null
|
||||||
mcp_xml_inject?: boolean
|
mcp_xml_inject?: boolean
|
||||||
|
simulate_claude_max_enabled?: boolean
|
||||||
supported_model_scopes?: string[]
|
supported_model_scopes?: string[]
|
||||||
copy_accounts_from_group_ids?: number[]
|
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 fields including Codex usage and model-level rate limits (Antigravity smart retry)
|
||||||
extra?: (CodexUsageSnapshot & {
|
extra?: (CodexUsageSnapshot & {
|
||||||
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
|
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
|
||||||
antigravity_credits_overages?: Record<string, { activated_at: string; active_until: string }>
|
|
||||||
} & Record<string, unknown>)
|
} & Record<string, unknown>)
|
||||||
proxy_id: number | null
|
proxy_id: number | null
|
||||||
concurrency: number
|
concurrency: number
|
||||||
@@ -721,6 +724,12 @@ export interface Account {
|
|||||||
cache_ttl_override_enabled?: boolean | null
|
cache_ttl_override_enabled?: boolean | null
|
||||||
cache_ttl_override_target?: string | 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 账号配额限制
|
// API Key 账号配额限制
|
||||||
quota_limit?: number | null
|
quota_limit?: number | null
|
||||||
quota_used?: number | null
|
quota_used?: number | null
|
||||||
|
|||||||
Reference in New Issue
Block a user