Merge pull request #663 from MMEXA/codex/gemini-interactions-antigravity-20260705

完善 Gemini Interactions 与 Antigravity 全链路兼容
This commit is contained in:
fawney19
2026-07-09 14:55:51 +08:00
committed by GitHub
67 changed files with 3107 additions and 461 deletions
@@ -34,9 +34,10 @@
<DropdownMenuItem
v-for="item in items"
:key="item.model"
:title="item.model"
@select="handleTestModel(item.model)"
>
{{ item.model }}
<span class="truncate">{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -52,12 +53,14 @@
:key="item.model"
>
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span
class="text-muted-foreground truncate mr-2 min-w-0 flex-1"
:title="item.model"
>
{{ item.label }}
</span>
<div class="min-w-0 flex-1 mr-2">
<div
class="text-muted-foreground truncate"
:title="item.model"
>
{{ item.label }}
</div>
</div>
<span :class="getQuotaRemainingClass(item.usedPercent)">
{{ item.remainingPercent.toFixed(1) }}%
</span>
@@ -115,6 +118,11 @@ import { testModel } from '@/api/endpoints/providers'
import type { UpstreamMetadata, QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import {
compareAntigravityQuotaItems,
dedupeAntigravityQuotaItemsByLabel,
resolveAntigravityQuotaLabel,
} from '@/features/providers/utils/antigravityQuota'
const props = defineProps<{
open: boolean
@@ -165,6 +173,25 @@ function getQuotaWindowLiveResetSeconds(
return null
}
function coercePercent(value: unknown): number | null {
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return null
return Math.min(Math.max(numericValue, 0), 100)
}
function coerceRemainingFraction(value: unknown): number | null {
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return null
return Math.min(Math.max(numericValue, 0), 1)
}
function secondsUntilUnixReset(resetAt: unknown): 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 buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefined): QuotaItem[] {
if (!quota) return []
@@ -175,6 +202,7 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin
? quota.windows.filter(window => String(window?.scope || '').trim().toLowerCase() === 'model')
: []
if (windows.length === 0) return []
const opaqueDisplayIndex = { value: 1 }
const items = windows
.map((window) => {
@@ -196,7 +224,7 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin
return {
model,
label: String(window.label || window.model || model),
label: resolveAntigravityQuotaLabel(model, window.label || window.model, opaqueDisplayIndex),
usedPercent,
remainingPercent,
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
@@ -204,8 +232,8 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin
})
.filter((item): item is QuotaItem => item !== null)
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
return items
items.sort(compareAntigravityQuotaItems)
return dedupeAntigravityQuotaItemsByLabel(items)
}
const items = computed<QuotaItem[]>(() => {
@@ -218,26 +246,26 @@ const items = computed<QuotaItem[]>(() => {
if (!quotaByModel || typeof quotaByModel !== 'object') return []
const result: QuotaItem[] = []
const opaqueDisplayIndex = { value: 1 }
for (const [model, rawInfo] of Object.entries(quotaByModel)) {
if (!model) continue
const info = (rawInfo || {}) as Record<string, unknown>
let usedPercent = Number(info['used_percent'])
if (!Number.isFinite(usedPercent)) {
const remainingFraction = Number(info['remaining_fraction'])
if (Number.isFinite(remainingFraction)) {
let usedPercent = coercePercent(info['used_percent'])
if (usedPercent === null) {
const remainingFraction = coerceRemainingFraction(info['remaining_fraction'])
if (remainingFraction !== null) {
usedPercent = (1 - remainingFraction) * 100
} else {
continue
}
}
if (usedPercent < 0) usedPercent = 0
if (usedPercent > 100) usedPercent = 100
usedPercent = coercePercent(usedPercent) ?? 0
const remainingPercent = Math.max(100 - usedPercent, 0)
let resetSeconds: number | null = null
let resetSeconds = secondsUntilUnixReset(info['reset_at'])
const resetTime = info['reset_time']
if (typeof resetTime === 'string' && resetTime.trim()) {
const ts = Date.parse(resetTime.trim())
@@ -247,11 +275,17 @@ const items = computed<QuotaItem[]>(() => {
}
}
result.push({ model, label: model, usedPercent, remainingPercent, resetSeconds })
result.push({
model,
label: resolveAntigravityQuotaLabel(model, info['display_name'], opaqueDisplayIndex),
usedPercent,
remainingPercent,
resetSeconds,
})
}
result.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
return result
result.sort(compareAntigravityQuotaItems)
return dedupeAntigravityQuotaItemsByLabel(result)
})
async function handleTestModel(modelName: string) {
@@ -361,31 +361,39 @@
/>
<div class="grid grid-cols-2 gap-3">
<ProviderQuotaProgressRow
v-for="group in getAntigravityQuotaSummaryForKey(key)"
:key="group.key"
:label="group.label"
:used-percent="group.usedPercent"
:remaining-percent="group.remainingPercent"
:meter-class="getQuotaRemainingClass(group.usedPercent)"
:bar-class="getQuotaRemainingBarColor(group.usedPercent)"
v-for="item in getAntigravityQuotaPreviewForKey(key)"
:key="item.model"
:label="item.label"
:title="item.model"
:used-percent="item.usedPercent"
:remaining-percent="item.remainingPercent"
:meter-class="getQuotaRemainingClass(item.usedPercent)"
:bar-class="getQuotaRemainingBarColor(item.usedPercent)"
>
<template #footer>
<div
v-if="group.resetSeconds !== null || group.usedPercent > 0"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
<template v-if="group.resetSeconds !== null && group.resetSeconds > 0">
{{ formatResetTime(group.resetSeconds) }}{{ legacyT('后重置') }}
</template>
<template v-else-if="group.resetSeconds !== null && group.resetSeconds <= 0">
{{ legacyT('已重置') }}
</template>
<template v-else>
{{ legacyT('重置时间未知') }}
</template>
<div class="mt-0.5 space-y-0.5">
<div
v-if="item.resetSeconds !== null"
class="text-[9px] text-muted-foreground/70"
>
<template v-if="item.resetSeconds !== null && item.resetSeconds > 0">
{{ formatResetTime(item.resetSeconds) }}{{ legacyT('后重置') }}
</template>
<template v-else-if="item.resetSeconds !== null && item.resetSeconds <= 0">
{{ legacyT('已重置') }}
</template>
</div>
</div>
</template>
</ProviderQuotaProgressRow>
<button
v-if="getAntigravityQuotaHiddenCountForKey(key) > 0"
type="button"
class="col-span-2 text-left text-[10px] text-muted-foreground hover:text-foreground transition-colors"
@click="openAntigravityQuotaDialog(key)"
>
{{ legacyT('另有') }} {{ getAntigravityQuotaHiddenCountForKey(key) }} {{ legacyT('个模型,查看全部') }}
</button>
</div>
</template>
</div>
@@ -949,6 +957,11 @@ 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,
} from '@/features/providers/utils/antigravityQuota'
import {
deleteEndpointKey,
recoverKeyHealth,
@@ -3348,53 +3361,72 @@ function secondsUntilReset(resetTime: string): number | null {
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 = Number(info.used_percent)
if (!Number.isFinite(usedPercent)) {
const remainingFraction = Number(info.remaining_fraction)
if (Number.isFinite(remainingFraction)) {
let usedPercent = coerceAntigravityPercent(info.used_percent)
if (usedPercent === undefined) {
const remainingFraction = coerceAntigravityRemainingFraction(info.remaining_fraction)
if (remainingFraction !== undefined) {
usedPercent = (1 - remainingFraction) * 100
} else {
continue
}
}
if (usedPercent < 0) usedPercent = 0
if (usedPercent > 100) usedPercent = 100
usedPercent = coerceAntigravityPercent(usedPercent) ?? 0
const remainingPercent = Math.max(100 - usedPercent, 0)
let resetSeconds: number | null = null
let resetSeconds = secondsUntilUnixReset(info.reset_at)
if (typeof info.reset_time === 'string' && info.reset_time.trim()) {
resetSeconds = secondsUntilReset(info.reset_time.trim())
resetSeconds = secondsUntilReset(info.reset_time.trim()) ?? resetSeconds
}
items.push({
model,
label: model,
label: resolveAntigravityQuotaLabel(model, info.display_name, opaqueDisplayIndex),
usedPercent,
remainingPercent,
resetSeconds,
})
}
// 按“最紧张”(已用最多)优先排序,便于快速定位额度风险;完整列表通过滚动展示
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
return items
items.sort(compareAntigravityQuotaItems)
return dedupeAntigravityQuotaItemsByLabel(items)
}
function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQuotaItem[] {
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
const windows = getQuotaWindowByScope(quota, 'model')
if (!quota || windows.length === 0) return []
const opaqueDisplayIndex = { value: 1 }
const items = windows
.map((window) => {
@@ -3418,7 +3450,11 @@ function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQ
return {
model,
label: String(window.label || window.model || model),
label: resolveAntigravityQuotaLabel(
model,
window.label || window.model,
opaqueDisplayIndex,
),
usedPercent: normalizedUsedPercent,
remainingPercent: normalizedRemainingPercent,
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
@@ -3426,124 +3462,24 @@ function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQ
})
.filter((item): item is AntigravityQuotaItem => item !== null)
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
return items
items.sort(compareAntigravityQuotaItems)
return dedupeAntigravityQuotaItemsByLabel(items)
}
// Antigravity 配额分组定义(按匹配优先级排列,具体规则在前)
interface AntigravityQuotaGroup {
key: string
label: string
match: (model: string) => boolean
}
const ANTIGRAVITY_QUOTA_PREVIEW_LIMIT = 6
const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroup[] = [
{ key: 'claude', label: 'Claude', match: m => m.includes('claude') },
{ key: 'gemini-2.5', label: 'Gemini 2.5', match: m => m.includes('gemini-2.5') || m.includes('gemini-2-5') },
{ key: 'gemini-3', label: 'Gemini 3', match: m => m.includes('gemini-3') && !m.includes('image') },
{ key: 'gemini-3-image', label: 'Gemini 3 Image', match: m => m.includes('gemini-3') && m.includes('image') },
]
interface AntigravityQuotaSummaryItem {
key: string
label: string
usedPercent: number // 组内最高已用百分比(最紧张)
remainingPercent: number // 100 - usedPercent
resetSeconds: number | null
}
function getAntigravityQuotaSummary(metadata: UpstreamMetadata | null | undefined): AntigravityQuotaSummaryItem[] {
const items = getAntigravityQuotaItems(metadata)
if (!items.length) return []
// 将每个模型归入分组
const groupMap = new Map<string, { label: string, maxUsed: number, resetSeconds: number | null }>()
for (const item of items) {
const model = item.model.toLowerCase()
const group = ANTIGRAVITY_QUOTA_GROUPS.find(g => g.match(model))
if (!group) continue
const existing = groupMap.get(group.key)
if (!existing) {
groupMap.set(group.key, {
label: group.label,
maxUsed: item.usedPercent,
resetSeconds: item.resetSeconds,
})
} else {
if (item.usedPercent > existing.maxUsed) {
existing.maxUsed = item.usedPercent
}
if (existing.resetSeconds === null) {
existing.resetSeconds = item.resetSeconds
} else if (item.resetSeconds !== null && item.resetSeconds < existing.resetSeconds) {
existing.resetSeconds = item.resetSeconds
}
}
}
// 按 ANTIGRAVITY_QUOTA_GROUPS 定义的顺序输出
const result: AntigravityQuotaSummaryItem[] = []
for (const group of ANTIGRAVITY_QUOTA_GROUPS) {
const data = groupMap.get(group.key)
if (!data) continue
result.push({
key: group.key,
label: data.label,
usedPercent: data.maxUsed,
remainingPercent: Math.max(100 - data.maxUsed, 0),
resetSeconds: data.resetSeconds,
})
}
return result
}
function getAntigravityQuotaSummaryForKey(key: EndpointAPIKey): AntigravityQuotaSummaryItem[] {
function getAntigravityQuotaItemsForKey(key: EndpointAPIKey): AntigravityQuotaItem[] {
const snapshotItems = getAntigravityQuotaItemsFromSnapshot(key)
if (snapshotItems.length > 0) {
const groupMap = new Map<string, { label: string, maxUsed: number, resetSeconds: number | null }>()
if (snapshotItems.length > 0) return snapshotItems
return getAntigravityQuotaItems(key.upstream_metadata)
}
for (const item of snapshotItems) {
const model = item.model.toLowerCase()
const group = ANTIGRAVITY_QUOTA_GROUPS.find(g => g.match(model))
if (!group) continue
function getAntigravityQuotaPreviewForKey(key: EndpointAPIKey): AntigravityQuotaItem[] {
return getAntigravityQuotaItemsForKey(key).slice(0, ANTIGRAVITY_QUOTA_PREVIEW_LIMIT)
}
const existing = groupMap.get(group.key)
if (!existing) {
groupMap.set(group.key, {
label: group.label,
maxUsed: item.usedPercent,
resetSeconds: item.resetSeconds,
})
} else {
if (item.usedPercent > existing.maxUsed) {
existing.maxUsed = item.usedPercent
}
if (existing.resetSeconds === null) {
existing.resetSeconds = item.resetSeconds
} else if (item.resetSeconds !== null && item.resetSeconds < existing.resetSeconds) {
existing.resetSeconds = item.resetSeconds
}
}
}
const result: AntigravityQuotaSummaryItem[] = []
for (const group of ANTIGRAVITY_QUOTA_GROUPS) {
const data = groupMap.get(group.key)
if (!data) continue
result.push({
key: group.key,
label: data.label,
usedPercent: data.maxUsed,
remainingPercent: Math.max(100 - data.maxUsed, 0),
resetSeconds: data.resetSeconds,
})
}
return result
}
return getAntigravityQuotaSummary(key.upstream_metadata)
function getAntigravityQuotaHiddenCountForKey(key: EndpointAPIKey): number {
return Math.max(getAntigravityQuotaItemsForKey(key).length - ANTIGRAVITY_QUOTA_PREVIEW_LIMIT, 0)
}
function getResetCountdownText(
@@ -0,0 +1,207 @@
import { describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h } from 'vue'
import AntigravityQuotaDialog from '@/features/providers/components/AntigravityQuotaDialog.vue'
import type { UpstreamMetadata } from '@/api/endpoints/types'
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string) => defineComponent({
name,
setup(_, { slots }) {
return () => h('div', [
slots.headerActions?.(),
slots.default?.(),
slots.footer?.(),
])
},
})
return {
Dialog: passthrough('DialogStub'),
DropdownMenu: passthrough('DropdownMenuStub'),
DropdownMenuTrigger: passthrough('DropdownMenuTriggerStub'),
DropdownMenuContent: passthrough('DropdownMenuContentStub'),
DropdownMenuItem: defineComponent({
name: 'DropdownMenuItemStub',
emits: ['select'],
setup(_, { emit, slots }) {
return () => h('button', { type: 'button', onClick: () => emit('select') }, slots.default?.())
},
}),
}
})
vi.mock('@/components/ui/button.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'ButtonStub',
setup(_, { attrs, slots }) {
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
},
}),
}
})
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
BarChart3: Icon,
Loader2: Icon,
Play: Icon,
}
})
vi.mock('@/api/endpoints/providers', () => ({
testModel: vi.fn(),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
error: vi.fn(),
success: vi.fn(),
}),
}))
vi.mock('@/utils/errorParser', () => ({
parseApiError: (value: unknown) => String(value),
}))
function mount(metadata: UpstreamMetadata) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(AntigravityQuotaDialog, {
open: true,
metadata,
keyName: 'Key-1',
})
},
}))
app.mount(root)
return {
root,
unmount: () => {
app.unmount()
root.remove()
},
}
}
describe('AntigravityQuotaDialog', () => {
it('renders opaque quota identifiers with concise visible labels', () => {
const rawIdentifier = 'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb'
const { root, unmount } = mount({
antigravity: {
quota_by_model: {
[rawIdentifier]: {
display_name: rawIdentifier,
remaining_fraction: 0.25,
used_percent: 75,
},
},
},
})
expect(root.textContent).toContain('Key-1')
expect(root.textContent).not.toContain(rawIdentifier)
expect(root.textContent).toContain('25.0%')
unmount()
})
it('orders important Gemini and Claude quota rows before low-priority rows', () => {
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.indexOf('Claude Opus 4.6 (Thinking)')).toBeLessThan(text.indexOf('Gemini 3.1 Pro (High)'))
expect(text.indexOf('Gemini 3.1 Pro (High)')).toBeLessThan(text.indexOf('Gemini 3.5 Flash (High)'))
expect(text.indexOf('Gemini 3.5 Flash (High)')).toBeLessThan(text.indexOf('Gemini 3.5 Flash (Medium)'))
expect(text.indexOf('Gemini 3.5 Flash (Medium)')).toBeLessThan(text.indexOf('Tab Flash Lite Preview'))
expect(text.indexOf('Tab Flash Lite Preview')).toBeLessThan(text.indexOf('chat_20706'))
unmount()
})
it('renders one row for duplicate quota labels and keeps the preferred active bucket', () => {
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 3\.1 Pro \(High\)/g)).toHaveLength(1)
expect(text).toContain('40.0%')
expect(text).not.toContain('95.0%')
expect(text.indexOf('Gemini 3.1 Pro (High)')).toBeLessThan(text.indexOf('Gemini 3.5 Flash (High)'))
unmount()
})
})
@@ -5,6 +5,7 @@ import { getDefaultEndpointBaseUrl, getDefaultEndpointPath } from '../endpoint-d
const apiFormats = [
{ value: 'openai:chat', default_path: '/v1/chat/completions' },
{ value: 'gemini:generate_content', default_path: '/v1beta/models/{model}:{action}' },
{ value: 'gemini:interactions', default_path: '/v1/interactions' },
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:embedContent' },
{ value: 'gemini:video', default_path: '/v1beta/models/{model}:predictLongRunning' },
{ value: 'openai:responses', default_path: '/v1/responses' },
@@ -31,6 +32,12 @@ describe('endpoint default paths', () => {
apiFormats,
})).toBe('/models/{model}:embedContent')
expect(getDefaultEndpointPath({
apiFormat: 'gemini:interactions',
providerType: 'custom',
apiFormats,
})).toBe('/interactions')
expect(getDefaultEndpointPath({
apiFormat: 'gemini:video',
providerType: 'custom',
@@ -218,6 +225,11 @@ describe('endpoint default paths', () => {
baseUrl: 'https://generativelanguage.googleapis.com',
})).toBe('https://generativelanguage.googleapis.com/v1beta')
expect(getDefaultEndpointBaseUrl({
apiFormat: 'gemini:interactions',
baseUrl: 'https://generativelanguage.googleapis.com',
})).toBe('https://generativelanguage.googleapis.com/v1')
expect(getDefaultEndpointBaseUrl({
apiFormat: 'gemini:video',
baseUrl: 'https://generativelanguage.googleapis.com',
@@ -98,11 +98,17 @@ function usesVersionedApiRootByDefault(apiFormat: string): boolean {
|| apiFormat === 'jina:rerank'
|| apiFormat === 'claude:messages'
|| apiFormat === 'gemini:generate_content'
|| apiFormat === 'gemini:interactions'
|| apiFormat === 'gemini:embedding'
|| apiFormat === 'gemini:video'
}
function versionedApiRootSuffix(apiFormat: string): '/v1' | '/v1beta' {
if (
apiFormat === 'gemini:interactions'
) {
return '/v1'
}
if (
apiFormat === 'gemini:generate_content'
|| apiFormat === 'gemini:embedding'
@@ -116,6 +122,7 @@ function versionedApiRootSuffix(apiFormat: string): '/v1' | '/v1beta' {
function skipsVersionedApiRootDefault(apiFormat: string, baseUrl: string): boolean {
if (
apiFormat === 'gemini:generate_content'
|| apiFormat === 'gemini:interactions'
|| apiFormat === 'gemini:embedding'
|| apiFormat === 'gemini:video'
) {
@@ -86,6 +86,17 @@ describe('buildDefaultModelTestRequestBody', () => {
expect(body.messages).toBeUndefined()
})
it('uses Gemini Interactions payloads for interaction endpoints', () => {
const body = JSON.parse(buildDefaultModelTestRequestBody('gemini-3.5-flash', 'gemini:interactions'))
expect(body).toEqual({
model: 'gemini-3.5-flash',
input: 'Hello! This is a test message.',
stream: true,
})
expect(body.messages).toBeUndefined()
})
it('uses image generation tools for image models on OpenAI Responses endpoints', () => {
const body = JSON.parse(buildDefaultModelTestRequestBody(
'gpt-image-2',
@@ -196,6 +196,14 @@ export function buildDefaultModelTestRequestBody(
}, null, 2)
}
if (normalizedApiFormat === 'gemini:interactions') {
return JSON.stringify({
model: modelName,
input: DEFAULT_MODEL_TEST_MESSAGE,
stream: true,
}, null, 2)
}
if (normalizedApiFormat === 'openai:responses' && modelSupportsImageGeneration(model)) {
return JSON.stringify({
model: modelName,
@@ -12,6 +12,7 @@ const ENDPOINT_SORT_ORDER = [
'openai:embedding',
'openai:rerank',
'gemini:generate_content',
'gemini:interactions',
'gemini:embedding',
'openai:video',
'gemini:video',
@@ -32,6 +32,7 @@ export function useProviderFilters(
{ value: 'openai:embedding', label: 'OpenAI Embedding' },
{ value: 'openai:rerank', label: 'OpenAI Rerank' },
{ value: 'gemini:generate_content', label: 'Gemini Generate Content' },
{ value: 'gemini:interactions', label: 'Gemini Interactions' },
{ value: 'gemini:embedding', label: 'Gemini Embedding' },
{ value: 'jina:embedding', label: 'Jina Embedding' },
{ value: 'jina:rerank', label: 'Jina Rerank' },
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest'
import {
compareAntigravityQuotaItems,
dedupeAntigravityQuotaItemsByLabel,
resolveAntigravityQuotaLabel,
} from '@/features/providers/utils/antigravityQuota'
describe('antigravityQuota', () => {
it('uses canonical labels from model ids before stale upstream labels', () => {
const opaqueDisplayIndex = { value: 1 }
expect(
resolveAntigravityQuotaLabel(
'gemini-3-flash-agent',
'Gemini 3.5 Flash (High)',
opaqueDisplayIndex,
),
).toBe('Gemini 3.5 Flash (High)')
expect(
resolveAntigravityQuotaLabel(
'gemini-3.5-flash-low',
'Gemini 3.5 Flash (Medium)',
opaqueDisplayIndex,
),
).toBe('Gemini 3.5 Flash (Medium)')
expect(
resolveAntigravityQuotaLabel(
'gemini-3.5-flash-extra-low',
'Gemini 3.5 Flash (Low)',
opaqueDisplayIndex,
),
).toBe('Gemini 3.5 Flash (Low)')
expect(
resolveAntigravityQuotaLabel(
'gemini-pro-agent',
'gemini-pro-agent',
opaqueDisplayIndex,
),
).toBe('Gemini 3.1 Pro (High)')
expect(
resolveAntigravityQuotaLabel(
'gemini-3.1-pro-high',
'gemini-3.1-pro-high',
opaqueDisplayIndex,
),
).toBe('Gemini 3.1 Pro (High)')
expect(
resolveAntigravityQuotaLabel(
'gemini-2.5-flash',
'Gemini 3.1 Flash Lite',
opaqueDisplayIndex,
),
).toBe('Gemini 3.1 Flash Lite')
expect(
resolveAntigravityQuotaLabel(
'claude-sonnet-4-6',
'Claude Sonnet 4.6 (Thinking)',
opaqueDisplayIndex,
),
).toBe('Claude Sonnet 4.6 (Thinking)')
expect(
resolveAntigravityQuotaLabel(
'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb',
'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb',
opaqueDisplayIndex,
),
).toBe('Key-1')
})
it('keeps latest Gemini and Claude quota buckets before low-priority buckets', () => {
const items = [
{
model: 'tab_flash_lite_preview',
label: 'Tab Flash Lite Preview',
remainingPercent: 1,
resetSeconds: 10,
},
{
model: 'gemini-3.5-flash-extra-low',
label: 'Gemini 3.5 Flash (Low)',
remainingPercent: 95,
resetSeconds: null,
},
{
model: 'gemini-3.5-flash-low',
label: 'Gemini 3.5 Flash (Medium)',
remainingPercent: 90,
resetSeconds: null,
},
{
model: 'gemini-3-flash-agent',
label: 'Gemini 3.5 Flash (High)',
remainingPercent: 80,
resetSeconds: null,
},
{
model: 'gemini-pro-agent',
label: 'Gemini 3.1 Pro (High)',
remainingPercent: 70,
resetSeconds: null,
},
{
model: 'chat_20706',
label: 'chat_20706',
remainingPercent: 0,
resetSeconds: 1,
},
{
model: 'claude-opus-4-6-thinking',
label: 'Claude Opus 4.6 (Thinking)',
remainingPercent: 100,
resetSeconds: null,
},
{
model: 'gemini-2.5-flash-lite',
label: 'Gemini 3.1 Flash Lite',
remainingPercent: 5,
resetSeconds: 5,
},
].sort(compareAntigravityQuotaItems)
expect(items.map(item => item.model)).toEqual([
'claude-opus-4-6-thinking',
'gemini-pro-agent',
'gemini-3-flash-agent',
'gemini-3.5-flash-low',
'gemini-3.5-flash-extra-low',
'gemini-2.5-flash-lite',
'tab_flash_lite_preview',
'chat_20706',
])
})
it('deduplicates display labels while keeping the current preferred quota bucket', () => {
const items = dedupeAntigravityQuotaItemsByLabel([
{
model: 'gemini-3.1-pro-high',
label: 'Gemini 3.1 Pro (High)',
remainingPercent: 95,
resetSeconds: null,
},
{
model: 'gemini-pro-agent',
label: 'Gemini 3.1 Pro (High)',
remainingPercent: 40,
resetSeconds: null,
},
{
model: 'claude-sonnet-4-6-thinking',
label: 'Claude Sonnet 4.6 (Thinking)',
remainingPercent: 95,
resetSeconds: null,
},
{
model: 'claude-sonnet-4-6',
label: 'Claude Sonnet 4.6 (Thinking)',
remainingPercent: 40,
resetSeconds: null,
},
{
model: 'gemini-3-flash-agent',
label: 'Gemini 3.5 Flash (High)',
remainingPercent: 80,
resetSeconds: null,
},
])
expect(items.map(item => item.model)).toEqual([
'claude-sonnet-4-6',
'gemini-pro-agent',
'gemini-3-flash-agent',
])
})
})
@@ -0,0 +1,117 @@
export interface AntigravityQuotaSortableItem {
model: string
label: string
remainingPercent: number
resetSeconds: number | null
}
const ANTIGRAVITY_MODEL_LABELS: Record<string, string> = {
'gemini-pro-agent': 'Gemini 3.1 Pro (High)',
'gemini-3.1-pro-high': 'Gemini 3.1 Pro (High)',
'gemini-3.1-pro-low': 'Gemini 3.1 Pro (Low)',
'gemini-3-flash-agent': 'Gemini 3.5 Flash (High)',
'gemini-3.5-flash-low': 'Gemini 3.5 Flash (Medium)',
'gemini-3.5-flash-extra-low': 'Gemini 3.5 Flash (Low)',
'claude-opus-4-6-thinking': 'Claude Opus 4.6 (Thinking)',
'claude-sonnet-4-6': 'Claude Sonnet 4.6 (Thinking)',
'claude-sonnet-4-6-thinking': 'Claude Sonnet 4.6 (Thinking)',
'gemini-3.1-flash-image': 'Gemini 3.1 Flash Image',
'gemini-3.1-flash-lite': 'Gemini 3.1 Flash Lite',
'gemini-3-flash': 'Gemini 3 Flash',
'gemini-2.5-pro': 'Gemini 2.5 Pro',
'gemini-2.5-flash-thinking': 'Gemini 3.1 Flash Lite',
'gemini-2.5-flash': 'Gemini 3.1 Flash Lite',
'gemini-2.5-flash-lite': 'Gemini 3.1 Flash Lite',
'gpt-oss-120b-medium': 'GPT-OSS 120B (Medium)',
'tab_flash_lite_preview': 'Tab Flash Lite Preview',
'tab_jump_flash_lite_preview': 'Tab Jump Flash Lite Preview',
'models/proactive-observer': 'Proactive Observer',
}
const ANTIGRAVITY_MODEL_PRIORITY: Record<string, number> = {
'claude-opus-4-6-thinking': 10,
'claude-sonnet-4-6': 20,
'claude-sonnet-4-6-thinking': 25,
'gemini-pro-agent': 30,
'gemini-3.1-pro-high': 35,
'gemini-3.1-pro-low': 40,
'gemini-3-flash-agent': 50,
'gemini-3.5-flash-low': 60,
'gemini-3.5-flash-extra-low': 70,
'gemini-3.1-flash-image': 80,
'gemini-3.1-flash-lite': 90,
'gemini-3-flash': 180,
'gemini-2.5-pro': 300,
'gemini-2.5-flash-thinking': 310,
'gemini-2.5-flash': 320,
'gemini-2.5-flash-lite': 330,
'gpt-oss-120b-medium': 700,
'models/proactive-observer': 780,
'tab_flash_lite_preview': 800,
'tab_jump_flash_lite_preview': 810,
}
export function isOpaqueAntigravityQuotaIdentifier(value: string): boolean {
return value.trim().startsWith('RateLimitResetCredit_')
}
export function resolveAntigravityQuotaLabel(
model: string,
rawLabel: unknown,
opaqueDisplayIndex: { value: number },
): string {
const normalizedModel = model.trim()
const canonical = ANTIGRAVITY_MODEL_LABELS[normalizedModel]
if (canonical) return canonical
const candidate = String(rawLabel || '').trim()
if (candidate && !isOpaqueAntigravityQuotaIdentifier(candidate)) return candidate
if (isOpaqueAntigravityQuotaIdentifier(normalizedModel) || (candidate && isOpaqueAntigravityQuotaIdentifier(candidate))) {
const label = `Key-${opaqueDisplayIndex.value}`
opaqueDisplayIndex.value += 1
return label
}
return candidate || normalizedModel
}
function getAntigravityModelPriority(model: string): number {
const normalizedModel = model.trim()
const explicit = ANTIGRAVITY_MODEL_PRIORITY[normalizedModel]
if (explicit !== undefined) return explicit
if (normalizedModel.startsWith('claude-')) return 30
if (normalizedModel.startsWith('gemini-3.')) return 200
if (normalizedModel.startsWith('gemini-2.')) return 390
if (normalizedModel.startsWith('gemini-')) return 490
if (normalizedModel.startsWith('gpt-oss-')) return 700
if (normalizedModel.startsWith('models/')) return 780
if (normalizedModel.startsWith('tab_')) return 800
if (normalizedModel.startsWith('chat_')) return 900
if (isOpaqueAntigravityQuotaIdentifier(normalizedModel)) return 950
return 850
}
export function compareAntigravityQuotaItems<T extends AntigravityQuotaSortableItem>(
a: T,
b: T,
): number {
return (getAntigravityModelPriority(a.model) - getAntigravityModelPriority(b.model))
|| ((a.resetSeconds ?? Number.POSITIVE_INFINITY) - (b.resetSeconds ?? Number.POSITIVE_INFINITY))
|| (a.remainingPercent - b.remainingPercent)
|| a.label.localeCompare(b.label)
|| a.model.localeCompare(b.model)
}
export function dedupeAntigravityQuotaItemsByLabel<T extends AntigravityQuotaSortableItem>(
items: T[],
): T[] {
const selectedByLabel = new Map<string, T>()
for (const item of items) {
const label = item.label.trim()
if (!label) continue
const selected = selectedByLabel.get(label)
if (!selected || compareAntigravityQuotaItems(item, selected) < 0) {
selectedByLabel.set(label, item)
}
}
return Array.from(selectedByLabel.values()).sort(compareAntigravityQuotaItems)
}