mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
Add Gemini CLI v1internal quota support
This commit is contained in:
@@ -35,7 +35,10 @@
|
||||
停用
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
v-if="!isEndpointConfigReadOnly"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<!-- 格式转换按钮 -->
|
||||
<span
|
||||
class="mr-1"
|
||||
@@ -161,7 +164,7 @@
|
||||
</div>
|
||||
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
|
||||
<div
|
||||
v-if="hasUrlChanges(endpoint)"
|
||||
v-if="!isEndpointConfigReadOnly && hasUrlChanges(endpoint)"
|
||||
class="flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<Button
|
||||
@@ -187,7 +190,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 请求/响应规则(请求头、请求体和响应头规则) -->
|
||||
<Collapsible v-model:open="endpointRulesExpanded[endpoint.id]">
|
||||
<Collapsible
|
||||
v-if="!isEndpointConfigReadOnly"
|
||||
v-model:open="endpointRulesExpanded[endpoint.id]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 有规则时显示可折叠的触发器 -->
|
||||
<CollapsibleTrigger
|
||||
@@ -1778,6 +1784,10 @@ const isFixedProvider = computed(() => {
|
||||
return !!t && t !== 'custom'
|
||||
})
|
||||
|
||||
const isEndpointConfigReadOnly = computed(() => {
|
||||
return (props.provider?.provider_type || '').trim().toLowerCase() === 'gemini_cli'
|
||||
})
|
||||
|
||||
// 新端点表单
|
||||
const newEndpoint = ref({
|
||||
api_format: '',
|
||||
@@ -3170,6 +3180,8 @@ watch(() => props.endpoints, (endpoints) => {
|
||||
|
||||
// 保存端点
|
||||
async function saveEndpoint(endpoint: ProviderEndpoint) {
|
||||
if (isEndpointConfigReadOnly.value) return
|
||||
|
||||
if (isEndpointRulesJsonMode(endpoint.id) && endpointRulesJsonDirty.value[endpoint.id]) {
|
||||
if (!applyEndpointRulesJsonDraft(endpoint.id, { notify: false })) return
|
||||
}
|
||||
|
||||
@@ -824,6 +824,72 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Gemini CLI 上游模型配额 -->
|
||||
<div
|
||||
v-if="provider.provider_type === 'gemini_cli' && hasGeminiCliQuotaDisplayData(key)"
|
||||
class="mt-2 p-2 rounded-md bg-muted/30"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-[10px] text-muted-foreground">模型配额</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<RefreshCw
|
||||
v-if="refreshingQuota"
|
||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="getGeminiCliQuotaUpdatedAt(key)"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatUpdatedAt(getGeminiCliQuotaUpdatedAt(key) || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="getGeminiCliAccountCreditsText(key, 'gemini_cli')"
|
||||
class="mb-2 text-[10px] font-medium text-foreground/90"
|
||||
>
|
||||
{{ getGeminiCliAccountCreditsText(key, 'gemini_cli') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getGeminiCliQuotaItems(key).length > 0"
|
||||
class="grid grid-cols-2 gap-3"
|
||||
>
|
||||
<div
|
||||
v-for="item in getGeminiCliQuotaItems(key)"
|
||||
: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>
|
||||
<span :class="getQuotaRemainingClass(item.usedPercent)">
|
||||
{{ item.remainingPercent.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(item.usedPercent)"
|
||||
:style="{ width: `${Math.max(item.remainingPercent, 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.resetSeconds !== null && item.remainingPercent < 100"
|
||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
<template v-if="item.resetSeconds > 0">
|
||||
{{ formatResetTime(item.resetSeconds) }}后重置
|
||||
</template>
|
||||
<template v-else>
|
||||
已重置
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Kiro 上游额度信息(仅当有元数据时显示) -->
|
||||
<div
|
||||
v-if="provider.provider_type === 'kiro' && (hasKiroQuotaDisplayData(key) || isKiroBannedKey(key))"
|
||||
@@ -1323,6 +1389,7 @@ import {
|
||||
getOAuthStatusDisplayWithFallback,
|
||||
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota'
|
||||
|
||||
// 扩展端点类型,包含密钥列表
|
||||
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||||
@@ -1947,7 +2014,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||||
}
|
||||
}
|
||||
|
||||
// Codex / Antigravity / Kiro / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
|
||||
// Codex / Gemini CLI / Antigravity / Kiro / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
|
||||
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
|
||||
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
|
||||
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
|
||||
@@ -2442,7 +2509,7 @@ function shouldAutoRefreshCodexQuota(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 OAuth Token 是否即将过期(Codex / Antigravity / Kiro / ChatGPT Web)
|
||||
// 检查 OAuth Token 是否即将过期(Codex / Gemini CLI / Antigravity / Kiro / ChatGPT Web)
|
||||
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
|
||||
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
|
||||
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
|
||||
@@ -2474,6 +2541,28 @@ function shouldAutoRefreshAntigravityQuota(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function shouldAutoRefreshGeminiCliQuota(): boolean {
|
||||
if (provider.value?.provider_type !== 'gemini_cli') return false
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
for (const { key } of allKeys.value) {
|
||||
if (!key.is_active) continue
|
||||
|
||||
if (isTokenExpiringSoon(key, now)) return true
|
||||
|
||||
if (!hasGeminiCliQuotaDisplayData(key)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const updatedAt = getGeminiCliQuotaUpdatedAt(key)
|
||||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function shouldAutoRefreshKiroQuota(): boolean {
|
||||
if (provider.value?.provider_type !== 'kiro') return false
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -2618,19 +2707,21 @@ function applyQuotaResults(
|
||||
return applied
|
||||
}
|
||||
|
||||
// 通用的自动刷新配额函数(支持 Codex、Antigravity、Kiro 和 ChatGPT Web)
|
||||
// 通用的自动刷新配额函数
|
||||
async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean } = {}) {
|
||||
const providerId = props.providerId
|
||||
if (!providerId) return
|
||||
if (refreshingQuota.value) return
|
||||
|
||||
const providerType = provider.value?.provider_type
|
||||
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
|
||||
if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
|
||||
|
||||
// 检查是否需要刷新
|
||||
let shouldRefresh = false
|
||||
if (providerType === 'codex') {
|
||||
shouldRefresh = shouldAutoRefreshCodexQuota()
|
||||
} else if (providerType === 'gemini_cli') {
|
||||
shouldRefresh = shouldAutoRefreshGeminiCliQuota()
|
||||
} else if (providerType === 'antigravity') {
|
||||
shouldRefresh = shouldAutoRefreshAntigravityQuota()
|
||||
} else if (providerType === 'kiro') {
|
||||
@@ -2646,6 +2737,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
|
||||
let hadCachedQuota = false
|
||||
if (providerType === 'codex') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaDisplayData(key))
|
||||
} else if (providerType === 'gemini_cli') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGeminiCliQuotaDisplayData(key))
|
||||
} else if (providerType === 'antigravity') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasAntigravityQuotaDisplayData(key))
|
||||
} else if (providerType === 'kiro') {
|
||||
@@ -2700,7 +2793,7 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
|
||||
async function handleKeyChanged() {
|
||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
||||
emit('refresh')
|
||||
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
|
||||
// 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空)
|
||||
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
|
||||
}
|
||||
|
||||
@@ -3156,6 +3249,14 @@ interface AntigravityQuotaItem {
|
||||
resetSeconds: number | null
|
||||
}
|
||||
|
||||
interface GeminiCliQuotaItem {
|
||||
model: string
|
||||
label: string
|
||||
usedPercent: number
|
||||
remainingPercent: number
|
||||
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
|
||||
@@ -3169,6 +3270,58 @@ function hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
return hasAntigravityQuotaData(key.upstream_metadata)
|
||||
}
|
||||
|
||||
function getGeminiCliQuotaUpdatedAt(key: EndpointAPIKey): number | undefined {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'gemini_cli')
|
||||
const quotaUpdatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||||
if (typeof quotaUpdatedAt === 'number') return quotaUpdatedAt
|
||||
const updatedAt = Number(key.upstream_metadata?.gemini_cli?.updated_at ?? NaN)
|
||||
return Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : undefined
|
||||
}
|
||||
|
||||
function hasGeminiCliQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
if (getGeminiCliAccountCreditsText(key, 'gemini_cli')) return true
|
||||
return getGeminiCliQuotaItems(key).length > 0
|
||||
}
|
||||
|
||||
function getGeminiCliQuotaItems(key: EndpointAPIKey): GeminiCliQuotaItem[] {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'gemini_cli')
|
||||
const windows = getQuotaWindowByScope(quota, 'model')
|
||||
if (!quota || windows.length === 0) return []
|
||||
|
||||
const items = windows
|
||||
.map((window) => {
|
||||
const model = String(window.model || window.label || window.code || '').trim()
|
||||
if (!model) return null
|
||||
|
||||
const usedPercent = getQuotaWindowUsedPercent(window)
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (usedPercent === undefined && remainingPercent === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedUsedPercent =
|
||||
usedPercent !== undefined
|
||||
? usedPercent
|
||||
: Math.max(100 - (remainingPercent ?? 0), 0)
|
||||
const normalizedRemainingPercent =
|
||||
remainingPercent !== undefined
|
||||
? remainingPercent
|
||||
: Math.max(100 - normalizedUsedPercent, 0)
|
||||
|
||||
return {
|
||||
model,
|
||||
label: String(window.label || window.model || model),
|
||||
usedPercent: normalizedUsedPercent,
|
||||
remainingPercent: normalizedRemainingPercent,
|
||||
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
|
||||
} satisfies GeminiCliQuotaItem
|
||||
})
|
||||
.filter((item): item is GeminiCliQuotaItem => item !== null)
|
||||
|
||||
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
|
||||
return items
|
||||
}
|
||||
|
||||
function formatUpdatedAt(updatedAt: number): string {
|
||||
if (!updatedAt || typeof updatedAt !== 'number') return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
@@ -37,6 +37,14 @@ describe('endpoint default paths', () => {
|
||||
})).toBe('/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:predict')
|
||||
})
|
||||
|
||||
it('uses Gemini CLI v1internal paths for fixed Gemini CLI endpoints', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'gemini:generate_content',
|
||||
providerType: 'gemini_cli',
|
||||
apiFormats,
|
||||
})).toBe('/v1internal:{action}')
|
||||
})
|
||||
|
||||
it('keeps Codex Responses root path without duplicating /v1', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:responses',
|
||||
|
||||
@@ -23,6 +23,11 @@ export function getDefaultEndpointPath(params: {
|
||||
}): string {
|
||||
const providerType = (params.providerType || '').toLowerCase()
|
||||
const normalizedApiFormat = normalizeEndpointApiFormat(params.apiFormat)
|
||||
if (providerType === 'gemini_cli') {
|
||||
if (normalizedApiFormat === 'gemini:generate_content') {
|
||||
return '/v1internal:{action}'
|
||||
}
|
||||
}
|
||||
if (providerType === 'vertex_ai') {
|
||||
if (normalizedApiFormat === 'gemini:generate_content') {
|
||||
return '/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:{action}'
|
||||
|
||||
Reference in New Issue
Block a user