mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Merge remote-tracking branch 'upstream/main'
# Conflicts: # apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/payloads.rs # apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/reads.rs # apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/update.rs # apps/aether-gateway/src/tests/control/admin/endpoints/routes.rs # frontend/src/features/models/components/GlobalModelFormDialog.vue # frontend/src/features/providers/components/ProviderModelFormDialog.vue # frontend/src/features/providers/components/provider-tabs/__tests__/model-test-request.spec.ts # frontend/src/features/providers/components/provider-tabs/model-test-request.ts
This commit is contained in:
117
frontend/src/api/__tests__/usage-contract.spec.ts
Normal file
117
frontend/src/api/__tests__/usage-contract.spec.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -207,6 +218,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
|
||||
@@ -367,7 +379,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 () => {
|
||||
@@ -428,7 +440,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 () => {
|
||||
|
||||
@@ -70,6 +70,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 {
|
||||
@@ -113,6 +114,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
|
||||
@@ -259,6 +271,7 @@ export interface UpstreamModel {
|
||||
owned_by?: string
|
||||
display_name?: string
|
||||
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
|
||||
model_test_capabilities?: ModelTestCapabilities | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface UsageFilters {
|
||||
user_id?: string // UUID
|
||||
provider_id?: string // UUID
|
||||
model?: string
|
||||
search?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
@@ -110,6 +111,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)
|
||||
@@ -184,8 +351,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> {
|
||||
@@ -244,16 +412,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?: {
|
||||
|
||||
@@ -131,13 +131,19 @@
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<form
|
||||
ref="loginFormEl"
|
||||
name="login"
|
||||
action="/api/auth/login"
|
||||
method="post"
|
||||
class="space-y-4"
|
||||
autocomplete="on"
|
||||
data-form-type="login"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label
|
||||
for="login-email"
|
||||
for="username"
|
||||
class="text-sm"
|
||||
>
|
||||
{{ emailLabel }}
|
||||
@@ -160,29 +166,35 @@
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="login-email"
|
||||
id="username"
|
||||
v-model="form.email"
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
placeholder="用户名或邮箱"
|
||||
autocomplete="off"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
:disable-autofill="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="login-password"
|
||||
for="password"
|
||||
class="text-sm"
|
||||
>
|
||||
密码
|
||||
</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
placeholder="输入密码"
|
||||
autocomplete="off"
|
||||
autocomplete="current-password"
|
||||
:disable-autofill="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -288,6 +300,7 @@ const ldapEnabled = ref(false)
|
||||
const ldapExclusive = ref(false)
|
||||
|
||||
const oauthProviders = ref<OAuthProviderInfo[]>([])
|
||||
const loginFormEl = ref<HTMLFormElement | null>(null)
|
||||
|
||||
// 保存用户的认证类型偏好
|
||||
watch(authType, (newType) => {
|
||||
@@ -328,30 +341,69 @@ function fillDemoAccount(type: 'admin' | 'user') {
|
||||
form.value.password = account.password
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!form.value.email || !form.value.password) {
|
||||
async function handleLogin(event?: Event) {
|
||||
const { email, password } = readCurrentLoginCredentials(event)
|
||||
|
||||
if (!email || !password) {
|
||||
showWarning('请输入邮箱和密码')
|
||||
return
|
||||
}
|
||||
|
||||
const success = await authStore.login(form.value.email, form.value.password, authType.value)
|
||||
const success = await authStore.login(email, password, authType.value)
|
||||
if (success) {
|
||||
const targetPath = consumeStoredRedirectPath() ?? (authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard')
|
||||
|
||||
try {
|
||||
const navigationFailure = await router.push(targetPath)
|
||||
if (navigationFailure) {
|
||||
throw navigationFailure
|
||||
}
|
||||
} catch {
|
||||
showError('登录成功,但跳转失败,请刷新页面或手动进入控制台')
|
||||
return
|
||||
}
|
||||
|
||||
showSuccess('登录成功,正在跳转...')
|
||||
|
||||
// 关闭对话框
|
||||
isOpen.value = false
|
||||
|
||||
// 延迟一下让用户看到成功消息
|
||||
setTimeout(() => {
|
||||
// 根据用户角色跳转到不同的仪表盘
|
||||
const targetPath = authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
router.push(targetPath)
|
||||
}, 1000)
|
||||
} else {
|
||||
showError(authStore.error || '登录失败,请检查邮箱和密码')
|
||||
}
|
||||
}
|
||||
|
||||
function readCurrentLoginCredentials(event?: Event): { email: string; password: string } {
|
||||
const formElement = event?.currentTarget instanceof HTMLFormElement
|
||||
? event.currentTarget
|
||||
: loginFormEl.value
|
||||
|
||||
const emailInput = formElement?.elements.namedItem('username')
|
||||
const passwordInput = formElement?.elements.namedItem('password')
|
||||
|
||||
const email = emailInput instanceof HTMLInputElement
|
||||
? emailInput.value.trim()
|
||||
: form.value.email.trim()
|
||||
const password = passwordInput instanceof HTMLInputElement
|
||||
? passwordInput.value
|
||||
: form.value.password
|
||||
|
||||
form.value.email = email
|
||||
form.value.password = password
|
||||
|
||||
return { email, password }
|
||||
}
|
||||
|
||||
function consumeStoredRedirectPath(): string | null {
|
||||
const redirectPath = sessionStorage.getItem('redirectPath')
|
||||
if (redirectPath) {
|
||||
sessionStorage.removeItem('redirectPath')
|
||||
}
|
||||
if (!redirectPath || redirectPath === '/' || !redirectPath.startsWith('/') || redirectPath.startsWith('//')) {
|
||||
return null
|
||||
}
|
||||
return redirectPath
|
||||
}
|
||||
|
||||
function handleOAuthLogin(providerType: string) {
|
||||
// 如果 sessionStorage 中没有 redirectPath(用户直接点击登录而非被守卫拦截),
|
||||
// 则不设置,让 AuthCallback 使用默认跳转逻辑
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
|
||||
import LoginDialog from '../LoginDialog.vue'
|
||||
|
||||
const authStoreMock = vi.hoisted(() => ({
|
||||
loading: false,
|
||||
error: '',
|
||||
canAccessAdmin: false,
|
||||
login: vi.fn(),
|
||||
}))
|
||||
|
||||
const routerPushMock = vi.hoisted(() => vi.fn())
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}))
|
||||
|
||||
const authApiMocks = vi.hoisted(() => ({
|
||||
getRegistrationSettings: vi.fn(),
|
||||
getAuthSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
const oauthApiMocks = vi.hoisted(() => ({
|
||||
getProviders: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: routerPushMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => authStoreMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => toastMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSiteInfo', () => ({
|
||||
useSiteInfo: () => ({
|
||||
siteName: 'Aether',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config/demo', () => ({
|
||||
isDemoMode: () => false,
|
||||
DEMO_ACCOUNTS: {
|
||||
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
|
||||
user: { email: 'user@demo.aether.io', password: 'demo123' },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
authApi: authApiMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/oauth', () => ({
|
||||
oauthApi: oauthApiMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/deviceId', () => ({
|
||||
getClientDeviceId: () => 'device-123',
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/url', () => ({
|
||||
getApiUrl: (path: string) => path,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/oauth-icons', () => ({
|
||||
getOAuthIcon: () => '',
|
||||
}))
|
||||
|
||||
vi.mock('../RegisterDialog.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'RegisterDialogStub',
|
||||
setup() {
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
Dialog: defineComponent({
|
||||
name: 'DialogStub',
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { slots }) {
|
||||
return () => props.modelValue ? h('div', { 'data-testid': 'dialog' }, slots.default?.()) : null
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ButtonStub',
|
||||
props: {
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'button' },
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h('button', {
|
||||
...attrs,
|
||||
type: props.type,
|
||||
disabled: props.disabled,
|
||||
}, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/label.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'LabelStub',
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h('label', attrs, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountLoginDialog() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(LoginDialog, {
|
||||
modelValue: true,
|
||||
'onUpdate:modelValue': vi.fn(),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authStoreMock.loading = false
|
||||
authStoreMock.error = ''
|
||||
authStoreMock.canAccessAdmin = false
|
||||
authStoreMock.login.mockReset()
|
||||
routerPushMock.mockReset()
|
||||
toastMocks.success.mockReset()
|
||||
toastMocks.warning.mockReset()
|
||||
toastMocks.error.mockReset()
|
||||
authApiMocks.getRegistrationSettings.mockResolvedValue({
|
||||
enable_registration: false,
|
||||
require_email_verification: false,
|
||||
email_configured: true,
|
||||
password_policy_level: 'weak',
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: null,
|
||||
})
|
||||
authApiMocks.getAuthSettings.mockResolvedValue({
|
||||
local_enabled: true,
|
||||
ldap_enabled: false,
|
||||
ldap_exclusive: false,
|
||||
})
|
||||
oauthApiMocks.getProviders.mockResolvedValue([])
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('LoginDialog password manager contract', () => {
|
||||
it('exposes standard login form and field autocomplete metadata', async () => {
|
||||
const root = mountLoginDialog()
|
||||
await settle()
|
||||
|
||||
const form = root.querySelector('form')
|
||||
expect(form?.getAttribute('name')).toBe('login')
|
||||
expect(form?.getAttribute('action')).toBe('/api/auth/login')
|
||||
expect(form?.getAttribute('method')).toBe('post')
|
||||
expect(form?.getAttribute('autocomplete')).toBe('on')
|
||||
expect(form?.getAttribute('data-form-type')).toBe('login')
|
||||
|
||||
const username = root.querySelector<HTMLInputElement>('input[name="username"]')
|
||||
const password = root.querySelector<HTMLInputElement>('input[name="password"]')
|
||||
|
||||
expect(username?.id).toBe('username')
|
||||
expect(username?.getAttribute('autocomplete')).toBe('username')
|
||||
expect(username?.getAttribute('autocapitalize')).toBe('none')
|
||||
expect(username?.getAttribute('spellcheck')).toBe('false')
|
||||
expect(password?.id).toBe('password')
|
||||
expect(password?.type).toBe('password')
|
||||
expect(password?.getAttribute('autocomplete')).toBe('current-password')
|
||||
})
|
||||
|
||||
it('submits DOM-filled credentials and awaits router navigation without timer delay', async () => {
|
||||
authStoreMock.login.mockResolvedValue(true)
|
||||
routerPushMock.mockResolvedValue(undefined)
|
||||
sessionStorage.setItem('redirectPath', '/admin/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"]')
|
||||
expect(form).not.toBeNull()
|
||||
expect(username).not.toBeNull()
|
||||
expect(password).not.toBeNull()
|
||||
|
||||
username!.value = ' admin@example.com '
|
||||
password!.value = 'secret-from-manager'
|
||||
form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await settle()
|
||||
|
||||
expect(authStoreMock.login).toHaveBeenCalledWith('admin@example.com', 'secret-from-manager', 'local')
|
||||
expect(routerPushMock).toHaveBeenCalledWith('/admin/dashboard')
|
||||
expect(sessionStorage.getItem('redirectPath')).toBeNull()
|
||||
expect(toastMocks.success).toHaveBeenCalledWith('登录成功,正在跳转...')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -359,7 +386,7 @@
|
||||
{{ isEditMode ? '保存' : '添加' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedModel && !isEditMode"
|
||||
v-if="(selectedModel || manualModelMode) && !isEditMode"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@click="clearSelection"
|
||||
@@ -398,6 +425,7 @@ import {
|
||||
EMBEDDING_API_FORMATS,
|
||||
buildGlobalModelCreatePayload,
|
||||
buildGlobalModelUpdatePayload,
|
||||
getModelDirectoryEmptyText,
|
||||
} from './global-model-form-helpers'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -420,6 +448,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(() => {
|
||||
@@ -482,6 +512,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) {
|
||||
@@ -494,6 +532,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)
|
||||
|
||||
@@ -754,11 +802,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
|
||||
}
|
||||
@@ -774,6 +826,7 @@ watch(() => props.open, (isOpen) => {
|
||||
// 选择模型并填充表单
|
||||
function selectModel(model: ModelsDevModelItem) {
|
||||
imageGenerationExplicitOverride.value = null
|
||||
manualModelMode.value = false
|
||||
selectedModel.value = model
|
||||
expandedProvider.value = model.providerId
|
||||
form.value.name = model.modelId
|
||||
@@ -823,6 +876,7 @@ function selectModel(model: ModelsDevModelItem) {
|
||||
// 清除选择(手动填写)
|
||||
function clearSelection() {
|
||||
imageGenerationExplicitOverride.value = null
|
||||
manualModelMode.value = false
|
||||
selectedModel.value = null
|
||||
form.value = defaultForm()
|
||||
tieredPricing.value = null
|
||||
@@ -843,6 +897,8 @@ function resetForm() {
|
||||
searchQuery.value = ''
|
||||
selectedModel.value = null
|
||||
expandedProvider.value = null
|
||||
manualModelMode.value = false
|
||||
modelListLoadFailed.value = false
|
||||
}
|
||||
|
||||
// 加载模型数据(编辑模式)
|
||||
@@ -885,6 +941,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和名称')
|
||||
|
||||
@@ -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('未找到模型')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -320,7 +273,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 {
|
||||
@@ -387,7 +340,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)
|
||||
@@ -422,15 +374,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>,
|
||||
// 能力配置
|
||||
@@ -446,7 +392,6 @@ const imageGenerationExplicitOverride = ref<boolean | null>(null)
|
||||
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
|
||||
})
|
||||
|
||||
@@ -462,8 +407,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)) : {},
|
||||
@@ -526,8 +469,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,
|
||||
@@ -543,7 +484,6 @@ function resetForm() {
|
||||
tieredPricingModified.value = false
|
||||
originalTieredPricing.value = ''
|
||||
availableGlobalModels.value = []
|
||||
manualGlobalModelMode.value = false
|
||||
}
|
||||
|
||||
function handleGlobalModelSelect(value: string) {
|
||||
@@ -554,16 +494,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 modelSupportsImageGeneration(model: {
|
||||
supported_capabilities?: string[] | null
|
||||
supports_image_generation?: boolean | null
|
||||
@@ -753,32 +683,6 @@ function _copyVideoPricingFromSelectedGlobal() {
|
||||
configTouched.value = true
|
||||
}
|
||||
|
||||
async function createManualGlobalModel(
|
||||
finalTieredPricing: TieredPricingConfig | null,
|
||||
cleanConfig: Record<string, unknown> | undefined,
|
||||
supportsImageGeneration: boolean,
|
||||
): 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,
|
||||
supportsImageGeneration ? '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
|
||||
@@ -816,7 +720,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
|
||||
}
|
||||
|
||||
@@ -850,21 +754,19 @@ async function handleSubmit() {
|
||||
showSuccess('模型配置已更新')
|
||||
} else {
|
||||
// 添加模式:只有用户修改了配置才提交 tiered_pricing,否则保持继承关系
|
||||
const selectedModel = manualGlobalModelMode.value
|
||||
? await createManualGlobalModel(finalTieredPricing, cleanConfig, supportsImageGeneration)
|
||||
: 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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('')
|
||||
@@ -716,6 +718,7 @@ function handleTestDialogClose() {
|
||||
modelTest.resetState()
|
||||
pendingMappingKey.value = null
|
||||
testingModelName.value = null
|
||||
testingSourceModel.value = null
|
||||
testingMapping.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
mappingTestEndpoints.value = null
|
||||
@@ -738,8 +741,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('暂无可用于测试的活跃端点')
|
||||
@@ -750,8 +770,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()
|
||||
@@ -763,6 +787,7 @@ function resetMappingTestRequestBody() {
|
||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
||||
testingModelName.value,
|
||||
selectedTestEndpoint.value?.api_format,
|
||||
testingSourceModel.value,
|
||||
)
|
||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||
}
|
||||
@@ -773,6 +798,7 @@ function syncMappingTestRequestBody() {
|
||||
const nextResetValue = buildDefaultModelTestRequestBody(
|
||||
testingModelName.value,
|
||||
selectedTestEndpoint.value?.api_format,
|
||||
testingSourceModel.value,
|
||||
)
|
||||
const next = syncModelTestRequestBodyDraft(
|
||||
testRequestBodyDraft.value,
|
||||
@@ -840,7 +866,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)
|
||||
}
|
||||
|
||||
// 测试正则映射
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ import {
|
||||
normalizeModelTestMappedModelSelection,
|
||||
parseModelTestRequestHeadersDraft,
|
||||
parseModelTestRequestBodyDraft,
|
||||
selectPreferredModelTestEndpoint,
|
||||
syncModelTestRequestBodyDraft,
|
||||
} from './model-test-request'
|
||||
|
||||
@@ -587,7 +588,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()
|
||||
@@ -595,6 +596,7 @@ async function testModelConnection(model: Model) {
|
||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
||||
requestedModelName,
|
||||
selectedTestEndpoint.value?.api_format,
|
||||
model,
|
||||
)
|
||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||
modelTest.testResult.value = null
|
||||
@@ -620,7 +622,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,
|
||||
@@ -638,6 +644,7 @@ function resetTestRequestBodyForSelectedEndpoint() {
|
||||
const nextResetValue = buildDefaultModelTestRequestBody(
|
||||
modelName,
|
||||
selectedTestEndpoint.value?.api_format,
|
||||
pendingTestModel.value,
|
||||
)
|
||||
const next = syncModelTestRequestBodyDraft(
|
||||
testRequestBodyDraft.value,
|
||||
|
||||
@@ -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'
|
||||
@@ -68,6 +71,28 @@ describe('buildDefaultModelTestRequestBody', () => {
|
||||
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',
|
||||
@@ -219,6 +244,7 @@ describe('isModelTestableApiFormat', () => {
|
||||
'openai:image',
|
||||
'claude:messages',
|
||||
'gemini:generate_content',
|
||||
'openai:image',
|
||||
'openai:embedding',
|
||||
'jina:rerank',
|
||||
])('allows synchronous model-test endpoint formats: %s', (apiFormat) => {
|
||||
@@ -226,6 +252,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 = [
|
||||
@@ -373,6 +468,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',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,350 +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
|
||||
auth_type?: string | null
|
||||
credential_kind?: string | null
|
||||
oauth_managed?: 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_OAUTH_INHERITS_PROVIDER_FORMATS = new Set([
|
||||
'claude_code',
|
||||
'codex',
|
||||
'chatgpt_web',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro',
|
||||
])
|
||||
|
||||
const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([
|
||||
'chatgpt_web',
|
||||
])
|
||||
|
||||
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,
|
||||
providerType?: string | null,
|
||||
): boolean {
|
||||
if (key.is_active === false) return false
|
||||
|
||||
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
|
||||
if (!isModelTestableApiFormat(endpointFormat)) return false
|
||||
|
||||
if (modelTestKeyInheritsProviderFormats(key, providerType)) return true
|
||||
|
||||
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[],
|
||||
providerType?: string | null,
|
||||
): boolean {
|
||||
return endpoint.is_active !== false
|
||||
&& isModelTestableApiFormat(endpoint.api_format)
|
||||
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint, providerType))
|
||||
}
|
||||
|
||||
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 modelTestKeyInheritsProviderFormats(
|
||||
key: ModelTestKeySource,
|
||||
providerType: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedProviderType = providerType?.trim().toLowerCase()
|
||||
if (!normalizedProviderType) return false
|
||||
|
||||
const authType = key.auth_type?.trim().toLowerCase()
|
||||
const credentialKind = key.credential_kind?.trim().toLowerCase()
|
||||
const oauthManaged = key.oauth_managed === true
|
||||
|| credentialKind === 'oauth_session'
|
||||
|| authType === 'oauth'
|
||||
|
||||
if (oauthManaged && MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return authType === 'bearer'
|
||||
&& MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -450,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 {
|
||||
const normalizedApiFormat = normalizeApiFormatAlias(apiFormat ?? '')
|
||||
|
||||
if (normalizedApiFormat.endsWith(':embedding')) {
|
||||
@@ -485,6 +183,24 @@ export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?:
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (normalizedApiFormat === '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: [
|
||||
@@ -557,4 +273,4 @@ export function parseModelTestRequestHeadersDraft(
|
||||
emptyError: null,
|
||||
invalidTypeError: '测试请求头必须是 JSON 对象',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
@@ -749,7 +790,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'
|
||||
@@ -769,6 +810,7 @@ import {
|
||||
resolveDisplayRequestStatus,
|
||||
resolveUsageStreamLabelSegments,
|
||||
} from '../utils/status'
|
||||
import { resolveRequestFailureNotice } from '../utils/errorNotice'
|
||||
|
||||
// 子组件
|
||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||
@@ -1076,6 +1118,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),
|
||||
)
|
||||
@@ -1985,6 +2029,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) {
|
||||
@@ -2031,12 +2076,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
|
||||
|
||||
|
||||
108
frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts
Normal file
108
frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
82
frontend/src/features/usage/utils/errorNotice.ts
Normal file
82
frontend/src/features/usage/utils/errorNotice.ts
Normal 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),
|
||||
]),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user