mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
Merge origin/main into fix/gemini-cli-v1internal
This commit is contained in:
@@ -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
|
||||
|
||||
+5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user