fix(frontend): deduplicate Antigravity quota groups

This commit is contained in:
ZheFox
2026-09-03 19:22:42 +08:00
parent 3dfc15963c
commit 09ef3adf70
10 changed files with 224 additions and 288 deletions
@@ -117,12 +117,13 @@ import Button from '@/components/ui/button.vue'
import { testModel } from '@/api/endpoints/providers'
import type { UpstreamMetadata, QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import {
compareAntigravityQuotaItems,
dedupeAntigravityQuotaItemsByLabel,
resolveAntigravityQuotaGroupLabel,
resolveAntigravityQuotaLabel,
summarizeAntigravityQuotaItems,
} from '@/features/providers/utils/antigravityQuota'
const props = defineProps<{
@@ -148,6 +149,7 @@ interface QuotaItem {
}
const { error: showError, success: showSuccess } = useToast()
const { t } = useI18n()
const testingModel = ref<string | null>(null)
function getQuotaSnapshotUpdatedAt(quota: QuotaStatusSnapshot | null | undefined): number | undefined {
@@ -253,7 +255,7 @@ function buildGroupedItemsFromQuotaSnapshot(
return windows
.map((window) => {
const code = String(window.code || '').trim()
const label = String(window.label || code).trim()
const label = resolveAntigravityQuotaGroupLabel(window, t)
if (!code || !label) return null
const usedPercent =
@@ -332,9 +334,8 @@ const rawItems = computed<QuotaItem[]>(() => {
return dedupeAntigravityQuotaItemsByLabel(result)
})
const items = computed<QuotaItem[]>(() => summarizeAntigravityQuotaItems(rawItems.value))
const groupedItems = computed<QuotaItem[]>(() => buildGroupedItemsFromQuotaSnapshot(props.quotaSnapshot))
const displayItems = computed<QuotaItem[]>(() => [...groupedItems.value, ...items.value])
const displayItems = computed<QuotaItem[]>(() => groupedItems.value)
async function handleTestModel(modelName: string) {
if (!props.providerId || testingModel.value) return
@@ -377,7 +377,7 @@
/>
<div class="grid grid-cols-2 gap-3">
<ProviderQuotaProgressRow
v-for="item in getAntigravityQuotaSummaryForKey(key)"
v-for="item in getAntigravityQuotaGroupItems(key)"
:key="item.model"
:label="item.label"
:title="item.label"
@@ -979,12 +979,7 @@ import ProviderMonthlyQuotaCard from '@/features/providers/components/ProviderMo
import ProviderQuotaProgressRow from '@/features/providers/components/ProviderQuotaProgressRow.vue'
import ProviderQuotaSectionHeader from '@/features/providers/components/ProviderQuotaSectionHeader.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import {
compareAntigravityQuotaItems,
dedupeAntigravityQuotaItemsByLabel,
resolveAntigravityQuotaLabel,
summarizeAntigravityQuotaItems,
} from '@/features/providers/utils/antigravityQuota'
import { resolveAntigravityQuotaGroupLabel } from '@/features/providers/utils/antigravityQuota'
import {
deleteEndpointKey,
recoverKeyHealth,
@@ -1004,7 +999,6 @@ import {
} from '@/api/endpoints'
import type {
UpstreamMetadata,
AntigravityModelQuota,
CodexUpstreamMetadata,
ChatGPTWebUpstreamMetadata,
GrokUpstreamMetadata,
@@ -1078,7 +1072,7 @@ const { error: showError, success: showSuccess, warning: showWarning } = useToas
const { confirm } = useConfirm()
const { copyToClipboard } = useClipboard()
const { tick: countdownTick, start: startCountdownTimer, stop: stopCountdownTimer } = useCountdownTimer()
const { legacyT, locale } = useI18n()
const { legacyT, locale, t } = useI18n()
function localizedApiError(err: unknown, fallback: string): string {
return legacyT(parseApiError(err, fallback))
@@ -3398,17 +3392,8 @@ interface GeminiCliQuotaItem {
resetSeconds: number | null
}
function hasAntigravityQuotaData(metadata: UpstreamMetadata | null | undefined): boolean {
const quotaByModel = metadata?.antigravity?.quota_by_model
return !!quotaByModel && typeof quotaByModel === 'object' && Object.keys(quotaByModel).length > 0
}
function hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
if (Array.isArray(quota?.windows) && quota.windows.length > 0) {
return true
}
return hasAntigravityQuotaData(key.upstream_metadata)
return getAntigravityQuotaGroupItems(key).length > 0
}
function getGeminiCliQuotaUpdatedAt(key: EndpointAPIKey): number | undefined {
@@ -3480,84 +3465,14 @@ function formatUpdatedAt(updatedAt: number): string {
const formatCodexUpdatedAt = formatUpdatedAt
const formatAntigravityUpdatedAt = formatUpdatedAt
function secondsUntilReset(resetTime: string): number | null {
if (!resetTime) return null
const ts = Date.parse(resetTime)
if (Number.isNaN(ts)) return null
const diff = Math.floor((ts - Date.now()) / 1000)
return diff > 0 ? diff : 0
}
function secondsUntilUnixReset(resetAt: number | string | null | undefined): number | null {
const numericResetAt = Number(resetAt)
if (!Number.isFinite(numericResetAt) || numericResetAt <= 0) return null
const now = Math.floor(Date.now() / 1000)
return Math.max(Math.floor(numericResetAt - now), 0)
}
function coerceAntigravityPercent(value: number | string | null | undefined): number | undefined {
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return undefined
return Math.min(Math.max(numericValue, 0), 100)
}
function coerceAntigravityRemainingFraction(value: number | string | null | undefined): number | undefined {
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return undefined
return Math.min(Math.max(numericValue, 0), 1)
}
function getAntigravityQuotaItems(metadata: UpstreamMetadata | null | undefined): AntigravityQuotaItem[] {
const quotaByModel = metadata?.antigravity?.quota_by_model
if (!quotaByModel || typeof quotaByModel !== 'object') return []
const items: AntigravityQuotaItem[] = []
const opaqueDisplayIndex = { value: 1 }
for (const [model, rawInfo] of Object.entries(quotaByModel)) {
if (!model) continue
const info: Partial<AntigravityModelQuota> = rawInfo || {}
let usedPercent = coerceAntigravityPercent(info.used_percent)
if (usedPercent === undefined) {
const remainingFraction = coerceAntigravityRemainingFraction(info.remaining_fraction)
if (remainingFraction !== undefined) {
usedPercent = (1 - remainingFraction) * 100
} else {
continue
}
}
usedPercent = coerceAntigravityPercent(usedPercent) ?? 0
const remainingPercent = Math.max(100 - usedPercent, 0)
let resetSeconds = secondsUntilUnixReset(info.reset_at)
if (typeof info.reset_time === 'string' && info.reset_time.trim()) {
resetSeconds = secondsUntilReset(info.reset_time.trim()) ?? resetSeconds
}
items.push({
model,
label: resolveAntigravityQuotaLabel(model, info.display_name, opaqueDisplayIndex),
usedPercent,
remainingPercent,
resetSeconds,
})
}
items.sort(compareAntigravityQuotaItems)
return dedupeAntigravityQuotaItemsByLabel(items)
}
function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQuotaItem[] {
function getAntigravityQuotaGroupItems(key: EndpointAPIKey): AntigravityQuotaItem[] {
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
const windows = getQuotaWindowByScope(quota, 'model')
const windows = getQuotaWindowByScope(quota, 'quota_group')
if (!quota || windows.length === 0) return []
const opaqueDisplayIndex = { value: 1 }
const items = windows
return windows
.map((window) => {
const model = String(window.model || window.label || window.code || '').trim()
const model = String(window.code || window.bucket_id || window.label || '').trim()
if (!model) return null
const usedPercent = getQuotaWindowUsedPercent(window)
@@ -3577,30 +3492,13 @@ function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQ
return {
model,
label: resolveAntigravityQuotaLabel(
model,
window.label || window.model,
opaqueDisplayIndex,
),
label: resolveAntigravityQuotaGroupLabel(window, t),
usedPercent: normalizedUsedPercent,
remainingPercent: normalizedRemainingPercent,
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
} satisfies AntigravityQuotaItem
})
.filter((item): item is AntigravityQuotaItem => item !== null)
items.sort(compareAntigravityQuotaItems)
return dedupeAntigravityQuotaItemsByLabel(items)
}
function getAntigravityQuotaItemsForKey(key: EndpointAPIKey): AntigravityQuotaItem[] {
const snapshotItems = getAntigravityQuotaItemsFromSnapshot(key)
if (snapshotItems.length > 0) return snapshotItems
return getAntigravityQuotaItems(key.upstream_metadata)
}
function getAntigravityQuotaSummaryForKey(key: EndpointAPIKey): AntigravityQuotaItem[] {
return summarizeAntigravityQuotaItems(getAntigravityQuotaItemsForKey(key))
}
function getResetCountdownText(
@@ -1,8 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h } from 'vue'
import AntigravityQuotaDialog from '@/features/providers/components/AntigravityQuotaDialog.vue'
import type { QuotaStatusSnapshot, UpstreamMetadata } from '@/api/endpoints/types'
import { createI18n, setI18nLocale, type Locale } from '@/i18n'
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
@@ -77,7 +78,12 @@ vi.mock('@/utils/errorParser', () => ({
parseApiError: (value: unknown) => String(value),
}))
function mount(metadata: UpstreamMetadata, quotaSnapshot?: QuotaStatusSnapshot) {
function mount(
metadata: UpstreamMetadata,
quotaSnapshot?: QuotaStatusSnapshot,
locale: Locale = 'zh-CN',
) {
setI18nLocale(locale)
const root = document.createElement('div')
document.body.appendChild(root)
@@ -91,6 +97,7 @@ function mount(metadata: UpstreamMetadata, quotaSnapshot?: QuotaStatusSnapshot)
})
},
}))
app.use(createI18n())
app.mount(root)
return {
@@ -103,140 +110,122 @@ function mount(metadata: UpstreamMetadata, quotaSnapshot?: QuotaStatusSnapshot)
}
describe('AntigravityQuotaDialog', () => {
it('renders grouped five-hour and weekly quota windows', () => {
afterEach(() => {
setI18nLocale('zh-CN')
})
it('renders only compact localized quota-group windows in Chinese', () => {
const { root, unmount } = mount({}, {
code: 'ok',
provider_type: 'antigravity',
exhausted: false,
windows: [{
code: 'group:0:3p-5h',
code: 'group:0:gemini-weekly',
label: 'Gemini Models · Weekly Limit Remaining',
scope: 'quota_group',
quota_group_label: 'Gemini models',
bucket_id: 'gemini-weekly',
window: 'weekly',
used_ratio: 0.1,
remaining_ratio: 0.9,
}, {
code: 'group:1:3p-5h',
label: 'Claude and GPT models · 5 hour',
scope: 'quota_group',
quota_group_label: 'Claude and GPT models',
bucket_id: '3p-5h',
window: '5h',
used_ratio: 0.75,
remaining_ratio: 0.25,
}, {
code: 'group:0:3p-weekly',
label: 'Claude and GPT models · weekly',
code: 'group:1:3p-weekly',
label: 'Claude and GPT models · Weekly Limit Remaining',
scope: 'quota_group',
quota_group_label: 'Claude and GPT models',
bucket_id: '3p-weekly',
window: 'weekly',
used_ratio: 0.2,
remaining_ratio: 0.8,
}, {
code: 'model:gemini-3.7-flash-tiered',
label: 'Gemini 3.7 Flash',
scope: 'model',
model: 'gemini-3.7-flash-tiered',
used_ratio: 0.05,
remaining_ratio: 0.95,
}],
})
const text = root.textContent || ''
expect(text).toContain('Claude and GPT models · 5 hour')
expect(text).toContain('Gemini Models · 周')
expect(text).toContain('90.0%')
expect(text).toContain('Claude and GPT models · 5小时')
expect(text).toContain('25.0%')
expect(text).toContain('Claude and GPT models · weekly')
expect(text).toContain('Claude and GPT models · 周')
expect(text).toContain('80.0%')
expect(text).not.toContain('Weekly Limit Remaining')
expect(text).not.toContain('Gemini额度')
expect(text).not.toContain('Claude & ChatGPT')
expect(text).not.toContain('Gemini 3.7 Flash')
unmount()
})
it('hides quota buckets outside the shared pool summary families', () => {
const rawIdentifier = 'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb'
const { root, unmount } = mount({
antigravity: {
quota_by_model: {
[rawIdentifier]: {
display_name: rawIdentifier,
remaining_fraction: 0.25,
used_percent: 75,
},
},
},
})
it('uses Weekly and 5 Hours for the English locale', () => {
const { root, unmount } = mount({}, {
code: 'ok',
provider_type: 'antigravity',
exhausted: false,
windows: [{
code: 'group:0:gemini-weekly',
label: 'Gemini Models · Weekly Limit Remaining',
scope: 'quota_group',
quota_group_label: 'Gemini Models',
bucket_id: 'gemini-weekly',
window: 'weekly',
remaining_ratio: 0.9,
}, {
code: 'group:1:3p-5h',
label: 'Claude and GPT models · 5 hour',
scope: 'quota_group',
quota_group_label: 'Claude and GPT models',
bucket_id: '3p-5h',
window: '5h',
remaining_ratio: 0.25,
}],
}, 'en-US')
const text = root.textContent || ''
expect(root.textContent).toContain('暂无配额数据')
expect(root.textContent).not.toContain('Key-1')
expect(root.textContent).not.toContain(rawIdentifier)
expect(root.textContent).not.toContain('25.0%')
expect(text).toContain('Gemini Models · Weekly')
expect(text).toContain('Claude and GPT models · 5 Hours')
expect(text).not.toContain('Weekly Limit Remaining')
unmount()
})
it('renders only the shared Gemini and Claude ChatGPT family summaries', () => {
it('does not fall back to the removed model-family summaries', () => {
const { root, unmount } = mount({
antigravity: {
quota_by_model: {
tab_flash_lite_preview: {
display_name: 'Tab Flash Lite Preview',
remaining_fraction: 0.01,
used_percent: 99,
},
'gemini-3-flash-agent': {
display_name: 'Gemini 3.5 Flash (High)',
remaining_fraction: 0.9,
used_percent: 10,
},
'gemini-pro-agent': {
display_name: 'gemini-pro-agent',
remaining_fraction: 0.95,
used_percent: 5,
},
'gemini-3.5-flash-low': {
display_name: 'Gemini 3.5 Flash (Medium)',
remaining_fraction: 0.8,
used_percent: 20,
},
'claude-opus-4-6-thinking': {
display_name: 'Claude Opus 4.6 (Thinking)',
remaining_fraction: 1,
used_percent: 0,
},
chat_20706: {
display_name: 'chat_20706',
remaining_fraction: 0,
used_percent: 100,
},
},
},
})
const text = root.textContent || ''
expect(text).not.toContain('gemini-pro-agent')
expect(text).not.toContain('claude-opus-4-6-thinking')
expect(text).toContain('Gemini额度')
expect(text).toContain('80%–95%')
expect(text).toContain('Claude & ChatGPT')
expect(text).toContain('100%')
expect(text).not.toContain('Gemini 3.1 Pro (High)')
expect(text).not.toContain('Gemini 3.5 Flash (High)')
expect(text).not.toContain('Gemini 3.5 Flash (Medium)')
expect(text).not.toContain('Tab Flash Lite Preview')
expect(text).not.toContain('chat_20706')
unmount()
})
it('collapses duplicate model buckets into one family range', () => {
const { root, unmount } = mount({
antigravity: {
quota_by_model: {
'gemini-3.1-pro-high': {
display_name: 'Gemini 3.1 Pro (High)',
remaining_fraction: 0.95,
used_percent: 5,
},
'gemini-pro-agent': {
display_name: 'Gemini 3.1 Pro (High)',
remaining_fraction: 0.4,
used_percent: 60,
},
'gemini-3-flash-agent': {
display_name: 'Gemini 3.5 Flash (High)',
remaining_fraction: 0.9,
used_percent: 10,
},
},
},
})
const text = root.textContent || ''
expect(text.match(/Gemini额度/g)).toHaveLength(1)
expect(text).toContain('40%–90%')
expect(text).not.toContain('95.0%')
expect(text).not.toContain('Gemini 3.1 Pro (High)')
expect(text).not.toContain('Gemini 3.5 Flash (High)')
expect(root.textContent).toContain('暂无配额数据')
expect(root.textContent).not.toContain('Gemini额度')
expect(root.textContent).not.toContain('Claude & ChatGPT')
expect(root.textContent).not.toContain('Gemini 3.5 Flash (High)')
expect(root.textContent).not.toContain('Claude Opus 4.6 (Thinking)')
unmount()
})