Merge upstream/main into feat/356-usage-record-columns

This commit is contained in:
RWDai
2026-05-18 20:33:05 +08:00
95 changed files with 14199 additions and 2756 deletions

View File

@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock, cachedRequestMock, dedupedRequestMock, buildCacheKeyMock } = vi.hoisted(() => ({
getMock: vi.fn(),
cachedRequestMock: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
dedupedRequestMock: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
buildCacheKeyMock: vi.fn(() => 'cache-key'),
}))
vi.mock('@/api/client', () => ({
default: {
get: getMock,
},
}))
vi.mock('@/utils/cache', () => ({
cachedRequest: cachedRequestMock,
dedupedRequest: dedupedRequestMock,
buildCacheKey: buildCacheKeyMock,
}))
import { usageApi } from '@/api/usage'
describe('usageApi contract alignment', () => {
beforeEach(() => {
getMock.mockReset()
cachedRequestMock.mockClear()
dedupedRequestMock.mockClear()
buildCacheKeyMock.mockClear()
})
it('loads current-user usage records from the Rust usage endpoint and normalizes pagination', async () => {
getMock.mockResolvedValueOnce({
data: {
records: [{ id: 'record-1' }],
pagination: {
total: 42,
limit: 10,
offset: 10,
has_more: true,
},
},
})
const result = await usageApi.getUsageRecords({
page: 2,
page_size: 10,
start_date: '2026-05-01',
end_date: '2026-05-16',
})
expect(getMock).toHaveBeenCalledWith('/api/users/me/usage', {
params: {
limit: 10,
offset: 10,
start_date: '2026-05-01',
end_date: '2026-05-16',
},
})
expect(result).toEqual({
records: [{ id: 'record-1' }],
total: 42,
page: 2,
page_size: 10,
})
})
it('loads admin usage for a specific user from admin usage endpoints', async () => {
getMock
.mockResolvedValueOnce({
data: {
total_requests: 7,
total_tokens: 99,
total_cost: 12.34,
avg_response_time: 456,
},
})
.mockResolvedValueOnce({
data: {
records: [{ id: 'record-2' }],
total: 1,
limit: 25,
offset: 0,
},
})
const result = await usageApi.getUserUsage('user-123', {
page: 1,
page_size: 25,
model: 'gpt-5.5',
})
expect(getMock).toHaveBeenNthCalledWith(1, '/api/admin/usage/stats', {
params: {
user_id: 'user-123',
model: 'gpt-5.5',
},
})
expect(getMock).toHaveBeenNthCalledWith(2, '/api/admin/usage/records', {
params: {
user_id: 'user-123',
limit: 25,
offset: 0,
model: 'gpt-5.5',
},
})
expect(result).toEqual({
records: [{ id: 'record-2' }],
stats: {
total_requests: 7,
total_tokens: 99,
total_cost: 12.34,
avg_response_time: 456,
},
})
})
})

View File

