mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
支持 Gemini Interactions 与 Antigravity 配额精细化
This commit is contained in:
@@ -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,21 @@
|
||||
: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
|
||||
v-if="item.label !== item.model"
|
||||
class="text-[9px] leading-tight text-muted-foreground/55 truncate font-mono"
|
||||
:title="item.model"
|
||||
>
|
||||
{{ item.model }}
|
||||
</div>
|
||||
</div>
|
||||
<span :class="getQuotaRemainingClass(item.usedPercent)">
|
||||
{{ item.remainingPercent.toFixed(1) }}%
|
||||
</span>
|
||||
@@ -165,6 +175,51 @@ 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 sortQuotaItems(a: QuotaItem, b: QuotaItem): number {
|
||||
return (a.remainingPercent - b.remainingPercent)
|
||||
|| ((a.resetSeconds ?? Number.POSITIVE_INFINITY) - (b.resetSeconds ?? Number.POSITIVE_INFINITY))
|
||||
|| a.label.localeCompare(b.label)
|
||||
|| a.model.localeCompare(b.model)
|
||||
}
|
||||
|
||||
function isOpaqueAntigravityQuotaIdentifier(value: string): boolean {
|
||||
return value.trim().startsWith('RateLimitResetCredit_')
|
||||
}
|
||||
|
||||
function resolveQuotaLabel(
|
||||
model: string,
|
||||
rawLabel: unknown,
|
||||
opaqueDisplayIndex: { value: number },
|
||||
): string {
|
||||
const candidate = String(rawLabel || '').trim()
|
||||
if (candidate && !isOpaqueAntigravityQuotaIdentifier(candidate)) return candidate
|
||||
if (isOpaqueAntigravityQuotaIdentifier(model) || (candidate && isOpaqueAntigravityQuotaIdentifier(candidate))) {
|
||||
const label = `Key-${opaqueDisplayIndex.value}`
|
||||
opaqueDisplayIndex.value += 1
|
||||
return label
|
||||
}
|
||||
return candidate || model
|
||||
}
|
||||
|
||||
function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefined): QuotaItem[] {
|
||||
if (!quota) return []
|
||||
|
||||
@@ -175,6 +230,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 +252,7 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin
|
||||
|
||||
return {
|
||||
model,
|
||||
label: String(window.label || window.model || model),
|
||||
label: resolveQuotaLabel(model, window.label || window.model, opaqueDisplayIndex),
|
||||
usedPercent,
|
||||
remainingPercent,
|
||||
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
|
||||
@@ -204,7 +260,7 @@ 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))
|
||||
items.sort(sortQuotaItems)
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -218,26 +274,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,10 +303,16 @@ const items = computed<QuotaItem[]>(() => {
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ model, label: model, usedPercent, remainingPercent, resetSeconds })
|
||||
result.push({
|
||||
model,
|
||||
label: resolveQuotaLabel(model, info['display_name'], opaqueDisplayIndex),
|
||||
usedPercent,
|
||||
remainingPercent,
|
||||
resetSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
result.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
|
||||
result.sort(sortQuotaItems)
|
||||
return result
|
||||
})
|
||||
|
||||
|
||||
@@ -361,31 +361,49 @@
|
||||
/>
|
||||
<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.label !== item.model"
|
||||
class="text-[9px] text-muted-foreground/55 truncate font-mono"
|
||||
:title="item.model"
|
||||
>
|
||||
{{ item.model }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.resetSeconds !== null || item.usedPercent > 0"
|
||||
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>
|
||||
<template v-else>
|
||||
{{ 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>
|
||||
@@ -3348,46 +3366,90 @@ 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 antigravityQuotaItemSort(a: AntigravityQuotaItem, b: AntigravityQuotaItem): number {
|
||||
return (a.remainingPercent - b.remainingPercent)
|
||||
|| ((a.resetSeconds ?? Number.POSITIVE_INFINITY) - (b.resetSeconds ?? Number.POSITIVE_INFINITY))
|
||||
|| a.label.localeCompare(b.label)
|
||||
|| a.model.localeCompare(b.model)
|
||||
}
|
||||
|
||||
function isOpaqueAntigravityQuotaIdentifier(value: string): boolean {
|
||||
return value.trim().startsWith('RateLimitResetCredit_')
|
||||
}
|
||||
|
||||
function resolveAntigravityQuotaLabel(
|
||||
model: string,
|
||||
rawLabel: unknown,
|
||||
opaqueDisplayIndex: { value: number },
|
||||
): string {
|
||||
const candidate = String(rawLabel || '').trim()
|
||||
if (candidate && !isOpaqueAntigravityQuotaIdentifier(candidate)) return candidate
|
||||
if (isOpaqueAntigravityQuotaIdentifier(model) || (candidate && isOpaqueAntigravityQuotaIdentifier(candidate))) {
|
||||
const label = `Key-${opaqueDisplayIndex.value}`
|
||||
opaqueDisplayIndex.value += 1
|
||||
return label
|
||||
}
|
||||
return candidate || model
|
||||
}
|
||||
|
||||
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))
|
||||
items.sort(antigravityQuotaItemSort)
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -3395,6 +3457,7 @@ function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQ
|
||||
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 +3481,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 +3493,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))
|
||||
items.sort(antigravityQuotaItemSort)
|
||||
return 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,124 @@
|
||||
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 model display names without losing raw model identifiers', () => {
|
||||
const { root, unmount } = mount({
|
||||
antigravity: {
|
||||
quota_by_model: {
|
||||
'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb': {
|
||||
display_name: 'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb',
|
||||
remaining_fraction: 0.25,
|
||||
used_percent: 75,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(root.textContent).toContain('Key-1')
|
||||
expect(root.textContent).toContain('RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb')
|
||||
expect(root.textContent).toContain('25.0%')
|
||||
|
||||
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'
|
||||
) {
|
||||
|
||||
+11
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user