feat: add AI Credits balance handling and update model status indicators

This commit is contained in:
SilentFlower
2026-03-15 23:50:28 +08:00
committed by erio
parent 17e4033340
commit ced90e1d84
12 changed files with 465 additions and 77 deletions

View File

@@ -124,10 +124,68 @@ type IneligibleTier struct {
type LoadCodeAssistResponse struct {
CloudAICompanionProject string `json:"cloudaicompanionProject"`
CurrentTier *TierInfo `json:"currentTier,omitempty"`
PaidTier *TierInfo `json:"paidTier,omitempty"`
PaidTier *PaidTierInfo `json:"paidTier,omitempty"`
IneligibleTiers []*IneligibleTier `json:"ineligibleTiers,omitempty"`
}
// PaidTierInfo 付费等级信息,包含 AI Credits 余额。
type PaidTierInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
AvailableCredits []AvailableCredit `json:"availableCredits,omitempty"`
}
// UnmarshalJSON 兼容 paidTier 既可能是字符串也可能是对象的情况。
func (p *PaidTierInfo) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 || string(data) == "null" {
return nil
}
if data[0] == '"' {
var id string
if err := json.Unmarshal(data, &id); err != nil {
return err
}
p.ID = id
return nil
}
type alias PaidTierInfo
var raw alias
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*p = PaidTierInfo(raw)
return nil
}
// AvailableCredit 表示一条 AI Credits 余额记录。
type AvailableCredit struct {
CreditType string `json:"creditType,omitempty"`
CreditAmount string `json:"creditAmount,omitempty"`
MinimumCreditAmountForUsage string `json:"minimumCreditAmountForUsage,omitempty"`
}
// GetAmount 将 creditAmount 解析为浮点数。
func (c *AvailableCredit) GetAmount() float64 {
if c.CreditAmount == "" {
return 0
}
var value float64
fmt.Sscanf(c.CreditAmount, "%f", &value)
return value
}
// GetMinimumAmount 将 minimumCreditAmountForUsage 解析为浮点数。
func (c *AvailableCredit) GetMinimumAmount() float64 {
if c.MinimumCreditAmountForUsage == "" {
return 0
}
var value float64
fmt.Sscanf(c.MinimumCreditAmountForUsage, "%f", &value)
return value
}
// OnboardUserRequest onboardUser 请求
type OnboardUserRequest struct {
TierID string `json:"tierId"`
@@ -157,6 +215,14 @@ func (r *LoadCodeAssistResponse) GetTier() string {
return ""
}
// GetAvailableCredits 返回 paid tier 中的 AI Credits 余额列表。
func (r *LoadCodeAssistResponse) GetAvailableCredits() []AvailableCredit {
if r.PaidTier == nil {
return nil
}
return r.PaidTier.AvailableCredits
}
// Client Antigravity API 客户端
type Client struct {
httpClient *http.Client

View File

@@ -190,7 +190,7 @@ func TestTierInfo_UnmarshalJSON_通过JSON嵌套结构(t *testing.T) {
func TestGetTier_PaidTier优先(t *testing.T) {
resp := &LoadCodeAssistResponse{
CurrentTier: &TierInfo{ID: "free-tier"},
PaidTier: &TierInfo{ID: "g1-pro-tier"},
PaidTier: &PaidTierInfo{ID: "g1-pro-tier"},
}
if got := resp.GetTier(); got != "g1-pro-tier" {
t.Errorf("应返回 paidTier: got %s", got)
@@ -209,7 +209,7 @@ func TestGetTier_回退到CurrentTier(t *testing.T) {
func TestGetTier_PaidTier为空ID(t *testing.T) {
resp := &LoadCodeAssistResponse{
CurrentTier: &TierInfo{ID: "free-tier"},
PaidTier: &TierInfo{ID: ""},
PaidTier: &PaidTierInfo{ID: ""},
}
// paidTier.ID 为空时应回退到 currentTier
if got := resp.GetTier(); got != "free-tier" {
@@ -217,6 +217,32 @@ func TestGetTier_PaidTier为空ID(t *testing.T) {
}
}
func TestGetAvailableCredits(t *testing.T) {
resp := &LoadCodeAssistResponse{
PaidTier: &PaidTierInfo{
ID: "g1-pro-tier",
AvailableCredits: []AvailableCredit{
{
CreditType: "GOOGLE_ONE_AI",
CreditAmount: "25",
MinimumCreditAmountForUsage: "5",
},
},
},
}
credits := resp.GetAvailableCredits()
if len(credits) != 1 {
t.Fatalf("AI Credits 数量不匹配: got %d", len(credits))
}
if credits[0].GetAmount() != 25 {
t.Errorf("CreditAmount 解析不正确: got %v", credits[0].GetAmount())
}
if credits[0].GetMinimumAmount() != 5 {
t.Errorf("MinimumCreditAmountForUsage 解析不正确: got %v", credits[0].GetMinimumAmount())
}
}
func TestGetTier_两者都为nil(t *testing.T) {
resp := &LoadCodeAssistResponse{}
if got := resp.GetTier(); got != "" {

View File

@@ -166,6 +166,13 @@ type AntigravityModelDetail struct {
SupportedMimeTypes map[string]bool `json:"supported_mime_types,omitempty"`
}
// AICredit 表示 Antigravity 账号的 AI Credits 余额信息。
type AICredit struct {
CreditType string `json:"credit_type,omitempty"`
Amount float64 `json:"amount,omitempty"`
MinimumBalance float64 `json:"minimum_balance,omitempty"`
}
// UsageInfo 账号使用量信息
type UsageInfo struct {
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
@@ -189,6 +196,9 @@ type UsageInfo struct {
// Antigravity 模型详细能力信息(与 antigravity_quota 同 key
AntigravityQuotaDetails map[string]*AntigravityModelDetail `json:"antigravity_quota_details,omitempty"`
// Antigravity AI Credits 余额
AICredits []AICredit `json:"ai_credits,omitempty"`
// Antigravity 废弃模型转发规则 (old_model_id -> new_model_id)
ModelForwardingRules map[string]string `json:"model_forwarding_rules,omitempty"`

View File

@@ -78,11 +78,11 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou
return nil, err
}
// 调用 LoadCodeAssist 获取订阅等级(非关键路径,失败不影响主流程)
tierRaw, tierNormalized := f.fetchSubscriptionTier(ctx, client, accessToken)
// 调用 LoadCodeAssist 获取订阅等级和 AI Credits 余额(非关键路径,失败不影响主流程)
tierRaw, tierNormalized, loadResp := f.fetchSubscriptionTier(ctx, client, accessToken)
// 转换为 UsageInfo
usageInfo := f.buildUsageInfo(modelsResp, tierRaw, tierNormalized)
usageInfo := f.buildUsageInfo(modelsResp, tierRaw, tierNormalized, loadResp)
return &QuotaResult{
UsageInfo: usageInfo,
@@ -90,20 +90,21 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou
}, nil
}
// fetchSubscriptionTier 获取账号订阅等级,失败返回空字符串
func (f *AntigravityQuotaFetcher) fetchSubscriptionTier(ctx context.Context, client *antigravity.Client, accessToken string) (raw, normalized string) {
// fetchSubscriptionTier 获取账号订阅等级,失败返回空字符串
// 同时返回 LoadCodeAssistResponse以便提取 AI Credits 余额。
func (f *AntigravityQuotaFetcher) fetchSubscriptionTier(ctx context.Context, client *antigravity.Client, accessToken string) (raw, normalized string, loadResp *antigravity.LoadCodeAssistResponse) {
loadResp, _, err := client.LoadCodeAssist(ctx, accessToken)
if err != nil {
slog.Warn("failed to fetch subscription tier", "error", err)
return "", ""
return "", "", nil
}
if loadResp == nil {
return "", ""
return "", "", nil
}
raw = loadResp.GetTier() // 已有方法paidTier > currentTier
normalized = normalizeTier(raw)
return raw, normalized
return raw, normalized, loadResp
}
// normalizeTier 将原始 tier 字符串归一化为 FREE/PRO/ULTRA/UNKNOWN
@@ -124,8 +125,8 @@ func normalizeTier(raw string) string {
}
}
// buildUsageInfo 将 API 响应转换为 UsageInfo
func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAvailableModelsResponse, tierRaw, tierNormalized string) *UsageInfo {
// buildUsageInfo 将 API 响应转换为 UsageInfo
func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAvailableModelsResponse, tierRaw, tierNormalized string, loadResp *antigravity.LoadCodeAssistResponse) *UsageInfo {
now := time.Now()
info := &UsageInfo{
UpdatedAt: &now,
@@ -190,6 +191,16 @@ func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAv
}
}
if loadResp != nil {
for _, credit := range loadResp.GetAvailableCredits() {
info.AICredits = append(info.AICredits, AICredit{
CreditType: credit.CreditType,
Amount: credit.GetAmount(),
MinimumBalance: credit.GetMinimumAmount(),
})
}
}
return info
}

View File

@@ -81,7 +81,7 @@ func TestBuildUsageInfo_BasicModels(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO")
info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO", nil)
// 基本字段
require.NotNil(t, info.UpdatedAt, "UpdatedAt should be set")
@@ -141,7 +141,7 @@ func TestBuildUsageInfo_DeprecatedModels(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
require.Len(t, info.ModelForwardingRules, 2)
require.Equal(t, "claude-sonnet-4-20250514", info.ModelForwardingRules["claude-3-sonnet-20240229"])
@@ -159,7 +159,7 @@ func TestBuildUsageInfo_NoDeprecatedModels(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
require.Nil(t, info.ModelForwardingRules, "ModelForwardingRules should be nil when no deprecated models")
}
@@ -171,7 +171,7 @@ func TestBuildUsageInfo_EmptyModels(t *testing.T) {
Models: map[string]antigravity.ModelInfo{},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
require.NotNil(t, info)
require.NotNil(t, info.AntigravityQuota)
@@ -193,7 +193,7 @@ func TestBuildUsageInfo_ModelWithNilQuotaInfo(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
require.NotNil(t, info)
require.Empty(t, info.AntigravityQuota, "models with nil QuotaInfo should be skipped")
@@ -222,7 +222,7 @@ func TestBuildUsageInfo_FiveHourPriorityOrder(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
require.NotNil(t, info.FiveHour, "FiveHour should be set when a priority model exists")
// claude-sonnet-4-20250514 is first in priority list, so it should be used
@@ -251,7 +251,7 @@ func TestBuildUsageInfo_FiveHourFallbackToClaude4(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
require.NotNil(t, info.FiveHour)
expectedUtilization := (1.0 - 0.60) * 100 // 40
@@ -277,7 +277,7 @@ func TestBuildUsageInfo_FiveHourFallbackToGemini(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
require.NotNil(t, info.FiveHour)
expectedUtilization := (1.0 - 0.30) * 100 // 70
@@ -298,7 +298,7 @@ func TestBuildUsageInfo_FiveHourNoPriorityModel(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
require.Nil(t, info.FiveHour, "FiveHour should be nil when no priority model exists")
}
@@ -317,7 +317,7 @@ func TestBuildUsageInfo_FiveHourWithEmptyResetTime(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
require.NotNil(t, info.FiveHour)
require.Nil(t, info.FiveHour.ResetsAt, "ResetsAt should be nil when ResetTime is empty")
@@ -338,7 +338,7 @@ func TestBuildUsageInfo_FullUtilization(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
quota := info.AntigravityQuota["claude-sonnet-4-20250514"]
require.NotNil(t, quota)
@@ -358,13 +358,38 @@ func TestBuildUsageInfo_ZeroUtilization(t *testing.T) {
},
}
info := fetcher.buildUsageInfo(modelsResp, "", "")
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
quota := info.AntigravityQuota["claude-sonnet-4-20250514"]
require.NotNil(t, quota)
require.Equal(t, 0, quota.Utilization)
}
func TestBuildUsageInfo_AICredits(t *testing.T) {
fetcher := &AntigravityQuotaFetcher{}
modelsResp := &antigravity.FetchAvailableModelsResponse{
Models: map[string]antigravity.ModelInfo{},
}
loadResp := &antigravity.LoadCodeAssistResponse{
PaidTier: &antigravity.PaidTierInfo{
ID: "g1-pro-tier",
AvailableCredits: []antigravity.AvailableCredit{
{
CreditType: "GOOGLE_ONE_AI",
CreditAmount: "25",
MinimumCreditAmountForUsage: "5",
},
},
},
}
info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO", loadResp)
require.Len(t, info.AICredits, 1)
require.Equal(t, "GOOGLE_ONE_AI", info.AICredits[0].CreditType)
require.Equal(t, 25.0, info.AICredits[0].Amount)
require.Equal(t, 5.0, info.AICredits[0].MinimumBalance)
}
func TestFetchQuota_ForbiddenReturnsIsForbidden(t *testing.T) {
// 模拟 FetchQuota 遇到 403 时的行为:
// FetchAvailableModels 返回 ForbiddenError → FetchQuota 应返回 is_forbidden=true

View File

@@ -76,19 +76,28 @@
</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 === 'overages'"
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 +108,11 @@
<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 === 'overages'
? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
: t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
}}
<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 +144,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 +164,37 @@ const isRateLimited = computed(() => {
return new Date(props.account.rate_limit_reset_at) > new Date()
})
type AccountModelStatusItem = {
kind: 'rate_limit' | 'overages'
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) {
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
| Record<string, { activated_at?: string; active_until: string }>
| undefined
if (overagesStates) {
items.push(...Object.entries(overagesStates)
.filter(([, info]) => new Date(info.active_until) > now)
.map(([model, info]) => ({ kind: 'overages' as const, model, reset_at: info.active_until })))
}
return items
})
const formatScopeName = (scope: string): string => {

View File

@@ -289,6 +289,33 @@
:resets-at="antigravityClaudeUsageFromAPI.resetTime"
color="amber"
/>
<div v-if="antigravityAICreditsDisplay.length > 0" class="mt-1 space-y-0.5 text-[10px] text-gray-500 dark:text-gray-400">
<div
v-for="credit in antigravityAICreditsDisplay"
:key="credit.creditType"
>
{{ t('admin.accounts.aiCreditsBalance') }}:
{{ credit.creditType }}
{{ credit.amount }}
<span v-if="credit.minimumBalance !== null">
(min {{ credit.minimumBalance }})
</span>
</div>
</div>
</div>
<div v-else-if="antigravityAICreditsDisplay.length > 0" class="space-y-0.5 text-[10px] text-gray-500 dark:text-gray-400">
<div
v-for="credit in antigravityAICreditsDisplay"
:key="credit.creditType"
>
{{ t('admin.accounts.aiCreditsBalance') }}:
{{ credit.creditType }}
{{ credit.amount }}
<span v-if="credit.minimumBalance !== null">
(min {{ credit.minimumBalance }})
</span>
</div>
</div>
<div v-else class="text-xs text-gray-400">-</div>
</template>
@@ -581,6 +608,20 @@ const antigravityClaudeUsageFromAPI = computed(() =>
])
)
const antigravityAICreditsDisplay = computed(() => {
const credits = usageInfo.value?.ai_credits
if (!credits || credits.length === 0) return []
return credits
.filter((credit) => (credit.amount ?? 0) > 0)
.map((credit) => ({
creditType: credit.credit_type || 'UNKNOWN',
amount: Number(credit.amount ?? 0).toFixed(0),
minimumBalance: typeof credit.minimum_balance === 'number'
? Number(credit.minimum_balance).toFixed(0)
: null,
}))
})
// Antigravity 账户类型(从 load_code_assist 响应中提取)
const antigravityTier = computed(() => {
const extra = props.account.extra as Record<string, unknown> | undefined

View File

@@ -0,0 +1,99 @@
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('会将超量请求中的模型显示为独立状态', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 1,
name: 'ag-1',
extra: {
antigravity_credits_overages: {
'claude-sonnet-4-5': {
activated_at: '2026-03-15T00:00:00Z',
active_until: '2099-03-15T00:00:00Z'
}
}
}
})
},
global: {
stubs: {
Icon: true
}
}
})
expect(wrapper.text()).toContain('⚡')
expect(wrapper.text()).toContain('CSon45')
})
it('普通模型限流仍显示原有限流状态', () => {
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('⚡')
})
})

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,42 @@ 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('GOOGLE_ONE_AI')
expect(wrapper.text()).toContain('25')
expect(wrapper.text()).toContain('(min 5)')
})
it('OpenAI OAuth 快照已过期时首屏会重新请求 usage', async () => {
getUsage.mockResolvedValue({
@@ -103,7 +169,7 @@ describe('AccountUsageCell', () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: {
account: makeAccount({
id: 2000,
platform: 'openai',
type: 'oauth',
@@ -114,7 +180,7 @@ describe('AccountUsageCell', () => {
codex_7d_used_percent: 34,
codex_7d_reset_at: '2026-03-13T12:00:00Z'
}
} as any
})
},
global: {
stubs: {
@@ -137,7 +203,7 @@ describe('AccountUsageCell', () => {
it('OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: {
account: makeAccount({
id: 2001,
platform: 'openai',
type: 'oauth',
@@ -148,7 +214,7 @@ describe('AccountUsageCell', () => {
codex_7d_used_percent: 34,
codex_7d_reset_at: '2099-03-13T12:00:00Z'
}
} as any
})
},
global: {
stubs: {
@@ -196,15 +262,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 +322,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 +390,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

@@ -1867,6 +1867,7 @@ 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}',
overloadedUntil: 'Overloaded until {time}',
viewTempUnschedDetails: 'View temp unschedulable details'
},
@@ -2239,6 +2240,7 @@ 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.',

View File

@@ -2052,6 +2052,7 @@ export default {
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
rateLimitedAutoResume: '{time} 自动恢复',
modelRateLimitedUntil: '{model} 限流至 {time}',
modelCreditOveragesUntil: '{model} 正在使用 AI Credits至 {time}',
overloadedUntil: '负载过重,重置时间:{time}',
viewTempUnschedDetails: '查看临时不可调度详情'
},
@@ -2389,6 +2390,7 @@ 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 限流不会切换到超量请求。',

View File

@@ -664,6 +664,7 @@ export interface Account {
// Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
extra?: (CodexUsageSnapshot & {
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>)
proxy_id: number | null
concurrency: number
@@ -780,6 +781,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