@@ -142,6 +142,17 @@ export interface RequestErrorFlow {
summary_source?: string | null
}
export interface RequestSchedulingFailure {
source?: string | null
reason?: string | null
reason_label?: string | null
title?: string | null
message?: string | null
reason_summary?: string | null
status_code?: number | null
no_upstream_attempt?: boolean | null
}
export interface RequestDetail {
id: string // UUID
request_id: string
@@ -206,6 +217,7 @@ export interface RequestDetail {
failure_summary?: RequestErrorDomain | null
errors?: RequestErrorDomains | null
error_flow?: RequestErrorFlow | null
scheduling_failure?: RequestSchedulingFailure | null
response_time_ms: number
first_byte_time_ms?: number | null
created_at: string
@@ -366,7 +378,7 @@ export interface TimeRangeParams {
export const dashboardApi = {
// 获取仪表盘统计数据
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
const cacheKey = buildCacheKey('dashboard:stats', params)
const cacheKey = buildCacheKey('dashboard:stats', params as Record<string, unknown> | undefined)
return cachedRequest(
cacheKey,
async () => {
@@ -427,7 +439,7 @@ export const dashboardApi = {
// 获取每日统计数据
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
const cacheKey = buildCacheKey('dashboard:daily-stats', params as Record<string, unknown> | undefined)
return cachedRequest(
cacheKey,
async () => {

View File

@@ -59,6 +59,7 @@ export interface Model {
global_model_display_name?: string
// 有效配置(合并 Model 和 GlobalModel 的 config
effective_config?: Record<string, unknown> | null
model_test_capabilities?: ModelTestCapabilities | null
}
export interface ModelCreate {
@@ -102,6 +103,17 @@ export interface ModelCapabilities {
[key: string]: boolean
}
export interface OpenAiImageModelTestCapability {
max_generation_count?: number | null
supports_generation?: boolean | null
supports_edit?: boolean | null
}
export interface ModelTestCapabilities {
'openai:image'?: OpenAiImageModelTestCapability | null
[apiFormat: string]: OpenAiImageModelTestCapability | Record<string, unknown> | null | undefined
}
export interface ProviderModelPriceInfo {
input_price_per_1m?: number | null
output_price_per_1m?: number | null
@@ -248,6 +260,7 @@ export interface UpstreamModel {
owned_by?: string
display_name?: string
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
model_test_capabilities?: ModelTestCapabilities | null
}
/**

View File

@@ -105,6 +105,7 @@ export interface UsageFilters {
user_id?: string // UUID
provider_id?: string // UUID
model?: string
search?: string
start_date?: string
end_date?: string
preset?: string
@@ -116,6 +117,172 @@ export interface UsageFilters {
page_size?: number
}
type UsageListResponse = {
records?: unknown
pagination?: {
total?: unknown
limit?: unknown
offset?: unknown
}
total?: unknown
limit?: unknown
offset?: unknown
}
function assertPositiveInteger(value: number, field: string): number {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`${field} must be a positive integer`)
}
return value
}
function assertNonNegativeInteger(value: number, field: string): number {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${field} must be a non-negative integer`)
}
return value
}
function assertNumber(value: unknown, field: string): number {
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new Error(`Usage response is missing numeric ${field}`)
}
return value
}
function assertUsageRecords(value: unknown): UsageRecord[] {
if (!Array.isArray(value)) {
throw new Error('Usage response is missing records array')
}
return value as UsageRecord[]
}
function compactParams(params: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '')
)
}
function offsetPaginationFromPage(filters?: Pick<UsageFilters, 'page' | 'page_size'>): {
page: number
pageSize: number | undefined
offset: number | undefined
} {
const page = assertPositiveInteger(filters?.page ?? 1, 'page')
if (filters?.page_size === undefined) {
return { page, pageSize: undefined, offset: undefined }
}
const pageSize = assertPositiveInteger(filters.page_size, 'page_size')
return {
page,
pageSize,
offset: assertNonNegativeInteger((page - 1) * pageSize, 'offset'),
}
}
function normalizeUsageRecordPage(
payload: UsageListResponse,
requested: { page: number; pageSize?: number; offset?: number }
): {
records: UsageRecord[]
total: number
page: number
page_size: number
} {
const records = assertUsageRecords(payload.records)
const pagination = payload.pagination
const total = assertNumber(pagination?.total ?? payload.total, 'pagination.total')
const limit = assertPositiveInteger(
assertNumber(pagination?.limit ?? payload.limit, 'pagination.limit'),
'pagination.limit'
)
const offset = assertNonNegativeInteger(
assertNumber(pagination?.offset ?? payload.offset, 'pagination.offset'),
'pagination.offset'
)
const resolvedPage = requested.pageSize !== undefined
? requested.page
: Math.floor(offset / limit) + 1
return {
records,
total,
page: resolvedPage,
page_size: limit,
}
}
function buildCurrentUserUsageParams(filters?: UsageFilters): {
params: Record<string, unknown>
pagination: { page: number; pageSize?: number; offset?: number }
} {
if (filters?.user_id || filters?.provider_id || filters?.model || filters?.granularity) {
throw new Error('getUsageRecords only supports current-user usage filters; use admin usage APIs for user/model/provider filters')
}
const pagination = offsetPaginationFromPage(filters)
return {
pagination,
params: compactParams({
start_date: filters?.start_date,
end_date: filters?.end_date,
preset: filters?.preset,
timezone: filters?.timezone,
tz_offset_minutes: filters?.tz_offset_minutes,
search: filters?.search,
limit: pagination.pageSize,
offset: pagination.offset,
}),
}
}
function buildAdminUsageRecordParams(userId: string, filters?: UsageFilters): {
params: Record<string, unknown>
} {
if (!userId.trim()) {
throw new Error('getUserUsage requires a non-empty user id')
}
if (filters?.provider_id || filters?.granularity) {
throw new Error('getUserUsage does not support provider_id or granularity filters')
}
const pagination = offsetPaginationFromPage(filters)
return {
params: compactParams({
user_id: userId,
start_date: filters?.start_date,
end_date: filters?.end_date,
preset: filters?.preset,
timezone: filters?.timezone,
tz_offset_minutes: filters?.tz_offset_minutes,
search: filters?.search,
model: filters?.model,
limit: pagination.pageSize,
offset: pagination.offset,
}),
}
}
function buildAdminUsageStatsParams(userId: string, filters?: UsageFilters): Record<string, unknown> {
if (!userId.trim()) {
throw new Error('getUserUsage requires a non-empty user id')
}
if (filters?.provider_id || filters?.granularity) {
throw new Error('getUserUsage stats does not support provider_id or granularity filters')
}
return compactParams({
user_id: userId,
start_date: filters?.start_date,
end_date: filters?.end_date,
preset: filters?.preset,
timezone: filters?.timezone,
tz_offset_minutes: filters?.tz_offset_minutes,
model: filters?.model,
})
}
function normalizeActivityHeatmapResponse(payload: unknown): ActivityHeatmap {
const today = new Date()
const endDate = today.toISOString().slice(0, 10)
@@ -190,8 +357,9 @@ export const usageApi = {
page: number
page_size: number
}> {
const response = await apiClient.get('/api/usage', { params: filters })
return response.data
const { params, pagination } = buildCurrentUserUsageParams(filters)
const response = await apiClient.get<UsageListResponse>('/api/users/me/usage', { params })
return normalizeUsageRecordPage(response.data, pagination)
},
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
@@ -250,16 +418,17 @@ export const usageApi = {
records: UsageRecord[]
stats: UsageStats
}> {
const response = await apiClient.get(`/api/users/${userId}/usage`, { params: filters })
return response.data
},
const statsParams = buildAdminUsageStatsParams(userId, filters)
const { params: recordParams } = buildAdminUsageRecordParams(userId, filters)
const [statsResponse, recordsResponse] = await Promise.all([
apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams }),
apiClient.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams }),
])
async exportUsage(format: 'csv' | 'json', filters?: UsageFilters): Promise<Blob> {
const response = await apiClient.get('/api/usage/export', {
params: { ...filters, format },
responseType: 'blob'
})
return response.data
return {
records: assertUsageRecords(recordsResponse.data.records),
stats: statsResponse.data,
}
},
async getAllUsageRecords(params?: {

View File

@@ -16,6 +16,27 @@
v-if="!isEditMode"
class="w-[260px] shrink-0 flex flex-col h-full"
>
<!-- 手动添加入口 -->
<button
type="button"
class="mb-3 w-full rounded-lg border px-3 py-2 text-left transition-colors"
:class="manualModelMode
? 'border-primary bg-primary/10 text-primary'
: 'border-border/60 bg-muted/20 hover:bg-muted/40'"
@click="enableManualModelMode"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium">手动添加模型</span>
<Plus class="h-4 w-4 shrink-0" />
</div>
<p
class="mt-1 text-xs"
:class="manualModelMode ? 'text-primary/80' : 'text-muted-foreground'"
>
无法联网获取目录时直接填写模型 ID 继续创建
</p>
</button>
<!-- 搜索框 -->
<div class="relative mb-3">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -88,7 +109,7 @@
v-if="groupedModels.length === 0"
class="text-center py-8 text-sm text-muted-foreground"
>
{{ searchQuery ? '未找到模型' : '加载中...' }}
{{ emptyModelListText }}
</div>
</template>
</div>
@@ -108,6 +129,12 @@
<h4 class="font-medium text-sm">
基本信息
</h4>
<div
v-if="manualModelMode && !isEditMode"
class="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-muted-foreground"
>
当前为手动添加模式填写模型 ID名称和价格后即可离线创建统一模型稍后可在模型详情中关联 Provider
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label
@@ -343,7 +370,7 @@
{{ isEditMode ? '保存' : '添加' }}
</Button>
<Button
v-if="selectedModel && !isEditMode"
v-if="(selectedModel || manualModelMode) && !isEditMode"
type="button"
variant="ghost"
@click="clearSelection"
@@ -382,6 +409,7 @@ import {
EMBEDDING_API_FORMATS,
buildGlobalModelCreatePayload,
buildGlobalModelUpdatePayload,
getModelDirectoryEmptyText,
} from './global-model-form-helpers'
const props = defineProps<{
@@ -404,6 +432,8 @@ const searchQuery = ref('')
const allModelsCache = ref<ModelsDevModelItem[]>([]) // 全部模型(缓存)
const selectedModel = ref<ModelsDevModelItem | null>(null)
const expandedProvider = ref<string | null>(null)
const manualModelMode = ref(false)
const modelListLoadFailed = ref(false)
// 当前显示的模型列表:有搜索词时用全部,否则只用官方
const allModels = computed(() => {
@@ -466,6 +496,14 @@ const groupedModels = computed(() => {
return result
})
const emptyModelListText = computed(() => {
return getModelDirectoryEmptyText({
searchQuery: searchQuery.value,
manualModelMode: manualModelMode.value,
modelListLoadFailed: modelListLoadFailed.value,
})
})
// 搜索时如果只有一个提供商,自动展开
watch(groupedModels, (groups) => {
if (searchQuery.value && groups.length === 1) {
@@ -478,6 +516,16 @@ function toggleProvider(providerId: string) {
expandedProvider.value = expandedProvider.value === providerId ? null : providerId
}
function enableManualModelMode() {
manualModelMode.value = true
selectedModel.value = null
expandedProvider.value = null
searchQuery.value = ''
if (!form.value.name && !form.value.display_name) {
form.value = defaultForm()
}
}
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)
@@ -710,11 +758,15 @@ function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
async function loadModels() {
if (allModelsCache.value.length > 0) return
loading.value = true
modelListLoadFailed.value = false
try {
// 只加载一次全部模型,过滤在 computed 中完成
allModelsCache.value = await getModelsDevList(false)
} catch (err) {
log.error('Failed to load models:', err)
modelListLoadFailed.value = true
enableManualModelMode()
showError('模型目录加载失败,已切换到手动添加模式,可离线继续创建')
} finally {
loading.value = false
}
@@ -729,6 +781,7 @@ watch(() => props.open, (isOpen) => {
// 选择模型并填充表单
function selectModel(model: ModelsDevModelItem) {
manualModelMode.value = false
selectedModel.value = model
expandedProvider.value = model.providerId
form.value.name = model.modelId
@@ -774,6 +827,7 @@ function selectModel(model: ModelsDevModelItem) {
// 清除选择(手动填写)
function clearSelection() {
manualModelMode.value = false
selectedModel.value = null
form.value = defaultForm()
tieredPricing.value = null
@@ -793,6 +847,8 @@ function resetForm() {
searchQuery.value = ''
selectedModel.value = null
expandedProvider.value = null
manualModelMode.value = false
modelListLoadFailed.value = false
}
// 加载模型数据(编辑模式)
@@ -828,6 +884,14 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
resetForm,
})
watch(() => form.value.name, (name) => {
if (!manualModelMode.value || isEditMode.value) return
const modelName = name.trim()
if (modelName && !form.value.display_name.trim()) {
form.value.display_name = modelName
}
})
async function handleSubmit() {
if (!form.value.name || !form.value.display_name) {
showError('请填写模型ID和名称')

View File

@@ -4,6 +4,7 @@ import {
EMBEDDING_API_FORMATS,
buildGlobalModelCreatePayload,
buildGlobalModelUpdatePayload,
getModelDirectoryEmptyText,
} from '../global-model-form-helpers'
const embeddingPricing = {
@@ -59,4 +60,26 @@ describe('global model form embedding payload helpers', () => {
api_formats: ['jina:embedding'],
})
})
it('surfaces manual-add guidance when the online model directory is unavailable', () => {
expect(getModelDirectoryEmptyText({
searchQuery: '',
manualModelMode: false,
modelListLoadFailed: true,
})).toBe('模型目录加载失败,请使用手动添加继续创建')
expect(getModelDirectoryEmptyText({
searchQuery: '',
manualModelMode: true,
modelListLoadFailed: false,
})).toBe('已切换到手动添加,可在右侧填写模型信息')
})
it('keeps search empty state ahead of manual/offline guidance', () => {
expect(getModelDirectoryEmptyText({
searchQuery: 'local-model',
manualModelMode: true,
modelListLoadFailed: true,
})).toBe('未找到模型')
})
})

View File

@@ -22,6 +22,19 @@ export interface GlobalModelFormPayloadState {
is_active?: boolean
}
export interface ModelDirectoryEmptyTextState {
searchQuery: string
manualModelMode: boolean
modelListLoadFailed: boolean
}
export function getModelDirectoryEmptyText(state: ModelDirectoryEmptyTextState): string {
if (state.searchQuery) return '未找到模型'
if (state.modelListLoadFailed) return '模型目录加载失败,请使用手动添加继续创建'
if (state.manualModelMode) return '已切换到手动添加,可在右侧填写模型信息'
return '加载中...'
}
function cleanGlobalModelConfig(form: GlobalModelFormPayloadState): Record<string, unknown> | undefined {
return form.config && Object.keys(form.config).length > 0 ? form.config : undefined
}

View File

@@ -11,27 +11,13 @@
class="space-y-4"
@submit.prevent="handleSubmit"
>
<!-- 添加模式选择或手动创建本地全局模型 -->
<!-- 添加模式选择本地全局模型 -->
<div
v-if="!isEditing"
class="space-y-3"
>
<div class="flex items-center justify-between gap-3">
<Label for="global-model">选择已有模型或手动添加 *</Label>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
@click="manualGlobalModelMode = !manualGlobalModelMode"
>
{{ manualGlobalModelMode ? '选择已有模型' : '手动添加' }}
</Button>
</div>
<div
v-if="!manualGlobalModelMode"
class="space-y-2"
>
<div class="space-y-1.5">
<Label for="global-model">选择已有模型 *</Label>
<Select
:model-value="form.global_model_id"
:disabled="loadingGlobalModels"
@@ -51,44 +37,11 @@
</SelectContent>
</Select>
</div>
<div
v-else
class="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3"
>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label
for="manual-global-model-name"
class="text-xs"
>模型ID *</Label>
<Input
id="manual-global-model-name"
v-model="form.manual_global_model_name"
placeholder="如 gpt-4o-mini"
@update:model-value="syncManualProviderName"
/>
</div>
<div class="space-y-1.5">
<Label
for="manual-global-model-display-name"
class="text-xs"
>显示名称</Label>
<Input
id="manual-global-model-display-name"
v-model="form.manual_global_model_display_name"
placeholder="默认使用模型ID"
/>
</div>
</div>
<p class="text-xs text-muted-foreground">
无法联网获取模型目录时可直接填写模型ID保存时会先创建本地全局模型再添加到当前 Provider
</p>
</div>
<p
v-if="availableGlobalModels.length === 0 && !loadingGlobalModels && !manualGlobalModelMode"
v-if="availableGlobalModels.length === 0 && !loadingGlobalModels"
class="text-xs text-muted-foreground"
>
没有可选择的本地全局模型可以切换到手动添加继续保存
没有可选择的本地全局模型请先在模型管理中添加全局模型
</p>
<div class="space-y-1.5">
<Label
@@ -300,7 +253,7 @@ import {
import { useToast } from '@/composables/useToast'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
import { createGlobalModel, listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
import type { Model, TieredPricingConfig } from '@/api/endpoints'
import {
@@ -349,7 +302,6 @@ const showCache1h = true
const submitting = ref(false)
const loadingGlobalModels = ref(false)
const availableGlobalModels = ref<GlobalModelResponse[]>([])
const manualGlobalModelMode = ref(false)
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)
@@ -384,15 +336,9 @@ const VIDEO_RESOLUTION_PRICE_PRESETS: Record<
],
}
const DEFAULT_MANUAL_GLOBAL_MODEL_PRICING: TieredPricingConfig = {
tiers: [{ up_to: null, input_price_per_1m: 0, output_price_per_1m: 0 }],
}
const form = ref({
global_model_id: '',
provider_model_name: '',
manual_global_model_name: '',
manual_global_model_display_name: '',
price_per_request: undefined as number | undefined,
config: {} as Record<string, unknown>,
// 能力配置
@@ -407,7 +353,6 @@ const form = ref({
const canSubmitCreate = computed(() => {
if (isEditing.value) return true
if (!form.value.provider_model_name.trim()) return false
if (manualGlobalModelMode.value) return !!form.value.manual_global_model_name.trim()
return !!form.value.global_model_id
})
@@ -422,8 +367,6 @@ watch(() => props.open, async (newOpen) => {
form.value = {
global_model_id: props.editingModel.global_model_id || '',
provider_model_name: props.editingModel.provider_model_name || '',
manual_global_model_name: '',
manual_global_model_display_name: '',
// 显示有效的按次计费价格(继承自全局模型)
price_per_request: props.editingModel.effective_price_per_request ?? props.editingModel.price_per_request ?? undefined,
config: effectiveConfig ? JSON.parse(JSON.stringify(effectiveConfig)) : {},
@@ -485,8 +428,6 @@ function resetForm() {
form.value = {
global_model_id: '',
provider_model_name: '',
manual_global_model_name: '',
manual_global_model_display_name: '',
price_per_request: undefined,
config: {},
supports_vision: undefined,
@@ -502,7 +443,6 @@ function resetForm() {
tieredPricingModified.value = false
originalTieredPricing.value = ''
availableGlobalModels.value = []
manualGlobalModelMode.value = false
}
function handleGlobalModelSelect(value: string) {
@@ -511,16 +451,6 @@ function handleGlobalModelSelect(value: string) {
form.value.provider_model_name = selectedModel?.name || form.value.provider_model_name
}
function syncManualProviderName(value: string | number) {
const modelName = String(value || '').trim()
if (!form.value.provider_model_name.trim()) {
form.value.provider_model_name = modelName
}
if (!form.value.manual_global_model_display_name.trim()) {
form.value.manual_global_model_display_name = modelName
}
}
function getNested(obj: Record<string, unknown>, path: string): unknown {
if (!obj || typeof obj !== 'object') return undefined
const parts = path.split('.').filter(Boolean)
@@ -657,28 +587,6 @@ function _copyVideoPricingFromSelectedGlobal() {
configTouched.value = true
}
async function createManualGlobalModel(finalTieredPricing: TieredPricingConfig | null, cleanConfig: Record<string, unknown> | undefined): Promise<GlobalModelResponse> {
const modelName = form.value.manual_global_model_name.trim()
const displayName = form.value.manual_global_model_display_name.trim() || modelName
const supportedCapabilities = [
form.value.supports_vision === true ? 'vision' : null,
form.value.supports_function_calling === true ? 'function_calling' : null,
form.value.supports_streaming === true ? 'streaming' : null,
form.value.supports_extended_thinking === true ? 'extended_thinking' : null,
form.value.supports_image_generation === true ? 'image_generation' : null,
].filter((capability): capability is string => capability !== null)
return createGlobalModel({
name: modelName,
display_name: displayName,
default_price_per_request: form.value.price_per_request,
default_tiered_pricing: finalTieredPricing || DEFAULT_MANUAL_GLOBAL_MODEL_PRICING,
supported_capabilities: supportedCapabilities.length ? supportedCapabilities : undefined,
config: cleanConfig,
is_active: true,
})
}
// 加载可用的全局模型(排除已添加的)
async function loadAvailableGlobalModels() {
loadingGlobalModels.value = true
@@ -716,7 +624,7 @@ function handleClose(value: boolean) {
async function handleSubmit() {
if (submitting.value) return
if (!isEditing.value && !canSubmitCreate.value) {
showError(manualGlobalModelMode.value ? '请填写模型ID和 Provider 模型名' : '请选择模型并填写 Provider 模型名', '错误')
showError('请选择模型并填写 Provider 模型名', '错误')
return
}
@@ -749,21 +657,19 @@ async function handleSubmit() {
showSuccess('模型配置已更新')
} else {
// 添加模式:只有用户修改了配置才提交 tiered_pricing否则保持继承关系
const selectedModel = manualGlobalModelMode.value
? await createManualGlobalModel(finalTieredPricing, cleanConfig)
: availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
if (!selectedModel) {
showError('请选择模型或切换到手动添加后填写模型ID', '错误')
showError('请选择模型', '错误')
return
}
await createModel(props.providerId, buildProviderModelCreatePayload({
globalModelId: selectedModel.id,
providerModelName: form.value.provider_model_name.trim(),
finalTieredPricing,
tieredPricingModified: manualGlobalModelMode.value ? false : tieredPricingModified.value,
pricePerRequest: manualGlobalModelMode.value ? undefined : form.value.price_per_request,
tieredPricingModified: tieredPricingModified.value,
pricePerRequest: form.value.price_per_request,
cleanConfig,
configTouched: manualGlobalModelMode.value ? false : configTouched.value,
configTouched: configTouched.value,
supportsVision: form.value.supports_vision,
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,

View File

@@ -49,7 +49,7 @@ describe('provider model form embedding helpers', () => {
expect('supports_embedding' in payload).toBe(false)
})
it('uses manually supplied provider model name in create payload', () => {
it('uses supplied provider model name in create payload', () => {
const payload = buildProviderModelCreatePayload({
globalModelId: 'gm-local-manual',
providerModelName: 'intranet-chat-model-v1',

View File

@@ -374,6 +374,7 @@ import {
isModelTestableEndpoint,
parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft,
selectPreferredModelTestEndpoint,
syncModelTestRequestBodyDraft,
} from './model-test-request'
@@ -428,6 +429,7 @@ const deletingGroup = ref<AliasGroup | null>(null)
const testingMapping = ref<string | null>(null)
const pendingMappingKey = ref<string | null>(null)
const testingModelName = ref<string | null>(null)
const testingSourceModel = ref<Model | null>(null)
const preselectedModelId = ref<string | null>(null)
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
const testRequestHeadersDraft = ref('')
@@ -715,6 +717,7 @@ function handleTestDialogClose() {
modelTest.resetState()
pendingMappingKey.value = null
testingModelName.value = null
testingSourceModel.value = null
testingMapping.value = null
selectedTestEndpoint.value = null
mappingTestEndpoints.value = null
@@ -737,8 +740,25 @@ function handleSelectTestEndpoint(endpointId: string) {
syncMappingTestRequestBody()
}
function findMappingTestModel(modelName: string): Model | null {
const normalized = modelName.trim()
if (!normalized) return null
return models.value.find(model => (
model.provider_model_name === normalized
|| model.global_model_name === normalized
|| model.global_model_display_name === normalized
|| (model.provider_model_mappings ?? []).some(alias => alias.name === normalized)
)) ?? null
}
// 测试映射(直连测试,带故障转移和实时进度)
function runMappingTest(testingKey: string, modelName: string, endpointsOverride?: ProviderEndpoint[]) {
function runMappingTest(
testingKey: string,
modelName: string,
endpointsOverride?: ProviderEndpoint[],
sourceModel?: Model | null,
) {
const endpoints = endpointsOverride ?? activeEndpoints.value
if (endpoints.length === 0) {
showError('暂无可用于测试的活跃端点')
@@ -749,8 +769,12 @@ function runMappingTest(testingKey: string, modelName: string, endpointsOverride
modelTest.dialogOpen.value = true
testingMapping.value = null
testingModelName.value = modelName
testingSourceModel.value = sourceModel ?? findMappingTestModel(modelName)
mappingTestEndpoints.value = endpointsOverride ?? null
selectedTestEndpoint.value = endpoints[0] ?? null
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(
testingSourceModel.value,
endpoints,
)
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
resetMappingTestRequestBody()
@@ -762,6 +786,7 @@ function resetMappingTestRequestBody() {
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
testingModelName.value,
selectedTestEndpoint.value?.api_format,
testingSourceModel.value,
)
testRequestBodyDraft.value = testRequestBodyResetValue.value
}
@@ -772,6 +797,7 @@ function syncMappingTestRequestBody() {
const nextResetValue = buildDefaultModelTestRequestBody(
testingModelName.value,
selectedTestEndpoint.value?.api_format,
testingSourceModel.value,
)
const next = syncModelTestRequestBodyDraft(
testRequestBodyDraft.value,
@@ -839,7 +865,7 @@ function scopedMappingEndpoints(item: CombinedMapping): ProviderEndpoint[] {
// 测试精确映射
function testMapping(item: CombinedMapping, mapping: MappingItem) {
runMappingTest(`${item.key}-${mapping.name}`, mapping.name, scopedMappingEndpoints(item))
runMappingTest(`${item.key}-${mapping.name}`, mapping.name, scopedMappingEndpoints(item), item.group?.model)
}
// 测试正则映射

View File

@@ -444,7 +444,27 @@
{{ attempt.key_name || maskKey(attempt.key_id) }}
</div>
<div
v-if="attemptDetail(attempt) !== '-'"
v-if="attemptImagePreviews(attempt).length > 0"
class="mt-2 flex flex-wrap gap-2"
>
<button
v-for="(preview, imageIndex) in attemptImagePreviews(attempt).slice(0, 3)"
:key="`${preview.src}-${imageIndex}`"
type="button"
class="h-16 w-16 overflow-hidden rounded-md border border-border/60 bg-muted/30 transition-colors hover:border-primary/60"
:title="preview.label"
@click.stop="openImagePreview(preview)"
>
<img
:src="preview.src"
:alt="preview.label"
class="h-full w-full object-contain"
loading="lazy"
>
</button>
</div>
<div
v-else-if="attemptDetail(attempt) !== '-'"
class="mt-1 break-all text-muted-foreground"
>
{{ attemptDetail(attempt) }}
@@ -519,6 +539,33 @@
</td>
<td class="px-3 py-2 text-muted-foreground">
<div
v-if="attemptImagePreviews(attempt).length > 0"
class="flex flex-wrap gap-2"
>
<button
v-for="(preview, imageIndex) in attemptImagePreviews(attempt).slice(0, 4)"
:key="`${preview.src}-${imageIndex}`"
type="button"
class="h-14 w-14 overflow-hidden rounded-md border border-border/60 bg-muted/30 transition-colors hover:border-primary/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/70"
:title="preview.label"
@click.stop="openImagePreview(preview)"
>
<img
:src="preview.src"
:alt="preview.label"
class="h-full w-full object-contain"
loading="lazy"
>
</button>
<span
v-if="attemptImagePreviews(attempt).length > 4"
class="flex h-14 items-center rounded-md border border-border/60 px-2 text-xs text-muted-foreground"
>
+{{ attemptImagePreviews(attempt).length - 4 }}
</span>
</div>
<div
v-else
class="line-clamp-2 break-all"
:title="attemptDetail(attempt)"
>
@@ -641,6 +688,36 @@
</TabsContent>
<TabsContent value="response-body">
<div
v-if="selectedInspectionImagePreviews.length > 0"
class="mb-3 rounded-md border border-border/60 bg-muted/20 p-3"
>
<div class="mb-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>图片预览</span>
<span>{{ selectedInspectionImagePreviews.length }} </span>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<button
v-for="(preview, index) in selectedInspectionImagePreviews"
:key="`${preview.src}-${index}`"
type="button"
class="group block overflow-hidden rounded-md border border-border/60 bg-background text-left transition-colors hover:border-primary/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/70"
@click="openImagePreview(preview)"
>
<div class="aspect-square w-full overflow-hidden bg-muted/30">
<img
:src="preview.src"
:alt="preview.label"
class="h-full w-full object-contain"
loading="lazy"
>
</div>
<div class="border-t border-border/60 px-2 py-1 text-[11px] text-muted-foreground">
{{ preview.label }}
</div>
</button>
</div>
</div>
<JsonContent
:data="selectedInspectionAttempt.response_body"
view-mode="formatted"
@@ -673,6 +750,39 @@
</Button>
</template>
</Dialog>
<Dialog
:open="Boolean(activeImagePreview)"
size="6xl"
:z-index="120"
@update:open="(val: boolean) => { if (!val) activeImagePreview = null }"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="text-lg font-semibold text-foreground leading-tight">
{{ activeImagePreview?.label || '图片预览' }}
</div>
</div>
</template>
<div class="flex max-h-[76vh] items-center justify-center overflow-auto rounded-md bg-muted/20 p-3">
<img
v-if="activeImagePreview"
:src="activeImagePreview.src"
:alt="activeImagePreview.label"
class="max-h-[72vh] max-w-full rounded-md object-contain"
>
</div>
<template #footer>
<Button
variant="outline"
@click="activeImagePreview = null"
>
关闭
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
@@ -698,7 +808,12 @@ import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
import JsonContent from '@/features/usage/components/RequestDetailDrawer/JsonContent.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useDarkMode } from '@/composables/useDarkMode'
import { extractModelTestResponsePreview, formatModelTestDiagnostic } from './model-test-request'
import {
extractModelTestImagePreviews,
extractModelTestResponsePreview,
formatModelTestDiagnostic,
} from './model-test-request'
import type { ModelTestImagePreview } from './model-test-request'
type TestEndpointOption = {
id: string
@@ -816,6 +931,7 @@ const inspectionTab = ref<'request-headers' | 'request-body' | 'response-headers
const selectedInspectionKey = ref<string | null>(null)
const inspectionExpandDepth = ref(0)
const inspectionCopiedStates = ref<Record<string, boolean>>({})
const activeImagePreview = ref<ModelTestImagePreview | null>(null)
watch(() => props.result, () => {
showAllAttempts.value = false
@@ -1129,6 +1245,12 @@ const selectedInspectionAttempt = computed(() => {
return inspectableAttempts.value[0] ?? resultAttempts.value[0] ?? null
})
const selectedInspectionImagePreviews = computed(() => (
selectedInspectionAttempt.value
? extractModelTestImagePreviews(selectedInspectionAttempt.value.response_body)
: []
))
const resultWinningTitle = computed(() => {
const summary = resultSummary.value
const keyName = summary.winning_key_name || summary.winning_key_id
@@ -1238,6 +1360,7 @@ function formatAuthType(authType: string): string {
if (lowered === 'codex') return 'Codex OAuth'
if (lowered === 'antigravity') return 'Antigravity OAuth'
if (lowered === 'kiro') return 'Kiro OAuth'
if (lowered === 'grok') return 'Grok OAuth'
return authType
}
@@ -1295,6 +1418,14 @@ function attemptDetail(attempt: TestAttemptDetail): string {
return '-'
}
function attemptImagePreviews(attempt: TestAttemptDetail): ModelTestImagePreview[] {
return extractModelTestImagePreviews(attempt.response_body)
}
function openImagePreview(preview: ModelTestImagePreview) {
activeImagePreview.value = preview
}
function inspectionKey(attempt: TestAttemptDetail): string {
return `${attempt.candidate_index}:${attempt.retry_index ?? 0}:${attempt.key_id}`
}

View File

@@ -272,6 +272,7 @@ import {
normalizeModelTestMappedModelSelection,
parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft,
selectPreferredModelTestEndpoint,
syncModelTestRequestBodyDraft,
} from './model-test-request'
@@ -586,7 +587,7 @@ async function testModelConnection(model: Model) {
}
pendingTestModel.value = model
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(model, activeEndpoints.value)
const requestedModelName = getModelTestRequestedModelName(model)
selectedTestMappedModelName.value = null
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
@@ -594,6 +595,7 @@ async function testModelConnection(model: Model) {
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
requestedModelName,
selectedTestEndpoint.value?.api_format,
model,
)
testRequestBodyDraft.value = testRequestBodyResetValue.value
modelTest.testResult.value = null
@@ -619,7 +621,11 @@ function syncTestRequestBodyModel() {
if (!modelName) return
const resetDraft = testRequestBodyResetValue.value
|| buildDefaultModelTestRequestBody(modelName, selectedTestEndpoint.value?.api_format)
|| buildDefaultModelTestRequestBody(
modelName,
selectedTestEndpoint.value?.api_format,
pendingTestModel.value,
)
const next = syncModelTestRequestBodyDraft(
testRequestBodyDraft.value,
testRequestBodyResetValue.value,
@@ -637,6 +643,7 @@ function resetTestRequestBodyForSelectedEndpoint() {
const nextResetValue = buildDefaultModelTestRequestBody(
modelName,
selectedTestEndpoint.value?.api_format,
pendingTestModel.value,
)
const next = syncModelTestRequestBodyDraft(
testRequestBodyDraft.value,

View File

@@ -3,12 +3,15 @@ import { describe, expect, it } from 'vitest'
import {
buildDefaultModelTestRequestBody,
buildExactModelMappingTestRequest,
extractModelTestImagePreviews,
extractModelTestResponsePreview,
formatModelTestDiagnostic,
getOpenAiImageModelTestMaxGenerationCount,
isModelTestableEndpoint,
isModelTestableApiFormat,
listModelTestMappedModelOptions,
normalizeModelTestMappedModelSelection,
selectPreferredModelTestEndpoint,
setModelTestRequestBodyModel,
syncModelTestRequestBodyDraft,
} from '../model-test-request'
@@ -55,6 +58,41 @@ describe('buildDefaultModelTestRequestBody', () => {
expect(body.input).toBeUndefined()
})
it('uses prompt payloads for openai image api formats', () => {
const body = JSON.parse(buildDefaultModelTestRequestBody('gpt-image-2', 'openai:image'))
expect(body).toEqual({
model: 'gpt-image-2',
prompt: 'Hello! This is a test message.',
n: 1,
size: '1024x1024',
stream: true,
})
expect(body.messages).toBeUndefined()
})
it('uses image generation tools for image models on OpenAI Responses endpoints', () => {
const body = JSON.parse(buildDefaultModelTestRequestBody(
'gpt-image-2',
'openai:responses',
{
effective_supports_image_generation: true,
},
))
expect(body.model).toBe('gpt-image-2')
expect(body.input).toBe('Hello! This is a test message.')
expect(body.tools).toEqual([
{
type: 'image_generation',
size: '1024x1024',
output_format: 'png',
},
])
expect(body.tool_choice).toEqual({ type: 'image_generation' })
expect(body.messages).toBeUndefined()
})
it('lists endpoint-scoped provider model mappings in test selection order', () => {
const options = listModelTestMappedModelOptions({
provider_model_name: 'claude-opus-4-6',
@@ -204,6 +242,7 @@ describe('isModelTestableApiFormat', () => {
'openai:responses',
'claude:messages',
'gemini:generate_content',
'openai:image',
'openai:embedding',
'jina:rerank',
])('allows synchronous model-test endpoint formats: %s', (apiFormat) => {
@@ -211,6 +250,75 @@ describe('isModelTestableApiFormat', () => {
})
})
describe('selectPreferredModelTestEndpoint', () => {
it('prefers openai image endpoints for image generation models', () => {
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
expect(selectPreferredModelTestEndpoint({
effective_supports_image_generation: true,
}, [chatEndpoint, imageEndpoint])).toBe(imageEndpoint)
})
it('prefers openai image endpoints from model-test capability metadata', () => {
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
expect(selectPreferredModelTestEndpoint({
effective_supports_image_generation: false,
model_test_capabilities: {
'openai:image': {
supports_generation: true,
max_generation_count: 4,
},
},
}, [chatEndpoint, imageEndpoint])).toBe(imageEndpoint)
})
it('does not treat edit-only image capability as generation support', () => {
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
expect(selectPreferredModelTestEndpoint({
effective_supports_image_generation: true,
model_test_capabilities: {
'openai:image': {
supports_generation: false,
supports_edit: true,
max_generation_count: 4,
},
},
}, [chatEndpoint, imageEndpoint])).toBe(chatEndpoint)
})
it('keeps the existing endpoint order for non-image models', () => {
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
expect(selectPreferredModelTestEndpoint({
effective_supports_image_generation: false,
}, [chatEndpoint, imageEndpoint])).toBe(chatEndpoint)
})
})
describe('getOpenAiImageModelTestMaxGenerationCount', () => {
it('reads image generation count from backend capability metadata', () => {
expect(getOpenAiImageModelTestMaxGenerationCount({
model_test_capabilities: {
'openai:image': {
max_generation_count: 4,
},
},
})).toBe(4)
})
it('returns null when backend capability metadata is absent', () => {
expect(getOpenAiImageModelTestMaxGenerationCount({
effective_supports_image_generation: true,
})).toBeNull()
})
})
describe('isModelTestableEndpoint', () => {
it('requires at least one active key compatible with the endpoint format', () => {
const keys = [
@@ -341,6 +449,95 @@ describe('extractModelTestResponsePreview', () => {
})).toBe('Rerank 结果2 条')
})
it('extracts image URLs from OpenAI image responses', () => {
expect(extractModelTestResponsePreview({
data: [
{
url: 'https://example.com/generated.png',
revised_prompt: 'A generated image',
},
],
})).toBe('图片https://example.com/generated.png')
})
it('summarizes base64 image responses without dumping the image payload', () => {
expect(extractModelTestResponsePreview({
data: [
{
b64_json: 'aGVsbG8=',
},
],
})).toBe('图片base64')
})
it('extracts renderable base64 image previews from OpenAI image responses', () => {
expect(extractModelTestImagePreviews({
data: [
{
b64_json: 'aGVsbG8=',
mime_type: 'image/jpeg',
},
],
})).toEqual([
{
src: 'data:image/jpeg;base64,aGVsbG8=',
label: '图片 1',
source: 'base64',
},
])
})
it('extracts image previews from nested response image urls', () => {
expect(extractModelTestImagePreviews({
output: [
{
content: [
{
type: 'output_image',
image_url: {
url: 'https://example.com/generated.png',
},
},
],
},
],
})).toEqual([
{
src: 'https://example.com/generated.png',
label: '图片 1',
source: 'url',
},
])
})
it('extracts image previews from OpenAI Responses image_generation_call results', () => {
expect(extractModelTestResponsePreview({
output: [
{
type: 'image_generation_call',
output_format: 'png',
result: 'aGVsbG8=',
},
],
})).toBe('图片base64')
expect(extractModelTestImagePreviews({
output: [
{
type: 'image_generation_call',
output_format: 'png',
result: 'aGVsbG8=',
},
],
})).toEqual([
{
src: 'data:image/png;base64,aGVsbG8=',
label: '图片 1',
source: 'base64',
},
])
})
it('falls back to response model when no text payload exists', () => {
expect(extractModelTestResponsePreview({
model: 'glm-4.5-air',

View File

@@ -0,0 +1,111 @@
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
import type { ModelTestCapabilities, OpenAiImageModelTestCapability } from '@/api/endpoints/types'
export type ModelTestEndpointSource = {
api_format: string
is_active?: boolean | null
}
export type ModelTestImageSource = {
effective_supports_image_generation?: boolean | null
supports_image_generation?: boolean | null
model_test_capabilities?: ModelTestCapabilities | null
}
export type ModelTestKeySource = {
api_formats?: string[] | null
is_active?: boolean | null
}
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
'openai:video',
'gemini:video',
'gemini:files',
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
pool_account_blocked: '账号已失效,需重新授权',
}
export function normalizeModelTestStringList(values: string[] | null | undefined): string[] {
return (values ?? [])
.map(value => value.trim())
.filter(Boolean)
}
export function isModelTestableApiFormat(apiFormat: string | null | undefined): boolean {
const normalized = normalizeApiFormatAlias(apiFormat ?? '')
return Boolean(normalized) && !MODEL_TEST_UNSUPPORTED_API_FORMATS.has(normalized)
}
export function modelTestKeySupportsEndpoint(
key: ModelTestKeySource,
endpoint: ModelTestEndpointSource,
): boolean {
if (key.is_active === false) return false
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
if (!isModelTestableApiFormat(endpointFormat)) return false
const keyFormats = normalizeModelTestStringList(key.api_formats)
if (keyFormats.length === 0) return true
return keyFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
}
export function isModelTestableEndpoint(
endpoint: ModelTestEndpointSource,
keys: ModelTestKeySource[],
): boolean {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint))
}
export function selectPreferredModelTestEndpoint<T extends ModelTestEndpointSource>(
model: ModelTestImageSource | null | undefined,
endpoints: T[],
): T | null {
if (modelSupportsImageGeneration(model)) {
const imageEndpoint = endpoints.find(
endpoint => normalizeApiFormatAlias(endpoint.api_format) === 'openai:image',
)
if (imageEndpoint) return imageEndpoint
}
return endpoints[0] ?? null
}
export function getOpenAiImageModelTestCapability(
model: ModelTestImageSource | null | undefined,
): OpenAiImageModelTestCapability | null {
const capability = model?.model_test_capabilities?.['openai:image']
return capability && typeof capability === 'object'
? capability as OpenAiImageModelTestCapability
: null
}
export function getOpenAiImageModelTestMaxGenerationCount(
model: ModelTestImageSource | null | undefined,
): number | null {
const maxGenerationCount = getOpenAiImageModelTestCapability(model)?.max_generation_count
return typeof maxGenerationCount === 'number' && Number.isFinite(maxGenerationCount)
? Math.max(1, Math.floor(maxGenerationCount))
: null
}
export function formatModelTestDiagnostic(value: string | null | undefined): string {
const normalized = value?.trim()
if (!normalized) return ''
return MODEL_TEST_DIAGNOSTIC_LABELS[normalized] ?? normalized
}
export function modelSupportsImageGeneration(model: ModelTestImageSource | null | undefined): boolean {
const imageCapability = getOpenAiImageModelTestCapability(model)
if (imageCapability) {
return imageCapability.supports_generation !== false
}
return Boolean(
model?.effective_supports_image_generation ?? model?.supports_image_generation,
)
}

View File

@@ -0,0 +1,457 @@
const MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH = 160
const MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS = 6
type JsonRecord = Record<string, unknown>
export type ModelTestImagePreview = {
src: string
label: string
source: 'base64' | 'url'
}
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
const text = extractResponseText(responseBody)
if (text) return text
const reasoning = extractResponseReasoning(responseBody)
if (reasoning) return `推理:${reasoning}`
const image = extractImagePreview(responseBody)
if (image) return image
const summary = extractResponseSummary(responseBody)
if (summary) return summary
return null
}
export function extractModelTestImagePreviews(responseBody: unknown): ModelTestImagePreview[] {
const previews: ModelTestImagePreview[] = []
collectImagePreviews(responseBody, previews, new Set(), 0)
return previews
}
function isJsonRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function compactPreviewText(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.replace(/\s+/g, ' ').trim()
if (!normalized) return null
if (normalized.length <= MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH) {
return normalized
}
return `${normalized.slice(0, MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH - 3)}...`
}
function joinPreviewParts(parts: string[]): string | null {
return compactPreviewText(parts.filter(Boolean).join(' '))
}
function extractTextFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4) return null
const directText = compactPreviewText(value)
if (directText) return directText
if (!Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (typeof part === 'string') return [part]
if (!isJsonRecord(part)) return []
const text = compactPreviewText(part.text)
?? compactPreviewText(part.content)
?? extractTextFromContentParts(part.parts, depth + 1)
return text ? [text] : []
})
return joinPreviewParts(parts)
}
function extractResponseText(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedText = extractResponseText(responseBody.response, depth + 1)
?? extractResponseText(responseBody.body, depth + 1)
if (wrappedText) return wrappedText
const outputText = compactPreviewText(responseBody.output_text)
if (outputText) return outputText
const topLevelContentText = extractTextFromContentParts(responseBody.content, depth + 1)
if (topLevelContentText) return topLevelContentText
const choicesText = extractChoicesText(responseBody.choices, depth + 1)
if (choicesText) return choicesText
const outputTextParts = extractOutputText(responseBody.output, depth + 1)
if (outputTextParts) return outputTextParts
const candidateText = extractGeminiCandidateText(responseBody.candidates, depth + 1)
if (candidateText) return candidateText
return null
}
function extractImagePreview(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedPreview = extractImagePreview(responseBody.response, depth + 1)
?? extractImagePreview(responseBody.body, depth + 1)
if (wrappedPreview) return wrappedPreview
const dataPreview = extractImagePreviewFromCollection(responseBody.data, depth + 1)
if (dataPreview) return dataPreview
const outputPreview = extractImagePreviewFromCollection(responseBody.output, depth + 1)
if (outputPreview) return outputPreview
const imagesPreview = extractImagePreviewFromCollection(responseBody.images, depth + 1)
if (imagesPreview) return imagesPreview
const contentPreview = extractImagePreviewFromContentParts(responseBody.content, depth + 1)
if (contentPreview) return contentPreview
return null
}
function collectImagePreviews(
value: unknown,
previews: ModelTestImagePreview[],
seen: Set<string>,
depth: number,
) {
if (depth > 5 || previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS || value == null) return
if (Array.isArray(value)) {
for (const item of value) {
collectImagePreviews(item, previews, seen, depth + 1)
if (previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS) return
}
return
}
if (!isJsonRecord(value)) return
collectImagePreviewFromRecord(value, previews, seen, depth)
const nestedValues = [
value.response,
value.body,
value.data,
value.output,
value.images,
value.content,
]
for (const nested of nestedValues) {
collectImagePreviews(nested, previews, seen, depth + 1)
if (previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS) return
}
}
function collectImagePreviewFromRecord(
value: JsonRecord,
previews: ModelTestImagePreview[],
seen: Set<string>,
depth: number,
) {
const mime = imageMimeFromRecord(value)
const imageUrl = value.image_url
if (typeof imageUrl === 'string') {
pushImagePreview(previews, seen, imageUrlToPreview(imageUrl, 'url'))
} else if (isJsonRecord(imageUrl)) {
pushImagePreview(previews, seen, imageUrlToPreview(imageUrl.url, 'url'))
pushImagePreview(previews, seen, base64ImageToPreview(imageUrl.b64_json, imageMimeFromRecord(imageUrl)))
}
pushImagePreview(previews, seen, imageUrlToPreview(value.url, 'url'))
pushImagePreview(previews, seen, base64ImageToPreview(value.b64_json, mime))
pushImagePreview(previews, seen, base64ImageToPreview(value.data, mime))
if (value.type === 'image_generation_call') {
pushImagePreview(previews, seen, base64ImageToPreview(value.result, mime))
}
if (depth <= 4) {
collectImagePreviews(value.source, previews, seen, depth + 1)
}
}
function pushImagePreview(
previews: ModelTestImagePreview[],
seen: Set<string>,
preview: ModelTestImagePreview | null,
) {
if (!preview || seen.has(preview.src) || previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS) {
return
}
seen.add(preview.src)
previews.push({
...preview,
label: `图片 ${previews.length + 1}`,
})
}
function imageUrlToPreview(value: unknown, source: 'url'): ModelTestImagePreview | null {
if (typeof value !== 'string') return null
const url = value.trim()
if (!url) return null
if (url.startsWith('data:image/')) {
return { src: url, label: 'base64', source: 'base64' }
}
try {
const parsed = new URL(url)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
} catch {
return null
}
return { src: url, label: 'URL', source }
}
function base64ImageToPreview(value: unknown, mime: string): ModelTestImagePreview | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed) return null
if (trimmed.startsWith('data:image/')) {
return { src: trimmed, label: 'base64', source: 'base64' }
}
const normalized = trimmed.replace(/\s+/g, '')
if (!normalized) return null
return {
src: `data:${mime};base64,${normalized}`,
label: 'base64',
source: 'base64',
}
}
function imageMimeFromRecord(value: JsonRecord): string {
const outputFormat = value.output_format
if (typeof outputFormat === 'string') {
const normalized = outputFormat.trim().toLowerCase()
if (/^[a-z0-9.+-]+$/.test(normalized)) return `image/${normalized}`
}
const raw = [
value.mime_type,
value.mime,
value.media_type,
value.content_type,
value.type,
].find(candidate => typeof candidate === 'string' && candidate.trim().startsWith('image/'))
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase()
if (/^image\/[a-z0-9.+-]+$/.test(normalized)) return normalized
}
return 'image/png'
}
function extractResponseReasoning(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedReasoning = extractResponseReasoning(responseBody.response, depth + 1)
?? extractResponseReasoning(responseBody.body, depth + 1)
if (wrappedReasoning) return wrappedReasoning
const directReasoning = compactPreviewText(responseBody.reasoning_content)
?? compactPreviewText(responseBody.thinking)
if (directReasoning) return directReasoning
const topLevelReasoning = extractReasoningFromContentParts(responseBody.content, depth + 1)
if (topLevelReasoning) return topLevelReasoning
const choicesReasoning = extractChoicesReasoning(responseBody.choices, depth + 1)
if (choicesReasoning) return choicesReasoning
const outputReasoning = extractOutputReasoning(responseBody.output, depth + 1)
if (outputReasoning) return outputReasoning
return null
}
function extractChoicesText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageText = isJsonRecord(choice.message)
? extractTextFromContentParts(choice.message.content, depth + 1)
: null
const deltaText = isJsonRecord(choice.delta)
? extractTextFromContentParts(choice.delta.content, depth + 1)
: null
const text = messageText ?? deltaText ?? extractTextFromContentParts(choice.text, depth + 1)
if (text) return text
}
return null
}
function extractChoicesReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageReasoning = isJsonRecord(choice.message)
? extractReasoningFromMessage(choice.message, depth + 1)
: null
const deltaReasoning = isJsonRecord(choice.delta)
? extractReasoningFromMessage(choice.delta, depth + 1)
: null
const reasoning = messageReasoning ?? deltaReasoning
if (reasoning) return reasoning
}
return null
}
function extractReasoningFromMessage(message: JsonRecord, depth: number): string | null {
return compactPreviewText(message.reasoning_content)
?? compactPreviewText(message.thinking)
?? extractReasoningFromContentParts(message.content, depth + 1)
}
function extractOutputText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const contentText = extractTextFromContentParts(outputItem.content, depth + 1)
?? extractResponseText(outputItem.response, depth + 1)
if (contentText) return contentText
}
return null
}
function extractOutputReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const reasoning = extractReasoningFromContentParts(outputItem.content, depth + 1)
?? compactPreviewText(outputItem.reasoning_content)
?? compactPreviewText(outputItem.thinking)
?? extractResponseReasoning(outputItem.response, depth + 1)
if (reasoning) return reasoning
}
return null
}
function extractImagePreviewFromCollection(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const item of value) {
if (!isJsonRecord(item)) continue
const preview = extractImagePreviewFromRecord(item, depth + 1)
if (preview) return preview
}
return null
}
function extractImagePreviewFromContentParts(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const part of value) {
if (!isJsonRecord(part)) continue
const preview = extractImagePreviewFromRecord(part, depth + 1)
if (preview) return preview
}
return null
}
function extractImagePreviewFromRecord(value: JsonRecord, depth: number): string | null {
if (depth > 4) return null
const imageUrl = value.image_url
if (typeof imageUrl === 'string' && imageUrl.trim()) {
return compactPreviewText(`图片:${imageUrl}`)
}
if (isJsonRecord(imageUrl)) {
const nestedUrl = compactPreviewText(imageUrl.url)
if (nestedUrl) return `图片:${nestedUrl}`
if (compactPreviewText(imageUrl.b64_json)) {
return '图片base64'
}
}
const url = compactPreviewText(value.url)
if (url) return `图片:${url}`
if (compactPreviewText(value.b64_json)) {
return '图片base64'
}
if (value.type === 'image_generation_call' && compactPreviewText(value.result)) {
return '图片base64'
}
return extractImagePreviewFromCollection(value.data, depth + 1)
?? extractImagePreviewFromCollection(value.images, depth + 1)
?? extractImagePreviewFromContentParts(value.content, depth + 1)
}
function extractGeminiCandidateText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const candidate of value) {
if (!isJsonRecord(candidate) || !isJsonRecord(candidate.content)) continue
const text = extractTextFromContentParts(candidate.content.parts, depth + 1)
if (text) return text
}
return null
}
function extractReasoningFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4 || !Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (!isJsonRecord(part)) return []
const reasoning = compactPreviewText(part.reasoning_content)
?? compactPreviewText(part.thinking)
?? compactPreviewText(part.reasoning)
?? extractReasoningFromContentParts(part.content, depth + 1)
?? extractReasoningFromContentParts(part.parts, depth + 1)
return reasoning ? [reasoning] : []
})
return joinPreviewParts(parts)
}
function extractResponseSummary(responseBody: unknown): string | null {
if (!isJsonRecord(responseBody)) return null
if (Array.isArray(responseBody.data)) {
const embeddingDimensions = responseBody.data
.map(item => isJsonRecord(item) && Array.isArray(item.embedding) ? item.embedding.length : null)
.find((size): size is number => typeof size === 'number')
if (embeddingDimensions != null) return `Embedding 维度:${embeddingDimensions}`
if (responseBody.data.length > 0) return `返回数据:${responseBody.data.length}`
}
if (Array.isArray(responseBody.results)) return `Rerank 结果:${responseBody.results.length}`
const model = compactPreviewText(responseBody.model)
if (model) return `返回模型:${model}`
return null
}

View File

@@ -1,9 +1,33 @@
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
import type { ProviderModelMapping } from '@/api/endpoints/types'
import type { TestModelRequest } from '@/api/endpoints/providers'
import {
modelSupportsImageGeneration,
normalizeModelTestStringList,
type ModelTestImageSource,
} from './model-test-capabilities'
export {
formatModelTestDiagnostic,
getOpenAiImageModelTestCapability,
getOpenAiImageModelTestMaxGenerationCount,
isModelTestableApiFormat,
isModelTestableEndpoint,
modelTestKeySupportsEndpoint,
selectPreferredModelTestEndpoint,
} from './model-test-capabilities'
export type {
ModelTestEndpointSource,
ModelTestImageSource,
ModelTestKeySource,
} from './model-test-capabilities'
export {
extractModelTestImagePreviews,
extractModelTestResponsePreview,
} from './model-test-preview'
export type { ModelTestImagePreview } from './model-test-preview'
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
const MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH = 160
type ModelTestMappingSource = {
provider_model_name: string
@@ -15,308 +39,20 @@ type ModelTestMappingEndpoint = {
api_format: string
}
type ModelTestEndpointSource = {
api_format: string
is_active?: boolean | null
}
type ModelTestKeySource = {
api_formats?: string[] | null
is_active?: boolean | null
}
export type ModelTestMappedModelOption = {
name: string
priority: number
}
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
'openai:video',
'gemini:video',
'gemini:files',
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
pool_account_blocked: '账号已失效,需重新授权',
}
type JsonRecord = Record<string, unknown>
export function isModelTestableApiFormat(apiFormat: string | null | undefined): boolean {
const normalized = normalizeApiFormatAlias(apiFormat ?? '')
return Boolean(normalized) && !MODEL_TEST_UNSUPPORTED_API_FORMATS.has(normalized)
}
export function modelTestKeySupportsEndpoint(
key: ModelTestKeySource,
endpoint: ModelTestEndpointSource,
): boolean {
if (key.is_active === false) return false
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
if (!isModelTestableApiFormat(endpointFormat)) return false
const keyFormats = normalizeStringList(key.api_formats ?? undefined)
if (keyFormats.length === 0) return true
return keyFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
}
export function isModelTestableEndpoint(
endpoint: ModelTestEndpointSource,
keys: ModelTestKeySource[],
): boolean {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint))
}
export function formatModelTestDiagnostic(value: string | null | undefined): string {
const normalized = value?.trim()
if (!normalized) return ''
return MODEL_TEST_DIAGNOSTIC_LABELS[normalized] ?? normalized
}
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
const text = extractResponseText(responseBody)
if (text) return text
const reasoning = extractResponseReasoning(responseBody)
if (reasoning) return `推理:${reasoning}`
const summary = extractResponseSummary(responseBody)
if (summary) return summary
return null
}
function normalizeStringList(values: string[] | undefined): string[] {
return (values ?? [])
.map(value => value.trim())
.filter(Boolean)
}
function isJsonRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function compactPreviewText(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.replace(/\s+/g, ' ').trim()
if (!normalized) return null
if (normalized.length <= MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH) {
return normalized
}
return `${normalized.slice(0, MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH - 3)}...`
}
function joinPreviewParts(parts: string[]): string | null {
return compactPreviewText(parts.filter(Boolean).join(' '))
}
function extractTextFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4) return null
const directText = compactPreviewText(value)
if (directText) return directText
if (!Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (typeof part === 'string') return [part]
if (!isJsonRecord(part)) return []
const text = compactPreviewText(part.text)
?? compactPreviewText(part.content)
?? extractTextFromContentParts(part.parts, depth + 1)
return text ? [text] : []
})
return joinPreviewParts(parts)
}
function extractResponseText(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedText = extractResponseText(responseBody.response, depth + 1)
?? extractResponseText(responseBody.body, depth + 1)
if (wrappedText) return wrappedText
const outputText = compactPreviewText(responseBody.output_text)
if (outputText) return outputText
const topLevelContentText = extractTextFromContentParts(responseBody.content, depth + 1)
if (topLevelContentText) return topLevelContentText
const choicesText = extractChoicesText(responseBody.choices, depth + 1)
if (choicesText) return choicesText
const outputTextParts = extractOutputText(responseBody.output, depth + 1)
if (outputTextParts) return outputTextParts
const candidateText = extractGeminiCandidateText(responseBody.candidates, depth + 1)
if (candidateText) return candidateText
return null
}
function extractResponseReasoning(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedReasoning = extractResponseReasoning(responseBody.response, depth + 1)
?? extractResponseReasoning(responseBody.body, depth + 1)
if (wrappedReasoning) return wrappedReasoning
const directReasoning = compactPreviewText(responseBody.reasoning_content)
?? compactPreviewText(responseBody.thinking)
if (directReasoning) return directReasoning
const topLevelReasoning = extractReasoningFromContentParts(responseBody.content, depth + 1)
if (topLevelReasoning) return topLevelReasoning
const choicesReasoning = extractChoicesReasoning(responseBody.choices, depth + 1)
if (choicesReasoning) return choicesReasoning
const outputReasoning = extractOutputReasoning(responseBody.output, depth + 1)
if (outputReasoning) return outputReasoning
return null
}
function extractChoicesText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageText = isJsonRecord(choice.message)
? extractTextFromContentParts(choice.message.content, depth + 1)
: null
const deltaText = isJsonRecord(choice.delta)
? extractTextFromContentParts(choice.delta.content, depth + 1)
: null
const text = messageText ?? deltaText ?? extractTextFromContentParts(choice.text, depth + 1)
if (text) return text
}
return null
}
function extractChoicesReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageReasoning = isJsonRecord(choice.message)
? extractReasoningFromMessage(choice.message, depth + 1)
: null
const deltaReasoning = isJsonRecord(choice.delta)
? extractReasoningFromMessage(choice.delta, depth + 1)
: null
const reasoning = messageReasoning ?? deltaReasoning
if (reasoning) return reasoning
}
return null
}
function extractReasoningFromMessage(message: JsonRecord, depth: number): string | null {
return compactPreviewText(message.reasoning_content)
?? compactPreviewText(message.thinking)
?? extractReasoningFromContentParts(message.content, depth + 1)
}
function extractOutputText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const contentText = extractTextFromContentParts(outputItem.content, depth + 1)
?? extractResponseText(outputItem.response, depth + 1)
if (contentText) return contentText
}
return null
}
function extractOutputReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const reasoning = extractReasoningFromContentParts(outputItem.content, depth + 1)
?? compactPreviewText(outputItem.reasoning_content)
?? compactPreviewText(outputItem.thinking)
?? extractResponseReasoning(outputItem.response, depth + 1)
if (reasoning) return reasoning
}
return null
}
function extractGeminiCandidateText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const candidate of value) {
if (!isJsonRecord(candidate) || !isJsonRecord(candidate.content)) continue
const text = extractTextFromContentParts(candidate.content.parts, depth + 1)
if (text) return text
}
return null
}
function extractReasoningFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4 || !Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (!isJsonRecord(part)) return []
const reasoning = compactPreviewText(part.reasoning_content)
?? compactPreviewText(part.thinking)
?? compactPreviewText(part.reasoning)
?? extractReasoningFromContentParts(part.content, depth + 1)
?? extractReasoningFromContentParts(part.parts, depth + 1)
return reasoning ? [reasoning] : []
})
return joinPreviewParts(parts)
}
function extractResponseSummary(responseBody: unknown): string | null {
if (!isJsonRecord(responseBody)) return null
if (Array.isArray(responseBody.data)) {
const embeddingDimensions = responseBody.data
.map(item => isJsonRecord(item) && Array.isArray(item.embedding) ? item.embedding.length : null)
.find((size): size is number => typeof size === 'number')
if (embeddingDimensions != null) return `Embedding 维度:${embeddingDimensions}`
if (responseBody.data.length > 0) return `返回数据:${responseBody.data.length}`
}
if (Array.isArray(responseBody.results)) return `Rerank 结果:${responseBody.results.length}`
const model = compactPreviewText(responseBody.model)
if (model) return `返回模型:${model}`
return null
}
function mappingApiFormatMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
const apiFormats = normalizeStringList(mapping.api_formats)
const apiFormats = normalizeModelTestStringList(mapping.api_formats)
if (apiFormats.length === 0) return true
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
return apiFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
}
function mappingEndpointMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
const endpointIds = normalizeStringList(mapping.endpoint_ids)
const endpointIds = normalizeModelTestStringList(mapping.endpoint_ids)
if (endpointIds.length === 0) return true
return endpointIds.includes(endpoint.id)
}
@@ -408,7 +144,11 @@ export function buildExactModelMappingTestRequest(
}
}
export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?: string | null): string {
export function buildDefaultModelTestRequestBody(
modelName: string,
apiFormat?: string | null,
model?: ModelTestImageSource | null,
): string {
if (apiFormat?.trim().toLowerCase().endsWith(':embedding')) {
return JSON.stringify({
model: modelName,
@@ -431,6 +171,34 @@ export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?:
}, null, 2)
}
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:image') {
return JSON.stringify({
model: modelName,
prompt: DEFAULT_MODEL_TEST_MESSAGE,
n: 1,
size: '1024x1024',
stream: true,
}, null, 2)
}
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:responses' && modelSupportsImageGeneration(model)) {
return JSON.stringify({
model: modelName,
input: DEFAULT_MODEL_TEST_MESSAGE,
tools: [
{
type: 'image_generation',
size: '1024x1024',
output_format: 'png',
},
],
tool_choice: {
type: 'image_generation',
},
stream: true,
}, null, 2)
}
return JSON.stringify({
model: modelName,
messages: [

View File

@@ -159,6 +159,47 @@
v-else-if="detail"
class="space-y-4"
>
<!-- 执行失败原因优先展示本地调度/运行时失败摘要 -->
<Card
v-if="failureNotice"
class="border-red-200 bg-red-50/80 shadow-sm dark:border-red-900/60 dark:bg-red-950/30"
>
<div class="p-3 sm:p-4 flex gap-3">
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-300">
<AlertTriangle class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-red-950 dark:text-red-100">
{{ failureNotice.title }}
</h4>
<Badge
v-if="failureNotice.isSchedulingFailure"
variant="outline"
class="border-red-300 bg-white/60 text-[10px] text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
>
调度阶段
</Badge>
</div>
<p class="text-sm leading-6 text-red-900 dark:text-red-100">
{{ failureNotice.message }}
</p>
<div
v-if="failureNotice.meta.length > 0"
class="flex flex-wrap gap-1.5"
>
<span
v-for="item in failureNotice.meta"
:key="item"
class="rounded-full border border-red-200 bg-white/70 px-2 py-0.5 text-[11px] font-mono text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200"
>
{{ item }}
</span>
</div>
</div>
</div>
</Card>
<!-- 费用与性能概览 -->
<Card>
<div class="p-3 sm:p-4">
@@ -701,7 +742,7 @@ import Separator from '@/components/ui/separator.vue'
import Skeleton from '@/components/ui/skeleton.vue'
import Tabs from '@/components/ui/tabs.vue'
import TabsContent from '@/components/ui/tabs-content.vue'
import { Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
@@ -721,6 +762,7 @@ import {
resolveDisplayRequestStatus,
resolveUsageStreamLabelSegments,
} from '../utils/status'
import { resolveRequestFailureNotice } from '../utils/errorNotice'
// 子组件
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
@@ -1023,6 +1065,8 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
return Object.keys(merged).length > 0 ? merged : null
})
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
const settlementInfo = computed<JsonRecord | null>(() =>
asRecord(detail.value?.settlement ?? null),
)
@@ -1826,6 +1870,7 @@ async function ensureBodyContentLoaded() {
failure_summary: response.failure_summary,
errors: response.errors,
error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure,
}
bodiesLoadedForRequestId.value = cacheKey
} catch (err) {
@@ -1872,12 +1917,13 @@ async function loadDetail(id: string, silent = false) {
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
response_body: sameRequest ? previousDetail?.response_body : undefined,
client_response_body: sameRequest ? previousDetail?.client_response_body : undefined,
request_error: sameRequest ? (previousDetail?.request_error ?? response.request_error) : response.request_error,
upstream_error: sameRequest ? (previousDetail?.upstream_error ?? response.upstream_error) : response.upstream_error,
client_error: sameRequest ? (previousDetail?.client_error ?? response.client_error) : response.client_error,
failure_summary: sameRequest ? (previousDetail?.failure_summary ?? response.failure_summary) : response.failure_summary,
errors: sameRequest ? (previousDetail?.errors ?? response.errors) : response.errors,
error_flow: sameRequest ? (previousDetail?.error_flow ?? response.error_flow) : response.error_flow,
request_error: response.request_error,
upstream_error: response.upstream_error,
client_error: response.client_error,
failure_summary: response.failure_summary,
errors: response.errors,
error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure,
}
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import type { RequestDetail } from '@/api/dashboard'
import { resolveRequestFailureNotice } from '../errorNotice'
function buildRequestDetail(overrides: Partial<RequestDetail> = {}): RequestDetail {
return {
id: 'usage-1',
request_id: 'req-1',
user: {
id: 'user-1',
username: 'alice',
email: 'alice@example.com',
},
api_key: {
id: 'key-1',
name: 'primary',
display: 'primary',
},
provider: 'OpenAI',
model: 'gpt-5',
tokens: {
input: 0,
output: 0,
total: 0,
},
cost: {
input: 0,
output: 0,
total: 0,
},
request_type: 'chat',
is_stream: true,
status_code: 503,
status: 'failed',
response_time_ms: 0,
created_at: '2026-05-14T10:33:21Z',
...overrides,
}
}
describe('request failure notice', () => {
it('prioritizes local scheduling failure details over generic 503 status', () => {
const notice = resolveRequestFailureNotice(buildRequestDetail({
error_message: 'generic 503',
failure_summary: {
status_code: 503,
message: '没有可用提供商支持模型 gpt-5 的流式请求',
},
scheduling_failure: {
source: 'local_execution_runtime_miss',
reason: 'all_candidates_skipped',
reason_label: '所有候选均被跳过',
title: '本地调度失败:所有候选均被跳过',
message: '没有可用提供商支持模型 gpt-5 的流式请求',
reason_summary: 'pool_account_exhausted 2 次',
status_code: 503,
no_upstream_attempt: true,
},
}))
expect(notice).toEqual({
title: '本地调度失败:所有候选均被跳过',
message: '没有可用提供商支持模型 gpt-5 的流式请求',
isSchedulingFailure: true,
meta: [
'pool_account_exhausted 2 次',
'所有候选均被跳过',
'all_candidates_skipped',
'HTTP 503',
'未进入上游执行',
],
})
})
it('falls back to the failure summary for upstream failures', () => {
const notice = resolveRequestFailureNotice(buildRequestDetail({
failure_summary: {
source: 'upstream_response',
status_code: 429,
type: 'insufficient_quota',
message: 'quota exceeded',
},
}))
expect(notice).toEqual({
title: '执行失败原因',
message: 'quota exceeded',
isSchedulingFailure: false,
meta: ['HTTP 429', 'insufficient_quota', 'upstream_response'],
})
})
it('does not show a stale notice when the refreshed detail has no error fields', () => {
const notice = resolveRequestFailureNotice(buildRequestDetail({
status_code: 200,
status: 'completed',
error_message: undefined,
scheduling_failure: null,
failure_summary: null,
client_error: null,
upstream_error: null,
request_error: null,
}))
expect(notice).toBeNull()
})
})

View File

@@ -0,0 +1,82 @@
import type { RequestDetail, RequestErrorDomain, RequestSchedulingFailure } from '@/api/dashboard'
export interface RequestFailureNotice {
title: string
message: string
meta: string[]
isSchedulingFailure: boolean
}
function nonEmptyString(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): RequestErrorDomain | null {
if (!nonEmptyString(domain?.message)) return null
return domain ?? null
}
function formatHttpStatus(statusCode: number | null | undefined): string | null {
return typeof statusCode === 'number' ? `HTTP ${statusCode}` : null
}
function uniqueMeta(values: Array<string | null | undefined>): string[] {
return Array.from(new Set(values.map(value => value?.trim()).filter((value): value is string => Boolean(value))))
}
function schedulingFailureMessage(
failure: RequestSchedulingFailure,
fallbackDomain: RequestErrorDomain | null,
fallbackErrorMessage: string | null,
): string | null {
return nonEmptyString(failure.message)
?? nonEmptyString(fallbackDomain?.message)
?? fallbackErrorMessage
?? nonEmptyString(failure.reason_label)
?? nonEmptyString(failure.reason)
}
export function resolveRequestFailureNotice(detail: RequestDetail | null | undefined): RequestFailureNotice | null {
if (!detail) return null
const fallbackDomain = normalizeErrorDomain(detail.failure_summary)
?? normalizeErrorDomain(detail.client_error)
?? normalizeErrorDomain(detail.upstream_error)
?? normalizeErrorDomain(detail.request_error)
const fallbackErrorMessage = nonEmptyString(detail.error_message ?? null)
const schedulingFailure = detail.scheduling_failure ?? null
if (schedulingFailure) {
const message = schedulingFailureMessage(schedulingFailure, fallbackDomain, fallbackErrorMessage)
if (message) {
return {
title: nonEmptyString(schedulingFailure.title) ?? '本地调度失败',
message,
isSchedulingFailure: true,
meta: uniqueMeta([
nonEmptyString(schedulingFailure.reason_summary),
nonEmptyString(schedulingFailure.reason_label),
nonEmptyString(schedulingFailure.reason),
formatHttpStatus(schedulingFailure.status_code ?? detail.status_code),
schedulingFailure.no_upstream_attempt ? '未进入上游执行' : null,
]),
}
}
}
const domain = fallbackDomain
const message = nonEmptyString(domain?.message) ?? fallbackErrorMessage
if (!message) return null
return {
title: '执行失败原因',
message,
isSchedulingFailure: false,
meta: uniqueMeta([
formatHttpStatus(domain?.status_code ?? detail.status_code),
nonEmptyString(domain?.type),
nonEmptyString(domain?.source),
]),
}
}