Merge pull request #1043 from touwaeriol/pr/antigravity-credits-overages

feat: Antigravity AI Credits overages handling & balance display
This commit is contained in:
Wesley Liddick
2026-03-16 09:22:19 +08:00
committed by GitHub
24 changed files with 1805 additions and 141 deletions

View File

@@ -76,19 +76,39 @@
</div>
</div>
<!-- Model Rate Limit Indicators (Antigravity OAuth Smart Retry) -->
<!-- Model Status Indicators (普通限流 / 超量请求中) -->
<div
v-if="activeModelRateLimits.length > 0"
v-if="activeModelStatuses.length > 0"
:class="[
activeModelRateLimits.length <= 4
activeModelStatuses.length <= 4
? 'flex flex-col gap-1'
: activeModelRateLimits.length <= 8
: activeModelStatuses.length <= 8
? 'columns-2 gap-x-2'
: 'columns-3 gap-x-2'
]"
>
<div v-for="item in activeModelRateLimits" :key="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
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"
>
<span></span>
{{ formatScopeName(item.model) }}
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
</span>
<!-- 普通模型限流 -->
<span
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"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
@@ -99,7 +119,13 @@
<div
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"
>
{{ t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) }}
{{
item.kind === 'credits_exhausted'
? t('admin.accounts.status.creditsExhaustedUntil', { time: formatTime(item.reset_at) })
: item.kind === 'credits_active'
? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
: t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
}}
<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"
></div>
@@ -131,6 +157,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { Account } from '@/types'
import { formatCountdown, formatDateTime, formatCountdownWithSuffix, formatTime } from '@/utils/format'
@@ -150,17 +177,44 @@ const isRateLimited = computed(() => {
return new Date(props.account.rate_limit_reset_at) > new Date()
})
type AccountModelStatusItem = {
kind: 'rate_limit' | 'credits_exhausted' | 'credits_active'
model: string
reset_at: string
}
// Computed: active model rate limits (Antigravity OAuth Smart Retry)
const activeModelRateLimits = computed(() => {
const modelLimits = (props.account.extra as Record<string, unknown> | undefined)?.model_rate_limits as
// Computed: active model statuses (普通模型限流 + 积分耗尽 + 走积分中)
const activeModelStatuses = computed<AccountModelStatusItem[]>(() => {
const extra = props.account.extra as Record<string, unknown> | undefined
const modelLimits = extra?.model_rate_limits as
| Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
| undefined
if (!modelLimits) return []
const now = new Date()
return Object.entries(modelLimits)
.filter(([, info]) => new Date(info.rate_limit_reset_at) > now)
.map(([model, info]) => ({ model, reset_at: info.rate_limit_reset_at }))
const items: AccountModelStatusItem[] = []
if (!modelLimits) return items
// 检查 AICredits key 是否生效(积分是否耗尽)
const aiCreditsEntry = modelLimits['AICredits']
const hasActiveAICredits = aiCreditsEntry && new Date(aiCreditsEntry.rate_limit_reset_at) > now
const allowOverages = !!(extra?.allow_overages)
for (const [model, info] of Object.entries(modelLimits)) {
if (new Date(info.rate_limit_reset_at) <= now) continue
if (model === 'AICredits') {
// AICredits key → 积分已用尽
items.push({ kind: 'credits_exhausted', model, reset_at: info.rate_limit_reset_at })
} else if (allowOverages && !hasActiveAICredits) {
// 普通模型限流 + overages 启用 + 积分可用 → 正在走积分
items.push({ kind: 'credits_active', model, reset_at: info.rate_limit_reset_at })
} else {
// 普通模型限流
items.push({ kind: 'rate_limit', model, reset_at: info.rate_limit_reset_at })
}
}
return items
})
const formatScopeName = (scope: string): string => {
@@ -182,7 +236,7 @@ const formatScopeName = (scope: string): string => {
'gemini-3.1-pro-high': 'G3PH',
'gemini-3.1-pro-low': 'G3PL',
'gemini-3-pro-image': 'G3PI',
'gemini-3.1-flash-image': 'GImage',
'gemini-3.1-flash-image': 'G31FI',
// 其他
'gpt-oss-120b-medium': 'GPT120',
'tab_flash_lite_preview': 'TabFL',

View File

@@ -289,6 +289,13 @@
:resets-at="antigravityClaudeUsageFromAPI.resetTime"
color="amber"
/>
<div v-if="aiCreditsDisplay" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
💳 {{ t('admin.accounts.aiCreditsBalance') }}: {{ aiCreditsDisplay }}
</div>
</div>
<div v-else-if="aiCreditsDisplay" class="text-[10px] text-gray-500 dark:text-gray-400">
💳 {{ t('admin.accounts.aiCreditsBalance') }}: {{ aiCreditsDisplay }}
</div>
<div v-else class="text-xs text-gray-400">-</div>
</template>
@@ -581,6 +588,14 @@ const antigravityClaudeUsageFromAPI = computed(() =>
])
)
const aiCreditsDisplay = computed(() => {
const credits = usageInfo.value?.ai_credits
if (!credits || credits.length === 0) return null
const total = credits.reduce((sum, credit) => sum + (credit.amount ?? 0), 0)
if (total <= 0) return null
return total.toFixed(0)
})
// Antigravity 账户类型(从 load_code_assist 响应中提取)
const antigravityTier = computed(() => {
const extra = props.account.extra as Record<string, unknown> | undefined

View File

@@ -2449,6 +2449,33 @@
</div>
</div>
</div>
<div v-if="form.platform === 'antigravity'" class="mt-3 flex items-center gap-2">
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
v-model="allowOverages"
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.allowOverages') }}
</span>
</label>
<div class="group relative">
<span
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
>
?
</span>
<div
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.allowOveragesTooltip') }}
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
</div>
</div>
</div>
<!-- Group Selection - 仅标准模式显示 -->
<GroupSelector
@@ -2991,6 +3018,7 @@ const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OF
const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
const soraAccountType = ref<'oauth' | 'apikey'>('oauth') // For sora: oauth or apikey (upstream)
const upstreamBaseUrl = ref('') // For upstream type: base URL
@@ -3017,6 +3045,13 @@ const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
const geminiAIStudioOAuthEnabled = ref(false)
function buildAntigravityExtra(): Record<string, unknown> | undefined {
const extra: Record<string, unknown> = {}
if (mixedScheduling.value) extra.mixed_scheduling = true
if (allowOverages.value) extra.allow_overages = true
return Object.keys(extra).length > 0 ? extra : undefined
}
const showMixedChannelWarning = ref(false)
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
null
@@ -3282,6 +3317,7 @@ watch(
accountCategory.value = 'oauth-based'
antigravityAccountType.value = 'oauth'
} else {
allowOverages.value = false
antigravityWhitelistModels.value = []
antigravityModelMappings.value = []
antigravityModelRestrictionMode.value = 'mapping'
@@ -3712,6 +3748,7 @@ const resetForm = () => {
sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
cacheTTLOverrideTarget.value = '5m'
allowOverages.value = false
antigravityAccountType.value = 'oauth'
upstreamBaseUrl.value = ''
upstreamApiKey.value = ''
@@ -3960,7 +3997,7 @@ const handleSubmit = async () => {
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
const extra = buildAntigravityExtra()
await createAccountAndFinish(form.platform, 'apikey', credentials, extra)
return
}
@@ -4706,7 +4743,7 @@ const handleAntigravityExchange = async (authCode: string) => {
if (antigravityModelMapping) {
credentials.model_mapping = antigravityModelMapping
}
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
const extra = buildAntigravityExtra()
await createAccountAndFinish('antigravity', 'oauth', credentials, extra)
} catch (error: any) {
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')

View File

@@ -1610,6 +1610,33 @@
</div>
</div>
</div>
<div v-if="account?.platform === 'antigravity'" class="mt-3 flex items-center gap-2">
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
v-model="allowOverages"
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.allowOverages') }}
</span>
</label>
<div class="group relative">
<span
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
>
?
</span>
<div
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.allowOveragesTooltip') }}
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
</div>
</div>
</div>
</div>
<!-- Group Selection - 仅标准模式显示 -->
@@ -1778,6 +1805,7 @@ const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const antigravityWhitelistModels = ref<string[]>([])
const antigravityModelMappings = ref<ModelMapping[]>([])
@@ -1980,8 +2008,11 @@ watch(
autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
// Load mixed scheduling setting (only for antigravity accounts)
mixedScheduling.value = false
allowOverages.value = false
const extra = newAccount.extra as Record<string, unknown> | undefined
mixedScheduling.value = extra?.mixed_scheduling === true
allowOverages.value = extra?.allow_overages === true
// Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
openaiPassthroughEnabled.value = false
@@ -2822,7 +2853,7 @@ const handleSubmit = async () => {
updatePayload.credentials = newCredentials
}
// For antigravity accounts, handle mixed_scheduling in extra
// For antigravity accounts, handle mixed_scheduling and allow_overages in extra
if (props.account.platform === 'antigravity') {
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
@@ -2831,6 +2862,11 @@ const handleSubmit = async () => {
} else {
delete newExtra.mixed_scheduling
}
if (allowOverages.value) {
newExtra.allow_overages = true
} else {
delete newExtra.allow_overages
}
updatePayload.extra = newExtra
}

View File

@@ -0,0 +1,162 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AccountStatusIndicator from '../AccountStatusIndicator.vue'
import type { Account } from '@/types'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
function makeAccount(overrides: Partial<Account>): Account {
return {
id: 1,
name: 'account',
platform: 'antigravity',
type: 'oauth',
proxy_id: null,
concurrency: 1,
priority: 1,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: true,
created_at: '2026-03-15T00:00:00Z',
updated_at: '2026-03-15T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null,
...overrides,
}
}
describe('AccountStatusIndicator', () => {
it('模型限流 + overages 启用 + 无 AICredits key → 显示 ⚡ (credits_active)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 1,
name: 'ag-1',
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'
}
}
}
})
},
global: {
stubs: {
Icon: true
}
}
})
expect(wrapper.text()).toContain('⚡')
expect(wrapper.text()).toContain('CSon45')
})
it('模型限流 + overages 未启用 → 普通限流样式(无 ⚡)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 2,
name: 'ag-2',
extra: {
model_rate_limits: {
'claude-sonnet-4-5': {
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('⚡')
})
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')
})
})

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AccountUsageCell from '../AccountUsageCell.vue'
import type { Account } from '@/types'
const { getUsage } = vi.hoisted(() => ({
getUsage: vi.fn()
@@ -24,6 +25,35 @@ vi.mock('vue-i18n', async () => {
}
})
function makeAccount(overrides: Partial<Account>): Account {
return {
id: 1,
name: 'account',
platform: 'antigravity',
type: 'oauth',
proxy_id: null,
concurrency: 1,
priority: 1,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: true,
created_at: '2026-03-15T00:00:00Z',
updated_at: '2026-03-15T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null,
...overrides,
}
}
describe('AccountUsageCell', () => {
beforeEach(() => {
getUsage.mockReset()
@@ -49,12 +79,12 @@ describe('AccountUsageCell', () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: {
account: makeAccount({
id: 1001,
platform: 'antigravity',
type: 'oauth',
extra: {}
} as any
})
},
global: {
stubs: {
@@ -72,6 +102,40 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('admin.accounts.usageWindow.gemini3Image|70|2026-03-01T09:00:00Z')
})
it('Antigravity 会显示 AI Credits 余额信息', async () => {
getUsage.mockResolvedValue({
ai_credits: [
{
credit_type: 'GOOGLE_ONE_AI',
amount: 25,
minimum_balance: 5
}
]
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 1002,
platform: 'antigravity',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('admin.accounts.aiCreditsBalance')
expect(wrapper.text()).toContain('25')
})
it('OpenAI OAuth 快照已过期时首屏会重新请求 usage', async () => {
getUsage.mockResolvedValue({
@@ -103,7 +167,7 @@ describe('AccountUsageCell', () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: {
account: makeAccount({
id: 2000,
platform: 'openai',
type: 'oauth',
@@ -114,7 +178,7 @@ describe('AccountUsageCell', () => {
codex_7d_used_percent: 34,
codex_7d_reset_at: '2026-03-13T12:00:00Z'
}
} as any
})
},
global: {
stubs: {
@@ -137,7 +201,7 @@ describe('AccountUsageCell', () => {
it('OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: {
account: makeAccount({
id: 2001,
platform: 'openai',
type: 'oauth',
@@ -148,7 +212,7 @@ describe('AccountUsageCell', () => {
codex_7d_used_percent: 34,
codex_7d_reset_at: '2099-03-13T12:00:00Z'
}
} as any
})
},
global: {
stubs: {
@@ -196,15 +260,15 @@ describe('AccountUsageCell', () => {
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: {
id: 2002,
platform: 'openai',
type: 'oauth',
extra: {}
} as any
},
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 2002,
platform: 'openai',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
@@ -256,16 +320,16 @@ describe('AccountUsageCell', () => {
seven_day: null
})
const wrapper = mount(AccountUsageCell, {
props: {
account: {
id: 2003,
platform: 'openai',
type: 'oauth',
updated_at: '2026-03-07T10:00:00Z',
extra: {}
} as any
},
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 2003,
platform: 'openai',
type: 'oauth',
updated_at: '2026-03-07T10:00:00Z',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
@@ -324,19 +388,19 @@ describe('AccountUsageCell', () => {
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: {
id: 2004,
platform: 'openai',
type: 'oauth',
rate_limit_reset_at: '2099-03-07T12:00:00Z',
extra: {
codex_5h_used_percent: 0,
codex_7d_used_percent: 0
}
} as any
},
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 2004,
platform: 'openai',
type: 'oauth',
rate_limit_reset_at: '2099-03-07T12:00:00Z',
extra: {
codex_5h_used_percent: 0,
codex_7d_used_percent: 0
}
})
},
global: {
stubs: {
UsageProgressBar: {

View File

@@ -245,6 +245,7 @@ export default {
// Common
common: {
loading: 'Loading...',
justNow: 'just now',
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
@@ -1655,6 +1656,14 @@ export default {
enabled: 'Enabled',
disabled: 'Disabled'
},
claudeMaxSimulation: {
title: 'Claude Max Usage Simulation',
tooltip:
'When enabled, for Claude models without upstream cache-write usage, the system deterministically maps tokens to a small input plus 1h cache creation while keeping total tokens unchanged.',
enabled: 'Enabled (simulate 1h cache)',
disabled: 'Disabled',
hint: 'Only token categories in usage billing logs are adjusted. No per-request mapping state is persisted.'
},
supportedScopes: {
title: 'Supported Model Families',
tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.',
@@ -1867,6 +1876,9 @@ export default {
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
rateLimitedAutoResume: 'Auto resumes in {time}',
modelRateLimitedUntil: '{model} rate limited until {time}',
modelCreditOveragesUntil: '{model} using AI Credits until {time}',
creditsExhausted: 'Credits Exhausted',
creditsExhaustedUntil: 'AI Credits exhausted, expected recovery at {time}',
overloadedUntil: 'Overloaded until {time}',
viewTempUnschedDetails: 'View temp unschedulable details'
},
@@ -1968,7 +1980,7 @@ export default {
resetQuota: 'Reset Quota',
quotaLimit: 'Quota Limit',
quotaLimitPlaceholder: '0 means unlimited',
quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Account will be paused when any limit is reached. Changing limits won\'t reset usage.',
quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Anthropic API key accounts can also configure client affinity. Changing limits won\'t reset usage.',
quotaLimitToggle: 'Enable Quota Limit',
quotaLimitToggleHint: 'When enabled, account will be paused when usage reaches the set limit',
quotaDailyLimit: 'Daily Limit',
@@ -2165,7 +2177,7 @@ export default {
// Quota control (Anthropic OAuth/SetupToken only)
quotaControl: {
title: 'Quota Control',
hint: 'Only applies to Anthropic OAuth/Setup Token accounts',
hint: 'Configure cost window, session limits, client affinity and other scheduling controls.',
windowCost: {
label: '5h Window Cost Limit',
hint: 'Limit account cost usage within the 5-hour window',
@@ -2220,8 +2232,26 @@ export default {
hint: 'Force all cache creation tokens to be billed as the selected TTL tier (5m or 1h)',
target: 'Target TTL',
targetHint: 'Select the TTL tier for billing'
},
clientAffinity: {
label: 'Client Affinity Scheduling',
hint: 'When enabled, new sessions prefer accounts previously used by this client to reduce account switching'
}
},
affinityNoClients: 'No affinity clients',
affinityClients: '{count} affinity clients:',
affinitySection: 'Client Affinity',
affinitySectionHint: 'Control how clients are distributed across accounts. Configure zone thresholds to balance load.',
affinityToggle: 'Enable Client Affinity',
affinityToggleHint: 'New sessions prefer accounts previously used by this client',
affinityBase: 'Base Limit (Green Zone)',
affinityBasePlaceholder: 'Empty = no limit',
affinityBaseHint: 'Max clients in green zone (full priority scheduling)',
affinityBaseOffHint: 'No green zone limit. All clients receive full priority scheduling.',
affinityBuffer: 'Buffer (Yellow Zone)',
affinityBufferPlaceholder: 'e.g. 3',
affinityBufferHint: 'Additional clients allowed in the yellow zone (degraded priority)',
affinityBufferInfinite: 'Unlimited',
expired: 'Expired',
proxy: 'Proxy',
noProxy: 'No Proxy',
@@ -2239,6 +2269,10 @@ export default {
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
mixedSchedulingTooltip:
'!! WARNING !! Antigravity Claude and Anthropic Claude cannot be used in the same context. If you have both Anthropic and Antigravity accounts, enabling this option will cause frequent 400 errors. When enabled, please use the group feature to isolate Antigravity accounts from Anthropic accounts. Make sure you understand this before enabling!!',
aiCreditsBalance: 'AI Credits',
allowOverages: 'Allow Overages (AI Credits)',
allowOveragesTooltip:
'Only use AI Credits after free quota is explicitly exhausted. Ordinary concurrent 429 rate limits will not switch to overages.',
creating: 'Creating...',
updating: 'Updating...',
accountCreated: 'Account created successfully',
@@ -2672,7 +2706,7 @@ export default {
geminiFlashDaily: 'Flash',
gemini3Pro: 'G3P',
gemini3Flash: 'G3F',
gemini3Image: 'GImage',
gemini3Image: 'G31FI',
claude: 'Claude'
},
tier: {
@@ -4185,40 +4219,55 @@ export default {
usage: 'Usage: Add to request header - x-api-key: <your-admin-api-key>'
},
soraS3: {
title: 'Sora S3 Storage',
description: 'Manage multiple Sora S3 endpoints and switch the active profile',
title: 'Sora Storage',
description: 'Manage Sora media storage profiles with S3 and Google Drive support',
newProfile: 'New Profile',
reloadProfiles: 'Reload Profiles',
empty: 'No Sora S3 profiles yet, create one first',
createTitle: 'Create Sora S3 Profile',
editTitle: 'Edit Sora S3 Profile',
empty: 'No storage profiles yet, create one first',
createTitle: 'Create Storage Profile',
editTitle: 'Edit Storage Profile',
selectProvider: 'Select Storage Type',
providerS3Desc: 'S3-compatible object storage',
providerGDriveDesc: 'Google Drive cloud storage',
profileID: 'Profile ID',
profileName: 'Profile Name',
setActive: 'Set as active after creation',
saveProfile: 'Save Profile',
activateProfile: 'Activate',
profileCreated: 'Sora S3 profile created',
profileSaved: 'Sora S3 profile saved',
profileDeleted: 'Sora S3 profile deleted',
profileActivated: 'Sora S3 active profile switched',
profileCreated: 'Storage profile created',
profileSaved: 'Storage profile saved',
profileDeleted: 'Storage profile deleted',
profileActivated: 'Active storage profile switched',
profileIDRequired: 'Profile ID is required',
profileNameRequired: 'Profile name is required',
profileSelectRequired: 'Please select a profile first',
endpointRequired: 'S3 endpoint is required when enabled',
bucketRequired: 'Bucket is required when enabled',
accessKeyRequired: 'Access Key ID is required when enabled',
deleteConfirm: 'Delete Sora S3 profile {profileID}?',
deleteConfirm: 'Delete storage profile {profileID}?',
columns: {
profile: 'Profile',
profileId: 'Profile ID',
name: 'Name',
provider: 'Type',
active: 'Active',
endpoint: 'Endpoint',
bucket: 'Bucket',
storagePath: 'Storage Path',
capacityUsage: 'Capacity / Used',
capacityUnlimited: 'Unlimited',
videoCount: 'Videos',
videoCompleted: 'completed',
videoInProgress: 'in progress',
quota: 'Default Quota',
updatedAt: 'Updated At',
actions: 'Actions'
actions: 'Actions',
rootFolder: 'Root folder',
testInTable: 'Test',
testingInTable: 'Testing...',
testTimeout: 'Test timed out (15s)'
},
enabled: 'Enable S3 Storage',
enabledHint: 'When enabled, Sora generated media files will be automatically uploaded to S3 storage',
enabled: 'Enable Storage',
enabledHint: 'When enabled, Sora generated media files will be automatically uploaded',
endpoint: 'S3 Endpoint',
region: 'Region',
bucket: 'Bucket',
@@ -4227,16 +4276,38 @@ export default {
secretAccessKey: 'Secret Access Key',
secretConfigured: '(Configured, leave blank to keep)',
cdnUrl: 'CDN URL',
cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL instead of presigned URLs',
cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL',
forcePathStyle: 'Force Path Style',
defaultQuota: 'Default Storage Quota',
defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited',
testConnection: 'Test Connection',
testing: 'Testing...',
testSuccess: 'S3 connection test successful',
testFailed: 'S3 connection test failed',
saved: 'Sora S3 settings saved successfully',
saveFailed: 'Failed to save Sora S3 settings'
testSuccess: 'Connection test successful',
testFailed: 'Connection test failed',
saved: 'Storage settings saved successfully',
saveFailed: 'Failed to save storage settings',
gdrive: {
authType: 'Authentication Method',
serviceAccount: 'Service Account',
clientId: 'Client ID',
clientSecret: 'Client Secret',
clientSecretConfigured: '(Configured, leave blank to keep)',
refreshToken: 'Refresh Token',
refreshTokenConfigured: '(Configured, leave blank to keep)',
serviceAccountJson: 'Service Account JSON',
serviceAccountConfigured: '(Configured, leave blank to keep)',
folderId: 'Folder ID (optional)',
authorize: 'Authorize Google Drive',
authorizeHint: 'Get Refresh Token via OAuth2',
oauthFieldsRequired: 'Please fill in Client ID and Client Secret first',
oauthSuccess: 'Google Drive authorization successful',
oauthFailed: 'Google Drive authorization failed',
closeWindow: 'This window will close automatically',
processing: 'Processing authorization...',
testStorage: 'Test Storage',
testSuccess: 'Google Drive storage test passed (upload, access, delete all OK)',
testFailed: 'Google Drive storage test failed'
}
},
streamTimeout: {
title: 'Stream Timeout Handling',
@@ -4707,6 +4778,7 @@ export default {
downloadLocal: 'Download',
canDownload: 'to download',
regenrate: 'Regenerate',
regenerate: 'Regenerate',
creatorPlaceholder: 'Describe the video or image you want to create...',
videoModels: 'Video Models',
imageModels: 'Image Models',
@@ -4723,6 +4795,13 @@ export default {
galleryEmptyTitle: 'No works yet',
galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.',
startCreating: 'Start Creating',
yesterday: 'Yesterday'
yesterday: 'Yesterday',
landscape: 'Landscape',
portrait: 'Portrait',
square: 'Square',
examplePrompt1: 'A golden Shiba Inu walking through the streets of Shibuya, Tokyo, camera following, cinematic shot, 4K',
examplePrompt2: 'Drone aerial view, green aurora reflecting on a glacial lake in Iceland, slow push-in',
examplePrompt3: 'Cyberpunk futuristic city, neon lights reflected in rain puddles, nightscape, cinematic colors',
examplePrompt4: 'Chinese ink painting style, a small boat drifting among misty mountains and rivers, classical atmosphere'
}
}

View File

@@ -245,6 +245,7 @@ export default {
// Common
common: {
loading: '加载中...',
justNow: '刚刚',
save: '保存',
cancel: '取消',
delete: '删除',
@@ -1974,7 +1975,7 @@ export default {
resetQuota: '重置配额',
quotaLimit: '配额限制',
quotaLimitPlaceholder: '0 表示不限制',
quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。修改限额不会重置已用额度。',
quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。Anthropic API Key 账号还可配置客户端亲和。修改限额不会重置已用额度。',
quotaLimitToggle: '启用配额限制',
quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度',
quotaDailyLimit: '日限额',
@@ -2052,6 +2053,9 @@ export default {
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
rateLimitedAutoResume: '{time} 自动恢复',
modelRateLimitedUntil: '{model} 限流至 {time}',
modelCreditOveragesUntil: '{model} 正在使用 AI Credits至 {time}',
creditsExhausted: '积分已用尽',
creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复',
overloadedUntil: '负载过重,重置时间:{time}',
viewTempUnschedDetails: '查看临时不可调度详情'
},
@@ -2105,7 +2109,7 @@ export default {
geminiFlashDaily: 'Flash',
gemini3Pro: 'G3P',
gemini3Flash: 'G3F',
gemini3Image: 'GImage',
gemini3Image: 'G31FI',
claude: 'Claude'
},
tier: {
@@ -2315,7 +2319,7 @@ export default {
// Quota control (Anthropic OAuth/SetupToken only)
quotaControl: {
title: '配额控制',
hint: '仅适用于 Anthropic OAuth/Setup Token 账号',
hint: '配置费用窗口、会话限制、客户端亲和等调度控制。',
windowCost: {
label: '5h窗口费用控制',
hint: '限制账号在5小时窗口内的费用使用',
@@ -2370,8 +2374,26 @@ export default {
hint: '将所有缓存创建 token 强制按指定的 TTL 类型5分钟或1小时计费',
target: '目标 TTL',
targetHint: '选择计费使用的 TTL 类型'
},
clientAffinity: {
label: '客户端亲和调度',
hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号'
}
},
affinityNoClients: '无亲和客户端',
affinityClients: '{count} 个亲和客户端:',
affinitySection: '客户端亲和',
affinitySectionHint: '控制客户端在账号间的分布。通过配置区域阈值来平衡负载。',
affinityToggle: '启用客户端亲和',
affinityToggleHint: '新会话优先调度到该客户端之前使用过的账号',
affinityBase: '基础限额(绿区)',
affinityBasePlaceholder: '留空表示不限制',
affinityBaseHint: '绿区最大客户端数量(完整优先级调度)',
affinityBaseOffHint: '未开启绿区限制,所有客户端均享受完整优先级调度',
affinityBuffer: '缓冲区(黄区)',
affinityBufferPlaceholder: '例如 3',
affinityBufferHint: '黄区允许的额外客户端数量(降级优先级调度)',
affinityBufferInfinite: '不限制',
expired: '已过期',
proxy: '代理',
noProxy: '无代理',
@@ -2389,6 +2411,10 @@ export default {
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
mixedSchedulingTooltip:
'!!注意!! Antigravity Claude 和 Anthropic Claude 无法在同个上下文中使用,如果你同时有 Anthropic 账号和 Antigravity 账号,开启此选项会导致经常 400 报错。开启后,请用分组功能做好 Antigravity 账号和 Anthropic 账号的隔离。一定要弄明白再开启!!',
aiCreditsBalance: 'AI Credits',
allowOverages: '允许超量请求 (AI Credits)',
allowOveragesTooltip:
'仅在免费配额被明确判定为耗尽后才会使用 AI Credits。普通并发 429 限流不会切换到超量请求。',
creating: '创建中...',
updating: '更新中...',
accountCreated: '账号创建成功',
@@ -4358,40 +4384,55 @@ export default {
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-api-key>'
},
soraS3: {
title: 'Sora S3 存储配置',
description: '以多配置列表方式管理 Sora S3 端点,并可切换生效配置',
title: 'Sora 存储配置',
description: '以多配置列表管理 Sora 媒体存储,支持 S3 和 Google Drive',
newProfile: '新建配置',
reloadProfiles: '刷新列表',
empty: '暂无 Sora S3 配置,请先创建',
createTitle: '新建 Sora S3 配置',
editTitle: '编辑 Sora S3 配置',
empty: '暂无存储配置,请先创建',
createTitle: '新建存储配置',
editTitle: '编辑存储配置',
selectProvider: '选择存储类型',
providerS3Desc: 'S3 兼容对象存储',
providerGDriveDesc: 'Google Drive 云盘',
profileID: '配置 ID',
profileName: '配置名称',
setActive: '创建后设为生效',
saveProfile: '保存配置',
activateProfile: '设为生效',
profileCreated: 'Sora S3 配置创建成功',
profileSaved: 'Sora S3 配置保存成功',
profileDeleted: 'Sora S3 配置删除成功',
profileActivated: 'Sora S3 生效配置已切换',
profileCreated: '存储配置创建成功',
profileSaved: '存储配置保存成功',
profileDeleted: '存储配置删除成功',
profileActivated: '生效配置已切换',
profileIDRequired: '请填写配置 ID',
profileNameRequired: '请填写配置名称',
profileSelectRequired: '请先选择配置',
endpointRequired: '启用时必须填写 S3 端点',
bucketRequired: '启用时必须填写存储桶',
accessKeyRequired: '启用时必须填写 Access Key ID',
deleteConfirm: '确定删除 Sora S3 配置 {profileID} 吗?',
deleteConfirm: '确定删除存储配置 {profileID} 吗?',
columns: {
profile: '配置',
profileId: 'Profile ID',
name: '名称',
provider: '存储类型',
active: '生效状态',
endpoint: '端点',
bucket: '存储',
storagePath: '存储路径',
capacityUsage: '容量 / 已用',
capacityUnlimited: '无限制',
videoCount: '视频数',
videoCompleted: '完成',
videoInProgress: '进行中',
quota: '默认配额',
updatedAt: '更新时间',
actions: '操作'
actions: '操作',
rootFolder: '根目录',
testInTable: '测试',
testingInTable: '测试中...',
testTimeout: '测试超时15秒'
},
enabled: '启用 S3 存储',
enabledHint: '启用后Sora 生成的媒体文件将自动上传到 S3 存储',
enabled: '启用存储',
enabledHint: '启用后Sora 生成的媒体文件将自动上传到存储',
endpoint: 'S3 端点',
region: '区域',
bucket: '存储桶',
@@ -4400,16 +4441,38 @@ export default {
secretAccessKey: 'Secret Access Key',
secretConfigured: '(已配置,留空保持不变)',
cdnUrl: 'CDN URL',
cdnUrlHint: '可选,配置后使用 CDN URL 访问文件,否则使用预签名 URL',
cdnUrlHint: '可选,配置后使用 CDN URL 访问文件',
forcePathStyle: '强制路径风格Path Style',
defaultQuota: '默认存储配额',
defaultQuotaHint: '未在用户或分组级别指定配额时的默认值0 表示无限制',
testConnection: '测试连接',
testing: '测试中...',
testSuccess: 'S3 连接测试成功',
testFailed: 'S3 连接测试失败',
saved: 'Sora S3 设置保存成功',
saveFailed: '保存 Sora S3 设置失败'
testSuccess: '连接测试成功',
testFailed: '连接测试失败',
saved: '存储设置保存成功',
saveFailed: '保存存储设置失败',
gdrive: {
authType: '认证方式',
serviceAccount: '服务账号',
clientId: 'Client ID',
clientSecret: 'Client Secret',
clientSecretConfigured: '(已配置,留空保持不变)',
refreshToken: 'Refresh Token',
refreshTokenConfigured: '(已配置,留空保持不变)',
serviceAccountJson: '服务账号 JSON',
serviceAccountConfigured: '(已配置,留空保持不变)',
folderId: 'Folder ID可选',
authorize: '授权 Google Drive',
authorizeHint: '通过 OAuth2 获取 Refresh Token',
oauthFieldsRequired: '请先填写 Client ID 和 Client Secret',
oauthSuccess: 'Google Drive 授权成功',
oauthFailed: 'Google Drive 授权失败',
closeWindow: '此窗口将自动关闭',
processing: '正在处理授权...',
testStorage: '测试存储',
testSuccess: 'Google Drive 存储测试成功(上传、访问、删除均正常)',
testFailed: 'Google Drive 存储测试失败'
}
},
streamTimeout: {
title: '流超时处理',
@@ -4905,6 +4968,7 @@ export default {
downloadLocal: '本地下载',
canDownload: '可下载',
regenrate: '重新生成',
regenerate: '重新生成',
creatorPlaceholder: '描述你想要生成的视频或图片...',
videoModels: '视频模型',
imageModels: '图片模型',
@@ -4921,6 +4985,13 @@ export default {
galleryEmptyTitle: '还没有任何作品',
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
startCreating: '开始创作',
yesterday: '昨天'
yesterday: '昨天',
landscape: '横屏',
portrait: '竖屏',
square: '方形',
examplePrompt1: '一只金色的柴犬在东京涩谷街头散步镜头跟随电影感画面4K 高清',
examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
}
}

View File

@@ -403,6 +403,8 @@ export interface AdminGroup extends Group {
// MCP XML 协议注入(仅 antigravity 平台使用)
mcp_xml_inject: boolean
// Claude usage 模拟开关(仅 anthropic 平台使用)
simulate_claude_max_enabled: boolean
// 支持的模型系列(仅 antigravity 平台使用)
supported_model_scopes?: string[]
@@ -497,6 +499,7 @@ export interface CreateGroupRequest {
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[]
// 从指定分组复制账号
copy_accounts_from_group_ids?: number[]
@@ -525,6 +528,7 @@ export interface UpdateGroupRequest {
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[]
copy_accounts_from_group_ids?: number[]
}
@@ -720,6 +724,12 @@ export interface Account {
cache_ttl_override_enabled?: boolean | null
cache_ttl_override_target?: string | null
// 客户端亲和调度(仅 Anthropic/Antigravity 平台有效)
// 启用后新会话会优先调度到客户端之前使用过的账号
client_affinity_enabled?: boolean | null
affinity_client_count?: number | null
affinity_clients?: string[] | null
// API Key 账号配额限制
quota_limit?: number | null
quota_used?: number | null
@@ -780,6 +790,11 @@ export interface AccountUsageInfo {
gemini_pro_minute?: UsageProgress | null
gemini_flash_minute?: UsageProgress | null
antigravity_quota?: Record<string, AntigravityModelQuota> | null
ai_credits?: Array<{
credit_type?: string
amount?: number
minimum_balance?: number
}> | null
// Antigravity 403 forbidden 状态
is_forbidden?: boolean
forbidden_reason?: string