Merge origin/main into fix/gemini-cli-v1internal

This commit is contained in:
Mas0nShi
2026-05-28 11:58:00 +08:00
410 changed files with 38026 additions and 6621 deletions
@@ -265,6 +265,7 @@ import { oauthApi, type OAuthProviderInfo } from '@/api/oauth'
import { getClientDeviceId } from '@/utils/deviceId'
import { getApiUrl } from '@/utils/url'
import { getOAuthIcon } from '@/utils/oauth-icons'
import { navigateAfterLogin } from '@/features/auth/utils/loginRedirect'
const props = defineProps<{
modelValue: boolean
@@ -361,15 +362,7 @@ async function handleLogin(event?: Event) {
if (success) {
const targetPath = consumeStoredRedirectPath() ?? (authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard')
try {
const navigationFailure = await router.push(targetPath)
if (navigationFailure) {
throw navigationFailure
}
} catch {
showError('登录成功,但跳转失败,请刷新页面或手动进入控制台')
return
}
await navigateAfterLogin(router, targetPath)
showSuccess('登录成功,正在跳转...')
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import LoginDialog from '../LoginDialog.vue'
@@ -29,12 +30,16 @@ const oauthApiMocks = vi.hoisted(() => ({
getProviders: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => routeMock,
useRouter: () => ({
push: routerPushMock,
}),
}))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRoute: () => routeMock,
useRouter: () => ({
push: routerPushMock,
}),
}
})
vi.mock('@/stores/auth', () => ({
useAuthStore: () => authStoreMock,
@@ -156,6 +161,21 @@ async function settle() {
}
}
async function createDuplicatedNavigationFailure(path: string) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path,
component: defineComponent({ setup: () => () => null }),
},
],
})
await router.push(path)
return router.push(path)
}
beforeEach(() => {
authStoreMock.loading = false
authStoreMock.error = ''
@@ -242,4 +262,26 @@ describe('LoginDialog password manager contract', () => {
expect(sessionStorage.getItem('redirectPath')).toBeNull()
expect(toastMocks.success).toHaveBeenCalledWith('登录成功,正在跳转...')
})
it('treats duplicated router navigation after successful auth as a completed login', async () => {
authStoreMock.login.mockResolvedValue(true)
routerPushMock.mockResolvedValue(await createDuplicatedNavigationFailure('/dashboard'))
const root = mountLoginDialog()
await settle()
const form = root.querySelector('form')
const username = root.querySelector<HTMLInputElement>('input[name="username"]')
const password = root.querySelector<HTMLInputElement>('input[name="password"]')
username!.value = '[email protected]'
password!.value = 'secret-from-manager'
form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await settle()
expect(authStoreMock.login).toHaveBeenCalledWith('[email protected]', 'secret-from-manager', 'local')
expect(routerPushMock).toHaveBeenCalledWith('/dashboard')
expect(toastMocks.error).not.toHaveBeenCalled()
expect(toastMocks.success).toHaveBeenCalledWith('登录成功,正在跳转...')
expect(root.querySelector('[data-testid="dialog"]')).toBeNull()
})
})
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
import { navigateAfterLogin } from '../loginRedirect'
function createRouterMock(push: Router['push']): Router {
return { push } as Router
}
async function createDuplicatedNavigationFailure(path: string) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path,
component: {},
},
],
})
await router.push(path)
return router.push(path)
}
async function createAbortedNavigationFailure(path: string) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path,
component: {},
},
],
})
router.beforeEach(() => false)
return router.push(path)
}
describe('navigateAfterLogin', () => {
it('treats duplicated Vue Router navigation as a completed login navigation', async () => {
const push = vi.fn<Router['push']>().mockResolvedValue(await createDuplicatedNavigationFailure('/dashboard'))
const documentNavigate = vi.fn()
const result = await navigateAfterLogin(createRouterMock(push), '/dashboard', documentNavigate)
expect(push).toHaveBeenCalledWith('/dashboard')
expect(documentNavigate).not.toHaveBeenCalled()
expect(result).toBe('already-there')
})
it('falls back to document navigation when route chunk loading rejects during SPA navigation', async () => {
const push = vi.fn<Router['push']>().mockRejectedValue(new Error('Failed to fetch dynamically imported module'))
const documentNavigate = vi.fn()
const result = await navigateAfterLogin(createRouterMock(push), '/admin/dashboard', documentNavigate)
expect(push).toHaveBeenCalledWith('/admin/dashboard')
expect(documentNavigate).toHaveBeenCalledWith('/admin/dashboard')
expect(result).toBe('document')
})
it('falls back to document navigation when the router reports a real navigation failure', async () => {
const push = vi.fn<Router['push']>().mockResolvedValue(await createAbortedNavigationFailure('/dashboard'))
const documentNavigate = vi.fn()
const result = await navigateAfterLogin(createRouterMock(push), '/dashboard', documentNavigate)
expect(push).toHaveBeenCalledWith('/dashboard')
expect(documentNavigate).toHaveBeenCalledWith('/dashboard')
expect(result).toBe('document')
})
})
@@ -0,0 +1,33 @@
import { isNavigationFailure, NavigationFailureType, type Router } from 'vue-router'
export type LoginNavigationResult = 'router' | 'already-there' | 'document'
type DocumentNavigate = (targetPath: string) => void
function defaultDocumentNavigate(targetPath: string) {
window.location.assign(targetPath)
}
export async function navigateAfterLogin(
router: Router,
targetPath: string,
documentNavigate: DocumentNavigate = defaultDocumentNavigate,
): Promise<LoginNavigationResult> {
try {
const navigationFailure = await router.push(targetPath)
if (isNavigationFailure(navigationFailure, NavigationFailureType.duplicated)) {
return 'already-there'
}
if (navigationFailure) {
documentNavigate(targetPath)
return 'document'
}
return 'router'
} catch {
documentNavigate(targetPath)
return 'document'
}
}
@@ -559,6 +559,7 @@ import RoutingTab from './RoutingTab.vue'
import ModelMappingsTab from './ModelMappingsTab.vue'
import { sortResolutionEntries } from '@/utils/form'
import { parseApiError } from '@/utils/errorParser'
import { formatCompactNumber, formatTokens } from '@/utils/format'
import { getGlobalModelRoutingPreview } from '@/api/global-models'
// 使用外部类型定义
@@ -734,13 +735,7 @@ function formatPixelLimit(value: number | null): string {
}
function formatPixels(value: number): string {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 2)}M px`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(0)}K px`
}
return `${value} px`
return `${formatCompactNumber(value)} px`
}
const detailTab = ref('basic')
@@ -790,12 +785,7 @@ function getTierCount(tieredPricing: TieredPricingConfig | undefined | null): nu
// 格式化阶梯上限(tokens 数量简化显示)
function formatTierLimit(limit: number | null | undefined): string {
if (limit == null) return ''
if (limit >= 1000000) {
return `${(limit / 1000000).toFixed(1)}M`
} else if (limit >= 1000) {
return `${(limit / 1000).toFixed(0)}K`
}
return limit.toString()
return formatTokens(limit)
}
// 获取 1h 缓存价格
@@ -286,6 +286,7 @@
import { ref, computed, watch, reactive } from 'vue'
import { Plus, X } from 'lucide-vue-next'
import { Button, Input, Label } from '@/components/ui'
import { formatTokens } from '@/utils/format'
import type { TieredPricingConfig, PricingTier, ImageOutputPriceRange } from '@/api/endpoints/types'
type ImageOutputQuality = 'low' | 'medium' | 'high'
@@ -434,17 +435,6 @@ function getAvailableThresholds(index: number) {
return options
}
// 格式化 token 数量
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}M`
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`
}
return tokens.toString()
}
// 缓存价格自动计算
function getAutoCacheCreation(index: number): number {
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
@@ -7,7 +7,7 @@
persistent
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-4">
<div class="max-h-[calc(100dvh-13rem)] space-y-4 overflow-y-auto overscroll-contain pr-1 sm:max-h-[min(72vh,44rem)] sm:pr-2">
<div class="space-y-3 rounded-lg border bg-muted/20 px-3 py-2.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-medium text-foreground">快捷多选</span>
@@ -98,7 +98,7 @@
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_19rem]">
<div class="min-w-0 space-y-3">
<div class="max-h-[420px] overflow-y-auto rounded-lg border">
<div class="rounded-lg border lg:max-h-[420px] lg:overflow-y-auto">
<div
v-if="loading"
class="py-10 text-center text-sm text-muted-foreground"
@@ -0,0 +1,507 @@
<template>
<Dialog
:model-value="modelValue"
title="号池代理均分"
description="选择号池和代理节点后生成分配预览,再写入账号独立代理。"
size="3xl"
persistent
@update:model-value="handleOpenChange"
>
<div class="space-y-4">
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div class="space-y-1.5">
<Label>号池</Label>
<Select
:model-value="selectedProviderId"
:disabled="loadingPools || executing || poolOptions.length === 0"
@update:model-value="(value: string) => selectedProviderId = value"
>
<SelectTrigger class="h-9 text-xs">
<SelectValue
:placeholder="loadingPools
? '加载号池中...'
: poolOptions.length === 0
? '暂无可用号池'
: '选择号池'"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="pool in poolOptions"
:key="pool.value"
:value="pool.value"
>
{{ pool.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<Label>代理节点</Label>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="executing || proxyNodeOptions.length === 0"
@click="selectAllProxyNodes"
>
全部
</Button>
</div>
<MultiSelect
v-model="selectedProxyNodeIds"
:options="proxyNodeOptions"
placeholder="选择代理节点"
empty-text="暂无可用代理节点"
trigger-class="h-9 text-xs"
dropdown-min-width="22rem"
:disabled="executing || proxyNodeOptions.length === 0"
/>
</div>
</div>
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div class="space-y-1.5">
<Label>模式</Label>
<div class="grid grid-cols-2 gap-2 rounded-lg border border-border/60 bg-muted/30 p-1">
<Button
type="button"
class="h-8 text-xs"
:variant="mode === 'fill' ? 'default' : 'ghost'"
:disabled="executing"
@click="mode = 'fill'"
>
<Shuffle class="mr-1.5 h-3.5 w-3.5" />
均衡补齐
</Button>
<Button
type="button"
class="h-8 text-xs"
:variant="mode === 'rewrite' ? 'default' : 'ghost'"
:disabled="executing"
@click="mode = 'rewrite'"
>
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
强制重排
</Button>
</div>
</div>
<Button
variant="outline"
class="h-9 px-3 text-xs"
:disabled="loadingKeys || executing || !canBuildPlan"
@click="loadKeysAndBuildPlan"
>
<RefreshCw
class="mr-1.5 h-3.5 w-3.5"
:class="{ 'animate-spin': loadingKeys }"
/>
{{ plan ? '刷新预览' : '生成预览' }}
</Button>
</div>
<div
v-if="loadingPools || loadingKeys"
class="rounded-lg border border-border/60 bg-muted/20 px-3 py-6 text-center text-sm text-muted-foreground"
>
{{ loadingText }}
</div>
<div
v-else-if="!plan"
class="rounded-lg border border-border/60 bg-muted/20 px-3 py-6 text-center text-sm text-muted-foreground"
>
选择号池和代理节点后生成分配预览
</div>
<div
v-else
class="space-y-3"
>
<div class="grid gap-2 text-xs sm:grid-cols-4">
<div class="rounded-lg border bg-background px-3 py-2">
<div class="text-muted-foreground">号池账号</div>
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.totalKeys }}</div>
</div>
<div class="rounded-lg border bg-background px-3 py-2">
<div class="text-muted-foreground">代理节点</div>
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.nodeCount }}</div>
</div>
<div class="rounded-lg border bg-background px-3 py-2">
<div class="text-muted-foreground">单节点上限</div>
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.maxPerNode }}</div>
</div>
<div class="rounded-lg border bg-background px-3 py-2">
<div class="text-muted-foreground">待写入</div>
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.changedCount }}</div>
</div>
</div>
<div class="rounded-lg border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<span v-if="mode === 'fill'">
保留 {{ plan.retainedCount }} 个有效既有绑定,处理 {{ plan.overflowCount }} 个超额绑定和 {{ plan.outsideSelectedProxyCount }} 个非选中节点绑定。
</span>
<span v-else>
将全部 {{ plan.totalKeys }} 个账号随机重新分配到选中的代理节点。
</span>
</div>
<div class="max-h-[360px] overflow-y-auto rounded-lg border">
<div
v-for="item in assignmentRows"
:key="item.nodeId"
class="grid gap-2 border-b px-3 py-2 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
>
<div class="min-w-0">
<div class="truncate text-sm font-medium">
{{ item.nodeName }}
</div>
<div class="mt-0.5 truncate text-xs text-muted-foreground">
{{ item.nodeMeta }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline">目标 {{ item.targetCount }}</Badge>
<Badge variant="secondary">保留 {{ item.retainedCount }}</Badge>
<Badge :variant="item.changedCount > 0 ? 'default' : 'outline'">
写入 {{ item.changedCount }}
</Badge>
</div>
</div>
</div>
<div
v-if="executing && progressTotal > 0"
class="space-y-1"
>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>正在写入代理绑定...</span>
<span>{{ progressDone }} / {{ progressTotal }}</span>
</div>
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-150"
:style="{ width: `${Math.round((progressDone / progressTotal) * 100)}%` }"
/>
</div>
</div>
</div>
</div>
<template #footer>
<Button
variant="outline"
:disabled="executing"
@click="handleOpenChange(false)"
>
关闭
</Button>
<Button
:disabled="executing || !plan || plan.changedCount === 0"
@click="executePlan"
>
<Loader2
v-if="executing"
class="mr-1.5 h-3.5 w-3.5 animate-spin"
/>
{{ executing ? '执行中...' : '执行分配' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Loader2, RefreshCw, Shuffle } from 'lucide-vue-next'
import {
Badge,
Button,
Dialog,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { MultiSelect } from '@/components/common'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { formatRegion } from '@/utils/region'
import {
batchActionPoolKeys,
getPoolOverview,
listPoolKeys,
type PoolKeyDetail,
type PoolOverviewItem,
} from '@/api/endpoints/pool'
import {
buildPoolProxyDistributionPlan,
type PoolProxyDistributionMode,
type PoolProxyDistributionPlan,
} from '@/features/pool/utils/poolProxyDistribution'
import type { ProxyNode } from '@/api/proxy-nodes'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
changed: []
}>()
const { success, error: showError, warning } = useToast()
const { confirm } = useConfirm()
const proxyNodesStore = useProxyNodesStore()
const loadingPools = ref(false)
const loadingKeys = ref(false)
const executing = ref(false)
const pools = ref<PoolOverviewItem[]>([])
const selectedProviderId = ref('')
const selectedProxyNodeIds = ref<string[]>([])
const mode = ref<PoolProxyDistributionMode>('fill')
const poolKeys = ref<PoolKeyDetail[]>([])
const plan = ref<PoolProxyDistributionPlan | null>(null)
const loadedKeysProviderId = ref('')
const progressDone = ref(0)
const progressTotal = ref(0)
const loadingKeyPage = ref(0)
let poolLoadRequestId = 0
let keyLoadRequestId = 0
const poolOptions = computed<MultiSelectOption[]>(() =>
pools.value
.filter(pool => pool.pool_enabled)
.map(pool => ({
value: pool.provider_id,
label: `${pool.provider_name} (${pool.total_keys})`,
})),
)
const selectableProxyNodes = computed<ProxyNode[]>(() => {
const online = proxyNodesStore.onlineNodes
return online.length > 0 ? online : []
})
const proxyNodeOptions = computed<MultiSelectOption[]>(() =>
selectableProxyNodes.value.map(node => ({
value: node.id,
label: `${node.name}${node.region ? ` · ${formatRegion(node.region, '')}` : ''} (${node.ip}:${node.port})`,
})),
)
const selectedNodes = computed(() => {
const selectedSet = new Set(selectedProxyNodeIds.value)
return selectableProxyNodes.value.filter(node => selectedSet.has(node.id))
})
const canBuildPlan = computed(() =>
Boolean(selectedProviderId.value) && selectedNodes.value.length > 0,
)
const loadingText = computed(() => {
if (loadingKeys.value) {
return loadingKeyPage.value > 0
? `正在加载账号列表,第 ${loadingKeyPage.value} 页...`
: '正在加载账号列表...'
}
return '正在加载号池和代理节点...'
})
const assignmentRows = computed(() => {
const nodeById = new Map(selectableProxyNodes.value.map(node => [node.id, node]))
return (plan.value?.assignments ?? []).map((assignment) => {
const node = nodeById.get(assignment.nodeId)
return {
nodeId: assignment.nodeId,
nodeName: node?.name || assignment.nodeId,
nodeMeta: node ? `${node.ip}:${node.port}${node.region ? ` | ${formatRegion(node.region, '')}` : ''}` : assignment.nodeId,
targetCount: assignment.targetCount,
retainedCount: assignment.retainedKeys.length,
changedCount: assignment.changedKeys.length,
}
})
})
function handleOpenChange(open: boolean): void {
emit('update:modelValue', open)
}
function selectAllProxyNodes(): void {
selectedProxyNodeIds.value = proxyNodeOptions.value.map(option => option.value)
}
function resetPreview(): void {
plan.value = null
poolKeys.value = []
loadedKeysProviderId.value = ''
progressDone.value = 0
progressTotal.value = 0
loadingKeyPage.value = 0
}
async function loadInitialData(): Promise<void> {
const requestId = ++poolLoadRequestId
loadingPools.value = true
resetPreview()
try {
await proxyNodesStore.ensureLoaded()
const overview = await getPoolOverview({ cacheTtlMs: 0 })
if (requestId !== poolLoadRequestId) return
pools.value = Array.isArray(overview.items) ? overview.items : []
if (!selectedProviderId.value || !poolOptions.value.some(option => option.value === selectedProviderId.value)) {
selectedProviderId.value = poolOptions.value[0]?.value ?? ''
}
selectAllProxyNodes()
} catch (err) {
if (requestId !== poolLoadRequestId) return
showError(parseApiError(err, '加载号池信息失败'))
} finally {
if (requestId === poolLoadRequestId) {
loadingPools.value = false
}
}
}
async function loadAllPoolKeys(providerId: string): Promise<PoolKeyDetail[]> {
const pageSize = 200
let page = 1
const keys: PoolKeyDetail[] = []
while (true) {
loadingKeyPage.value = page
const result = await listPoolKeys(providerId, {
page,
page_size: pageSize,
status: 'all',
}, {
cacheTtlMs: 0,
})
const pageKeys = Array.isArray(result.keys) ? result.keys : []
keys.push(...pageKeys)
if (keys.length >= result.total || pageKeys.length === 0) {
return keys
}
page += 1
}
}
async function loadKeysAndBuildPlan(): Promise<void> {
if (!canBuildPlan.value) {
warning('请先选择号池和代理节点')
return
}
const providerId = selectedProviderId.value
const requestId = ++keyLoadRequestId
loadingKeys.value = true
plan.value = null
try {
const keys = await loadAllPoolKeys(providerId)
if (requestId !== keyLoadRequestId || selectedProviderId.value !== providerId) return
poolKeys.value = keys
loadedKeysProviderId.value = providerId
buildPreviewPlan()
} catch (err) {
if (requestId !== keyLoadRequestId) return
showError(parseApiError(err, '加载号池账号失败'))
} finally {
if (requestId === keyLoadRequestId) {
loadingKeys.value = false
loadingKeyPage.value = 0
}
}
}
function buildPreviewPlan(): void {
if (!canBuildPlan.value || loadedKeysProviderId.value !== selectedProviderId.value) {
plan.value = null
return
}
plan.value = buildPoolProxyDistributionPlan({
mode: mode.value,
keys: poolKeys.value,
nodes: selectedNodes.value.map(node => ({ id: node.id, name: node.name })),
})
}
async function executePlan(): Promise<void> {
if (!plan.value || !selectedProviderId.value) return
if (plan.value.changedCount === 0) {
success('当前分配已经满足目标,无需写入')
emit('changed')
emit('update:modelValue', false)
return
}
const confirmed = await confirm({
title: '执行号池代理均分',
message: `将写入 ${plan.value.changedCount} 个账号代理绑定,是否继续?`,
confirmText: '开始分配',
variant: 'warning',
})
if (!confirmed || !plan.value) return
executing.value = true
progressDone.value = 0
progressTotal.value = plan.value.changedCount
const providerId = selectedProviderId.value
let affected = 0
try {
for (const assignment of plan.value.assignments) {
const keyIds = assignment.changedKeys.map(key => key.key_id)
for (let index = 0; index < keyIds.length; index += 2000) {
const batch = keyIds.slice(index, index + 2000)
if (batch.length === 0) continue
const result = await batchActionPoolKeys(providerId, {
key_ids: batch,
action: 'set_proxy',
payload: { node_id: assignment.nodeId, enabled: true },
})
affected += Number(result.affected || 0)
progressDone.value += batch.length
}
}
success(`号池代理均分完成,已写入 ${affected} 个账号`)
emit('changed')
emit('update:modelValue', false)
} catch (err) {
showError(parseApiError(err, '执行号池代理均分失败'))
} finally {
executing.value = false
progressDone.value = 0
progressTotal.value = 0
}
}
watch(
() => props.modelValue,
(open) => {
if (open) {
void loadInitialData()
} else {
keyLoadRequestId += 1
resetPreview()
}
},
)
watch([selectedProviderId, selectedProxyNodeIds, mode], () => {
if (!props.modelValue || loadingKeys.value || executing.value) return
if (loadedKeysProviderId.value === selectedProviderId.value && poolKeys.value.length > 0) {
buildPreviewPlan()
} else {
plan.value = null
}
})
</script>
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import {
buildPoolProxyDistributionPlan,
type PoolProxyDistributionKey,
} from '@/features/pool/utils/poolProxyDistribution'
const nodes = [
{ id: 'node-a', name: 'Node A' },
{ id: 'node-b', name: 'Node B' },
]
function key(id: string, nodeId?: string | null): PoolProxyDistributionKey {
return {
key_id: id,
key_name: id,
proxy: nodeId ? { node_id: nodeId, enabled: true } : null,
}
}
function fixedRng(): () => number {
return () => 0
}
function assignedIds(plan: ReturnType<typeof buildPoolProxyDistributionPlan>): string[] {
return plan.assignments.flatMap(item => item.keys.map(key => key.key_id)).sort()
}
describe('buildPoolProxyDistributionPlan', () => {
it('keeps existing selected-node proxy bindings and fills empty capacity', () => {
const plan = buildPoolProxyDistributionPlan({
mode: 'fill',
nodes,
rng: fixedRng(),
keys: [
key('a-1', 'node-a'),
key('a-2', 'node-a'),
key('b-1', 'node-b'),
key('new-1', null),
],
})
expect(plan.totalKeys).toBe(4)
expect(plan.maxPerNode).toBe(2)
expect(plan.retainedCount).toBe(3)
expect(plan.changedCount).toBe(1)
expect(assignedIds(plan)).toEqual(['a-1', 'a-2', 'b-1', 'new-1'])
expect(plan.assignments.map(item => item.keys).map(keys => keys.length).sort()).toEqual([2, 2])
})
it('moves overflowed existing bindings before final assignment', () => {
const plan = buildPoolProxyDistributionPlan({
mode: 'fill',
nodes,
rng: fixedRng(),
keys: [
key('a-1', 'node-a'),
key('a-2', 'node-a'),
key('a-3', 'node-a'),
key('a-4', 'node-a'),
key('b-1', 'node-b'),
],
})
const nodeA = plan.assignments.find(item => item.nodeId === 'node-a')!
const nodeB = plan.assignments.find(item => item.nodeId === 'node-b')!
expect(plan.maxPerNode).toBe(3)
expect(plan.overflowCount).toBe(1)
expect(nodeA.keys).toHaveLength(3)
expect(nodeB.keys).toHaveLength(2)
expect(nodeB.changedKeys).toHaveLength(1)
expect(assignedIds(plan)).toEqual(['a-1', 'a-2', 'a-3', 'a-4', 'b-1'])
})
it('reassigns accounts bound to non-selected proxy nodes', () => {
const plan = buildPoolProxyDistributionPlan({
mode: 'fill',
nodes,
rng: fixedRng(),
keys: [
key('outside-1', 'node-c'),
key('empty-1', null),
],
})
expect(plan.outsideSelectedProxyCount).toBe(1)
expect(plan.changedCount).toBe(2)
expect(plan.assignments.every(item => item.keys.length === 1)).toBe(true)
})
it('force rewrites all accounts into balanced random targets', () => {
const plan = buildPoolProxyDistributionPlan({
mode: 'rewrite',
nodes,
rng: fixedRng(),
keys: [
key('k-1', 'node-a'),
key('k-2', 'node-a'),
key('k-3', 'node-b'),
key('k-4', null),
key('k-5', 'node-c'),
],
})
expect(plan.maxPerNode).toBe(3)
expect(plan.retainedCount).toBe(0)
expect(assignedIds(plan)).toEqual(['k-1', 'k-2', 'k-3', 'k-4', 'k-5'])
expect(plan.assignments.map(item => item.keys).map(keys => keys.length).sort()).toEqual([2, 3])
expect(plan.assignments.every(item => item.keys.length <= plan.maxPerNode)).toBe(true)
})
it('supports fewer accounts than selected proxy nodes', () => {
const plan = buildPoolProxyDistributionPlan({
mode: 'rewrite',
nodes: [
...nodes,
{ id: 'node-c', name: 'Node C' },
],
rng: fixedRng(),
keys: [key('only-1', null)],
})
expect(plan.maxPerNode).toBe(1)
expect(plan.assignments.map(item => item.keys).map(keys => keys.length).sort()).toEqual([0, 0, 1])
expect(assignedIds(plan)).toEqual(['only-1'])
})
})
@@ -112,4 +112,16 @@ describe('poolStatsDisplay', () => {
total_cost_usd: '$12.35',
})
})
it('promotes large token totals above M', () => {
const display = buildPoolStatsDisplay(
createCodexKey({ total_tokens: 1_500_000_000 }),
'openai',
'account_total',
)
expect(display.kind).toBe('account_total')
if (display.kind !== 'account_total') throw new Error('expected account total display')
expect(metricValues(display.metrics).total_tokens).toBe('1.5B')
})
})
@@ -0,0 +1,266 @@
export type PoolProxyDistributionMode = 'fill' | 'rewrite'
export interface PoolProxyDistributionKey {
key_id: string
key_name?: string | null
proxy?: {
node_id?: string | null
enabled?: boolean
} | null
}
export interface PoolProxyDistributionNode {
id: string
name?: string | null
}
export interface PoolProxyDistributionAssignment {
nodeId: string
targetCount: number
retainedKeys: PoolProxyDistributionKey[]
assignedKeys: PoolProxyDistributionKey[]
changedKeys: PoolProxyDistributionKey[]
keys: PoolProxyDistributionKey[]
}
export interface PoolProxyDistributionPlan {
mode: PoolProxyDistributionMode
totalKeys: number
nodeCount: number
maxPerNode: number
assignments: PoolProxyDistributionAssignment[]
retainedCount: number
changedCount: number
outsideSelectedProxyCount: number
overflowCount: number
}
export interface PoolProxyDistributionOptions {
mode: PoolProxyDistributionMode
keys: PoolProxyDistributionKey[]
nodes: PoolProxyDistributionNode[]
rng?: () => number
}
interface MutableAssignment {
nodeId: string
targetCount: number
retainedKeys: PoolProxyDistributionKey[]
assignedKeys: PoolProxyDistributionKey[]
}
export function buildPoolProxyDistributionPlan(
options: PoolProxyDistributionOptions,
): PoolProxyDistributionPlan {
const rng = options.rng ?? Math.random
const keys = uniqueKeys(options.keys)
const nodeIds = uniqueNodeIds(options.nodes)
const selectedNodeSet = new Set(nodeIds)
const totalKeys = keys.length
const nodeCount = nodeIds.length
const maxPerNode = nodeCount > 0 ? Math.ceil(totalKeys / nodeCount) : 0
if (totalKeys === 0 || nodeCount === 0) {
return {
mode: options.mode,
totalKeys,
nodeCount,
maxPerNode,
assignments: [],
retainedCount: 0,
changedCount: 0,
outsideSelectedProxyCount: 0,
overflowCount: 0,
}
}
const targetCounts = buildTargetCounts({
keys,
nodeIds,
selectedNodeSet,
mode: options.mode,
rng,
})
const mutableAssignments = new Map<string, MutableAssignment>()
for (const nodeId of nodeIds) {
mutableAssignments.set(nodeId, {
nodeId,
targetCount: targetCounts.get(nodeId) ?? 0,
retainedKeys: [],
assignedKeys: [],
})
}
const pendingKeys: PoolProxyDistributionKey[] = []
let outsideSelectedProxyCount = 0
let overflowCount = 0
if (options.mode === 'fill') {
const keysByNode = new Map<string, PoolProxyDistributionKey[]>()
for (const key of keys) {
const nodeId = getKeyProxyNodeId(key)
if (nodeId && selectedNodeSet.has(nodeId)) {
const grouped = keysByNode.get(nodeId) ?? []
grouped.push(key)
keysByNode.set(nodeId, grouped)
} else {
if (nodeId) outsideSelectedProxyCount += 1
pendingKeys.push(key)
}
}
for (const nodeId of nodeIds) {
const assignment = mutableAssignments.get(nodeId)
if (!assignment) continue
const currentKeys = shuffle(keysByNode.get(nodeId) ?? [], rng)
const retainedKeys = currentKeys.slice(0, assignment.targetCount)
const overflowKeys = currentKeys.slice(assignment.targetCount)
assignment.retainedKeys.push(...retainedKeys)
pendingKeys.push(...overflowKeys)
overflowCount += overflowKeys.length
}
} else {
pendingKeys.push(...keys)
}
const shuffledPendingKeys = shuffle(pendingKeys, rng)
const slots = shuffle(buildOpenSlots(mutableAssignments), rng)
for (let index = 0; index < shuffledPendingKeys.length; index += 1) {
const nodeId = slots[index]
if (!nodeId) break
mutableAssignments.get(nodeId)?.assignedKeys.push(shuffledPendingKeys[index])
}
const assignments = nodeIds.map((nodeId) => {
const assignment = mutableAssignments.get(nodeId)!
const nodeKeys = [...assignment.retainedKeys, ...assignment.assignedKeys]
const changedKeys = nodeKeys.filter(key => getKeyProxyNodeId(key) !== nodeId)
return {
nodeId,
targetCount: assignment.targetCount,
retainedKeys: assignment.retainedKeys,
assignedKeys: assignment.assignedKeys,
changedKeys,
keys: nodeKeys,
}
})
return {
mode: options.mode,
totalKeys,
nodeCount,
maxPerNode,
assignments,
retainedCount: assignments.reduce((sum, item) => sum + item.retainedKeys.length, 0),
changedCount: assignments.reduce((sum, item) => sum + item.changedKeys.length, 0),
outsideSelectedProxyCount,
overflowCount,
}
}
function buildTargetCounts(options: {
keys: PoolProxyDistributionKey[]
nodeIds: string[]
selectedNodeSet: Set<string>
mode: PoolProxyDistributionMode
rng: () => number
}): Map<string, number> {
const baseCount = Math.floor(options.keys.length / options.nodeIds.length)
const extraCount = options.keys.length % options.nodeIds.length
const existingCounts = new Map<string, number>()
if (options.mode === 'fill') {
for (const key of options.keys) {
const nodeId = getKeyProxyNodeId(key)
if (nodeId && options.selectedNodeSet.has(nodeId)) {
existingCounts.set(nodeId, (existingCounts.get(nodeId) ?? 0) + 1)
}
}
}
const extraNodeIds = new Set(
options.nodeIds
.map(nodeId => ({
nodeId,
existingCount: existingCounts.get(nodeId) ?? 0,
rank: options.rng(),
}))
.sort((left, right) => {
if (options.mode === 'fill' && left.existingCount !== right.existingCount) {
return right.existingCount - left.existingCount
}
return left.rank - right.rank
})
.slice(0, extraCount)
.map(item => item.nodeId),
)
return new Map(
options.nodeIds.map((nodeId) => [
nodeId,
baseCount + (extraNodeIds.has(nodeId) ? 1 : 0),
]),
)
}
function buildOpenSlots(assignments: Map<string, MutableAssignment>): string[] {
const slots: string[] = []
for (const assignment of assignments.values()) {
const openSlotCount = Math.max(
assignment.targetCount - assignment.retainedKeys.length - assignment.assignedKeys.length,
0,
)
for (let index = 0; index < openSlotCount; index += 1) {
slots.push(assignment.nodeId)
}
}
return slots
}
function getKeyProxyNodeId(key: PoolProxyDistributionKey): string | null {
const nodeId = key.proxy?.node_id?.trim()
return nodeId || null
}
function uniqueNodeIds(nodes: PoolProxyDistributionNode[]): string[] {
const seen = new Set<string>()
const ids: string[] = []
for (const node of nodes) {
const id = node.id.trim()
if (!id || seen.has(id)) continue
seen.add(id)
ids.push(id)
}
return ids
}
function uniqueKeys(keys: PoolProxyDistributionKey[]): PoolProxyDistributionKey[] {
const seen = new Set<string>()
const items: PoolProxyDistributionKey[] = []
for (const key of keys) {
const id = key.key_id.trim()
if (!id || seen.has(id)) continue
seen.add(id)
items.push(key)
}
return items
}
function shuffle<T>(items: T[], rng: () => number): T[] {
const result = [...items]
for (let index = result.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(clampRandom(rng()) * (index + 1))
const current = result[index]
result[index] = result[swapIndex]
result[swapIndex] = current
}
return result
}
function clampRandom(value: number): number {
if (!Number.isFinite(value)) return 0
if (value < 0) return 0
if (value >= 1) return 0.999999999
return value
}
@@ -1,5 +1,6 @@
import type { QuotaWindowUsageSnapshot } from '@/api/endpoints/types/statusSnapshot'
import type { PoolManagementStatsMode } from '@/features/pool/utils/poolManagementState'
import { formatCompactNumber } from '@/utils/format'
export type PoolStatsMetricKey = 'request_count' | 'total_tokens' | 'total_cost_usd'
export type PoolStatsDisplayKind = 'account_total' | 'codex_cycle'
@@ -63,9 +64,7 @@ export function formatPoolStatInteger(value: number | null | undefined): string
export function formatPoolTokenCount(value: number | null | undefined): string {
const n = Number(value ?? 0)
if (!Number.isFinite(n) || n <= 0) return '0'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(Math.round(n))
return formatCompactNumber(Math.round(n), { fractionDigits: 1 })
}
export function formatPoolStatUsd(value: number | string | null | undefined): string {
@@ -19,6 +19,49 @@
class="pl-8 h-9"
/>
</div>
<DropdownMenu :modal="false">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-9 w-9 shrink-0"
:disabled="loadingGlobalModels || loadingProviderKeys || fetchingAutoMatchedModels || providerKeys.length === 0"
:title="autoMatchButtonTitle"
aria-label="按密钥匹配"
>
<Loader2
v-if="loadingProviderKeys || fetchingAutoMatchedModels"
class="w-4 h-4 animate-spin"
/>
<ListChecks
v-else
class="w-4 h-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="w-72 max-h-80 overflow-y-auto"
>
<DropdownMenuItem
v-for="key in providerKeys"
:key="key.id"
class="flex-col items-start gap-0.5"
:disabled="fetchingAutoMatchedModels"
@select="applyAutoMatchFromKey(key)"
>
<span class="w-full truncate font-medium">
{{ getAutoMatchKeyLabel(key) }}
</span>
<span
v-if="getAutoMatchKeyDetail(key)"
class="w-full truncate text-xs text-muted-foreground"
>
{{ getAutoMatchKeyDetail(key) }}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<!-- 模型列表 -->
@@ -126,45 +169,68 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Layers, Loader2, Search, Check } from 'lucide-vue-next'
import { Layers, Loader2, Search, Check, ListChecks } from 'lucide-vue-next'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
import {
getGlobalModels,
type GlobalModelResponse
} from '@/api/endpoints/global-models'
import {
getProviderModels,
getProviderKeys,
batchAssignModelsToProvider,
deleteModel,
type Model
type Model,
type EndpointAPIKey
} from '@/api/endpoints'
const props = defineProps<{
type AutoMatchKey = Pick<EndpointAPIKey, 'id' | 'name' | 'api_key_masked'>
interface Props {
open: boolean
providerId: string
providerName?: string
}>()
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
'changed': []
}>()
const { error: showError, success } = useToast()
interface AutoMatchKeyLike {
id: string
name?: string | null
api_key_masked?: string | null
}
const { error: showError, success, warning: showWarning } = useToast()
const { confirmWarning } = useConfirm()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
// 状态
const loadingGlobalModels = ref(false)
const loadingProviderKeys = ref(false)
const saving = ref(false)
const fetchingAutoMatchedModels = ref(false)
// 数据
const allGlobalModels = ref<GlobalModelResponse[]>([])
const existingModels = ref<Model[]>([])
const providerKeys = ref<AutoMatchKey[]>([])
// 选择状态(本地状态,保存时才提交)
const selectedGlobalModelIds = ref<Set<string>>(new Set())
@@ -175,6 +241,12 @@ const initialGlobalModelIds = ref<Set<string>>(new Set())
// 搜索状态
const searchQuery = ref('')
const autoMatchButtonTitle = computed(() => {
if (loadingProviderKeys.value) return '正在加载密钥'
if (providerKeys.value.length === 0) return '暂无可用于匹配的密钥'
return '选择密钥,并按该密钥的上游模型自动勾选同名模型'
})
// 已关联的全局模型 ID 集合(从已有数据计算)
const existingGlobalModelIds = computed(() => {
return new Set(
@@ -264,6 +336,77 @@ function toggleAllGlobalModels() {
selectedGlobalModelIds.value = new Set(selectedGlobalModelIds.value)
}
function normalizeModelName(name: string | null | undefined): string {
return (name || '').trim()
}
function getAutoMatchKeyLabel(key: AutoMatchKeyLike): string {
return key.name || key.api_key_masked || key.id.slice(0, 8)
}
function getAutoMatchKeyDetail(key: AutoMatchKeyLike): string {
if (key.name && key.api_key_masked) return key.api_key_masked
return key.name ? key.id.slice(0, 8) : ''
}
async function applyAutoMatchFromKey(key: AutoMatchKey) {
if (!props.providerId || !key || fetchingAutoMatchedModels.value) return
fetchingAutoMatchedModels.value = true
try {
const result = await fetchCachedModels(props.providerId, key.id, true)
if (!props.open) return
if (result.warning) {
showWarning(`部分格式获取失败: ${result.warning}`)
}
if (result.models.length === 0) {
if (result.error) {
showError(result.error, '获取上游模型失败')
} else {
showWarning('此 Key 未返回可用模型')
}
return
}
const upstreamModelIds = new Set(
result.models
.map(model => normalizeModelName(model.id))
.filter(Boolean)
)
const matchedGlobalModelIds = allGlobalModels.value
.filter(model => upstreamModelIds.has(normalizeModelName(model.name)))
.map(model => model.id)
if (matchedGlobalModelIds.length === 0) {
showWarning('未找到与此 Key 上游模型 ID 同名的全局模型')
return
}
const nextSelected = new Set(selectedGlobalModelIds.value)
let newlySelectedCount = 0
for (const id of matchedGlobalModelIds) {
if (!nextSelected.has(id)) {
newlySelectedCount++
}
nextSelected.add(id)
}
selectedGlobalModelIds.value = nextSelected
searchQuery.value = ''
if (newlySelectedCount > 0) {
success(`已按 ${getAutoMatchKeyLabel(key)} 勾选 ${matchedGlobalModelIds.length} 个同名模型`)
} else {
success(`${matchedGlobalModelIds.length} 个同名模型已在选中列表中`)
}
} catch (err: unknown) {
showError(parseApiError(err, '自动匹配模型失败'), '错误')
} finally {
fetchingAutoMatchedModels.value = false
}
}
// 处理关闭
async function handleClose() {
if (hasChanges.value) {
@@ -355,12 +498,14 @@ watch(() => props.open, async (isOpen) => {
searchQuery.value = ''
selectedGlobalModelIds.value = new Set()
initialGlobalModelIds.value = new Set()
providerKeys.value = []
fetchingAutoMatchedModels.value = false
}
})
// 加载数据
async function loadData() {
await Promise.all([loadGlobalModels(), loadExistingModels()])
await Promise.all([loadGlobalModels(), loadExistingModels(), loadProviderKeys()])
syncGlobalModelSelection()
}
@@ -385,4 +530,17 @@ async function loadExistingModels() {
showError(parseApiError(err, '加载已关联模型失败'), '错误')
}
}
// 加载密钥列表
async function loadProviderKeys() {
try {
loadingProviderKeys.value = true
providerKeys.value = await getProviderKeys(props.providerId)
} catch (err: unknown) {
providerKeys.value = []
showError(parseApiError(err, '加载密钥失败'), '错误')
} finally {
loadingProviderKeys.value = false
}
}
</script>
@@ -156,10 +156,16 @@
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
:model-value="getDisplayedPath(endpoint)"
:placeholder="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'"
:disabled="isFixedProvider"
:placeholder="getEndpointDefaultPath(endpoint) || '留空使用默认'"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
/>
<p
v-if="getEndpointDefaultPath(endpoint)"
class="text-[10px] text-muted-foreground truncate"
:title="getEndpointDefaultPath(endpoint)"
>
当前默认路径:{{ getEndpointDefaultPath(endpoint) }}
</p>
</div>
</div>
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
@@ -969,6 +975,13 @@
size="sm"
:placeholder="newEndpointDefaultPath || '留空使用默认'"
/>
<p
v-if="newEndpointDefaultPath"
class="text-[10px] text-muted-foreground truncate"
:title="newEndpointDefaultPath"
>
当前默认路径:{{ newEndpointDefaultPath }}
</p>
</div>
</div>
</div>
@@ -1869,10 +1882,11 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
})
}
function getEndpointDefaultPath(endpoint: ProviderEndpoint): string {
return getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url)
}
function getDisplayedPath(endpoint: ProviderEndpoint): string {
if (isFixedProvider.value) {
return getDefaultPath(endpoint.api_format, endpoint.base_url)
}
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
}
@@ -3211,13 +3225,13 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
savingEndpointId.value = endpoint.id
try {
// 仅提交变更字段,避免 fixed provider 因 base_url/custom_path 被锁定而更新失败
// 仅提交变更字段;fixed provider 锁定 base_url,但允许覆盖 custom_path。
const payload: Record<string, unknown> = {}
if (!isFixedProvider.value) {
if (state.url !== endpoint.base_url) payload.base_url = state.url
if (state.path !== (endpoint.custom_path || '')) payload.custom_path = state.path || null
}
if (state.path !== (endpoint.custom_path || '')) payload.custom_path = state.path || null
if (hasRulesChanges(endpoint)) payload.header_rules = rulesToHeaderRules(state.rules)
if (hasResponseHeaderRulesChanges(endpoint)) {
@@ -212,7 +212,7 @@ const emit = defineEmits<{
saved: []
}>()
const { success, error: showError } = useToast()
const { success, error: showError, warning: showWarning } = useToast()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
const isOpen = computed(() => props.open)
@@ -295,9 +295,8 @@ async function fetchUpstreamModels() {
.filter((m: UpstreamModel) => !existingModelIds.value.has(m.id))
.map((m: UpstreamModel) => m.id)
hasQueried.value = true
// 如果有部分失败,显示警告提示
if (result.error) {
showError(`部分格式获取失败: ${result.error}`, '警告')
if (result.warning) {
showWarning(result.warning, '部分格式获取失败')
}
} else if (result.error) {
errorMessage.value = result.error
@@ -395,7 +395,7 @@ const emit = defineEmits<{
saved: []
}>()
const { success, error: showError } = useToast()
const { success, error: showError, warning: showWarning } = useToast()
const { confirmWarning } = useConfirm()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
@@ -691,6 +691,9 @@ async function fetchUpstreamModels(forceRefresh = false) {
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
const upstreamIds = new Set(result.models.map((m: UpstreamModel) => m.id))
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
if (result.warning) {
showWarning(result.warning, '部分格式获取失败')
}
} else if (result.error) {
showError(result.error, '获取上游模型失败')
}
@@ -322,7 +322,7 @@ const emit = defineEmits<{
'saved': []
}>()
const { error: showError, success: showSuccess } = useToast()
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
type EndpointOption = {
@@ -564,6 +564,9 @@ async function fetchUpstreamModels() {
const mergedCustom = new Set([...allCustomNames.value, ...customFromSelected])
allCustomNames.value = Array.from(mergedCustom).filter(name => !upstreamIds.has(name))
}
if (result.warning) {
showWarning(result.warning, '部分格式获取失败')
}
if (result.error) {
showError(result.error, '获取上游模型失败')
}
@@ -1408,6 +1408,8 @@ function parseImportText(text: string): {
const refreshTokenCamel = obj.refreshToken
const accessToken = obj.access_token
const accessTokenCamel = obj.accessToken
const sessionToken = obj.session_token
const sessionTokenCamel = obj.sessionToken
const grokSsoToken = isGrokProvider.value
? normalizeStringField(obj.sso_token) ?? normalizeStringField(obj.ssoToken) ?? normalizeStringField(obj.token) ?? grokCookieImport?.access_token
: undefined
@@ -1417,12 +1419,15 @@ function parseImportText(text: string): {
const normalizedAccessToken = typeof accessToken === 'string' && accessToken.trim()
? accessToken.trim()
: (typeof accessTokenCamel === 'string' && accessTokenCamel.trim() ? accessTokenCamel.trim() : undefined)
const importedAccessToken = normalizedAccessToken ?? grokSsoToken
const normalizedSessionToken = typeof sessionToken === 'string' && sessionToken.trim()
? sessionToken.trim()
: (typeof sessionTokenCamel === 'string' && sessionTokenCamel.trim() ? sessionTokenCamel.trim() : undefined)
const importedAccessToken = normalizedAccessToken ?? grokSsoToken ?? normalizedSessionToken
if (normalizedRefreshToken || importedAccessToken) {
return {
refresh_token: normalizedRefreshToken,
access_token: importedAccessToken,
expires_at: normalizeNumberField(obj.expires_at) ?? normalizeNumberField(obj.expiresAt),
expires_at: normalizeExpiryField(obj.expires_at) ?? normalizeExpiryField(obj.expiresAt) ?? normalizeExpiryField(obj.expired),
name: (typeof obj.name === 'string' ? obj.name : undefined) || (typeof obj.oauth_email === 'string' ? obj.oauth_email : undefined),
email: normalizeStringField(obj.email) ?? normalizeStringField(obj.oauth_email),
account_id: normalizeStringField(obj.account_id) ?? normalizeStringField(obj.accountId) ?? normalizeStringField(obj.chatgpt_account_id) ?? normalizeStringField(obj.chatgptAccountId),
@@ -1534,6 +1539,18 @@ function normalizeNumberField(value: unknown): number | undefined {
return undefined
}
function normalizeExpiryField(value: unknown): number | undefined {
const numeric = normalizeNumberField(value)
if (numeric) return numeric
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value.trim())
if (Number.isFinite(parsed) && parsed > 0) {
return Math.floor(parsed / 1000)
}
}
return undefined
}
function isLikelyJwtToken(token: string): boolean {
const parts = token.trim().split('.')
if (parts.length !== 3 || parts.some(part => !part)) return false
@@ -205,6 +205,7 @@ import { RefreshCw, RotateCcw } from 'lucide-vue-next'
import { getPoolStatus, clearPoolCooldown, resetPoolCost } from '@/api/endpoints/pool'
import type { PoolStatusResponse } from '@/api/endpoints/pool'
import { parseApiError } from '@/utils/errorParser'
import { formatTokens } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import Card from '@/components/ui/card.vue'
@@ -287,12 +288,6 @@ function formatTTL(seconds: number): string {
return m > 0 ? `${m}m ${s}s` : `${s}s`
}
function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
return String(tokens)
}
function formatEmaHeat(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0.0'
return value.toFixed(1)
@@ -1122,7 +1122,7 @@
</div>
<div>
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">使用额度</span>
<span class="text-muted-foreground">剩余额度</span>
<span :class="getQuotaRemainingClass(getChatGPTWebQuotaUsedPercent(key))">
{{ getChatGPTWebQuotaRemainingPercent(key).toFixed(1) }}%
</span>
@@ -1136,7 +1136,7 @@
</div>
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
<span>
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_used) }} /
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_remaining) }} /
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_total) }}
</span>
<span v-if="getChatGPTWebQuotaDisplay(key)?.image_quota_reset_at">
@@ -1389,7 +1389,7 @@
:open="batchAssignDialogOpen"
:provider-id="provider.id"
:provider-name="provider.name"
@update:open="batchAssignDialogOpen = $event"
@update:open="handleBatchAssignDialogOpenUpdate"
@changed="handleBatchAssignChanged"
/>
@@ -1506,6 +1506,7 @@ import {
} from '../utils/quotaAutoRefreshCooldown'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
import { formatCompactNumber } from '@/utils/format'
import {
canEditOAuthCredential,
canExportOAuthCredential,
@@ -2700,13 +2701,10 @@ const formatKiroUpdatedAt = formatUpdatedAt
// 格式化 Kiro 使用量(带单位)
function formatKiroUsage(value: number | undefined): string {
if (value === undefined || value === null) return '-'
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`
}
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}K`
}
return value.toFixed(1)
const normalized = Number(value)
if (!Number.isFinite(normalized)) return '-'
if (normalized >= 1000) return formatCompactNumber(normalized, { fractionDigits: 1 })
return normalized.toFixed(1)
}
// 格式化 Kiro 重置时间
@@ -3186,6 +3184,10 @@ function handleBatchAssign() {
batchAssignDialogOpen.value = true
}
function handleBatchAssignDialogOpenUpdate(value: boolean) {
batchAssignDialogOpen.value = value
}
// 处理批量关联完成
async function handleBatchAssignChanged() {
await Promise.all([loadEndpoints(), loadMappingPreview()])
@@ -3,9 +3,12 @@ import { describe, expect, it } from 'vitest'
import { getDefaultEndpointPath } from '../endpoint-default-paths'
const apiFormats = [
{ value: 'openai:chat', default_path: '/v1/chat/completions' },
{ value: 'gemini:generate_content', default_path: '/v1beta/models/{model}:{action}' },
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:{action}' },
{ value: 'openai:responses', default_path: '/v1/responses' },
{ value: 'openai:embedding', default_path: '/v1/embeddings' },
{ value: 'claude:messages', default_path: '/v1/messages' },
]
describe('endpoint default paths', () => {
@@ -52,4 +55,73 @@ describe('endpoint default paths', () => {
apiFormats,
})).toBe('/responses')
})
it('drops /v1 from OpenAI-compatible defaults when base URL includes a path', () => {
expect(getDefaultEndpointPath({
apiFormat: 'openai:chat',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/api',
apiFormats,
})).toBe('/chat/completions')
expect(getDefaultEndpointPath({
apiFormat: 'openai:embedding',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/api?tenant=demo',
apiFormats,
})).toBe('/embeddings')
expect(getDefaultEndpointPath({
apiFormat: 'openai:chat',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/openai',
apiFormats,
})).toBe('/chat/completions')
expect(getDefaultEndpointPath({
apiFormat: 'openai:chat',
providerType: 'custom',
baseUrl: 'https://proxy.example.com',
apiFormats,
})).toBe('/v1/chat/completions')
})
it('drops /v1 from OpenAI-compatible defaults when base URL already includes a known API root', () => {
expect(getDefaultEndpointPath({
apiFormat: 'openai:chat',
providerType: 'custom',
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
apiFormats,
})).toBe('/chat/completions')
expect(getDefaultEndpointPath({
apiFormat: 'openai:responses',
providerType: 'custom',
baseUrl: 'https://api.openai.example/v1',
apiFormats,
})).toBe('/responses')
})
it('keeps /v1 for Claude Messages defaults unless base URL already ends with v1', () => {
expect(getDefaultEndpointPath({
apiFormat: 'claude:messages',
providerType: 'custom',
baseUrl: 'https://api.anthropic.example/v1',
apiFormats,
})).toBe('/messages')
expect(getDefaultEndpointPath({
apiFormat: 'claude:messages',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/api',
apiFormats,
})).toBe('/v1/messages')
expect(getDefaultEndpointPath({
apiFormat: 'claude:messages',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/anthropic',
apiFormats,
})).toBe('/v1/messages')
})
})
@@ -15,6 +15,75 @@ function isCodexUrl(baseUrl: string): boolean {
return url.includes('/backend-api/codex') || url.endsWith('/codex')
}
function parseBaseUrlParts(baseUrl?: string | null): { host: string; path: string } | null {
const raw = (baseUrl || '').trim()
if (!raw) return null
try {
const parsed = new URL(raw)
return {
host: parsed.hostname.toLowerCase(),
path: parsed.pathname.replace(/\/+$/, '').toLowerCase(),
}
} catch {
const pathStart = raw.indexOf('/')
return {
host: '',
path: pathStart >= 0 ? raw.slice(pathStart).split('?')[0].replace(/\/+$/, '').toLowerCase() : '',
}
}
}
function baseUrlHasPathApiRoot(baseUrl?: string | null): boolean {
const path = parseBaseUrlParts(baseUrl)?.path
return !!path && path !== '/'
}
function baseUrlEndsWithV1Root(baseUrl?: string | null): boolean {
return parseBaseUrlParts(baseUrl)?.path.endsWith('/v1') ?? false
}
function isBigModelCodingApiRoot(baseUrl?: string | null): boolean {
const parts = parseBaseUrlParts(baseUrl)
return parts?.host === 'open.bigmodel.cn' && parts.path === '/api/coding/paas/v4'
}
function isGoogleOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
const parts = parseBaseUrlParts(baseUrl)
return parts?.host === 'generativelanguage.googleapis.com'
&& (parts.path === '/v1beta/openai' || parts.path === '/v1/openai')
}
function isVertexOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
const parts = parseBaseUrlParts(baseUrl)
return !!parts
&& (parts.host === 'aiplatform.googleapis.com' || parts.host.endsWith('.aiplatform.googleapis.com') || parts.host.endsWith('-aiplatform.googleapis.com'))
&& parts.path.endsWith('/endpoints/openapi')
}
function openAiCompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
return baseUrlEndsWithV1Root(baseUrl)
|| baseUrlHasPathApiRoot(baseUrl)
|| isBigModelCodingApiRoot(baseUrl)
|| isGoogleOpenAiCompatApiRoot(baseUrl)
|| isVertexOpenAiCompatApiRoot(baseUrl)
}
function v1CompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
return baseUrlEndsWithV1Root(baseUrl)
}
function stripV1PrefixForApiRoot(path: string): string {
return path.replace(/^\/v1(?=\/)/i, '')
}
function isOpenAiCompatibleFormat(apiFormat: string): boolean {
return apiFormat.startsWith('openai:') || apiFormat.startsWith('jina:')
}
function isClaudeCompatibleFormat(apiFormat: string): boolean {
return apiFormat === 'claude:messages'
}
export function getDefaultEndpointPath(params: {
apiFormat: string
providerType?: string | null
@@ -48,5 +117,11 @@ export function getDefaultEndpointPath(params: {
if (normalizedApiFormat === 'openai:responses' && isCodex) {
return '/responses'
}
if (openAiCompatibleBaseIncludesApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
return stripV1PrefixForApiRoot(defaultPath)
}
if (v1CompatibleBaseIncludesApiRoot(params.baseUrl) && isClaudeCompatibleFormat(normalizedApiFormat)) {
return stripV1PrefixForApiRoot(defaultPath)
}
return defaultPath
}
@@ -99,6 +99,33 @@
</div>
</div>
<div
v-if="showKeySelector"
class="space-y-2"
>
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-foreground">
测试 Key
</div>
<div class="text-xs text-muted-foreground">
{{ keySelectionStatus }}
</div>
</div>
<MultiSelect
:model-value="selectedKeyIds"
:options="keyOptions"
:placeholder="keySelectorPlaceholder"
search-placeholder="搜索 Key"
empty-text="暂无可选 Key"
no-results-text="未找到匹配 Key"
trigger-class="h-9 min-h-9 rounded-md border-border/60 text-xs"
dropdown-min-width="24rem"
:search-threshold="0"
:disabled="keyOptionsLoading && keyOptions.length === 0"
@update:model-value="emit('update:selectedKeyIds', $event)"
/>
</div>
<div class="grid gap-4 lg:grid-cols-2 lg:items-start">
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
@@ -772,9 +799,11 @@ import {
} from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Textarea from '@/components/ui/textarea.vue'
import MultiSelect from '@/components/common/MultiSelect.vue'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { TestAttemptDetail, TestCandidateSummary, TestModelFailoverResponse } from '@/api/endpoints/providers'
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
import JsonContent from '@/features/usage/components/RequestDetailDrawer/JsonContent.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useDarkMode } from '@/composables/useDarkMode'
@@ -797,6 +826,8 @@ type TestModelMappingOption = {
priority?: number
}
type TestKeyOption = MultiSelectOption
const props = defineProps<{
open: boolean
result: TestModelFailoverResponse | null
@@ -818,6 +849,9 @@ const props = defineProps<{
modelMappingAvailable?: boolean
modelMappingOptions?: TestModelMappingOption[]
selectedModelMapping?: string | null
keyOptions?: TestKeyOption[]
selectedKeyIds?: string[]
keyOptionsLoading?: boolean
startDisabled?: boolean
}>()
@@ -827,15 +861,30 @@ const emit = defineEmits<{
start: []
selectEndpoint: [endpointId: string]
selectModelMapping: [modelName: string]
'update:selectedKeyIds': [value: string[]]
'update:requestHeadersDraft': [value: string]
'update:requestBodyDraft': [value: string]
}>()
const endpoints = computed(() => props.endpoints ?? [])
const modelMappingOptions = computed(() => props.modelMappingOptions ?? [])
const keyOptions = computed(() => props.keyOptions ?? [])
const selectedKeyIds = computed(() => props.selectedKeyIds ?? [])
const keyOptionsLoading = computed(() => props.keyOptionsLoading === true)
const modelMappingAvailable = computed(
() => props.modelMappingAvailable === true && modelMappingOptions.value.length > 0,
)
const showKeySelector = computed(() => (
keyOptionsLoading.value || keyOptions.value.length > 0 || selectedKeyIds.value.length > 0
))
const keySelectorPlaceholder = computed(() => (
keyOptionsLoading.value && keyOptions.value.length === 0 ? '正在加载 Key' : '默认调度(不指定 Key)'
))
const keySelectionStatus = computed(() => {
if (selectedKeyIds.value.length > 0) return `已选 ${selectedKeyIds.value.length}`
if (keyOptionsLoading.value) return '加载中'
return '默认'
})
const requestedModelName = computed(() => props.requestedModelName?.trim() || '')
const selectedModelMapping = computed(() => props.selectedModelMapping?.trim() || '')
const selectedModelMappingValue = computed(() => (
@@ -232,12 +232,16 @@
:model-mapping-available="testModelMappingAvailable"
:model-mapping-options="testModelMappingOptions"
:selected-model-mapping="selectedTestMappedModelName"
:key-options="testKeyOptions"
:selected-key-ids="selectedTestKeyIds"
:key-options-loading="loadingModelTestKeys"
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
@close="handleTestDialogClose"
@back="handleTestDialogBack"
@start="handleStartPendingTest"
@select-endpoint="handleSelectTestEndpoint"
@select-model-mapping="handleSelectModelMapping"
@update:selected-key-ids="handleSelectTestKeyIds"
@update:request-headers-draft="testRequestHeadersDraft = $event"
@update:request-body-draft="testRequestBodyDraft = $event"
/>
@@ -257,7 +261,7 @@ import {
type Model,
type ProviderEndpoint,
} from '@/api/endpoints'
import { type EndpointAPIKey } from '@/api/endpoints/keys'
import { getProviderKeys, type EndpointAPIKey } from '@/api/endpoints/keys'
import { updateModel } from '@/api/endpoints/models'
import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
@@ -269,6 +273,7 @@ import {
isModelTestableApiFormat,
isModelTestableEndpoint,
listModelTestMappedModelOptions,
modelTestKeySupportsEndpoint,
normalizeModelTestMappedModelSelection,
parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft,
@@ -307,6 +312,10 @@ const testRequestHeadersResetValue = ref('')
const testRequestBodyDraft = ref('')
const testRequestBodyResetValue = ref('')
const selectedTestMappedModelName = ref<string | null>(null)
const selectedTestKeyIds = ref<string[]>([])
const modelTestProviderKeys = ref<EndpointAPIKey[]>([])
const modelTestKeysLoadedProviderId = ref<string | null>(null)
const loadingModelTestKeys = ref(false)
const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced))
const activeEndpoints = computed(() => (props.endpoints ?? [])
.filter(endpoint => {
@@ -336,6 +345,32 @@ const mappedTestModelName = computed(() => {
: null
})
const testModelMappingAvailable = computed(() => testModelMappingOptions.value.length > 0)
const providerKeysForModelTest = computed(() => (
modelTestKeysLoadedProviderId.value === props.provider.id
? modelTestProviderKeys.value
: props.providerKeys ?? []
))
const testKeyOptions = computed(() => {
const endpoint = selectedTestEndpoint.value
if (!endpoint) return []
const seen = new Set<string>()
return [...providerKeysForModelTest.value]
.filter((key) => {
if (seen.has(key.id)) return false
seen.add(key.id)
return modelTestKeySupportsEndpoint(key, endpoint, props.provider.provider_type)
})
.sort((left, right) => {
const priority = left.internal_priority - right.internal_priority
if (priority !== 0) return priority
return formatTestKeyOptionLabel(left).localeCompare(formatTestKeyOptionLabel(right))
})
.map(key => ({
value: key.id,
label: formatTestKeyOptionLabel(key),
}))
})
const effectiveTestRequestModelName = computed(() => (
mappedTestModelName.value || pendingRequestedModelName.value
))
@@ -506,6 +541,7 @@ function handleTestDialogClose() {
pendingTestModel.value = null
selectedTestEndpoint.value = null
selectedTestMappedModelName.value = null
selectedTestKeyIds.value = []
testRequestHeadersDraft.value = ''
testRequestHeadersResetValue.value = ''
testRequestBodyDraft.value = ''
@@ -524,6 +560,7 @@ function handleSelectTestEndpoint(endpointId: string) {
selectedTestEndpoint.value = endpoint
syncSelectedTestModelMapping()
resetTestRequestBodyForSelectedEndpoint()
pruneSelectedTestKeyIds()
}
function handleSelectModelMapping(modelName: string) {
@@ -534,6 +571,10 @@ function handleSelectModelMapping(modelName: string) {
syncTestRequestBodyModel()
}
function handleSelectTestKeyIds(ids: string[]) {
selectedTestKeyIds.value = normalizeSelectedTestKeyIds(ids)
}
async function handleStartPendingTest() {
if (modelTest.testing.value) return
if (!pendingTestModel.value) return
@@ -557,6 +598,7 @@ async function handleStartPendingTest() {
}
selectedTestEndpoint.value = endpoint
pruneSelectedTestKeyIds()
const model = pendingTestModel.value
const modelName = model.global_model_name || model.provider_model_name
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
@@ -567,6 +609,7 @@ async function handleStartPendingTest() {
apiFormat: endpoint.api_format,
endpointId: endpoint.id,
endpointBaseUrl: endpoint.base_url,
apiKeyIds: selectedTestKeyIds.value,
applyModelMapping: Boolean(mappedTestModelName.value),
mappedModelName: mappedTestModelName.value ?? undefined,
requestHeaders,
@@ -591,6 +634,7 @@ async function testModelConnection(model: Model) {
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(model, activeEndpoints.value)
const requestedModelName = getModelTestRequestedModelName(model)
selectedTestMappedModelName.value = null
selectedTestKeyIds.value = []
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
@@ -601,6 +645,49 @@ async function testModelConnection(model: Model) {
testRequestBodyDraft.value = testRequestBodyResetValue.value
modelTest.testResult.value = null
modelTest.dialogOpen.value = true
void ensureModelTestKeysLoaded()
}
function normalizeSelectedTestKeyIds(ids: string[]): string[] {
const allowed = new Set(testKeyOptions.value.map(option => option.value))
const selected = ids
.map(id => id.trim())
.filter(id => id && allowed.has(id))
return [...new Set(selected)]
}
function pruneSelectedTestKeyIds() {
if (selectedTestKeyIds.value.length === 0) return
selectedTestKeyIds.value = normalizeSelectedTestKeyIds(selectedTestKeyIds.value)
}
async function ensureModelTestKeysLoaded() {
if (modelTestKeysLoadedProviderId.value === props.provider.id || loadingModelTestKeys.value) {
return
}
loadingModelTestKeys.value = true
try {
modelTestProviderKeys.value = await getProviderKeys(props.provider.id)
modelTestKeysLoadedProviderId.value = props.provider.id
pruneSelectedTestKeyIds()
} catch (err: unknown) {
showError(parseApiError(err, '加载测试 Key 失败'), '错误')
} finally {
loadingModelTestKeys.value = false
}
}
function formatTestKeyOptionLabel(key: EndpointAPIKey): string {
const name = key.name?.trim()
const masked = key.api_key_masked?.trim()
const authType = key.auth_type?.trim()
const primary = name || masked || key.id
const suffix = [
masked && masked !== primary ? masked : '',
authType || '',
].filter(Boolean)
return suffix.length > 0 ? `${primary} · ${suffix.join(' · ')}` : primary
}
function getModelTestRequestedModelName(model: Model | null): string {
@@ -661,6 +748,17 @@ watch(
() => syncTestRequestBodyModel(),
)
watch(testKeyOptions, () => pruneSelectedTestKeyIds())
watch(
() => props.provider.id,
() => {
modelTestProviderKeys.value = []
modelTestKeysLoadedProviderId.value = null
selectedTestKeyIds.value = []
},
)
// 暴露给父组件
defineExpose({
reload: refresh
@@ -372,6 +372,11 @@ describe('isModelTestableEndpoint', () => {
})
describe('formatModelTestDiagnostic', () => {
it('maps model permission skips to an actionable label', () => {
expect(formatModelTestDiagnostic('key_model_not_allowed'))
.toBe('Key 未允许当前模型,已跳过')
})
it('maps pool account blocked scheduler code to an actionable label', () => {
expect(formatModelTestDiagnostic('pool_account_blocked')).toBe('账号已失效,需重新授权')
})
@@ -41,6 +41,7 @@ const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
key_model_not_allowed: 'Key 未允许当前模型,已跳过',
pool_account_blocked: '账号已失效,需重新授权',
}
@@ -11,7 +11,7 @@ import type { UpstreamModel } from '@/api/endpoints/types'
export type { UpstreamModel }
type FetchResult = { models: UpstreamModel[]; error?: string; fromCache?: boolean }
type FetchResult = { models: UpstreamModel[]; error?: string; warning?: string; fromCache?: boolean }
// 进行中的请求(用于去重并发请求)
const pendingRequests = new Map<string, Promise<FetchResult>>()
@@ -54,14 +54,14 @@ export function useUpstreamModelsCache() {
const response = await adminApi.queryProviderModels(providerId, apiKeyId, forceRefresh)
if (response.success && response.data?.models) {
const partialWarning = response.data.warning ?? response.data.error
return {
models: response.data.models,
// 传递部分格式获取失败的 warning(后端 success=true 但仍可能附带 error)
error: response.data.error ? parseUpstreamModelError(response.data.error) : undefined,
warning: partialWarning ? parseUpstreamModelError(partialWarning) : undefined,
fromCache: response.data.from_cache
}
} else {
const rawError = response.data?.error || '获取上游模型失败'
const rawError = response.data?.error || response.data?.warning || '获取上游模型失败'
return { models: [], error: parseUpstreamModelError(rawError) }
}
} catch (err: unknown) {
@@ -542,6 +542,7 @@ import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
import { requestTraceApi, type RequestTrace, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
import { formatTokens } from '@/utils/format'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { useDarkMode } from '@/composables/useDarkMode'
import { resolveTimelineFinalStatus } from '../utils/status'
@@ -629,7 +630,7 @@ const usageData = computed(() => props.usageData)
// 格式化数字
const formatNumber = (num: number): string => {
return num.toLocaleString('zh-CN')
return formatTokens(num)
}
// 获取最终状态标签
@@ -1219,13 +1220,16 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknow
const body = raw.body
const bodyRef = readStringField(raw, 'body_ref') ?? readStringField(raw, 'bodyRef')
const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState')
const meaningfulBodyState = bodyState && bodyState.toLowerCase() !== 'none'
? bodyState
: ''
if (
statusCode == null &&
!hasRenderableValue(headers) &&
!hasRenderableValue(body) &&
!bodyRef &&
!bodyState
!meaningfulBodyState
) {
return null
}
@@ -1235,7 +1239,7 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknow
if (hasRenderableValue(headers)) data.headers = headers
if (hasRenderableValue(body)) data.body = body
if (bodyRef) data.body_ref = bodyRef
if (bodyState) data.body_state = bodyState
if (meaningfulBodyState) data.body_state = meaningfulBodyState
return data
}
@@ -1352,15 +1356,22 @@ const currentAttemptKeyFormatsDisplay = computed(() => {
.map(format => formatApiFormat(format))
.join(' / ')
})
const SKIP_REASON_LABELS: Record<string, string> = {
auth_api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
pool_key_lease_busy: '池内账号正被其他请求占用',
provider_concurrency_limit_reached: '上游提供商并发已达上限',
provider_key_concurrency_limit_reached: '上游账号并发已达上限',
provider_request_body_build_failed: '上游请求体转换失败',
provider_request_body_missing: '无法构建上游请求体',
}
const currentAttemptSkipReasonDisplay = computed(() => {
const attempt = currentAttempt.value
if (!attempt?.skip_reason) return ''
if (attempt.skip_reason === 'provider_request_body_build_failed') {
return '上游请求体转换失败'
}
if (attempt.skip_reason === 'provider_request_body_missing') {
return '无法构建上游请求体'
const skipReasonLabel = SKIP_REASON_LABELS[attempt.skip_reason]
if (skipReasonLabel) {
return skipReasonLabel
}
if (attempt.skip_reason !== 'transport_unsupported') {
@@ -18,13 +18,13 @@
<!-- 固定头部 - 整合基本信息 -->
<div class="sticky top-0 z-10 bg-background border-b px-3 sm:px-6 py-3 sm:py-4 flex-shrink-0">
<!-- 第一行:标题、模型、状态、操作按钮 -->
<div class="flex items-center justify-between gap-4 mb-3">
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center justify-between gap-2 sm:gap-4 mb-3">
<div class="flex min-w-0 flex-1 items-center gap-2 sm:gap-3 flex-wrap">
<h3 class="text-lg font-semibold">
请求详情
</h3>
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
<span>{{ detail?.model || '-' }}</span>
<div class="flex min-w-0 max-w-[10rem] items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded sm:max-w-none">
<span class="truncate">{{ detail?.model || '-' }}</span>
<template v-if="detail?.target_model && detail.target_model !== detail.model">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -38,7 +38,7 @@
clip-rule="evenodd"
/>
</svg>
<span>{{ detail.target_model }}</span>
<span class="truncate">{{ detail.target_model }}</span>
</template>
</div>
<Badge
@@ -55,8 +55,8 @@
</Badge>
<Badge
v-if="detail && resolveUsageStreamLabelSegments(detail).hasConversion"
:variant="resolveUsageStreamLabelSegments(detail).client === '流式' ? 'secondary' : 'outline'"
:class="resolveUsageStreamLabelSegments(detail).client === '流式'
:variant="streamBadgeVariant(resolveUsageStreamLabelSegments(detail).client === '流式')"
:class="streamBadgeVariant(resolveUsageStreamLabelSegments(detail).client === '流式') === 'secondary'
? 'text-xs inline-flex items-center gap-1'
: 'text-xs inline-flex items-center gap-1 border-border/60 text-muted-foreground'"
>
@@ -66,8 +66,8 @@
</Badge>
<Badge
v-else-if="detail"
:variant="isUsageUpstreamStream(detail) ? 'secondary' : 'outline'"
:class="isUsageUpstreamStream(detail)
:variant="streamBadgeVariant(isUsageUpstreamStream(detail))"
:class="streamBadgeVariant(isUsageUpstreamStream(detail)) === 'secondary'
? 'text-xs'
: 'text-xs border-border/60 text-muted-foreground'"
>
@@ -112,20 +112,20 @@
<!-- 第二行:关键元信息 -->
<div
v-if="detail"
class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"
class="flex items-center flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground sm:gap-x-4"
>
<span class="flex items-center gap-1">
<span class="flex min-w-0 items-center gap-1">
<span class="font-medium text-foreground">ID:</span>
<span
class="font-mono"
:title="fullRequestId"
>{{ displayRequestId }}</span>
</span>
<span class="opacity-40">|</span>
<span class="hidden opacity-40 sm:inline">|</span>
<span>{{ formatDateTime(detail.created_at) }}</span>
<span class="opacity-40">|</span>
<span class="hidden opacity-40 sm:inline">|</span>
<span>{{ formatApiFormat(detail.api_format) }}</span>
<span class="opacity-40">|</span>
<span class="hidden opacity-40 sm:inline">|</span>
<span>用户: {{ detail.user?.username || 'Unknown' }}</span>
</div>
</div>
@@ -203,88 +203,33 @@
<!-- 费用与性能概览 -->
<Card>
<div class="p-3 sm:p-4">
<!-- 总费用和响应时间(独立显示) -->
<div class="flex items-center mb-4">
<div class="flex items-center">
<span class="text-xs text-muted-foreground w-[56px]">总费用</span>
<span class="text-lg font-bold text-green-600 dark:text-green-400">
<div class="mb-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground/70">{{ priceSourceLabel }}</span>
<span class="text-muted-foreground">|</span>
<span class="whitespace-nowrap">
<span class="text-muted-foreground">总费用</span>
<span class="ml-1 font-bold text-green-600 dark:text-green-400">
${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }}
</span>
</div>
<Separator
orientation="vertical"
class="h-6 mx-6"
/>
<div class="flex items-center">
<span class="text-xs text-muted-foreground w-[56px]">响应时间</span>
<span class="text-lg font-bold">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).value : 'N/A' }}</span>
<span class="text-sm text-muted-foreground ml-1">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).unit : '' }}</span>
</div>
<template v-if="detailOutputRate != null">
<Separator
orientation="vertical"
class="h-6 mx-6"
/>
<div class="flex items-center">
<span class="text-xs text-muted-foreground w-[56px]">输出速度</span>
<span class="text-lg font-bold text-primary">{{ formatOutputRateValue(detailOutputRate) }}</span>
<span class="text-sm text-muted-foreground ml-1">tps</span>
</div>
</template>
</div>
<div
v-if="hasDetailPerformanceBreakdown"
class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 text-xs"
>
<div class="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div class="text-muted-foreground mb-1">
首字时间
</div>
<div class="font-mono text-foreground">
{{ formatDurationMs(detail.first_byte_time_ms) }}
</div>
</div>
<div class="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div class="text-muted-foreground mb-1">
生成耗时
</div>
<div class="font-mono text-foreground">
{{ formatDurationMs(detailGenerationTimeMs) }}
</div>
</div>
<div class="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div class="text-muted-foreground mb-1">
输出 Tokens
</div>
<div class="font-mono text-foreground">
{{ formatNumber(detailOutputTokens) }}
</div>
</div>
</span>
<span class="text-muted-foreground">|</span>
<span class="whitespace-nowrap">
<span class="text-muted-foreground">耗时</span>
<span class="ml-1 font-bold">
{{ formatDurationMs(detail.first_byte_time_ms) }} / {{ formatDurationMs(detail.response_time_ms) }}
</span>
</span>
<span class="text-muted-foreground">|</span>
<span class="whitespace-nowrap">
<span class="text-muted-foreground">输出速度</span>
<span class="ml-1 font-bold text-primary">{{ formatOutputRateValue(detailOutputRate) }}tps</span>
</span>
</div>
<!-- 分隔线 -->
<Separator class="mb-4" />
<!-- ========== 1. 费用聚合计算 ========== -->
<div class="text-xs text-muted-foreground mb-3 flex items-center gap-2 flex-wrap">
<span class="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground/70">{{ priceSourceLabel }}</span>
<span class="text-foreground">|</span>
<span class="font-mono text-foreground">
总费用 = Token费用 <span class="font-medium">${{ tokenCostTotal.toFixed(6) }}</span>
<template v-if="perRequestCost > 0">
+ 按次费用 <span class="font-medium">${{ perRequestCost.toFixed(6) }}</span>
</template>
<template v-if="imageOutputCostTotal > 0">
+ 图片输出费用 <span class="font-medium">${{ imageOutputCostTotal.toFixed(6) }}</span>
</template>
<template v-if="videoCostTotal > 0">
+ {{ detail.video_billing?.task_type === 'image' ? '图像' : detail.video_billing?.task_type === 'audio' ? '音频' : '视频' }}费用 <span class="font-medium">${{ videoCostTotal.toFixed(6) }}</span>
</template>
</span>
</div>
<!-- ========== 2. Token分阶段成本 ========== -->
<!-- ========== 1. Token分阶段成本 ========== -->
<div
v-if="hasTokenCost"
class="space-y-2 mb-3"
@@ -292,6 +237,7 @@
<!-- 阶梯标题 -->
<div class="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
<span class="font-medium text-foreground">Token 计费</span>
<span class="font-mono font-medium text-foreground">${{ tokenCostTotal.toFixed(6) }}</span>
<span class="text-muted-foreground/60">(输入 {{ formatNumber(displayInputTokens) }} + 缓存创建 {{ cacheCreationSummaryText }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
<Badge
v-if="displayTiers.length > 1"
@@ -409,8 +355,9 @@
v-if="perRequestCost > 0 && !detail.video_billing"
class="space-y-2 mb-3"
>
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-2 text-xs">
<span class="font-medium text-foreground">按次计费</span>
<span class="font-mono font-medium text-foreground">${{ perRequestCost.toFixed(6) }}</span>
</div>
<div class="rounded-lg p-3 bg-primary/5 border border-primary/30 space-y-2">
<div
@@ -537,9 +484,9 @@
<!-- 请求链路追踪卡片 -->
<div>
<HorizontalRequestTimeline
v-if="showTimeline && (detail.request_id || detail.id)"
v-if="showTimeline && traceTimelineRequestId"
ref="timelineRef"
:request-id="detail.request_id || detail.id"
:request-id="traceTimelineRequestId"
:override-status-code="detail.status_code"
:request-status="detail.status"
:request-api-format="detail.api_format || null"
@@ -794,7 +741,7 @@ import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageS
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatShortRequestId } from '@/utils/format'
import { formatCompactNumber, formatShortRequestId, formatTokens } from '@/utils/format'
import { log } from '@/utils/logger'
import { getEffectiveInputTokens } from '../token-normalization'
import {
@@ -802,7 +749,6 @@ import {
formatOutputRate,
formatOutputRateValue,
getDisplayOutputRate,
getGenerationTimeMs,
} from '../performance'
import {
formatUsageStreamLabel,
@@ -828,6 +774,16 @@ import {
type RenderBlock,
} from '../conversation'
type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
'pending',
'streaming',
'completed',
'failed',
'cancelled',
])
const props = defineProps<{
isOpen: boolean
requestId: string | null
@@ -838,7 +794,7 @@ const emit = defineEmits<{
requestState: [state: {
id: string
requestId?: string | null
status?: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
status?: RequestStateStatus
statusCode?: number | null
responseTimeMs?: number | null
imageProgress?: ImageProgress | null
@@ -922,7 +878,7 @@ function formatErrorDomainMeta(domain: NormalizedErrorDomain): string {
function mapTraceFinalStatusToRequestStatus(
status?: RequestTrace['final_status'] | null
): 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled' | undefined {
): RequestStateStatus | undefined {
switch (status) {
case 'success':
return 'completed'
@@ -939,6 +895,49 @@ function mapTraceFinalStatusToRequestStatus(
}
}
function normalizeRequestStateStatus(status: unknown): RequestStateStatus | undefined {
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : ''
return REQUEST_STATE_STATUSES.has(normalized as RequestStateStatus)
? normalized as RequestStateStatus
: undefined
}
function hasRequestFailureSignal(statusCode?: number | null, errorMessage?: string | null): boolean {
return (typeof statusCode === 'number' && statusCode >= 400) ||
(typeof errorMessage === 'string' && errorMessage.trim().length > 0)
}
function resolveRequestStateStatus(
status: unknown,
statusCode?: number | null,
errorMessage?: string | null
): RequestStateStatus | undefined {
const normalized = normalizeRequestStateStatus(status)
if ((normalized == null || normalized === 'pending' || normalized === 'streaming') &&
hasRequestFailureSignal(statusCode, errorMessage)) {
return 'failed'
}
return normalized
}
function resolveRequestStateStatusFromDetail(nextDetail: Pick<RequestDetail, 'status' | 'status_code' | 'error_message'>): RequestStateStatus | undefined {
return resolveRequestStateStatus(nextDetail.status, nextDetail.status_code, nextDetail.error_message)
}
function emitDetailRequestState(nextDetail: RequestDetail) {
const id = props.requestId
if (!id) return
emit('requestState', {
id,
requestId: nextDetail.request_id || nextDetail.id || null,
status: resolveRequestStateStatusFromDetail(nextDetail),
statusCode: nextDetail.status_code ?? undefined,
responseTimeMs: nextDetail.response_time_ms ?? undefined,
errorMessage: nextDetail.error_message ?? undefined,
})
}
function handleTraceState(state: {
loaded: boolean
hasTrace: boolean
@@ -953,7 +952,11 @@ function handleTraceState(state: {
const id = props.requestId
if (!id) return
const status = mapTraceFinalStatusToRequestStatus(state.finalStatus)
const status = resolveRequestStateStatus(
mapTraceFinalStatusToRequestStatus(state.finalStatus),
state.statusCode,
state.errorMessage
)
const imageFailed = state.imageProgress?.phase === 'failed'
if (!status && !state.imageProgress && state.statusCode == null && state.latencyMs == null) return
@@ -996,6 +999,17 @@ function getNestedString(record: JsonRecord | null, ...path: string[]): string |
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function getCaseInsensitiveString(record: JsonRecord | null, key: string): string | null {
if (!record) return null
const normalizedKey = key.toLowerCase()
for (const [name, value] of Object.entries(record)) {
if (name.toLowerCase() === normalizedKey && typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return null
}
function normalizeCacheTtlPricing(value: unknown): CacheTTLPriceEntry[] {
if (!Array.isArray(value)) return []
return value
@@ -1045,16 +1059,6 @@ const detailOutputTokens = computed(() => {
return detail.value.tokens?.output ?? detail.value.output_tokens ?? 0
})
const detailGenerationTimeMs = computed(() => {
if (!detail.value) return null
return getGenerationTimeMs({
response_time_ms: detail.value.response_time_ms,
first_byte_time_ms: detail.value.first_byte_time_ms,
is_stream: detail.value.is_stream,
upstream_is_stream: detail.value.upstream_is_stream,
})
})
const detailOutputRate = computed(() => {
if (!detail.value) return null
return getDisplayOutputRate({
@@ -1066,11 +1070,6 @@ const detailOutputRate = computed(() => {
})
})
const hasDetailPerformanceBreakdown = computed(() => {
if (!detail.value) return false
return detail.value.first_byte_time_ms != null || detailGenerationTimeMs.value != null || detailOutputTokens.value > 0
})
// 监听标签页切换
watch(activeTab, (newTab) => {
if (!['request-headers', 'response-headers'].includes(newTab) && viewMode.value === 'compare') {
@@ -1089,12 +1088,32 @@ watch(activeTab, (newTab) => {
const { isDark } = useDarkMode()
function streamBadgeVariant(isStream: boolean): 'secondary' | 'outline' {
if (isDark.value) {
return isStream ? 'outline' : 'secondary'
}
return isStream ? 'secondary' : 'outline'
}
const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
const meta = detail.value?.metadata
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null
return meta as Record<string, unknown>
})
const traceRecord = computed<Record<string, unknown> | null>(() =>
asRecord(detail.value?.trace ?? null),
)
const traceTimelineRequestId = computed(() =>
getNestedString(traceRecord.value, 'trace_id')
?? getNestedString(traceRequestMetadata.value, 'trace_id')
?? getCaseInsensitiveString(asRecord(detail.value?.request_headers ?? null), 'x-trace-id')
?? detail.value?.request_id
?? detail.value?.id
?? null,
)
const metadataPanelData = computed<Record<string, unknown> | null>(() => {
if (!detail.value) return null
@@ -2070,8 +2089,9 @@ async function loadDetail(id: string, silent = false) {
const prevKey = previousDetail?.request_id || previousDetail?.id
const currKey = response.request_id || response.id
const sameRequest = !!prevKey && prevKey === currKey
detail.value = {
const nextDetail: RequestDetail = {
...response,
status: resolveRequestStateStatusFromDetail(response) ?? response.status,
request_body: sameRequest ? previousDetail?.request_body : undefined,
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
response_body: sameRequest ? previousDetail?.response_body : undefined,
@@ -2084,7 +2104,9 @@ async function loadDetail(id: string, silent = false) {
error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure,
}
detail.value = nextDetail
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
emitDetailRequestState(nextDetail)
// 首次加载时优先停留在轻量 tab,避免默认触发大 body 加载
if (!silent) {
@@ -2274,12 +2296,7 @@ function getTaskTypeLabel(taskType: string): string {
}
function formatNumber(num: number): string {
if (num >= 1_000_000) {
return `${(num / 1_000_000).toFixed(1) }M`
} else if (num >= 1_000) {
return `${(num / 1_000).toFixed(1) }K`
}
return num.toLocaleString()
return formatTokens(num)
}
function parseImageSizePixels(size: string | null): number | null {
@@ -2301,21 +2318,7 @@ function formatImagePriceBucket(bucket: string): string {
}
function formatPixels(value: number): string {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 2)}M px`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(0)}K px`
}
return `${value} px`
}
// 格式化响应时间,自动选择合适的单位
function formatResponseTime(ms: number): { value: string; unit: string } {
if (ms >= 1_000) {
return { value: (ms / 1_000).toFixed(2), unit: 's' }
}
return { value: ms.toString(), unit: 'ms' }
return `${formatCompactNumber(value)} px`
}
// 格式化价格,修复浮点数精度问题
@@ -6,11 +6,11 @@
<button
v-if="dropdown"
type="button"
class="flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-border/60 bg-background px-3 text-left text-xs"
class="flex h-8 w-full min-w-0 cursor-pointer items-center justify-between gap-2 rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-left text-xs text-foreground shadow-sm backdrop-blur transition-all focus:border-primary/60 focus:outline-none focus:ring-2 focus:ring-primary/40"
@click="toggleOpen"
>
<span class="truncate">{{ selectedLabel }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<ChevronDown class="h-4 w-4 shrink-0 text-muted-foreground opacity-50" />
</button>
<div
@@ -5,27 +5,51 @@
<TimeRangePicker
v-model="timeRangeModel"
:show-granularity="false"
class="hidden shrink-0 md:flex"
/>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 通用搜索 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="usage-records-search"
v-model="localSearch"
:placeholder="isAdmin ? '搜索用户/密钥' : '搜索密钥/模型'"
class="w-[7.5rem] sm:w-48 h-8 text-xs border-border/60 pl-8"
/>
<div class="order-1 flex w-full items-center gap-2 md:order-none md:w-auto">
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="usage-records-search"
v-model="localSearch"
:placeholder="isAdmin ? '搜索用户/密钥' : '搜索密钥/模型'"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 md:hidden"
:class="autoRefresh ? 'text-primary' : ''"
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'"
@click="$emit('update:autoRefresh', !autoRefresh)"
>
<RefreshCcw
class="w-3.5 h-3.5"
:class="autoRefresh ? 'animate-spin' : ''"
/>
</Button>
</div>
<div class="contents md:hidden">
<div class="order-3 grid w-full grid-cols-2 gap-2 md:hidden">
<!-- 时间范围筛选 -->
<TimeRangePicker
v-model="timeRangeModel"
:show-granularity="false"
class="min-w-0"
preset-trigger-class="!w-full"
/>
<!-- 用户筛选(仅管理员可见) -->
<ServerUserSelector
v-if="isAdmin"
class="flex-1 min-w-0 sm:flex-none sm:w-40"
class="min-w-0"
:model-value="filterUser"
:initial-users="availableUsers"
dropdown
@@ -37,7 +61,7 @@
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-40 h-8 text-xs border-border/60">
<SelectTrigger class="h-8 w-full min-w-0 text-xs border-border/60">
<SelectValue placeholder="模型" />
</SelectTrigger>
<SelectContent>
@@ -60,7 +84,7 @@
:model-value="filterProvider"
@update:model-value="$emit('update:filterProvider', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
<SelectTrigger class="h-8 w-full min-w-0 text-xs border-border/60">
<SelectValue placeholder="提供商" />
</SelectTrigger>
<SelectContent>
@@ -82,7 +106,7 @@
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
<SelectTrigger class="h-8 w-full min-w-0 text-xs border-border/60">
<SelectValue placeholder="格式" />
</SelectTrigger>
<SelectContent>
@@ -104,7 +128,7 @@
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-28 h-8 text-xs border-border/60">
<SelectTrigger class="h-8 w-full min-w-0 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
@@ -142,9 +166,10 @@
<!-- 列显示配置(桌面端) -->
<MultiSelect
v-model="visibleColumnIds"
class="hidden md:block"
:options="columnSelectOptions"
placeholder="显示列"
trigger-class="hidden md:flex w-40 h-8 text-xs border-border/60"
trigger-class="w-40 h-8 text-xs border-border/60"
dropdown-min-width="14rem"
:searchable="false"
/>
@@ -156,7 +181,7 @@
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
class="hidden h-8 w-8 shrink-0 md:inline-flex"
:class="autoRefresh ? 'text-primary' : ''"
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'"
@click="$emit('update:autoRefresh', !autoRefresh)"
@@ -187,7 +212,25 @@
<!-- 第一行:模型 + 费用 -->
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<span class="text-sm font-medium truncate block">{{ record.model }}</span>
<div class="flex min-w-0 items-center gap-1">
<span class="text-sm font-medium truncate">{{ record.model }}</span>
<Badge
v-if="getReasoningEffort(record)"
variant="outline"
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
:title="getReasoningEffortTitle(record)"
>
{{ getReasoningEffort(record) }}
</Badge>
<Badge
v-if="getFastBadge(record)"
variant="outline"
class="h-4 rounded-full px-1.5 text-[10px] leading-4 text-foreground flex-shrink-0"
:title="getFastBadgeTitle(record)"
>
fast
</Badge>
</div>
<span
v-if="getActualModel(record)"
class="text-[11px] text-muted-foreground truncate block"
@@ -236,8 +279,8 @@
</Badge>
<Badge
v-else-if="getStreamModeSegments(record).hasConversion"
:variant="getStreamModeSegments(record).client === '流式' ? 'secondary' : 'outline'"
:class="getStreamModeSegments(record).client === '流式'
:variant="streamBadgeVariant(getStreamModeSegments(record).client === '流式')"
:class="(streamBadgeVariant(getStreamModeSegments(record).client === '流式') === 'secondary')
? 'whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center gap-0.5'
: 'whitespace-nowrap border-border/60 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center gap-0.5'"
>
@@ -247,8 +290,8 @@
</Badge>
<Badge
v-else
:variant="getUpstreamStream(record) ? 'secondary' : 'outline'"
:class="getUpstreamStream(record)
:variant="streamBadgeVariant(getUpstreamStream(record))"
:class="(streamBadgeVariant(getUpstreamStream(record)) === 'secondary')
? 'whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center'
: 'whitespace-nowrap border-border/60 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center'"
>
@@ -556,7 +599,7 @@
v-if="getActualModel(record)"
class="flex flex-col text-xs gap-0.5"
>
<div class="flex items-center gap-1 truncate">
<div class="flex min-w-0 items-center gap-1">
<span class="truncate">{{ record.model }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -570,13 +613,47 @@
clip-rule="evenodd"
/>
</svg>
<Badge
v-if="getReasoningEffort(record)"
variant="outline"
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
:title="getReasoningEffortTitle(record)"
>
{{ getReasoningEffort(record) }}
</Badge>
<Badge
v-if="getFastBadge(record)"
variant="outline"
class="h-4 rounded-full px-1.5 text-[10px] leading-4 text-foreground flex-shrink-0"
:title="getFastBadgeTitle(record)"
>
fast
</Badge>
</div>
<span class="text-muted-foreground truncate">{{ getActualModel(record) }}</span>
</div>
<span
v-else
class="truncate block"
>{{ record.model }}</span>
class="flex min-w-0 items-center gap-1"
>
<span class="truncate">{{ record.model }}</span>
<Badge
v-if="getReasoningEffort(record)"
variant="outline"
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
:title="getReasoningEffortTitle(record)"
>
{{ getReasoningEffort(record) }}
</Badge>
<Badge
v-if="getFastBadge(record)"
variant="outline"
class="h-4 rounded-full px-1.5 text-[10px] leading-4 text-foreground flex-shrink-0"
:title="getFastBadgeTitle(record)"
>
fast
</Badge>
</span>
</TableCell>
<TableCell
v-if="isAdmin && isColumnVisible('provider')"
@@ -597,42 +674,20 @@
>({{ record.rate_multiplier }}x)</span>
</span>
</div>
<!-- 故障转移图标(优先显示) -->
<svg
<Shuffle
v-if="record.has_fallback"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
data-usage-attempt-marker="fallback"
class="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0"
title="此请求发生了 Provider 故障转移"
>
<path d="m16 3 4 4-4 4" />
<path d="M20 7H4" />
<path d="m8 21-4-4 4-4" />
<path d="M4 17h16" />
</svg>
<!-- 重试图标(仅在无故障转移时显示) -->
<svg
v-else-if="record.has_retry"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-label="发生 Provider 故障转移"
/>
<RefreshCcw
v-if="record.has_retry"
data-usage-attempt-marker="retry"
class="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 flex-shrink-0"
title="此请求发生了亲和缓存重试"
>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M21 21v-5h-5" />
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
title="此请求发生了重试"
aria-label="发生重试"
/>
</div>
</TableCell>
<TableCell
@@ -676,7 +731,14 @@
<TableCell v-if="isColumnVisible('status')" class="text-center py-4 w-[10%]">
<!-- 优先显示请求状态 -->
<Badge
v-if="getDisplayStatus(record) === 'pending'"
v-if="isUsageRecordFailed(record)"
variant="destructive"
class="whitespace-nowrap"
>
失败
</Badge>
<Badge
v-else-if="getDisplayStatus(record) === 'pending'"
variant="outline"
class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground"
>
@@ -689,13 +751,6 @@
>
传输中
</Badge>
<Badge
v-else-if="isUsageRecordFailed(record)"
variant="destructive"
class="whitespace-nowrap"
>
失败
</Badge>
<Badge
v-else-if="record.status === 'cancelled'"
variant="outline"
@@ -705,8 +760,8 @@
</Badge>
<Badge
v-else-if="getStreamModeSegments(record).hasConversion"
:variant="getStreamModeSegments(record).client === '流式' ? 'secondary' : 'outline'"
:class="getStreamModeSegments(record).client === '流式'
:variant="streamBadgeVariant(getStreamModeSegments(record).client === '流式')"
:class="(streamBadgeVariant(getStreamModeSegments(record).client === '流式') === 'secondary')
? 'whitespace-nowrap inline-flex items-center gap-1'
: 'whitespace-nowrap border-border/60 text-muted-foreground inline-flex items-center gap-1'"
>
@@ -716,8 +771,8 @@
</Badge>
<Badge
v-else
:variant="getUpstreamStream(record) ? 'secondary' : 'outline'"
:class="getUpstreamStream(record)
:variant="streamBadgeVariant(getUpstreamStream(record))"
:class="(streamBadgeVariant(getUpstreamStream(record)) === 'secondary')
? 'whitespace-nowrap'
: 'whitespace-nowrap border-border/60 text-muted-foreground'"
>
@@ -871,7 +926,7 @@ import {
SortableTableHead,
TableFilterMenu,
} from '@/components/ui'
import { RefreshCcw, Search } from 'lucide-vue-next'
import { RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
import {
@@ -888,7 +943,9 @@ import {
resolveUsageStreamLabelSegments
} from '../utils/status'
import { useRowClick } from '@/composables/useRowClick'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { useDarkMode } from '@/composables/useDarkMode'
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
import type { DateRangeParams, UsageRecord } from '../types'
import { MultiSelect, TimeRangePicker } from '@/components/common'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
@@ -1013,20 +1070,11 @@ const emit = defineEmits<{
'prefetchDetail': [id: string]
}>()
// 静态常量(放在 defineProps/defineEmits 之后)
const AVAILABLE_API_FORMATS = [
{ value: 'openai:chat', label: 'OpenAI Chat' },
{ value: 'openai:responses', label: 'OpenAI Responses' },
{ value: 'openai:responses:compact', label: 'OpenAI Responses Compact' },
{ value: 'openai:video', label: 'OpenAI Video' },
{ value: 'claude:messages', label: 'Claude Messages' },
{ value: 'gemini:generate_content', label: 'Gemini Generate Content' },
{ value: 'gemini:video', label: 'Gemini Video' },
{ value: 'gemini:files', label: 'Gemini Files' },
] as const
// 使用模块级常量
const availableApiFormats = AVAILABLE_API_FORMATS
// 使用统一 API 格式枚举,避免使用记录筛选项和系统格式列表漂移。
const availableApiFormats = API_FORMAT_ORDER.map((value) => ({
value,
label: formatApiFormat(value),
}))
const adminVisibleColumnIds = useLocalStorage<UsageRecordColumnId[]>(
'usage-records-visible-columns-admin',
@@ -1112,19 +1160,6 @@ const providerFilterOptions = computed<FilterOption[]>(() => [
})),
])
function formatClientFamily(value: string | null | undefined): string {
const normalized = value?.trim().toLowerCase()
if (!normalized) return '-'
if (normalized === 'codex') return 'Codex'
if (normalized === 'codex_vscode') return 'Codex VS Code'
if (normalized === 'claude_code') return 'Claude Code'
if (normalized === 'opencode') return 'OpenCode'
if (normalized === 'gemini_cli') return 'Gemini CLI'
if (normalized === 'openai_js_sdk') return 'OpenAI JS SDK'
if (normalized === 'generic') return '通用客户端'
return value?.trim() || '-'
}
const clientFamilyFilterOptions = computed<FilterOption[]>(() => {
const families = new Set<string>(props.availableClientFamilies)
props.records.forEach((record) => {
@@ -1219,6 +1254,15 @@ watch(localSearch, (value) => {
// 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
const { isDark } = useDarkMode()
// 暗色模式下交换"流式"与"标准"徽章的填充/描边样式
function streamBadgeVariant(isStream: boolean): 'secondary' | 'outline' {
if (isDark.value) {
return isStream ? 'outline' : 'secondary'
}
return isStream ? 'secondary' : 'outline'
}
function handleRowMouseDown(event: MouseEvent, id: string) {
handleMouseDown(event)
@@ -1345,12 +1389,39 @@ function getActualModel(record: UsageRecord): string | null {
return null
}
function getReasoningEffort(record: UsageRecord): string | null {
const effort = record.reasoning_effort?.trim()
return effort || null
}
function getReasoningEffortTitle(record: UsageRecord): string {
const effort = getReasoningEffort(record)
return effort ? `Reasoning: ${effort}` : ''
}
function getServiceTier(record: UsageRecord): string | null {
const serviceTier = record.service_tier?.trim().toLowerCase()
return serviceTier || null
}
function getFastBadge(record: UsageRecord): boolean {
return getServiceTier(record) === 'priority'
}
function getFastBadgeTitle(record: UsageRecord): string {
const serviceTier = getServiceTier(record)
return serviceTier ? `Service tier: ${serviceTier}` : ''
}
// 获取模型列的 tooltip
function getModelTooltip(record: UsageRecord): string {
const actualModel = getActualModel(record)
const reasoningEffort = getReasoningEffort(record)
const fastSuffix = getFastBadge(record) ? '\nService tier: priority' : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${fastSuffix}`
if (actualModel) {
return `${record.model} -> ${actualModel}`
return `${record.model} -> ${actualModel}${suffix}`
}
return record.model
return `${record.model}${suffix}`
}
</script>
@@ -481,4 +481,33 @@ describe('HorizontalRequestTimeline', () => {
expect(root.textContent).not.toContain('不再重试')
expect(root.textContent).not.toContain('该错误被标记为敏感上游错误')
})
it('keeps the failure message when upstream response only records an empty body state', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-empty-body-state',
provider_id: 'provider-empty-body-state',
provider_name: 'Provider Empty Body State',
key_id: 'key-empty-body-state',
key_name: 'Empty Body State Key',
candidate_index: 0,
status: 'failed',
error_type: 'stream_missing_terminal_event',
error_message: 'execution runtime stream ended before provider terminal event',
extra_data: {
upstream_response: {
body_state: 'none',
},
},
}),
])
const root = mountTimeline(trace)
await nextTick()
expect(root.textContent).toContain('错误信息')
expect(root.textContent).toContain('execution runtime stream ended before provider terminal event')
expect(root.querySelector('.error-block .error-json')).toBeNull()
expect(root.textContent).not.toContain('"body_state":"none"')
})
})
@@ -74,14 +74,15 @@ vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
setup(_, { attrs }) {
return () => h('span', attrs)
},
})
return {
RefreshCcw: Icon,
Search: Icon,
Shuffle: Icon,
ChevronDown: Icon,
Check: Icon,
}
@@ -180,7 +181,8 @@ describe('UsageRecordsTable', () => {
.filter((element) => element.textContent?.trim() === '100 tps')
expect(tpsElements.some((element) => element.classList.contains('text-[11px]'))).toBe(false)
const titles = [...root.querySelectorAll<HTMLElement>('[title]')].map((element) => element.title)
const titles = [...root.querySelectorAll<HTMLElement>('[title]')]
.map((element) => element.getAttribute('title'))
expect(titles).toContain([
'首字: 0.50s',
'总耗时: 1.00s',
@@ -255,6 +257,19 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).not.toContain('等待中')
})
it('shows failed instead of waiting when an active row has an HTTP error code', () => {
const root = mountUsageRecordsTable([buildRecord({
status: 'pending',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: null,
first_byte_time_ms: null,
})])
expect(root.textContent).toContain('失败')
expect(root.textContent).not.toContain('等待中')
})
it('renders output TPS in the non-admin usage table', () => {
const root = mountUsageRecordsTable([buildRecord()], { isAdmin: false })
@@ -262,4 +277,37 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('0.50s / 1.00s')
expect(root.textContent).toContain('gpt-5')
})
it('shows reasoning effort next to the model name', () => {
const root = mountUsageRecordsTable([buildRecord({ reasoning_effort: 'xhigh' })])
expect(root.textContent).toContain('gpt-5')
expect(root.textContent).toContain('xhigh')
})
it('shows fast badge for priority service tier', () => {
const root = mountUsageRecordsTable([buildRecord({ service_tier: 'priority' })])
expect(root.textContent).toContain('gpt-5')
expect(root.textContent).toContain('fast')
})
it('offers embedding API formats in the usage record filter', () => {
const root = mountUsageRecordsTable([buildRecord({ api_format: 'openai:chat' })])
expect(root.textContent).toContain('OpenAI Embedding')
expect(root.textContent).toContain('Gemini Embedding')
expect(root.textContent).toContain('Jina Embedding')
expect(root.textContent).toContain('Doubao Embedding')
})
it('shows retry and fallback markers together when both flags are set', () => {
const root = mountUsageRecordsTable([buildRecord({
has_fallback: true,
has_retry: true,
})])
expect(root.querySelector('[data-usage-attempt-marker="fallback"]')).not.toBeNull()
expect(root.querySelector('[data-usage-attempt-marker="retry"]')).not.toBeNull()
})
})
@@ -102,6 +102,47 @@ describe('useUsageData', () => {
expect(totalRecords.value).toBe(1)
})
it('keeps locally resolved failure fields when a stale active record refreshes', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'failed',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: 125_000,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'pending',
status_code: undefined,
error_message: undefined,
response_time_ms: null,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'failed',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: 125_000,
})
})
it('continues loading admin breakdowns when the summary request fails', async () => {
const isAdminPage = ref(true)
const {
@@ -13,6 +13,7 @@ import type {
import { createDefaultStats } from '../types'
import { log } from '@/utils/logger'
import { getErrorStatus } from '@/types/api-error'
import { isUsageProviderVisible, normalizeUsageProviderStats } from '../utils/providerStats'
export interface UseUsageDataOptions {
isAdminPage: Ref<boolean>
@@ -38,11 +39,6 @@ export interface FilterParams {
client_family?: string
}
function isUsageProviderVisible(provider: string | undefined | null): provider is string {
const normalized = provider?.trim().toLowerCase()
return !!normalized && !['unknown', 'unknow', 'pending'].includes(normalized)
}
export function useUsageData(options: UseUsageDataOptions) {
const { isAdminPage } = options
@@ -171,29 +167,8 @@ export function useUsageData(options: UseUsageDataOptions) {
return true
}
const visibleProviderData = providerData.filter(item => isUsageProviderVisible(item.provider))
providerStats.value = visibleProviderData.map(item => ({
providerId: item.provider_id,
providerKey: item.provider_key,
providerIdentitySource: item.provider_identity_source,
provider: item.provider,
requests: item.request_count,
totalTokens: item.total_tokens || 0,
effectiveInputTokens: item.effective_input_tokens || 0,
totalInputContext: item.total_input_context || 0,
outputTokens: item.output_tokens || 0,
cacheReadTokens: item.cache_read_tokens || 0,
cacheCreationTokens: item.cache_creation_tokens || 0,
cacheHitRate: item.cache_hit_rate || 0,
totalCost: item.total_cost,
actualCost: item.actual_cost,
successRate: item.success_rate,
avgResponseTime: item.avg_response_time_ms > 0
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
: '-'
}))
availableProviders.value = visibleProviderData.map(item => item.provider).sort()
providerStats.value = normalizeUsageProviderStats(providerData)
availableProviders.value = providerStats.value.map(item => item.provider).sort()
} catch (error) {
if (requestId !== loadStatsRequestId) {
return true
@@ -534,6 +509,9 @@ export function useUsageData(options: UseUsageDataOptions) {
actual_cost: existing.actual_cost ?? record.actual_cost,
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
status_code: existing.status_code ?? record.status_code,
error_message: existing.error_message ?? record.error_message,
image_progress: existing.image_progress ?? record.image_progress,
is_stream: upstreamIsStream,
upstream_is_stream: upstreamIsStream,
client_requested_stream: clientRequestedStream,
@@ -545,7 +523,9 @@ export function useUsageData(options: UseUsageDataOptions) {
api_key_name: existing.api_key_name || record.api_key_name,
provider_key_name: existing.provider_key_name || record.provider_key_name,
rate_multiplier: existing.rate_multiplier ?? record.rate_multiplier,
target_model: existing.target_model || record.target_model
target_model: existing.target_model || record.target_model,
reasoning_effort: existing.reasoning_effort || record.reasoning_effort,
service_tier: existing.service_tier || record.service_tier
}
}
+2
View File
@@ -96,6 +96,8 @@ export interface UsageRecord {
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
api_format?: string
endpoint_api_format?: string // 端点原生格式
has_format_conversion?: boolean // 是否发生了格式转换
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { formatClientFamily } from '../clientFamily'
describe('formatClientFamily', () => {
it('labels supported clients and SDKs', () => {
expect(formatClientFamily('qwen_code')).toBe('Qwen Code')
expect(formatClientFamily('roo_code')).toBe('Roo Code')
expect(formatClientFamily('kilocode')).toBe('KiloCode')
expect(formatClientFamily('cherrystudio')).toBe('Cherry Studio')
expect(formatClientFamily('openui')).toBe('OpenUI')
expect(formatClientFamily('openai_python_sdk')).toBe('OpenAI Python SDK')
expect(formatClientFamily('anthropic_js_sdk')).toBe('Anthropic JS SDK')
})
it('renders unrecognized families as unknown', () => {
expect(formatClientFamily(null)).toBe('unknown')
expect(formatClientFamily('')).toBe('unknown')
expect(formatClientFamily('custom-client')).toBe('unknown')
})
})
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { isUsageProviderVisible, normalizeUsageProviderStats } from '../providerStats'
describe('usage provider stats normalization', () => {
it('maps admin provider aggregation fields to table fields', () => {
const rows = normalizeUsageProviderStats([
{
provider_id: 'provider-openai',
provider_key: 'provider-openai',
provider_identity_source: 'provider_id',
provider: 'OpenAI',
request_count: 12,
total_tokens: 3456,
effective_input_tokens: 1200,
total_input_context: 1600,
output_tokens: 2256,
cache_read_tokens: 240,
cache_creation_tokens: 60,
cache_hit_rate: 15,
total_cost: 0.123456,
actual_cost: 0.2,
avg_response_time_ms: 1250,
success_rate: 91.67,
error_count: 1,
},
])
expect(rows).toEqual([
{
providerId: 'provider-openai',
providerKey: 'provider-openai',
providerIdentitySource: 'provider_id',
provider: 'OpenAI',
requests: 12,
totalTokens: 3456,
effectiveInputTokens: 1200,
totalInputContext: 1600,
outputTokens: 2256,
cacheReadTokens: 240,
cacheCreationTokens: 60,
cacheHitRate: 15,
totalCost: 0.123456,
actualCost: 0.2,
successRate: 91.67,
avgResponseTime: '1.25s',
},
])
})
it('filters placeholder providers', () => {
expect(isUsageProviderVisible('OpenAI')).toBe(true)
expect(isUsageProviderVisible('unknown')).toBe(false)
expect(isUsageProviderVisible('unknow')).toBe(false)
expect(isUsageProviderVisible('pending')).toBe(false)
expect(isUsageProviderVisible(' ')).toBe(false)
})
})
@@ -0,0 +1,29 @@
export function formatClientFamily(value: string | null | undefined): string {
const normalized = value?.trim().toLowerCase()
if (!normalized) return 'unknown'
if (normalized === 'codex') return 'Codex'
if (normalized === 'codex_vscode') return 'Codex VS Code'
if (normalized === 'claude_code') return 'Claude Code'
if (normalized === 'opencode') return 'OpenCode'
if (normalized === 'gemini_cli') return 'Gemini CLI'
if (normalized === 'openai_js_sdk') return 'OpenAI JS SDK'
if (normalized === 'openai_python_sdk') return 'OpenAI Python SDK'
if (normalized === 'anthropic_js_sdk') return 'Anthropic JS SDK'
if (normalized === 'anthropic_python_sdk') return 'Anthropic Python SDK'
if (normalized === 'qwen_code') return 'Qwen Code'
if (normalized === 'roo_code') return 'Roo Code'
if (normalized === 'kilocode') return 'KiloCode'
if (normalized === 'cherrystudio') return 'Cherry Studio'
if (normalized === 'openui') return 'OpenUI'
if (normalized === 'cursor') return 'Cursor'
if (normalized === 'windsurf') return 'Windsurf'
if (normalized === 'continue') return 'Continue'
if (normalized === 'cline') return 'Cline'
if (normalized === 'aider') return 'Aider'
if (normalized === 'langchain') return 'LangChain'
if (normalized === 'llamaindex') return 'LlamaIndex'
if (normalized === 'sdk') return 'SDK'
if (normalized === 'generic') return 'generic'
if (normalized === 'unknown') return 'unknown'
return 'unknown'
}
@@ -0,0 +1,41 @@
import type { UsageByProvider } from '@/api/usage'
import type { ProviderStatsItem } from '../types'
function metricValue(value: number | null | undefined): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
export function isUsageProviderVisible(provider: string | undefined | null): provider is string {
const normalized = provider?.trim().toLowerCase()
return !!normalized && !['unknown', 'unknow', 'pending'].includes(normalized)
}
export function formatProviderAverageResponseTime(avgResponseTimeMs: number | null | undefined): string {
const value = metricValue(avgResponseTimeMs)
return value > 0 ? `${(value / 1000).toFixed(2)}s` : '-'
}
export function normalizeUsageProviderStats(providerData: UsageByProvider[]): ProviderStatsItem[] {
return providerData
.filter(item => isUsageProviderVisible(item.provider))
.map(item => ({
providerId: item.provider_id,
providerKey: item.provider_key,
providerIdentitySource: item.provider_identity_source,
provider: item.provider,
requests: metricValue(item.request_count),
totalTokens: metricValue(item.total_tokens),
effectiveInputTokens: metricValue(item.effective_input_tokens),
totalInputContext: metricValue(item.total_input_context),
outputTokens: metricValue(item.output_tokens),
cacheReadTokens: metricValue(item.cache_read_tokens),
cacheCreationTokens: metricValue(item.cache_creation_tokens),
cacheHitRate: metricValue(item.cache_hit_rate),
totalCost: metricValue(item.total_cost),
actualCost: typeof item.actual_cost === 'number' && Number.isFinite(item.actual_cost)
? item.actual_cost
: undefined,
successRate: metricValue(item.success_rate),
avgResponseTime: formatProviderAverageResponseTime(item.avg_response_time_ms),
}))
}