Merge remote-tracking branch 'origin/aether-rust-pioneer' into aether-rust-pioneer

# Conflicts:
#	crates/aether-data-contracts/src/repository/usage/mod.rs
#	crates/aether-data/src/repository/global_models/postgres.rs
#	crates/aether-data/src/repository/usage/postgres/mod.rs
This commit is contained in:
fawney19
2026-05-05 18:53:14 +08:00
107 changed files with 7399 additions and 244 deletions

View File

@@ -200,6 +200,7 @@ export interface ModelExport {
supports_streaming?: boolean | null
supports_extended_thinking?: boolean | null
supports_image_generation?: boolean | null
supports_embedding?: boolean | null
is_active: boolean
config?: Record<string, unknown>
}
@@ -446,6 +447,49 @@ export interface PercentileItem {
p99_first_byte_time_ms?: number | null
}
export interface ProviderPerformanceSummary {
request_count: number
success_rate: number
avg_output_tps: number | null
avg_first_byte_time_ms: number | null
avg_response_time_ms: number | null
}
export interface ProviderPerformanceItem {
provider_id: string
provider: string
request_count: number
success_count: number
error_count: number
success_rate: number
output_tokens: number
avg_output_tps: number | null
avg_first_byte_time_ms: number | null
avg_response_time_ms: number | null
p90_response_time_ms: number | null
p90_first_byte_time_ms: number | null
tps_sample_count: number
first_byte_sample_count: number
}
export interface ProviderPerformanceTimelineItem {
date: string
provider_id: string
provider: string
request_count: number
output_tokens: number
avg_output_tps: number | null
avg_first_byte_time_ms: number | null
avg_response_time_ms: number | null
success_rate: number
}
export interface ProviderPerformanceResponse {
summary: ProviderPerformanceSummary
providers: ProviderPerformanceItem[]
timeline: ProviderPerformanceTimelineItem[]
}
export interface ErrorDistributionItem {
category: string
count: number
@@ -931,6 +975,28 @@ export const adminApi = {
)
},
async getProviderPerformance(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
granularity?: 'day' | 'hour'
limit?: number
}): Promise<ProviderPerformanceResponse> {
const cacheKey = buildCacheKey('admin:stats:performance:providers', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<ProviderPerformanceResponse>('/api/admin/stats/performance/providers', {
params
})
return response.data
},
20 * 1000
)
},
async getErrorDistribution(params?: {
start_date?: string
end_date?: string

View File

@@ -15,6 +15,30 @@ describe('api format display helpers', () => {
expect(normalizeApiFormatAlias('OPENAI_RESPONSES')).toBe(API_FORMATS.OPENAI_RESPONSES)
expect(normalizeApiFormatAlias('OPENAI_RESPONSES_COMPACT')).toBe(API_FORMATS.OPENAI_RESPONSES_COMPACT)
expect(normalizeApiFormatAlias('GEMINI_GENERATE_CONTENT')).toBe(API_FORMATS.GEMINI_GENERATE_CONTENT)
expect(normalizeApiFormatAlias('OPENAI_EMBEDDING')).toBe(API_FORMATS.OPENAI_EMBEDDING)
expect(normalizeApiFormatAlias('OPENAI_RERANK')).toBe(API_FORMATS.OPENAI_RERANK)
expect(normalizeApiFormatAlias('GEMINI_EMBEDDING')).toBe(API_FORMATS.GEMINI_EMBEDDING)
expect(normalizeApiFormatAlias('JINA_EMBEDDING')).toBe(API_FORMATS.JINA_EMBEDDING)
expect(normalizeApiFormatAlias('JINA_RERANK')).toBe(API_FORMATS.JINA_RERANK)
expect(normalizeApiFormatAlias('DOUBAO_EMBEDDING')).toBe(API_FORMATS.DOUBAO_EMBEDDING)
})
it('formats rerank api format ids distinctly from chat formats', () => {
expect(formatApiFormat(API_FORMATS.OPENAI_RERANK)).toBe('OpenAI Rerank')
expect(formatApiFormat(API_FORMATS.JINA_RERANK)).toBe('Jina Rerank')
expect(formatApiFormatShort(API_FORMATS.OPENAI_RERANK)).toBe('ORR')
expect(formatApiFormatShort(API_FORMATS.JINA_RERANK)).toBe('JR')
})
it('formats embedding api format ids distinctly from chat formats', () => {
expect(formatApiFormat(API_FORMATS.OPENAI_EMBEDDING)).toBe('OpenAI Embedding')
expect(formatApiFormat(API_FORMATS.GEMINI_EMBEDDING)).toBe('Gemini Embedding')
expect(formatApiFormat(API_FORMATS.JINA_EMBEDDING)).toBe('Jina Embedding')
expect(formatApiFormat(API_FORMATS.DOUBAO_EMBEDDING)).toBe('Doubao Embedding')
expect(formatApiFormatShort(API_FORMATS.OPENAI_EMBEDDING)).toBe('OE')
expect(formatApiFormatShort(API_FORMATS.GEMINI_EMBEDDING)).toBe('GE')
expect(formatApiFormatShort(API_FORMATS.JINA_EMBEDDING)).toBe('JE')
expect(formatApiFormatShort(API_FORMATS.DOUBAO_EMBEDDING)).toBe('DE')
})
it('does not remap retired api format ids', () => {
@@ -40,15 +64,59 @@ describe('api format display helpers', () => {
it('sorts only current canonical formats into known slots', () => {
expect(sortApiFormats([
'openai:compact',
API_FORMATS.DOUBAO_EMBEDDING,
API_FORMATS.OPENAI,
API_FORMATS.OPENAI_RESPONSES,
API_FORMATS.OPENAI_EMBEDDING,
API_FORMATS.OPENAI_RERANK,
API_FORMATS.GEMINI_EMBEDDING,
API_FORMATS.JINA_EMBEDDING,
API_FORMATS.JINA_RERANK,
])).toEqual([
API_FORMATS.OPENAI,
API_FORMATS.OPENAI_RESPONSES,
API_FORMATS.OPENAI_EMBEDDING,
API_FORMATS.OPENAI_RERANK,
API_FORMATS.GEMINI_EMBEDDING,
API_FORMATS.JINA_EMBEDDING,
API_FORMATS.JINA_RERANK,
API_FORMATS.DOUBAO_EMBEDDING,
'openai:compact',
])
})
it('keeps embedding formats after chat/generation formats within each family', () => {
expect(sortApiFormats([
API_FORMATS.GEMINI_EMBEDDING,
API_FORMATS.OPENAI_EMBEDDING,
API_FORMATS.OPENAI_RERANK,
API_FORMATS.GEMINI_GENERATE_CONTENT,
API_FORMATS.OPENAI,
])).toEqual([
API_FORMATS.OPENAI,
API_FORMATS.OPENAI_EMBEDDING,
API_FORMATS.OPENAI_RERANK,
API_FORMATS.GEMINI_GENERATE_CONTENT,
API_FORMATS.GEMINI_EMBEDDING,
])
})
it('groups embedding api formats by provider family', () => {
expect(groupApiFormats([
API_FORMATS.DOUBAO_EMBEDDING,
API_FORMATS.JINA_RERANK,
API_FORMATS.JINA_EMBEDDING,
API_FORMATS.GEMINI_EMBEDDING,
API_FORMATS.OPENAI_EMBEDDING,
API_FORMATS.OPENAI_RERANK,
])).toEqual([
{ family: 'openai', label: 'OpenAI', formats: [API_FORMATS.OPENAI_EMBEDDING, API_FORMATS.OPENAI_RERANK] },
{ family: 'gemini', label: 'Gemini', formats: [API_FORMATS.GEMINI_EMBEDDING] },
{ family: 'jina', label: 'Jina', formats: [API_FORMATS.JINA_EMBEDDING, API_FORMATS.JINA_RERANK] },
{ family: 'doubao', label: 'Doubao', formats: [API_FORMATS.DOUBAO_EMBEDDING] },
])
})
it('groups retired enum-style aliases as unknown raw families', () => {
expect(groupApiFormats(['OPENAI_CLI'])).toEqual([{
family: 'openai_cli',

View File

@@ -8,10 +8,16 @@ export const API_FORMATS = {
OPENAI_RESPONSES_COMPACT: 'openai:responses:compact',
OPENAI_IMAGE: 'openai:image',
OPENAI_VIDEO: 'openai:video',
OPENAI_EMBEDDING: 'openai:embedding',
OPENAI_RERANK: 'openai:rerank',
GEMINI: 'gemini:generate_content',
GEMINI_GENERATE_CONTENT: 'gemini:generate_content',
GEMINI_VIDEO: 'gemini:video',
GEMINI_FILES: 'gemini:files',
GEMINI_EMBEDDING: 'gemini:embedding',
JINA_EMBEDDING: 'jina:embedding',
JINA_RERANK: 'jina:rerank',
DOUBAO_EMBEDDING: 'doubao:embedding',
} as const
export type APIFormat = typeof API_FORMATS[keyof typeof API_FORMATS]
@@ -24,9 +30,15 @@ export const API_FORMAT_LABELS: Record<string, string> = {
[API_FORMATS.OPENAI_RESPONSES_COMPACT]: 'OpenAI Responses Compact',
[API_FORMATS.OPENAI_IMAGE]: 'OpenAI Image',
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
[API_FORMATS.OPENAI_EMBEDDING]: 'OpenAI Embedding',
[API_FORMATS.OPENAI_RERANK]: 'OpenAI Rerank',
[API_FORMATS.GEMINI_GENERATE_CONTENT]: 'Gemini Generate Content',
[API_FORMATS.GEMINI_VIDEO]: 'Gemini Video',
[API_FORMATS.GEMINI_FILES]: 'Gemini Files',
[API_FORMATS.GEMINI_EMBEDDING]: 'Gemini Embedding',
[API_FORMATS.JINA_EMBEDDING]: 'Jina Embedding',
[API_FORMATS.JINA_RERANK]: 'Jina Rerank',
[API_FORMATS.DOUBAO_EMBEDDING]: 'Doubao Embedding',
CLAUDE: 'Claude Messages',
CLAUDE_MESSAGES: 'Claude Messages',
OPENAI: 'OpenAI Chat',
@@ -34,10 +46,16 @@ export const API_FORMAT_LABELS: Record<string, string> = {
OPENAI_RESPONSES_COMPACT: 'OpenAI Responses Compact',
OPENAI_IMAGE: 'OpenAI Image',
OPENAI_VIDEO: 'OpenAI Video',
OPENAI_EMBEDDING: 'OpenAI Embedding',
OPENAI_RERANK: 'OpenAI Rerank',
GEMINI: 'Gemini Generate Content',
GEMINI_GENERATE_CONTENT: 'Gemini Generate Content',
GEMINI_VIDEO: 'Gemini Video',
GEMINI_FILES: 'Gemini Files',
GEMINI_EMBEDDING: 'Gemini Embedding',
JINA_EMBEDDING: 'Jina Embedding',
JINA_RERANK: 'Jina Rerank',
DOUBAO_EMBEDDING: 'Doubao Embedding',
}
// API 格式缩写映射(用于空间紧凑的显示场景)
@@ -47,21 +65,33 @@ export const API_FORMAT_SHORT: Record<string, string> = {
[API_FORMATS.OPENAI_RESPONSES_COMPACT]: 'ORC',
[API_FORMATS.OPENAI_IMAGE]: 'OI',
[API_FORMATS.OPENAI_VIDEO]: 'OV',
[API_FORMATS.OPENAI_EMBEDDING]: 'OE',
[API_FORMATS.OPENAI_RERANK]: 'ORR',
[API_FORMATS.CLAUDE_MESSAGES]: 'CM',
[API_FORMATS.GEMINI_GENERATE_CONTENT]: 'G',
[API_FORMATS.GEMINI_VIDEO]: 'GV',
[API_FORMATS.GEMINI_FILES]: 'GF',
[API_FORMATS.GEMINI_EMBEDDING]: 'GE',
[API_FORMATS.JINA_EMBEDDING]: 'JE',
[API_FORMATS.JINA_RERANK]: 'JR',
[API_FORMATS.DOUBAO_EMBEDDING]: 'DE',
OPENAI: 'O',
OPENAI_RESPONSES: 'OR',
OPENAI_RESPONSES_COMPACT: 'ORC',
OPENAI_IMAGE: 'OI',
OPENAI_VIDEO: 'OV',
OPENAI_EMBEDDING: 'OE',
OPENAI_RERANK: 'ORR',
CLAUDE: 'CM',
CLAUDE_MESSAGES: 'CM',
GEMINI: 'G',
GEMINI_GENERATE_CONTENT: 'G',
GEMINI_VIDEO: 'GV',
GEMINI_FILES: 'GF',
GEMINI_EMBEDDING: 'GE',
JINA_EMBEDDING: 'JE',
JINA_RERANK: 'JR',
DOUBAO_EMBEDDING: 'DE',
}
// API 格式排序顺序(统一的显示顺序)
@@ -69,12 +99,18 @@ export const API_FORMAT_ORDER: string[] = [
API_FORMATS.OPENAI,
API_FORMATS.OPENAI_RESPONSES,
API_FORMATS.OPENAI_RESPONSES_COMPACT,
API_FORMATS.OPENAI_EMBEDDING,
API_FORMATS.OPENAI_RERANK,
API_FORMATS.OPENAI_IMAGE,
API_FORMATS.OPENAI_VIDEO,
API_FORMATS.CLAUDE_MESSAGES,
API_FORMATS.GEMINI_GENERATE_CONTENT,
API_FORMATS.GEMINI_EMBEDDING,
API_FORMATS.GEMINI_VIDEO,
API_FORMATS.GEMINI_FILES,
API_FORMATS.JINA_EMBEDDING,
API_FORMATS.JINA_RERANK,
API_FORMATS.DOUBAO_EMBEDDING,
]
// Family 显示名称映射
@@ -82,6 +118,8 @@ export const API_FORMAT_FAMILY_LABELS: Record<string, string> = {
openai: 'OpenAI',
claude: 'Claude',
gemini: 'Gemini',
jina: 'Jina',
doubao: 'Doubao',
}
// Kind 显示名称映射
@@ -94,10 +132,12 @@ export const API_FORMAT_KIND_LABELS: Record<string, string> = {
image: 'Image',
video: 'Video',
files: 'Files',
embedding: 'Embedding',
rerank: 'Rerank',
}
// Family 排序顺序
const FAMILY_ORDER = ['openai', 'claude', 'gemini']
const FAMILY_ORDER = ['openai', 'claude', 'gemini', 'jina', 'doubao']
// 工具函数:从 API 格式中提取 family 和 kind
export function parseApiFormat(format: string): { family: string; kind: string } {
@@ -124,6 +164,10 @@ export function normalizeApiFormatAlias(format: string | null | undefined): stri
return API_FORMATS.OPENAI_IMAGE
case 'OPENAI_VIDEO':
return API_FORMATS.OPENAI_VIDEO
case 'OPENAI_EMBEDDING':
return API_FORMATS.OPENAI_EMBEDDING
case 'OPENAI_RERANK':
return API_FORMATS.OPENAI_RERANK
case 'GEMINI':
case 'GEMINI_GENERATE_CONTENT':
return API_FORMATS.GEMINI_GENERATE_CONTENT
@@ -131,6 +175,14 @@ export function normalizeApiFormatAlias(format: string | null | undefined): stri
return API_FORMATS.GEMINI_VIDEO
case 'GEMINI_FILES':
return API_FORMATS.GEMINI_FILES
case 'GEMINI_EMBEDDING':
return API_FORMATS.GEMINI_EMBEDDING
case 'JINA_EMBEDDING':
return API_FORMATS.JINA_EMBEDDING
case 'JINA_RERANK':
return API_FORMATS.JINA_RERANK
case 'DOUBAO_EMBEDDING':
return API_FORMATS.DOUBAO_EMBEDDING
default:
return raw.toLowerCase()
}

View File

@@ -38,6 +38,7 @@ export interface Model {
supports_streaming?: boolean | null
supports_extended_thinking?: boolean | null
supports_image_generation?: boolean | null
supports_embedding?: boolean | null
// 有效值(合并 Model 和 GlobalModel 默认值后的结果)
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置
effective_input_price?: number | null
@@ -48,6 +49,7 @@ export interface Model {
effective_supports_streaming?: boolean | null
effective_supports_extended_thinking?: boolean | null
effective_supports_image_generation?: boolean | null
effective_supports_embedding?: boolean | null
is_active: boolean
is_available: boolean
created_at: string
@@ -96,6 +98,7 @@ export interface ModelCapabilities {
supports_vision: boolean
supports_function_calling: boolean
supports_streaming: boolean
supports_embedding: boolean
[key: string]: boolean
}
@@ -130,6 +133,7 @@ export interface ModelCatalogProviderDetail {
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
supports_embedding?: boolean | null
is_active: boolean
mapping_id?: string | null
}
@@ -211,6 +215,7 @@ export interface GlobalModelResponse {
default_tiered_pricing: TieredPricingConfig
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null
supports_embedding?: boolean | null
// 模型配置JSON格式
config?: Record<string, unknown> | null
// 统计数据

View File

@@ -340,6 +340,7 @@ export const meApi = {
default_price_per_request: number | null
default_tiered_pricing: TieredPricingConfig | null
supported_capabilities: string[] | null
supports_embedding?: boolean | null
config: Record<string, unknown> | null
usage_count: number
}>

View File

@@ -72,6 +72,7 @@ export interface ModelsDevModelItem {
supportsStructuredOutput?: boolean
supportsTemperature?: boolean
supportsAttachment?: boolean
supportsEmbedding?: boolean
openWeights?: boolean
deprecated?: boolean
official?: boolean // 是否来自官方提供商
@@ -180,6 +181,9 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
supportsStructuredOutput: model.structured_output,
supportsTemperature: model.temperature,
supportsAttachment: model.attachment,
supportsEmbedding: model.id.toLowerCase().includes('embedding')
|| model.name.toLowerCase().includes('embedding')
|| model.family?.toLowerCase().includes('embedding') === true,
openWeights: model.open_weights,
deprecated: model.deprecated,
official: provider.official,

View File

@@ -15,6 +15,7 @@ export interface PublicGlobalModel {
default_price_per_request: number | null // 按次计费价格
// Key 能力支持
supported_capabilities: string[] | null
supports_embedding?: boolean | null
// 模型配置JSON
config: Record<string, unknown> | null
// 调用次数

View File

@@ -142,7 +142,7 @@
>描述</Label>
<Input
id="model-description"
:model-value="form.config?.description || ''"
:model-value="getConfigInputValue('description')"
placeholder="简短描述此模型的特点"
@update:model-value="(v) => setConfigField('description', v || undefined)"
/>
@@ -155,7 +155,7 @@
>最大输出 Token</Label>
<Input
id="model-output-limit"
:model-value="form.config?.output_limit ?? ''"
:model-value="getConfigInputValue('output_limit')"
type="number"
min="1"
placeholder=" 8192"
@@ -169,7 +169,7 @@
>上下文窗口</Label>
<Input
id="model-context-limit"
:model-value="form.config?.context_limit ?? ''"
:model-value="getConfigInputValue('context_limit')"
type="number"
min="1"
placeholder=" 200000"
@@ -177,6 +177,33 @@
/>
</div>
</div>
<div class="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-2">
<div class="flex items-start gap-2">
<Checkbox
:model-value="isEmbeddingEnabled"
class="mt-0.5"
@update:model-value="setEmbeddingEnabled"
/>
<div class="space-y-1">
<div class="text-sm font-medium">
Embedding
</div>
<p class="text-xs text-muted-foreground">
标记为 Embeddings 模型,并使用独立的 embedding API 格式,不按 Chat 模型处理。
</p>
<div
v-if="isEmbeddingEnabled"
class="flex flex-wrap gap-1.5"
>
<span
v-for="format in embeddingApiFormats"
:key="format"
class="rounded-md border border-border/60 bg-background px-2 py-0.5 text-[11px] font-mono text-muted-foreground"
>{{ format }}</span>
</div>
</div>
</div>
</div>
</section>
<!-- 价格配置 -->
@@ -333,7 +360,7 @@ import {
Loader2, Layers, SquarePen,
Search, ChevronRight, Plus, Trash2
} from 'lucide-vue-next'
import { Dialog, Button, Input, Label } from '@/components/ui'
import { Dialog, Button, Input, Label, Checkbox } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
@@ -349,10 +376,13 @@ import {
createGlobalModel,
updateGlobalModel,
type GlobalModelResponse,
type GlobalModelCreate,
type GlobalModelUpdate,
} from '@/api/global-models'
import type { TieredPricingConfig } from '@/api/endpoints/types'
import {
EMBEDDING_API_FORMATS,
buildGlobalModelCreatePayload,
buildGlobalModelUpdatePayload,
} from './global-model-form-helpers'
const props = defineProps<{
open: boolean
@@ -476,6 +506,8 @@ const VIDEO_RESOLUTION_PRICE_PRESETS: Record<
],
}
const embeddingApiFormats = [...EMBEDDING_API_FORMATS]
interface FormData {
name: string
display_name: string
@@ -496,6 +528,12 @@ const defaultForm = (): FormData => ({
const form = ref<FormData>(defaultForm())
const isEmbeddingEnabled = computed(() => {
return form.value.supported_capabilities?.includes('embedding') === true
|| form.value.config?.embedding === true
|| form.value.config?.model_type === 'embedding'
})
const KEEP_FALSE_CONFIG_KEYS = new Set(['streaming'])
// 设置 config 字段
@@ -510,6 +548,34 @@ function setConfigField(key: string, value: unknown) {
}
}
function getConfigInputValue(key: string): string | number {
const value = form.value.config?.[key]
return typeof value === 'string' || typeof value === 'number' ? value : ''
}
function setEmbeddingEnabled(enabled: boolean) {
const caps = new Set(form.value.supported_capabilities || [])
if (enabled) {
caps.add('embedding')
setConfigField('embedding', true)
setConfigField('model_type', 'embedding')
setConfigField('streaming', false)
form.value.config = {
...(form.value.config || {}),
api_formats: [...embeddingApiFormats],
}
} else {
caps.delete('embedding')
setConfigField('embedding', undefined)
if (form.value.config?.model_type === 'embedding') setConfigField('model_type', undefined)
if (Array.isArray(form.value.config?.api_formats)
&& form.value.config.api_formats.every((format) => embeddingApiFormats.includes(String(format)))) {
setConfigField('api_formats', undefined)
}
}
form.value.supported_capabilities = [...caps]
}
function getNested(obj: unknown, path: string): unknown {
if (!obj || typeof obj !== 'object') return undefined
const parts = path.split('.').filter(Boolean)
@@ -670,7 +736,7 @@ function selectModel(model: ModelsDevModelItem) {
// 构建 config
const config: Record<string, unknown> = {
streaming: true,
streaming: model.supportsEmbedding ? false : true,
}
if (model.supportsVision) config.vision = true
if (model.supportsToolCall) config.function_calling = true
@@ -687,6 +753,10 @@ function selectModel(model: ModelsDevModelItem) {
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
form.value.config = config
form.value.supported_capabilities = model.supportsEmbedding ? ['embedding'] : []
if (model.supportsEmbedding) {
setEmbeddingEnabled(true)
}
loadVideoPricingFromConfig()
if (model.inputPrice !== undefined || model.outputPrice !== undefined) {
@@ -796,26 +866,11 @@ async function handleSubmit() {
submitting.value = true
try {
if (isEditMode.value && props.model) {
const updateData: GlobalModelUpdate = {
display_name: form.value.display_name,
config: cleanConfig || null,
default_price_per_request: form.value.default_price_per_request ?? null,
default_tiered_pricing: finalTieredPricing,
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : null,
is_active: form.value.is_active,
}
const updateData = buildGlobalModelUpdatePayload(form.value, finalTieredPricing)
await updateGlobalModel(props.model.id, updateData)
success('模型更新成功')
} else {
const createData: GlobalModelCreate = {
name: form.value.name ?? '',
display_name: form.value.display_name ?? '',
config: cleanConfig,
default_price_per_request: form.value.default_price_per_request ?? undefined,
default_tiered_pricing: finalTieredPricing,
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : undefined,
is_active: form.value.is_active,
}
const createData = buildGlobalModelCreatePayload(form.value, finalTieredPricing)
await createGlobalModel(createData)
success('模型创建成功')
clearSelection()

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
EMBEDDING_API_FORMATS,
buildGlobalModelCreatePayload,
buildGlobalModelUpdatePayload,
} from '../global-model-form-helpers'
const embeddingPricing = {
tiers: [{ up_to: null, input_price_per_1m: 0.02, output_price_per_1m: 0 }],
}
describe('global model form embedding payload helpers', () => {
it('preserves embedding metadata in create payloads', () => {
const payload = buildGlobalModelCreatePayload({
name: 'text-embedding-3-small',
display_name: 'text-embedding-3-small',
supported_capabilities: ['embedding'],
config: {
streaming: false,
embedding: true,
model_type: 'embedding',
api_formats: [...EMBEDDING_API_FORMATS],
},
is_active: true,
}, embeddingPricing)
expect(payload).toMatchObject({
name: 'text-embedding-3-small',
supported_capabilities: ['embedding'],
config: {
streaming: false,
embedding: true,
model_type: 'embedding',
api_formats: ['openai:embedding', 'gemini:embedding', 'jina:embedding', 'doubao:embedding'],
},
})
})
it('preserves embedding metadata in update payloads', () => {
const payload = buildGlobalModelUpdatePayload({
name: 'unused-on-update',
display_name: 'Jina Embeddings v3',
supported_capabilities: ['embedding'],
config: {
streaming: false,
embedding: true,
model_type: 'embedding',
api_formats: ['jina:embedding'],
},
is_active: true,
}, embeddingPricing)
expect(payload.supported_capabilities).toEqual(['embedding'])
expect(payload.config).toEqual({
streaming: false,
embedding: true,
model_type: 'embedding',
api_formats: ['jina:embedding'],
})
})
})

View File

@@ -0,0 +1,56 @@
import type { GlobalModelCreate, GlobalModelUpdate } from '@/api/global-models'
import type { TieredPricingConfig } from '@/api/endpoints/types'
export const EMBEDDING_API_FORMATS = [
'openai:embedding',
'gemini:embedding',
'jina:embedding',
'doubao:embedding',
] as const
export const RERANK_API_FORMATS = [
'openai:rerank',
'jina:rerank',
] as const
export interface GlobalModelFormPayloadState {
name: string
display_name: string
default_price_per_request?: number
supported_capabilities?: string[]
config?: Record<string, unknown>
is_active?: boolean
}
function cleanGlobalModelConfig(form: GlobalModelFormPayloadState): Record<string, unknown> | undefined {
return form.config && Object.keys(form.config).length > 0 ? form.config : undefined
}
export function buildGlobalModelCreatePayload(
form: GlobalModelFormPayloadState,
defaultTieredPricing: TieredPricingConfig,
): GlobalModelCreate {
return {
name: form.name ?? '',
display_name: form.display_name ?? '',
config: cleanGlobalModelConfig(form),
default_price_per_request: form.default_price_per_request ?? undefined,
default_tiered_pricing: defaultTieredPricing,
supported_capabilities: form.supported_capabilities?.length ? form.supported_capabilities : undefined,
is_active: form.is_active,
}
}
export function buildGlobalModelUpdatePayload(
form: GlobalModelFormPayloadState,
defaultTieredPricing: TieredPricingConfig,
): GlobalModelUpdate {
return {
display_name: form.display_name,
config: cleanGlobalModelConfig(form) || null,
default_price_per_request: form.default_price_per_request ?? null,
default_tiered_pricing: defaultTieredPricing,
supported_capabilities: form.supported_capabilities?.length ? form.supported_capabilities : null,
is_active: form.is_active,
}
}

View File

@@ -41,6 +41,17 @@
>
所有全局模型已添加到此 Provider
</p>
<div
v-if="selectedGlobalModelSupportsEmbedding"
class="rounded-lg border border-border/60 bg-muted/20 px-3 py-2"
>
<div class="text-sm font-medium">
Embedding
</div>
<p class="text-xs text-muted-foreground">
此模型将继承全局模型的 Embeddings 元数据不按 Chat 能力处理
</p>
</div>
</div>
<!-- 编辑模式显示模型信息 -->
@@ -56,6 +67,13 @@
<p class="text-sm text-muted-foreground font-mono">
{{ editingModel?.provider_model_name }}
</p>
<Badge
v-if="editingModelSupportsEmbedding"
variant="secondary"
class="mt-2 text-xs"
>
Embedding
</Badge>
</div>
</div>
</div>
@@ -214,6 +232,7 @@ import {
SelectValue,
SelectContent,
SelectItem,
Badge,
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
@@ -221,6 +240,11 @@ import { createModel, updateModel, getProviderModels } from '@/api/endpoints/mod
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
import type { Model, TieredPricingConfig } from '@/api/endpoints'
import {
buildProviderModelCreatePayload,
buildProviderModelUpdatePayload,
modelSupportsEmbedding,
} from './provider-model-form-helpers'
interface Props {
open: boolean
@@ -245,6 +269,16 @@ const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | nu
const isEditing = computed(() => !!props.editingModel)
const selectedGlobalModel = computed(() => {
return availableGlobalModels.value.find(model => model.id === form.value.global_model_id) || null
})
const selectedGlobalModelSupportsEmbedding = computed(() => modelSupportsEmbedding(selectedGlobalModel.value))
const editingModelSupportsEmbedding = computed(() => {
return props.editingModel?.effective_supports_embedding === true
|| modelSupportsEmbedding(props.editingModel)
})
// 1h 缓存定价始终显示
const showCache1h = true
@@ -571,35 +605,36 @@ async function handleSubmit() {
if (isEditing.value && props.editingModel) {
// 编辑模式
// 注意:使用 null 而不是 undefined 来显式清空字段undefined 会被 JSON 序列化忽略)
await updateModel(props.providerId, props.editingModel.id, {
tiered_pricing: finalTieredPricing,
price_per_request: form.value.price_per_request ?? null,
config: cleanConfig || null,
supports_vision: form.value.supports_vision,
supports_function_calling: form.value.supports_function_calling,
supports_streaming: form.value.supports_streaming,
supports_extended_thinking: form.value.supports_extended_thinking,
supports_image_generation: form.value.supports_image_generation,
is_active: form.value.is_active
})
await updateModel(props.providerId, props.editingModel.id, buildProviderModelUpdatePayload({
finalTieredPricing,
pricePerRequest: form.value.price_per_request,
cleanConfig,
supportsVision: form.value.supports_vision,
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,
supportsExtendedThinking: form.value.supports_extended_thinking,
supportsImageGeneration: form.value.supports_image_generation,
isActive: form.value.is_active
}))
showSuccess('模型配置已更新')
} else {
// 添加模式:只有用户修改了配置才提交 tiered_pricing否则保持继承关系
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
await createModel(props.providerId, {
global_model_id: form.value.global_model_id,
provider_model_name: selectedModel?.name || '',
// 只有修改了才提交,否则传 undefined 让后端继承 GlobalModel 配置
tiered_pricing: tieredPricingModified.value ? finalTieredPricing : undefined,
price_per_request: form.value.price_per_request,
config: configTouched.value ? cleanConfig : undefined,
supports_vision: form.value.supports_vision,
supports_function_calling: form.value.supports_function_calling,
supports_streaming: form.value.supports_streaming,
supports_extended_thinking: form.value.supports_extended_thinking,
supports_image_generation: form.value.supports_image_generation,
is_active: form.value.is_active
})
await createModel(props.providerId, buildProviderModelCreatePayload({
globalModelId: form.value.global_model_id,
providerModelName: selectedModel?.name || '',
finalTieredPricing,
tieredPricingModified: tieredPricingModified.value,
pricePerRequest: form.value.price_per_request,
cleanConfig,
configTouched: configTouched.value,
supportsVision: form.value.supports_vision,
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,
supportsExtendedThinking: form.value.supports_extended_thinking,
supportsImageGeneration: form.value.supports_image_generation,
isActive: form.value.is_active
}))
showSuccess('模型已添加')
}
emit('update:open', false)

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
buildProviderModelCreatePayload,
buildProviderModelUpdatePayload,
modelSupportsEmbedding,
} from '../provider-model-form-helpers'
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 0.02, output_price_per_1m: 0 }],
}
describe('provider model form embedding helpers', () => {
it.each([
{ supported_capabilities: ['embedding'], config: {} },
{ supported_capabilities: null, config: { embedding: true } },
{ supported_capabilities: null, config: { model_type: 'embedding' } },
{ supported_capabilities: null, config: { api_formats: ['doubao:embedding'] } },
{ supports_embedding: true, effective_supports_embedding: null, config: {} },
{ supports_embedding: null, effective_supports_embedding: true, config: {} },
])('detects embedding metadata from %o', (model) => {
expect(modelSupportsEmbedding(model)).toBe(true)
})
it('keeps provider create payload inherited from the selected embedding global model', () => {
const payload = buildProviderModelCreatePayload({
globalModelId: 'gm-embedding',
providerModelName: 'text-embedding-3-small',
finalTieredPricing: pricing,
tieredPricingModified: false,
pricePerRequest: undefined,
cleanConfig: {
embedding: true,
model_type: 'embedding',
api_formats: ['openai:embedding'],
},
configTouched: false,
supportsStreaming: false,
isActive: true,
})
expect(payload).toMatchObject({
global_model_id: 'gm-embedding',
provider_model_name: 'text-embedding-3-small',
tiered_pricing: undefined,
config: undefined,
supports_streaming: false,
})
expect('supports_embedding' in payload).toBe(false)
})
it('preserves edited provider embedding config without posting unsupported embedding controls', () => {
const payload = buildProviderModelUpdatePayload({
finalTieredPricing: pricing,
pricePerRequest: undefined,
cleanConfig: {
streaming: false,
embedding: true,
model_type: 'embedding',
api_formats: ['gemini:embedding'],
},
supportsStreaming: false,
isActive: true,
})
expect(payload.config).toEqual({
streaming: false,
embedding: true,
model_type: 'embedding',
api_formats: ['gemini:embedding'],
})
expect(payload.supports_streaming).toBe(false)
expect('supports_embedding' in payload).toBe(false)
})
})

View File

@@ -0,0 +1,79 @@
import type { ModelCreate, ModelUpdate, TieredPricingConfig } from '@/api/endpoints'
interface EmbeddingMetadataCarrier {
supported_capabilities?: string[] | null
supports_embedding?: boolean | null
effective_supports_embedding?: boolean | null
config?: Record<string, unknown> | null
}
export interface ProviderModelCreatePayloadInput {
globalModelId: string
providerModelName: string
finalTieredPricing: TieredPricingConfig | null
tieredPricingModified: boolean
pricePerRequest?: number
cleanConfig?: Record<string, unknown>
configTouched: boolean
supportsVision?: boolean
supportsFunctionCalling?: boolean
supportsStreaming?: boolean
supportsExtendedThinking?: boolean
supportsImageGeneration?: boolean
isActive: boolean
}
export interface ProviderModelUpdatePayloadInput {
finalTieredPricing: TieredPricingConfig | null
pricePerRequest?: number
cleanConfig?: Record<string, unknown>
supportsVision?: boolean
supportsFunctionCalling?: boolean
supportsStreaming?: boolean
supportsExtendedThinking?: boolean
supportsImageGeneration?: boolean
isActive: boolean
}
export function modelSupportsEmbedding(model: EmbeddingMetadataCarrier | null | undefined): boolean {
if (!model) return false
if ('effective_supports_embedding' in model && model.effective_supports_embedding === true) return true
if ('supports_embedding' in model && model.supports_embedding === true) return true
const supportedCapabilities = 'supported_capabilities' in model ? model.supported_capabilities : null
const config = model.config || {}
return supportedCapabilities?.includes('embedding') === true
|| config.embedding === true
|| config.model_type === 'embedding'
|| (Array.isArray(config.api_formats) && config.api_formats.some((format) => String(format).endsWith(':embedding')))
}
export function buildProviderModelCreatePayload(input: ProviderModelCreatePayloadInput): ModelCreate {
return {
global_model_id: input.globalModelId,
provider_model_name: input.providerModelName,
tiered_pricing: input.tieredPricingModified && input.finalTieredPricing ? input.finalTieredPricing : undefined,
price_per_request: input.pricePerRequest,
config: input.configTouched ? input.cleanConfig : undefined,
supports_vision: input.supportsVision,
supports_function_calling: input.supportsFunctionCalling,
supports_streaming: input.supportsStreaming,
supports_extended_thinking: input.supportsExtendedThinking,
supports_image_generation: input.supportsImageGeneration,
is_active: input.isActive,
}
}
export function buildProviderModelUpdatePayload(input: ProviderModelUpdatePayloadInput): ModelUpdate {
return {
tiered_pricing: input.finalTieredPricing,
price_per_request: input.pricePerRequest ?? null,
config: input.cleanConfig || null,
supports_vision: input.supportsVision,
supports_function_calling: input.supportsFunctionCalling,
supports_streaming: input.supportsStreaming,
supports_extended_thinking: input.supportsExtendedThinking,
supports_image_generation: input.supportsImageGeneration,
is_active: input.isActive,
}
}

View File

@@ -700,7 +700,7 @@ function runMappingTest(testingKey: string, modelName: string) {
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(modelName)
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(modelName, selectedTestEndpoint.value?.api_format)
testRequestBodyDraft.value = testRequestBodyResetValue.value
}

View File

@@ -543,6 +543,7 @@ async function testModelConnection(model: Model) {
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
model.global_model_name || model.provider_model_name,
selectedTestEndpoint.value?.api_format,
)
testRequestBodyDraft.value = testRequestBodyResetValue.value
modelTest.testResult.value = null

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { buildDefaultModelTestRequestBody } from '../model-test-request'
describe('buildDefaultModelTestRequestBody', () => {
it.each([
'openai:embedding',
'gemini:embedding',
'jina:embedding',
'doubao:embedding',
' OPENAI:EMBEDDING ',
])('uses embedding input payloads for %s api formats', (apiFormat) => {
const body = JSON.parse(buildDefaultModelTestRequestBody('text-embedding-3-small', apiFormat))
expect(body).toEqual({
model: 'text-embedding-3-small',
input: 'This is a test embedding input.',
})
expect(body.messages).toBeUndefined()
expect(body.stream).toBeUndefined()
})
it.each([
'openai:rerank',
'jina:rerank',
' JINA:RERANK ',
])('uses rerank query/documents payloads for %s api formats', (apiFormat) => {
const body = JSON.parse(buildDefaultModelTestRequestBody('bge-reranker-base', apiFormat))
expect(body.model).toBe('bge-reranker-base')
expect(body.query).toBe('This is a test rerank query.')
expect(body.documents).toHaveLength(2)
expect(body.top_n).toBe(1)
expect(body.return_documents).toBe(true)
expect(body.messages).toBeUndefined()
expect(body.stream).toBeUndefined()
})
it('keeps chat payloads for chat api formats', () => {
const body = JSON.parse(buildDefaultModelTestRequestBody('gpt-5.1', 'openai:chat'))
expect(body.messages).toEqual([{ role: 'user', content: 'Hello! This is a test message.' }])
expect(body.stream).toBe(true)
expect(body.input).toBeUndefined()
})
})

View File

@@ -4,7 +4,27 @@ const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
export const POOL_TEST_CONCURRENCY = 5
export const SINGLE_TEST_CONCURRENCY = 1
export function buildDefaultModelTestRequestBody(modelName: string): string {
export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?: string | null): string {
if (apiFormat?.trim().toLowerCase().endsWith(':embedding')) {
return JSON.stringify({
model: modelName,
input: 'This is a test embedding input.',
}, null, 2)
}
if (apiFormat?.trim().toLowerCase().endsWith(':rerank')) {
return JSON.stringify({
model: modelName,
query: 'This is a test rerank query.',
documents: [
'This document is relevant to the test query.',
'This document is unrelated.',
],
top_n: 1,
return_documents: true,
}, null, 2)
}
return JSON.stringify({
model: modelName,
messages: [

View File

@@ -8,10 +8,16 @@ const ENDPOINT_SORT_ORDER = [
'openai:chat',
'openai:responses',
'openai:responses:compact',
'openai:embedding',
'openai:rerank',
'gemini:generate_content',
'gemini:embedding',
'openai:video',
'gemini:video',
'gemini:files',
'jina:embedding',
'jina:rerank',
'doubao:embedding',
]
/**

View File

@@ -27,7 +27,13 @@ export function useProviderFilters(
{ value: 'openai:chat', label: 'OpenAI Chat' },
{ value: 'openai:responses', label: 'OpenAI Responses' },
{ value: 'openai:responses:compact', label: 'OpenAI Responses Compact' },
{ value: 'openai:embedding', label: 'OpenAI Embedding' },
{ value: 'openai:rerank', label: 'OpenAI Rerank' },
{ value: 'gemini:generate_content', label: 'Gemini Generate Content' },
{ value: 'gemini:embedding', label: 'Gemini Embedding' },
{ value: 'jina:embedding', label: 'Jina Embedding' },
{ value: 'jina:rerank', label: 'Jina Rerank' },
{ value: 'doubao:embedding', label: 'Doubao Embedding' },
]
const modelFilters = computed<FilterOption[]>(() => {

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { MOCK_API_FORMATS, MOCK_GLOBAL_MODELS } from '../data'
describe('embedding mock metadata', () => {
it('exposes embedding model metadata to frontend code without chat treatment', () => {
const model = MOCK_GLOBAL_MODELS.find(item => item.name === 'text-embedding-3-small')
expect(model).toMatchObject({
supported_capabilities: ['embedding'],
supports_embedding: true,
config: {
streaming: false,
embedding: true,
model_type: 'embedding',
api_formats: ['openai:embedding'],
},
})
})
it('includes all embedding API formats as distinct catalog formats', () => {
const embeddingFormats = MOCK_API_FORMATS.formats
.filter(format => format.value.endsWith(':embedding'))
.map(format => [format.value, format.label])
expect(embeddingFormats).toEqual([
['openai:embedding', 'OpenAI Embedding'],
['gemini:embedding', 'Gemini Embedding'],
['jina:embedding', 'Jina Embedding'],
['doubao:embedding', 'Doubao Embedding'],
])
})
it('includes rerank API formats as distinct catalog formats', () => {
const rerankFormats = MOCK_API_FORMATS.formats
.filter(format => format.value.endsWith(':rerank'))
.map(format => [format.value, format.label])
expect(rerankFormats).toEqual([
['openai:rerank', 'OpenAI Rerank'],
['jina:rerank', 'Jina Rerank'],
])
})
})

View File

@@ -546,20 +546,21 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
billing_type: 'pay_as_you_go',
monthly_used_usd: 5.29,
is_active: true,
total_endpoints: 4,
active_endpoints: 4,
total_endpoints: 5,
active_endpoints: 5,
total_keys: 11,
active_keys: 11,
total_models: 8,
active_models: 8,
total_models: 9,
active_models: 9,
avg_health_score: 0.863,
unhealthy_endpoints: 1,
api_formats: ['claude:messages', 'gemini:generate_content', 'openai:chat', 'openai:responses'],
api_formats: ['claude:messages', 'gemini:generate_content', 'openai:chat', 'openai:responses', 'openai:embedding'],
endpoint_health_details: [
{ api_format: 'claude:messages', health_score: 1.0, is_active: true, active_keys: 2 },
{ api_format: 'gemini:generate_content', health_score: 1.0, is_active: true, active_keys: 2 },
{ api_format: 'openai:chat', health_score: 0.85, is_active: true, active_keys: 2 },
{ api_format: 'openai:responses', health_score: 1.0, is_active: true, active_keys: 1 }
{ api_format: 'openai:responses', health_score: 1.0, is_active: true, active_keys: 1 },
{ api_format: 'openai:embedding', health_score: 0.98, is_active: true, active_keys: 1 }
],
created_at: '2024-12-07T22:56:09.712806+08:00',
updated_at: new Date().toISOString()
@@ -805,6 +806,46 @@ export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
},
provider_count: 2,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-010',
name: 'text-embedding-3-small',
display_name: 'text-embedding-3-small',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 0.02, output_price_per_1m: 0 }]
},
supported_capabilities: ['embedding'],
supports_embedding: true,
config: {
streaming: false,
embedding: true,
model_type: 'embedding',
api_formats: ['openai:embedding'],
dimensions: 1536,
description: 'OpenAI 文本向量嵌入模型'
},
provider_count: 1,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-rerank-001',
name: 'bge-reranker-base',
display_name: 'bge-reranker-base',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 0.05, output_price_per_1m: 0 }]
},
supported_capabilities: ['rerank'],
config: {
streaming: false,
rerank: true,
model_type: 'rerank',
api_formats: ['openai:rerank'],
description: '文本重排序模型'
},
provider_count: 1,
created_at: '2024-01-01T00:00:00Z'
}
]
@@ -878,9 +919,15 @@ export const MOCK_API_FORMATS = {
{ value: 'openai:chat', label: 'OpenAI Chat', default_path: '/v1/chat/completions', aliases: [] },
{ value: 'openai:responses', label: 'OpenAI Responses', default_path: '/v1/responses', aliases: [] },
{ value: 'openai:responses:compact', label: 'OpenAI Responses Compact', default_path: '/v1/responses/compact', aliases: [] },
{ value: 'openai:embedding', label: 'OpenAI Embedding', default_path: '/v1/embeddings', aliases: [] },
{ value: 'openai:rerank', label: 'OpenAI Rerank', default_path: '/v1/rerank', aliases: [] },
{ value: 'openai:image', label: 'OpenAI Image', default_path: '/v1/images/generations', aliases: [] },
{ value: 'openai:video', label: 'OpenAI Video', default_path: '/v1/videos', aliases: [] },
{ value: 'gemini:generate_content', label: 'Gemini Generate Content', default_path: '/v1beta/models/{model}:{action}', aliases: [] },
{ value: 'gemini:video', label: 'Gemini Video', default_path: '/v1beta/models/{model}:predictLongRunning', aliases: [] }
{ value: 'gemini:embedding', label: 'Gemini Embedding', default_path: '/v1beta/models/{model}:embedContent', aliases: [] },
{ value: 'gemini:video', label: 'Gemini Video', default_path: '/v1beta/models/{model}:predictLongRunning', aliases: [] },
{ value: 'jina:embedding', label: 'Jina Embedding', default_path: '/v1/embeddings', aliases: [] },
{ value: 'jina:rerank', label: 'Jina Rerank', default_path: '/v1/rerank', aliases: [] },
{ value: 'doubao:embedding', label: 'Doubao Embedding', default_path: '/embeddings/multimodal', aliases: [] }
]
}

View File

@@ -228,6 +228,19 @@ const MOCK_ENDPOINT_STATUS = {
last_event_at: new Date().toISOString(),
// 94.0% 成功率successRate=0.940, failRate=0.043, skipRate=0.017
events: generateHealthEvents(100, 0.940, 0.043, 0.017, 800, 600)
},
{
api_format: 'openai:embedding',
api_path: '/v1/embeddings',
total_attempts: 620,
success_count: 612,
failed_count: 6,
skipped_count: 2,
success_rate: 0.987,
provider_count: 1,
key_count: 1,
last_event_at: new Date().toISOString(),
events: generateHealthEvents(40, 0.987, 0.01, 0.003, 320, 140)
}
]
}
@@ -447,6 +460,12 @@ function getMockEndpointExtras(apiFormat: string) {
extras.config = { upstream_stream_policy: 'force_stream' }
} else if (normalizedFormat === 'openai:responses') {
extras.config = { upstream_stream_policy: 'force_non_stream' }
} else if (normalizedFormat === 'openai:embedding') {
extras.custom_path = '/v1/embeddings'
extras.config = { route_kind: 'embedding' }
} else if (normalizedFormat === 'openai:rerank' || normalizedFormat === 'jina:rerank') {
extras.custom_path = '/v1/rerank'
extras.config = { route_kind: 'rerank' }
} else if (normalizedFormat === 'gemini:generate_content') {
extras.custom_path = '/v1beta/models/gemini-3-pro-preview:generateContent'
extras.body_rules = [
@@ -720,6 +739,23 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
})))
},
'GET /api/users/me/available-models': async () => {
await delay()
const models = MOCK_GLOBAL_MODELS.filter(model => model.is_active).map(model => ({
id: model.id,
name: model.name,
display_name: model.display_name,
is_active: model.is_active,
default_price_per_request: model.default_price_per_request ?? null,
default_tiered_pricing: model.default_tiered_pricing,
supported_capabilities: model.supported_capabilities ?? null,
supports_embedding: model.supports_embedding ?? null,
config: model.config ?? null,
usage_count: model.usage_count ?? 0,
}))
return createMockResponse({ models, total: models.length })
},
'GET /api/users/me/preferences': async () => {
await delay()
return createMockResponse(getCurrentProfile().preferences || { theme: 'auto', language: 'zh-CN' })
@@ -1146,6 +1182,7 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
default_tiered_pricing: m.default_tiered_pricing,
default_price_per_request: m.default_price_per_request,
supported_capabilities: m.supported_capabilities,
supports_embedding: m.supports_embedding,
config: m.config
})),
total: MOCK_GLOBAL_MODELS.length
@@ -1419,6 +1456,8 @@ function generateMockModelsForProvider(providerId: string) {
const hasClaude = provider.api_formats.some(f => f.includes('claude'))
const hasOpenAI = provider.api_formats.some(f => f.includes('openai'))
const hasGemini = provider.api_formats.some(f => f.includes('gemini'))
const hasEmbedding = provider.api_formats.some(f => f.endsWith(':embedding'))
const hasRerank = provider.api_formats.some(f => f.endsWith(':rerank'))
const models: Record<string, unknown>[] = []
const now = new Date().toISOString()
@@ -1503,6 +1542,66 @@ function generateMockModelsForProvider(providerId: string) {
}
)
}
if (hasEmbedding) {
models.push({
id: `pm-${providerId}-embedding-1`,
provider_id: providerId,
global_model_id: 'gm-010',
provider_model_name: 'text-embedding-3-small',
global_model_name: 'text-embedding-3-small',
global_model_display_name: 'text-embedding-3-small',
effective_input_price: 0.02,
effective_output_price: 0,
supports_embedding: true,
effective_supports_embedding: true,
supports_streaming: false,
effective_supports_streaming: false,
config: {
embedding: true,
model_type: 'embedding',
api_formats: ['openai:embedding'],
},
effective_config: {
embedding: true,
model_type: 'embedding',
api_formats: ['openai:embedding'],
streaming: false,
},
is_active: true,
is_available: true,
created_at: provider.created_at,
updated_at: now
})
}
if (hasRerank) {
models.push({
id: `pm-${providerId}-rerank-1`,
provider_id: providerId,
global_model_id: 'gm-rerank-001',
provider_model_name: 'bge-reranker-base',
global_model_name: 'bge-reranker-base',
global_model_display_name: 'bge-reranker-base',
effective_input_price: 0.05,
effective_output_price: 0,
supports_streaming: false,
effective_supports_streaming: false,
config: {
rerank: true,
model_type: 'rerank',
api_formats: ['openai:rerank'],
},
effective_config: {
rerank: true,
model_type: 'rerank',
api_formats: ['openai:rerank'],
streaming: false,
},
is_active: true,
is_available: true,
created_at: provider.created_at,
updated_at: now
})
}
if (hasGemini) {
models.push(
{

View File

@@ -692,6 +692,7 @@ interface ModelProviderDisplay {
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
supports_extended_thinking?: boolean | null
supports_embedding?: boolean | null
}
const { success, error: showError } = useToast()
@@ -766,6 +767,7 @@ const editingProviderModel = computed<Model | null>(() => {
supports_vision: p.supports_vision,
supports_function_calling: p.supports_function_calling,
supports_extended_thinking: p.supports_extended_thinking,
supports_embedding: p.supports_embedding,
is_active: p.is_active,
global_model_display_name: selectedModel.value?.display_name,
} as Model
@@ -1180,7 +1182,8 @@ async function loadModelProviders(_globalModelId: string) {
// 能力信息
supports_vision: p.supports_vision,
supports_function_calling: p.supports_function_calling,
supports_streaming: p.supports_streaming
supports_streaming: p.supports_streaming,
supports_embedding: p.supports_embedding
}))
} catch (err: unknown) {
if (requestId !== modelProvidersRequestId) return

View File

@@ -491,6 +491,183 @@
</Card>
</div>
<Card class="space-y-4 p-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-sm font-semibold">
Provider 性能
</h3>
<p class="text-xs text-muted-foreground">
{{ providerPerformanceSubtitle }}
</p>
</div>
<Badge variant="outline">
Top {{ providerPerformanceRows.length || 0 }}
</Badge>
</div>
<div
v-if="providerPerformanceLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="space-y-4"
>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div
v-for="card in providerPerformanceSummaryCards"
:key="card.title"
class="rounded-xl border border-border/70 bg-card/70 px-4 py-3"
>
<div class="flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">{{ card.title }}</span>
<component
:is="card.icon"
class="h-4 w-4"
:class="card.iconClass"
/>
</div>
<div class="mt-3 text-2xl font-semibold tracking-tight">
{{ card.value }}
</div>
<div class="mt-2 text-xs text-muted-foreground">
{{ card.hint }}
</div>
</div>
</div>
<div
v-if="providerPerformanceRows.length"
class="overflow-x-auto rounded-lg border border-border/70"
>
<table class="min-w-full divide-y divide-border/70 text-sm">
<thead class="bg-muted/30 text-xs text-muted-foreground">
<tr>
<th class="px-3 py-2 text-left font-medium">
Provider
</th>
<th class="px-3 py-2 text-right font-medium">
请求
</th>
<th class="px-3 py-2 text-right font-medium">
成功率
</th>
<th class="px-3 py-2 text-right font-medium">
输出 TPS
</th>
<th class="px-3 py-2 text-right font-medium">
平均首字
</th>
<th class="px-3 py-2 text-right font-medium">
平均响应
</th>
<th class="px-3 py-2 text-right font-medium">
P90 响应 / 首字
</th>
<th class="px-3 py-2 text-right font-medium">
样本
</th>
</tr>
</thead>
<tbody class="divide-y divide-border/60">
<tr
v-for="provider in providerPerformanceRows"
:key="provider.provider_id"
class="bg-background/40"
>
<td class="max-w-[220px] px-3 py-2">
<div class="truncate font-medium">
{{ provider.provider }}
</div>
<div class="truncate text-xs text-muted-foreground">
{{ provider.provider_id }}
</div>
</td>
<td class="px-3 py-2 text-right">
{{ formatMetricNumber(provider.request_count) }}
</td>
<td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.success_rate, '%') }}
</td>
<td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.avg_output_tps, '/s') }}
</td>
<td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.avg_first_byte_time_ms, 'ms') }}
</td>
<td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.avg_response_time_ms, 'ms') }}
</td>
<td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.p90_response_time_ms, 'ms', 0) }}
/
{{ formatProviderPerformanceMetric(provider.p90_first_byte_time_ms, 'ms', 0) }}
</td>
<td class="px-3 py-2 text-right text-xs text-muted-foreground">
{{ formatMetricNumber(provider.tps_sample_count) }} /
{{ formatMetricNumber(provider.first_byte_sample_count) }}
</td>
</tr>
</tbody>
</table>
</div>
<div
v-else
class="rounded-lg border border-dashed border-border/70 px-3 py-4 text-sm text-muted-foreground"
>
当前没有 Provider 性能数据
</div>
</div>
</Card>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card class="space-y-3 p-4">
<h3 class="text-sm font-semibold">
输出 TPS 趋势
</h3>
<div
v-if="providerPerformanceLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="h-[260px]"
>
<LineChart
:data="providerTpsChartData"
:options="providerTpsChartOptions"
/>
</div>
</Card>
<Card class="space-y-3 p-4">
<h3 class="text-sm font-semibold">
平均首字趋势
</h3>
<div
v-if="providerPerformanceLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="h-[260px]"
>
<LineChart
:data="providerFirstByteChartData"
:options="providerLatencyChartOptions"
/>
</div>
</Card>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card class="p-4">
<ErrorDistributionChart
@@ -562,11 +739,20 @@ import {
Activity,
AlertTriangle,
Cable,
CheckCircle2,
GitBranch,
Gauge,
ShieldCheck,
Timer,
Workflow,
Zap,
} from 'lucide-vue-next'
import { adminApi, type ErrorDistributionResponse, type PercentileItem } from '@/api/admin'
import {
adminApi,
type ErrorDistributionResponse,
type PercentileItem,
type ProviderPerformanceResponse,
} from '@/api/admin'
import { dashboardApi, type ProviderStatus } from '@/api/dashboard'
import {
monitoringApi,
@@ -586,6 +772,10 @@ import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
import { formatDate, formatNumber, formatTokens } from '@/utils/format'
import { log } from '@/utils/logger'
import {
buildProviderPerformanceChartData,
formatProviderPerformanceMetric,
} from './performanceAnalysisHelpers'
const LIVE_REFRESH_INTERVAL_MS = 10_000
@@ -601,6 +791,8 @@ const errorLoading = ref(false)
const providerStatus = ref<ProviderStatus[]>([])
const providerLoading = ref(false)
const providerPerformance = ref<ProviderPerformanceResponse | null>(null)
const providerPerformanceLoading = ref(false)
const systemStatus = ref<AdminMonitoringSystemStatus | null>(null)
const resilienceStatus = ref<AdminMonitoringResilienceStatus | null>(null)
@@ -615,6 +807,7 @@ const liveLastUpdatedAt = ref<string | null>(null)
let percentilesRequestId = 0
let errorsRequestId = 0
let providersRequestId = 0
let providerPerformanceRequestId = 0
let liveRequestId = 0
let loadAllPromise: Promise<void> | null = null
let hasPendingLoadAll = false
@@ -699,6 +892,28 @@ async function loadProviders() {
}
}
async function loadProviderPerformance() {
const requestId = ++providerPerformanceRequestId
providerPerformanceLoading.value = true
try {
const data = await adminApi.getProviderPerformance({
...buildTimeRangeParams(),
granularity: 'day',
limit: 8,
})
if (requestId !== providerPerformanceRequestId) return
providerPerformance.value = data
} catch (error) {
if (requestId !== providerPerformanceRequestId) return
providerPerformance.value = null
log.error('加载 Provider 性能统计失败', error)
} finally {
if (requestId === providerPerformanceRequestId) {
providerPerformanceLoading.value = false
}
}
}
async function loadLiveData(options: { silent?: boolean } = {}) {
const requestId = ++liveRequestId
const initialLoad = !liveReady.value
@@ -866,6 +1081,83 @@ const fallbackRows = computed(() => {
}))
})
const providerPerformanceRows = computed(() => providerPerformance.value?.providers ?? [])
const providerPerformanceSubtitle = computed(() => {
const requests = providerPerformance.value?.summary.request_count ?? 0
return `完成窗口内 ${formatMetricNumber(requests)} 个 Provider 请求样本`
})
const providerPerformanceSummaryCards = computed(() => {
const summary = providerPerformance.value?.summary
return [
{
title: '输出 TPS',
value: formatProviderPerformanceMetric(summary?.avg_output_tps, '/s'),
hint: `请求 ${formatMetricNumber(summary?.request_count)}`,
icon: Zap,
iconClass: 'text-amber-500',
},
{
title: '平均首字',
value: formatProviderPerformanceMetric(summary?.avg_first_byte_time_ms, 'ms'),
hint: '成功请求首字样本',
icon: Timer,
iconClass: 'text-sky-500',
},
{
title: '平均响应',
value: formatProviderPerformanceMetric(summary?.avg_response_time_ms, 'ms'),
hint: '成功请求响应耗时',
icon: Gauge,
iconClass: 'text-violet-500',
},
{
title: '成功率',
value: formatProviderPerformanceMetric(summary?.success_rate, '%'),
hint: `${formatMetricNumber(providerPerformanceRows.value.length)} 个 Provider`,
icon: CheckCircle2,
iconClass: 'text-emerald-500',
},
]
})
const providerTpsChartData = computed(() => (
buildProviderPerformanceChartData(
providerPerformance.value?.timeline ?? [],
'avg_output_tps',
providerPerformanceRows.value,
)
))
const providerFirstByteChartData = computed(() => (
buildProviderPerformanceChartData(
providerPerformance.value?.timeline ?? [],
'avg_first_byte_time_ms',
providerPerformanceRows.value,
)
))
const providerTpsChartOptions = computed(() => ({
scales: {
y: {
ticks: {
callback: (value: string | number) => `${value}/s`,
},
},
},
}))
const providerLatencyChartOptions = computed(() => ({
scales: {
y: {
ticks: {
callback: (value: string | number) => `${value}ms`,
},
},
},
}))
const liveSummaryCards = computed(() => [
{
title: '系统健康',
@@ -920,7 +1212,8 @@ const isRefreshing = computed(() => (
liveRefreshing.value ||
percentileLoading.value ||
errorLoading.value ||
providerLoading.value
providerLoading.value ||
providerPerformanceLoading.value
))
async function loadAll() {
@@ -929,7 +1222,12 @@ async function loadAll() {
return loadAllPromise
}
loadAllPromise = Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
loadAllPromise = Promise.all([
loadPercentiles(),
loadErrors(),
loadProviders(),
loadProviderPerformance(),
])
.then(() => undefined)
.finally(() => {
loadAllPromise = null
@@ -983,6 +1281,7 @@ onUnmounted(() => {
percentilesRequestId += 1
errorsRequestId += 1
providersRequestId += 1
providerPerformanceRequestId += 1
liveRequestId += 1
})
</script>

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
buildProviderPerformanceChartData,
formatProviderPerformanceMetric,
} from '../performanceAnalysisHelpers'
import type { ProviderPerformanceItem, ProviderPerformanceTimelineItem } from '@/api/admin'
describe('performanceAnalysisHelpers', () => {
it('formats null provider metrics as placeholders', () => {
expect(formatProviderPerformanceMetric(null, 'ms')).toBe('-')
expect(formatProviderPerformanceMetric(undefined, '/s')).toBe('-')
expect(formatProviderPerformanceMetric(Number.NaN)).toBe('-')
expect(formatProviderPerformanceMetric(18.456, '/s')).toBe('18.46/s')
})
it('builds stable provider trend datasets with null gaps', () => {
const providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = [
{ provider_id: 'provider-a', provider: 'OpenAI' },
{ provider_id: 'provider-b', provider: 'Anthropic' },
]
const timeline: ProviderPerformanceTimelineItem[] = [
{
date: '2024-03-21',
provider_id: 'provider-a',
provider: 'OpenAI',
request_count: 2,
output_tokens: 100,
avg_output_tps: 25,
avg_first_byte_time_ms: 120,
avg_response_time_ms: 1000,
success_rate: 100,
},
{
date: '2024-03-22',
provider_id: 'provider-b',
provider: 'Anthropic',
request_count: 1,
output_tokens: 40,
avg_output_tps: null,
avg_first_byte_time_ms: 220,
avg_response_time_ms: 1500,
success_rate: 100,
},
]
const chart = buildProviderPerformanceChartData(timeline, 'avg_output_tps', providers)
expect(chart.labels).toEqual(['2024-03-21', '2024-03-22'])
expect(chart.datasets.map(dataset => dataset.label)).toEqual(['OpenAI', 'Anthropic'])
expect(chart.datasets[0].data).toEqual([25, null])
expect(chart.datasets[1].data).toEqual([null, null])
})
})

View File

@@ -0,0 +1,64 @@
import type { ChartData } from 'chart.js'
import type {
ProviderPerformanceItem,
ProviderPerformanceTimelineItem,
} from '@/api/admin'
export type ProviderPerformanceMetricKey = 'avg_output_tps' | 'avg_first_byte_time_ms'
const PROVIDER_CHART_COLORS = [
'rgb(59, 130, 246)',
'rgb(16, 185, 129)',
'rgb(234, 179, 8)',
'rgb(239, 68, 68)',
'rgb(139, 92, 246)',
'rgb(14, 165, 233)',
'rgb(249, 115, 22)',
'rgb(20, 184, 166)',
]
export function formatProviderPerformanceMetric(
value: number | null | undefined,
suffix = '',
decimals = 2
): string {
if (value == null || Number.isNaN(value)) {
return '-'
}
return `${value.toFixed(decimals)}${suffix}`
}
export function buildProviderPerformanceChartData(
timeline: ProviderPerformanceTimelineItem[],
metric: ProviderPerformanceMetricKey,
providers: Pick<ProviderPerformanceItem, 'provider_id' | 'provider'>[] = []
): ChartData<'line'> {
const labels = Array.from(new Set(timeline.map(item => item.date)))
const providerMap = new Map<string, string>()
for (const provider of providers) {
providerMap.set(provider.provider_id, provider.provider)
}
for (const item of timeline) {
if (!providerMap.has(item.provider_id)) {
providerMap.set(item.provider_id, item.provider)
}
}
return {
labels,
datasets: Array.from(providerMap.entries()).map(([providerId, provider], index) => {
const byDate = new Map(
timeline
.filter(item => item.provider_id === providerId)
.map(item => [item.date, item[metric]] as const)
)
return {
label: provider,
data: labels.map(label => byDate.get(label) ?? null),
borderColor: PROVIDER_CHART_COLORS[index % PROVIDER_CHART_COLORS.length],
tension: 0.25,
pointRadius: 2,
}
}),
}
}

View File

@@ -80,6 +80,14 @@
<div>
<div class="flex items-center gap-2">
<span class="font-medium hover:text-primary transition-colors">{{ model.display_name || model.name }}</span>
<Badge
v-for="capability in getModelCapabilityLabels(model)"
:key="capability"
variant="secondary"
class="text-[10px] px-1.5 py-0"
>
{{ capability }}
</Badge>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<span>{{ model.name }}</span>
@@ -150,6 +158,16 @@
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<span class="font-medium truncate block">{{ model.display_name || model.name }}</span>
<div class="flex flex-wrap gap-1 mt-1">
<Badge
v-for="capability in getModelCapabilityLabels(model)"
:key="capability"
variant="secondary"
class="text-[10px] px-1.5 py-0"
>
{{ capability }}
</Badge>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<span class="truncate">{{ model.name }}</span>
<button
@@ -228,6 +246,7 @@ import UserModelDetailDrawer from './components/UserModelDetailDrawer.vue'
import { useRowClick } from '@/composables/useRowClick'
import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
import { getModelCapabilityLabels } from './model-catalog-helpers'
const { error: showError } = useToast()
const { copyToClipboard } = useClipboard()

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import type { PublicGlobalModel } from '@/api/public-models'
import { getModelCapabilityLabels, supportsEmbedding, supportsRerank } from '../model-catalog-helpers'
function model(overrides: Partial<PublicGlobalModel>): PublicGlobalModel {
return {
id: 'gm-test',
name: 'model-test',
display_name: 'Model Test',
is_active: true,
default_tiered_pricing: null,
default_price_per_request: null,
supported_capabilities: null,
config: null,
usage_count: 0,
...overrides,
}
}
describe('model catalog embedding helpers', () => {
it('labels embedding models distinctly from chat models', () => {
expect(getModelCapabilityLabels(model({
supported_capabilities: ['embedding'],
config: { streaming: false, api_formats: ['openai:embedding'] },
}))).toEqual(['Embedding'])
expect(getModelCapabilityLabels(model({
config: { streaming: true },
}))).toEqual(['Chat'])
})
it('detects embedding metadata from explicit and config-derived frontend fields', () => {
expect(supportsEmbedding(model({ supports_embedding: true }))).toBe(true)
expect(supportsEmbedding(model({ config: { embedding: true } }))).toBe(true)
expect(supportsEmbedding(model({ config: { model_type: 'embedding' } }))).toBe(true)
expect(supportsEmbedding(model({ config: { api_formats: ['jina:embedding'] } }))).toBe(true)
expect(supportsEmbedding(model({ config: { api_formats: ['openai:chat'] } }))).toBe(false)
})
it('labels rerank models distinctly from chat and embedding models', () => {
const rerank = model({
supported_capabilities: ['rerank'],
config: { streaming: false, api_formats: ['jina:rerank'] },
})
expect(supportsRerank(rerank)).toBe(true)
expect(getModelCapabilityLabels(rerank)).toEqual(['Rerank'])
expect(supportsRerank(model({ config: { api_formats: ['openai:embedding'] } }))).toBe(false)
})
})

View File

@@ -96,6 +96,23 @@
{{ model.config?.image_generation === true ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Database class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">
Embedding
</p>
<p class="text-xs text-muted-foreground">
向量嵌入
</p>
</div>
<Badge
:variant="supportsEmbedding(model) ? 'default' : 'secondary'"
class="text-xs"
>
{{ supportsEmbedding(model) ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Eye class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
@@ -303,6 +320,7 @@ import {
Zap,
Copy,
Layers,
Database,
Image as ImageIcon
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
@@ -376,6 +394,14 @@ function getFirst1hCachePrice(tieredPricing: TieredPricingConfig | undefined | n
return get1hCachePrice(tieredPricing.tiers[0])
}
function supportsEmbedding(model: PublicGlobalModel): boolean {
return model.supports_embedding === true
|| model.supported_capabilities?.includes('embedding') === true
|| model.config?.embedding === true
|| model.config?.model_type === 'embedding'
|| (Array.isArray(model.config?.api_formats) && model.config.api_formats.some((format) => String(format).endsWith(':embedding')))
}
// 添加 ESC 键监听
useEscapeKey(() => {
if (props.open) {

View File

@@ -0,0 +1,41 @@
import type { PublicGlobalModel } from '@/api/public-models'
export function supportsEmbedding(model: PublicGlobalModel): boolean {
return model.supports_embedding === true
|| model.supported_capabilities?.includes('embedding') === true
|| model.config?.embedding === true
|| model.config?.model_type === 'embedding'
|| (Array.isArray(model.config?.api_formats) && model.config.api_formats.some((format) => String(format).endsWith(':embedding')))
}
export function supportsRerank(model: PublicGlobalModel): boolean {
return model.supported_capabilities?.includes('rerank') === true
|| model.config?.rerank === true
|| model.config?.model_type === 'rerank'
|| (Array.isArray(model.config?.api_formats) && model.config.api_formats.some((format) => String(format).endsWith(':rerank')))
}
export function hasVideoPricing(model: PublicGlobalModel): boolean {
const billing = model.config?.billing
const video = billing && typeof billing === 'object' && !Array.isArray(billing)
? (billing as Record<string, unknown>).video
: null
const priceByResolution = video && typeof video === 'object' && !Array.isArray(video)
? (video as Record<string, unknown>).price_per_second_by_resolution
: null
return !!priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
}
export function getModelCapabilityLabels(model: PublicGlobalModel): string[] {
const labels: string[] = []
if (supportsRerank(model)) {
labels.push('Rerank')
} else if (supportsEmbedding(model)) {
labels.push('Embedding')
} else {
labels.push('Chat')
}
if (model.config?.image_generation === true) labels.push('Image')
if (hasVideoPricing(model)) labels.push('Video')
return labels
}