feat(ui): 新增模型请求链路预览功能

- 添加后端 API 获取 GlobalModel 的请求链路信息 (/api/admin/models/global/{id}/routing)
- 新增 RoutingTab 组件展示模型的请求链路树状结构
- 支持全局 Key 优先和提供商优先两种模式的可视化
- 重构批量模型管理对话框为单列勾选模式
- 修复 errorParser.ts 字符串拼接格式问题
- 若干 Vue 模板格式优化
This commit is contained in:
fawney19
2026-01-12 23:28:37 +08:00
parent bde5bf4c76
commit 9fea71a70c
15 changed files with 1897 additions and 654 deletions

View File

@@ -6,6 +6,16 @@ import type {
GlobalModelWithStats,
GlobalModelListResponse,
ModelCatalogProviderDetail,
ModelRoutingPreviewResponse,
} from './types'
// 重新导出路由相关类型供外部使用
export type {
RoutingKeyInfo,
RoutingEndpointInfo,
RoutingModelMapping,
RoutingProviderInfo,
ModelRoutingPreviewResponse,
} from './types'
/**
@@ -97,3 +107,15 @@ export async function getGlobalModelProviders(globalModelId: string): Promise<{
)
return response.data
}
/**
* 获取 GlobalModel 的请求链路预览
*/
export async function getGlobalModelRoutingPreview(
globalModelId: string
): Promise<ModelRoutingPreviewResponse> {
const response = await client.get(
`/api/admin/models/global/${globalModelId}/routing`
)
return response.data
}

View File

@@ -622,3 +622,83 @@ export interface ImportFromUpstreamResponse {
success: ImportFromUpstreamSuccessItem[]
errors: ImportFromUpstreamErrorItem[]
}
// ========== 路由预览相关类型 ==========
/**
* Key 路由信息
*/
export interface RoutingKeyInfo {
id: string
name: string
masked_key: string
internal_priority: number
global_priority?: number | null
rpm_limit?: number | null
is_adaptive: boolean
effective_rpm?: number | null
cache_ttl_minutes: number
health_score: number
is_active: boolean
api_formats: string[]
circuit_breaker_open: boolean
circuit_breaker_formats: string[]
}
/**
* Endpoint 路由信息
*/
export interface RoutingEndpointInfo {
id: string
api_format: string
base_url: string
custom_path?: string | null
is_active: boolean
keys: RoutingKeyInfo[]
total_keys: number
active_keys: number
}
/**
* 模型名称映射信息
*/
export interface RoutingModelMapping {
name: string
priority: number
api_formats?: string[] | null
}
/**
* Provider 路由信息
*/
export interface RoutingProviderInfo {
id: string
name: string
model_id: string
provider_priority: number
billing_type?: string | null
monthly_quota_usd?: number | null
monthly_used_usd?: number | null
is_active: boolean
provider_model_name: string
model_mappings: RoutingModelMapping[]
model_is_active: boolean
endpoints: RoutingEndpointInfo[]
total_endpoints: number
active_endpoints: number
}
/**
* 模型请求链路预览响应
*/
export interface ModelRoutingPreviewResponse {
global_model_id: string
global_model_name: string
display_name: string
is_active: boolean
providers: RoutingProviderInfo[]
total_providers: number
active_providers: number
scheduling_mode: string
priority_mode: string
}

View File

@@ -21,4 +21,5 @@ export {
deleteGlobalModel,
batchAssignToProviders,
getGlobalModelProviders,
getGlobalModelRoutingPreview,
} from './endpoints/global-models'

View File

@@ -5,7 +5,10 @@ const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger v-bind="props" as-child>
<CollapsibleTrigger
v-bind="props"
as-child
>
<slot />
</CollapsibleTrigger>
</template>

View File

@@ -95,14 +95,14 @@
type="button"
class="flex-1 px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-md transition-all duration-200"
:class="[
detailTab === 'providers'
detailTab === 'routing'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="detailTab = 'providers'"
@click="detailTab = 'routing'"
>
<span class="hidden sm:inline">关联提供商</span>
<span class="sm:hidden">提供商</span>
<span class="hidden sm:inline">链路控制</span>
<span class="sm:hidden">链路</span>
</button>
</div>
@@ -407,269 +407,17 @@
</div>
</div>
<!-- Tab 2: 关联提供商 -->
<div v-show="detailTab === 'providers'">
<Card class="overflow-hidden">
<!-- 标题栏 -->
<div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div>
<h4 class="text-sm font-semibold">
关联提供商列表
</h4>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="添加关联"
@click="$emit('addProvider')"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="刷新"
@click="$emit('refreshProviders')"
>
<RefreshCw
class="w-3.5 h-3.5"
:class="loadingProviders ? 'animate-spin' : ''"
<!-- Tab 2: 链路控制 -->
<div v-show="detailTab === 'routing'">
<RoutingTab
v-if="model"
ref="routingTabRef"
:global-model-id="model.id"
@add-provider="$emit('addProvider')"
@edit-provider="handleEditProviderFromRouting"
@toggle-provider-status="handleToggleProviderFromRouting"
@delete-provider="handleDeleteProviderFromRouting"
/>
</Button>
</div>
</div>
</div>
<!-- 表格内容 -->
<div
v-if="loadingProviders"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<template v-else-if="providers.length > 0">
<!-- 桌面端表格 -->
<Table class="hidden sm:table">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-10 font-semibold">
Provider
</TableHead>
<TableHead class="w-[100px] h-10 font-semibold">
能力
</TableHead>
<TableHead class="w-[200px] h-10 font-semibold">
价格 ($/M)
</TableHead>
<TableHead class="w-[100px] h-10 font-semibold text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="provider in providers"
:key="provider.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<TableCell class="py-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="provider.is_active ? '活跃' : '停用'"
/>
<span class="font-medium truncate">{{ provider.name }}</span>
</div>
</TableCell>
<TableCell class="py-3">
<div class="flex gap-0.5">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
title="流式输出"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
title="视觉理解"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
title="工具调用"
/>
</div>
</TableCell>
<TableCell class="py-3">
<div class="text-xs font-mono space-y-0.5">
<!-- Token 计费输入/输出 -->
<div v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0">
<span class="text-muted-foreground">输入/输出:</span>
<span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span>
<!-- 阶梯标记 -->
<span
v-if="(provider.tier_count || 1) > 1"
class="ml-1 text-muted-foreground"
title="阶梯计费"
>[阶梯]</span>
</div>
<!-- 缓存价格 -->
<div
v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>缓存:</span>
<span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 1h 缓存价格 -->
<div
v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>1h 缓存:</span>
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 按次计费 -->
<div v-if="(provider.price_per_request || 0) > 0">
<span class="text-muted-foreground">按次:</span>
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/</span>
</div>
<!-- 无定价 -->
<span
v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)"
class="text-muted-foreground"
>-</span>
</div>
</TableCell>
<TableCell class="py-3 text-center">
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑此关联"
@click="$emit('editProvider', provider)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="provider.is_active ? '停用此关联' : '启用此关联'"
@click="$emit('toggleProviderStatus', provider)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除此关联"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="provider in providers"
:key="provider.id"
class="p-4 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
/>
<span class="font-medium truncate">{{ provider.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('editProvider', provider)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('toggleProviderStatus', provider)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div class="flex items-center gap-3 text-xs">
<div class="flex gap-1">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
/>
</div>
<div
v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0"
class="text-muted-foreground font-mono"
>
${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}
</div>
</div>
</div>
</div>
</template>
<div
v-else
class="text-center py-12"
>
<!-- 空状态 -->
<Building2 class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
<p class="text-sm text-muted-foreground">
暂无关联提供商
</p>
<Button
size="sm"
variant="outline"
class="mt-4"
@click="$emit('addProvider')"
>
<Plus class="w-4 h-4 mr-1" />
添加第一个关联
</Button>
</div>
</Card>
</div>
</div>
</Card>
@@ -688,12 +436,8 @@ import {
Zap,
Image,
Building2,
Plus,
Edit,
Trash2,
Power,
Loader2,
RefreshCw,
Copy,
Layers,
BarChart3
@@ -711,14 +455,15 @@ import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import RoutingTab from './RoutingTab.vue'
// 使用外部类型定义
import type { GlobalModelResponse } from '@/api/global-models'
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
import type { CapabilityDefinition } from '@/api/endpoints'
import type { RoutingProviderInfo } from '@/api/global-models'
const props = withDefaults(defineProps<Props>(), {
loadingProviders: false,
hasBlockingDialogOpen: false,
})
const emit = defineEmits<{
@@ -729,7 +474,6 @@ const emit = defineEmits<{
'editProvider': [provider: any]
'deleteProvider': [provider: any]
'toggleProviderStatus': [provider: any]
'refreshProviders': []
}>()
const { success: showSuccess, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
@@ -737,12 +481,48 @@ const { copyToClipboard } = useClipboard()
interface Props {
model: GlobalModelResponse | null
open: boolean
providers: any[]
loadingProviders?: boolean
hasBlockingDialogOpen?: boolean
capabilities?: CapabilityDefinition[]
}
// RoutingTab 引用
const routingTabRef = ref<InstanceType<typeof RoutingTab> | null>(null)
// 将 RoutingProviderInfo 转换为父组件期望的格式
function convertRoutingProviderToLegacyFormat(provider: RoutingProviderInfo) {
return {
id: provider.id,
model_id: provider.model_id,
name: provider.name,
is_active: provider.model_is_active
}
}
// 处理从 RoutingTab 来的编辑事件
function handleEditProviderFromRouting(provider: RoutingProviderInfo) {
emit('editProvider', convertRoutingProviderToLegacyFormat(provider))
}
// 处理从 RoutingTab 来的状态切换事件
function handleToggleProviderFromRouting(provider: RoutingProviderInfo) {
emit('toggleProviderStatus', convertRoutingProviderToLegacyFormat(provider))
}
// 处理从 RoutingTab 来的删除事件
function handleDeleteProviderFromRouting(provider: RoutingProviderInfo) {
emit('deleteProvider', convertRoutingProviderToLegacyFormat(provider))
}
// 刷新路由数据
function refreshRoutingData() {
routingTabRef.value?.loadRoutingData?.()
}
// 暴露刷新方法给父组件
defineExpose({
refreshRoutingData
})
// 根据能力名称获取显示名称
function getCapabilityDisplayName(capName: string): string {
const cap = props.capabilities?.find(c => c.name === capName)

File diff suppressed because it is too large Load Diff

View File

@@ -107,8 +107,12 @@
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ model.display_name }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
<p class="text-sm font-medium truncate">
{{ model.display_name }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.name }}
</p>
</div>
</div>
</div>
@@ -159,8 +163,12 @@
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ model.id }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.owned_by || model.id }}</p>
<p class="text-sm font-medium truncate">
{{ model.id }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.owned_by || model.id }}
</p>
</div>
</div>
</div>
@@ -172,7 +180,9 @@
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Layers class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">{{ searchQuery ? '无匹配结果' : '暂无可用模型' }}</p>
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无可用模型' }}
</p>
<p
v-if="!upstreamModelsLoaded"
class="text-xs mt-1"

View File

@@ -76,7 +76,10 @@
</div>
<!-- 模型列表 -->
<div v-else class="space-y-2">
<div
v-else
class="space-y-2"
>
<!-- 全选/取消 -->
<div class="flex items-center justify-between px-1">
<div class="flex items-center gap-2">

View File

@@ -131,7 +131,10 @@
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedModels.includes(model) ? 'bg-primary border-primary' : ''"
>
<Check v-if="selectedModels.includes(model)" class="w-3 h-3 text-primary-foreground" />
<Check
v-if="selectedModels.includes(model)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<span class="text-sm font-mono truncate">{{ model }}</span>
</div>
@@ -174,18 +177,28 @@
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedModels.includes(model.name) ? 'bg-primary border-primary' : ''"
>
<Check v-if="selectedModels.includes(model.name)" class="w-3 h-3 text-primary-foreground" />
<Check
v-if="selectedModels.includes(model.name)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ model.display_name }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
<p class="text-sm font-medium truncate">
{{ model.display_name }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.name }}
</p>
</div>
</div>
</div>
</div>
<!-- 上游模型组 -->
<div v-for="group in filteredUpstreamGroups" :key="group.api_format">
<div
v-for="group in filteredUpstreamGroups"
:key="group.api_format"
>
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
@click="toggleGroupCollapse(group.api_format)"
@@ -222,7 +235,10 @@
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedModels.includes(model.id) ? 'bg-primary border-primary' : ''"
>
<Check v-if="selectedModels.includes(model.id)" class="w-3 h-3 text-primary-foreground" />
<Check
v-if="selectedModels.includes(model.id)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<span class="text-sm font-mono truncate">{{ model.id }}</span>
</div>
@@ -235,8 +251,15 @@
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Shield class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}</p>
<p v-if="!upstreamModelsLoaded" class="text-xs mt-1">点击闪电按钮从上游获取模型</p>
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}
</p>
<p
v-if="!upstreamModelsLoaded"
class="text-xs mt-1"
>
点击闪电按钮从上游获取模型
</p>
</div>
</template>
</div>
@@ -250,10 +273,18 @@
{{ hasChanges ? '有未保存的更改' : '' }}
</p>
<div class="flex items-center gap-2">
<Button :disabled="saving || !hasChanges" @click="handleSave">
<Button
:disabled="saving || !hasChanges"
@click="handleSave"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
<Button variant="outline" @click="handleCancel">取消</Button>
<Button
variant="outline"
@click="handleCancel"
>
取消
</Button>
</div>
</div>
</template>

View File

@@ -83,7 +83,10 @@
</div>
<!-- 端点 API 格式 -->
<div class="flex items-center gap-1.5 flex-wrap mt-3">
<template v-for="endpoint in endpoints" :key="endpoint.id">
<template
v-for="endpoint in endpoints"
:key="endpoint.id"
>
<span
class="text-xs px-2 py-0.5 rounded-md border border-border bg-background hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors font-medium"
:class="{ 'opacity-40': !endpoint.is_active }"
@@ -324,7 +327,10 @@
v-for="(format, idx) in getKeyApiFormats(key, endpoint)"
:key="format"
>
<span v-if="idx > 0" class="text-muted-foreground/40">/</span>
<span
v-if="idx > 0"
class="text-muted-foreground/40"
>/</span>
<span>{{ API_FORMAT_SHORT[format] || format }} {{ getKeyRateMultiplier(key, format) }}x</span>
</template>
<span v-if="key.rate_limit">| {{ key.rate_limit }}rpm</span>

View File

@@ -68,9 +68,15 @@
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly_quota">月卡额度</SelectItem>
<SelectItem value="pay_as_you_go">按量付费</SelectItem>
<SelectItem value="free_tier">免费套餐</SelectItem>
<SelectItem value="monthly_quota">
月卡额度
</SelectItem>
<SelectItem value="pay_as_you_go">
按量付费
</SelectItem>
<SelectItem value="free_tier">
免费套餐
</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -314,11 +314,11 @@ export function parseUpstreamModelError(error: string): string {
}
// 没有匹配特定关键词,但有详细信息,使用它作为补充
if (friendlyMsg) {
const truncated = detailMsg.length > 80 ? detailMsg.substring(0, 80) + '...' : detailMsg
const truncated = detailMsg.length > 80 ? `${detailMsg.substring(0, 80) }...` : detailMsg
return `${friendlyMsg}: ${truncated}`
}
// 没有友好消息,直接使用详细信息
const truncated = detailMsg.length > 100 ? detailMsg.substring(0, 100) + '...' : detailMsg
const truncated = detailMsg.length > 100 ? `${detailMsg.substring(0, 100) }...` : detailMsg
return truncated
}
} catch {
@@ -358,7 +358,7 @@ export function parseUpstreamModelError(error: string): string {
// 默认返回原始错误(截断过长的部分)
if (error.length > 100) {
return error.substring(0, 100) + '...'
return `${error.substring(0, 100) }...`
}
return error
}

View File

@@ -418,6 +418,7 @@
<!-- 模型详情抽屉 -->
<ModelDetailDrawer
ref="modelDetailDrawerRef"
:model="selectedModel"
:open="!!selectedModel"
:providers="selectedModelProviders"
@@ -437,211 +438,72 @@
<!-- 批量添加关联提供商对话框 -->
<Dialog
:model-value="batchAddProvidersDialogOpen"
title="批量添加关联提供商"
description="为模型批量添加 Provider 实现, 提供商将自动继承模型的价格和能力, 可在添加后单独修改"
:title="selectedModel ? `批量管理提供商 - ${selectedModel.display_name}` : '批量管理提供商'"
description="选中的提供商将被关联到模型,取消选中将移除关联"
:icon="Server"
size="4xl"
size="2xl"
@update:model-value="handleBatchAddProvidersDialogUpdate"
>
<template #default>
<div
v-if="selectedModel"
class="space-y-4"
>
<!-- 模型信息头部 -->
<div class="rounded-lg border bg-muted/30 p-4">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-lg">
{{ selectedModel.display_name }}
</p>
<p class="text-sm text-muted-foreground font-mono">
{{ selectedModel.name }}
</p>
</div>
<Badge
variant="outline"
class="text-xs"
>
当前 {{ selectedModelProviders.length }} Provider
</Badge>
<div class="space-y-4">
<!-- 搜索栏 -->
<div class="flex items-center gap-2">
<div class="flex-1 relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
v-model="batchProviderSearchQuery"
placeholder="搜索提供商..."
class="pl-8 h-9"
/>
</div>
</div>
<!-- 左右对比布局 -->
<div class="flex gap-2 items-stretch">
<!-- 左侧可添加的提供商 -->
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">
可添加
</p>
<Button
v-if="availableProvidersForBatchAdd.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="toggleSelectAllLeft"
>
{{ isAllLeftSelected ? '取消全选' : '全选' }}
</Button>
</div>
<Badge
variant="secondary"
class="text-xs"
>
{{ availableProvidersForBatchAdd.length }}
</Badge>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<!-- 单列提供商列表 -->
<div class="border rounded-lg overflow-hidden">
<div class="max-h-96 overflow-y-auto">
<div
v-if="loadingProviderOptions"
class="flex items-center justify-center h-full"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<div
v-else-if="availableProvidersForBatchAdd.length === 0"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Building2 class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
所有 Provider 均已关联
</p>
</div>
<div
v-else
class="p-2 space-y-1"
>
<div
v-for="provider in availableProvidersForBatchAdd"
:key="provider.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors"
:class="selectedLeftProviderIds.includes(provider.id)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50 cursor-pointer'"
@click="toggleLeftSelection(provider.id)"
>
<Checkbox
:checked="selectedLeftProviderIds.includes(provider.id)"
@update:checked="toggleLeftSelection(provider.id)"
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
{{ provider.name }}
</p>
</div>
<Badge
:variant="provider.is_active ? 'outline' : 'secondary'"
:class="provider.is_active ? 'text-green-600 border-green-500/60' : ''"
class="text-xs shrink-0"
>
{{ provider.is_active ? '活跃' : '停用' }}
</Badge>
</div>
</div>
</div>
</div>
<!-- 中间操作按钮 -->
<div class="flex flex-col items-center justify-center w-12 shrink-0 gap-2">
<Button
variant="outline"
size="sm"
class="w-9 h-8"
:class="selectedLeftProviderIds.length > 0 && !submittingBatchAddProviders ? 'border-primary' : ''"
:disabled="selectedLeftProviderIds.length === 0 || submittingBatchAddProviders"
title="添加选中"
@click="batchAddSelectedProviders"
<template v-else>
<!-- 提供商组 -->
<div v-if="filteredBatchProviders.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10"
>
<Loader2
v-if="submittingBatchAddProviders"
class="w-4 h-4 animate-spin"
/>
<ChevronRight
v-else
class="w-6 h-6 stroke-[3]"
:class="selectedLeftProviderIds.length > 0 && !submittingBatchAddProviders ? 'text-primary' : ''"
/>
</Button>
<Button
variant="outline"
size="sm"
class="w-9 h-8"
:class="selectedRightProviderIds.length > 0 && !submittingBatchRemoveProviders ? 'border-primary' : ''"
:disabled="selectedRightProviderIds.length === 0 || submittingBatchRemoveProviders"
title="移除选中"
@click="batchRemoveSelectedProviders"
>
<Loader2
v-if="submittingBatchRemoveProviders"
class="w-4 h-4 animate-spin"
/>
<ChevronLeft
v-else
class="w-6 h-6 stroke-[3]"
:class="selectedRightProviderIds.length > 0 && !submittingBatchRemoveProviders ? 'text-primary' : ''"
/>
</Button>
</div>
<!-- 右侧已添加的提供商 -->
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">
已添加
</p>
<Button
v-if="selectedModelProviders.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="toggleSelectAllRight"
>
{{ isAllRightSelected ? '取消全选' : '全选' }}
</Button>
<span class="text-xs font-medium">提供商</span>
<span class="text-xs text-muted-foreground">({{ filteredBatchProviders.length }})</span>
</div>
<Badge
variant="secondary"
class="text-xs"
<button
type="button"
class="text-xs text-primary hover:underline shrink-0"
@click="toggleAllBatchProviders"
>
{{ selectedModelProviders.length }}
</Badge>
{{ isAllBatchProvidersSelected ? '取消全选' : '全选' }}
</button>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<div class="space-y-1 p-2">
<div
v-if="selectedModelProviders.length === 0"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
v-for="provider in filteredBatchProviders"
:key="provider.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
@click="toggleBatchProviderSelection(provider.id)"
>
<Building2 class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
暂无关联提供商
</p>
</div>
<div
v-else
class="p-2 space-y-1"
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="isBatchProviderSelected(provider.id) ? 'bg-primary border-primary' : ''"
>
<!-- 已存在的可选中删除 -->
<div
v-for="provider in selectedModelProviders"
:key="'existing-' + provider.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
:class="selectedRightProviderIds.includes(provider.id)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50'"
@click="toggleRightSelection(provider.id)"
>
<Checkbox
:checked="selectedRightProviderIds.includes(provider.id)"
@update:checked="toggleRightSelection(provider.id)"
@click.stop
<Check
v-if="isBatchProviderSelected(provider.id)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
<p class="text-sm font-medium truncate">
{{ provider.name }}
</p>
</div>
@@ -655,17 +517,46 @@
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-if="filteredBatchProviders.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Building2 class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
{{ batchProviderSearchQuery ? '无匹配结果' : '暂无可用提供商' }}
</p>
</div>
</template>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between w-full">
<p class="text-xs text-muted-foreground">
{{ hasBatchProviderChanges ? `${batchProviderPendingChangesCount} 项更改待保存` : '' }}
</p>
<div class="flex items-center gap-2">
<Button
:disabled="!hasBatchProviderChanges || submittingBatchProviders"
@click="saveBatchProviderChanges"
>
<Loader2
v-if="submittingBatchProviders"
class="w-4 h-4 mr-1 animate-spin"
/>
{{ submittingBatchProviders ? '保存中...' : '保存' }}
</Button>
<Button
variant="outline"
@click="closeBatchAddProvidersDialog"
>
关闭
</Button>
</div>
</div>
</template>
</Dialog>
@@ -698,8 +589,7 @@ import {
Power,
Copy,
Server,
ChevronRight,
ChevronLeft,
Check,
} from 'lucide-vue-next'
import ModelDetailDrawer from '@/features/models/components/ModelDetailDrawer.vue'
import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue'
@@ -722,7 +612,6 @@ import {
TableCell,
Badge,
Dialog,
Checkbox,
Pagination,
RefreshButton,
} from '@/components/ui'
@@ -743,10 +632,10 @@ const { copyToClipboard } = useClipboard()
// 状态
const loading = ref(false)
const submitting = ref(false)
const detailTab = ref('basic')
const searchQuery = ref('')
const selectedModel = ref<GlobalModelResponse | null>(null)
const modelDetailDrawerRef = ref<InstanceType<typeof ModelDetailDrawer> | null>(null)
const createModelDialogOpen = ref(false)
const editingModel = ref<GlobalModelResponse | null>(null)
@@ -765,12 +654,15 @@ const loadingModelProviders = ref(false)
// 批量添加关联提供商
const batchAddProvidersDialogOpen = ref(false)
const selectedProviderIds = ref<string[]>([])
const submittingBatchAddProviders = ref(false)
const submittingBatchRemoveProviders = ref(false)
const submittingBatchProviders = ref(false)
const providerOptions = ref<any[]>([])
const loadingProviderOptions = ref(false)
// 单列勾选模式所需状态
const batchProviderSearchQuery = ref('')
const selectedBatchProviderIds = ref<Set<string>>(new Set())
const initialBatchProviderIds = ref<Set<string>>(new Set())
// 编辑提供商模型
const editProviderDialogOpen = ref(false)
const editingProvider = ref<any>(null)
@@ -844,154 +736,152 @@ const capabilityFilters = ref({
extendedThinking: false,
})
// 左侧选中的 Provider用于添加
const selectedLeftProviderIds = ref<string[]>([])
// 右侧选中的 Provider用于移除只能选新增的
const selectedRightProviderIds = ref<string[]>([])
// 可用于批量添加的 Provider (排除已有实现的和已选中的)
const availableProvidersForBatchAdd = computed(() => {
if (!selectedModel.value) return []
const existingProviderIds = new Set(
selectedModelProviders.value.map(p => p.id)
)
const selectedIds = new Set(selectedProviderIds.value)
return providerOptions.value.filter(
provider => !existingProviderIds.has(provider.id) && !selectedIds.has(provider.id)
)
// 过滤后的提供商列表
const filteredBatchProviders = computed(() => {
const query = batchProviderSearchQuery.value.toLowerCase().trim()
return providerOptions.value.filter(p => {
if (query && !p.name.toLowerCase().includes(query)) {
return false
}
return true
})
})
// 是否全选了左侧
const isAllLeftSelected = computed(() => {
return availableProvidersForBatchAdd.value.length > 0 &&
selectedLeftProviderIds.value.length === availableProvidersForBatchAdd.value.length
// 检查提供商是否已选中
function isBatchProviderSelected(providerId: string): boolean {
return selectedBatchProviderIds.value.has(providerId)
}
// 是否全选
const isAllBatchProvidersSelected = computed(() => {
if (filteredBatchProviders.value.length === 0) return false
return filteredBatchProviders.value.every(p => isBatchProviderSelected(p.id))
})
// 是否全选了右侧
const isAllRightSelected = computed(() => {
return selectedModelProviders.value.length > 0 &&
selectedRightProviderIds.value.length === selectedModelProviders.value.length
// 计算待添加的提供商
const batchProvidersToAdd = computed(() => {
const toAdd: string[] = []
for (const id of selectedBatchProviderIds.value) {
if (!initialBatchProviderIds.value.has(id)) {
toAdd.push(id)
}
}
return toAdd
})
// 切换左侧选择
function toggleLeftSelection(providerId: string) {
const index = selectedLeftProviderIds.value.indexOf(providerId)
if (index === -1) {
selectedLeftProviderIds.value.push(providerId)
// 计算待移除的提供商
const batchProvidersToRemove = computed(() => {
const toRemove: string[] = []
for (const id of initialBatchProviderIds.value) {
if (!selectedBatchProviderIds.value.has(id)) {
toRemove.push(id)
}
}
return toRemove
})
// 是否有变更
const hasBatchProviderChanges = computed(() => {
return batchProvidersToAdd.value.length > 0 || batchProvidersToRemove.value.length > 0
})
// 待变更数量
const batchProviderPendingChangesCount = computed(() => {
return batchProvidersToAdd.value.length + batchProvidersToRemove.value.length
})
// 切换提供商选择
function toggleBatchProviderSelection(providerId: string) {
if (selectedBatchProviderIds.value.has(providerId)) {
selectedBatchProviderIds.value.delete(providerId)
} else {
selectedLeftProviderIds.value.splice(index, 1)
selectedBatchProviderIds.value.add(providerId)
}
selectedBatchProviderIds.value = new Set(selectedBatchProviderIds.value)
}
// 切换右侧选择(只能选新增的)
function toggleRightSelection(providerId: string) {
const index = selectedRightProviderIds.value.indexOf(providerId)
if (index === -1) {
selectedRightProviderIds.value.push(providerId)
// 全选/取消全选
function toggleAllBatchProviders() {
const allIds = filteredBatchProviders.value.map(p => p.id)
if (isAllBatchProvidersSelected.value) {
for (const id of allIds) {
selectedBatchProviderIds.value.delete(id)
}
} else {
selectedRightProviderIds.value.splice(index, 1)
for (const id of allIds) {
selectedBatchProviderIds.value.add(id)
}
}
selectedBatchProviderIds.value = new Set(selectedBatchProviderIds.value)
}
// 全选/取消全选左侧
function toggleSelectAllLeft() {
if (isAllLeftSelected.value) {
selectedLeftProviderIds.value = []
} else {
selectedLeftProviderIds.value = availableProvidersForBatchAdd.value.map(p => p.id)
}
// 同步初始选择状态
function syncBatchProviderSelection() {
const existingIds = new Set(selectedModelProviders.value.map((p: any) => p.id))
selectedBatchProviderIds.value = new Set(existingIds)
initialBatchProviderIds.value = new Set(existingIds)
}
// 全选/取消全选右侧
function toggleSelectAllRight() {
if (isAllRightSelected.value) {
selectedRightProviderIds.value = []
} else {
selectedRightProviderIds.value = selectedModelProviders.value.map(p => p.id)
}
}
// 批量添加选中的 Provider直接调用 API
async function batchAddSelectedProviders() {
if (!selectedModel.value || selectedLeftProviderIds.value.length === 0) return
// 保存变更
async function saveBatchProviderChanges() {
if (!hasBatchProviderChanges.value || submittingBatchProviders.value || !selectedModel.value) return
submittingBatchProviders.value = true
try {
submittingBatchAddProviders.value = true
let totalSuccess = 0
const allErrors: string[] = []
// 并行移除提供商
if (batchProvidersToRemove.value.length > 0) {
const { deleteModel } = await import('@/api/endpoints')
const removePromises = batchProvidersToRemove.value.map(async (providerId) => {
const existingProvider = selectedModelProviders.value.find((p: any) => p.id === providerId)
if (existingProvider && existingProvider.model_id) {
return deleteModel(providerId, existingProvider.model_id)
}
return null
})
const results = await Promise.allSettled(removePromises)
for (const result of results) {
if (result.status === 'fulfilled' && result.value !== null) {
totalSuccess++
} else if (result.status === 'rejected') {
allErrors.push(parseApiError(result.reason, '移除失败'))
}
}
}
// 添加提供商
if (batchProvidersToAdd.value.length > 0) {
const result = await batchAssignToProviders(selectedModel.value.id, {
provider_ids: selectedLeftProviderIds.value,
provider_ids: batchProvidersToAdd.value,
create_models: true
})
if (result.success.length > 0) {
success(`成功添加 ${result.success.length} 个 Provider`)
}
totalSuccess += result.success.length
if (result.errors.length > 0) {
const errorMessages = result.errors
.map(e => {
const provider = providerOptions.value.find(p => p.id === e.provider_id)
const providerName = provider?.name || e.provider_id
return `${providerName}: ${e.error}`
})
.join('\n')
showError(errorMessages, '部分 Provider 添加失败')
allErrors.push(...result.errors.map(e => e.error))
}
}
// 清空左侧选择,刷新右侧列表和外层表格
selectedLeftProviderIds.value = []
if (totalSuccess > 0) {
success(`成功处理 ${totalSuccess} 个提供商`)
}
if (allErrors.length > 0) {
showError(`部分操作失败: ${allErrors.slice(0, 3).join(', ')}${allErrors.length > 3 ? '...' : ''}`, '警告')
}
// 刷新数据并关闭对话框
await loadModelProviders(selectedModel.value.id)
// 刷新外层模型列表以更新 provider_count
await loadGlobalModels()
// 刷新路由数据
modelDetailDrawerRef.value?.refreshRoutingData?.()
closeBatchAddProvidersDialog()
} catch (err: any) {
showError(parseApiError(err, '批量添加失败'), '错误')
showError(parseApiError(err, '保存失败'), '错误')
} finally {
submittingBatchAddProviders.value = false
}
}
// 批量移除选中的 Provider直接调用 API
async function batchRemoveSelectedProviders() {
if (!selectedModel.value || selectedRightProviderIds.value.length === 0) return
try {
submittingBatchRemoveProviders.value = true
const { deleteModel } = await import('@/api/endpoints')
let successCount = 0
const errors: string[] = []
for (const providerId of selectedRightProviderIds.value) {
const provider = selectedModelProviders.value.find(p => p.id === providerId)
if (!provider?.model_id) continue
try {
await deleteModel(providerId, provider.model_id)
successCount++
} catch (err: any) {
errors.push(`${provider.name}: ${parseApiError(err, '删除失败')}`)
}
}
if (successCount > 0) {
success(`成功移除 ${successCount} 个 Provider`)
}
if (errors.length > 0) {
showError(errors.join('\n'), '部分 Provider 移除失败')
}
// 清空右侧选择,刷新列表和外层表格
selectedRightProviderIds.value = []
await loadModelProviders(selectedModel.value.id)
// 刷新外层模型列表以更新 provider_count
await loadGlobalModels()
} catch (err: any) {
showError(parseApiError(err, '批量移除失败'), '错误')
} finally {
submittingBatchRemoveProviders.value = false
submittingBatchProviders.value = false
}
}
@@ -1135,15 +1025,18 @@ async function ensureProviderOptions() {
// 打开添加关联提供商对话框
function openAddProviderDialog() {
if (!selectedModel.value) return
selectedProviderIds.value = []
batchProviderSearchQuery.value = ''
batchAddProvidersDialogOpen.value = true
ensureProviderOptions()
ensureProviderOptions().then(() => {
// 同步选择状态
syncBatchProviderSelection()
})
}
// 处理批量添加 Provider 对话框关闭事件
function handleBatchAddProvidersDialogUpdate(value: boolean) {
// 只有在不处于提交状态时才允许关闭
if (!value && submittingBatchAddProviders.value) {
if (!value && submittingBatchProviders.value) {
return
}
batchAddProvidersDialogOpen.value = value
@@ -1152,10 +1045,10 @@ function handleBatchAddProvidersDialogUpdate(value: boolean) {
// 关闭批量添加对话框
function closeBatchAddProvidersDialog() {
batchAddProvidersDialogOpen.value = false
selectedProviderIds.value = []
selectedLeftProviderIds.value = []
selectedRightProviderIds.value = []
submittingBatchAddProviders.value = false
batchProviderSearchQuery.value = ''
selectedBatchProviderIds.value = new Set()
initialBatchProviderIds.value = new Set()
submittingBatchProviders.value = false
}
// 抽屉控制函数
@@ -1199,6 +1092,8 @@ async function toggleProviderStatus(provider: any) {
await updateModel(provider.id, provider.model_id, { is_active: newStatus })
provider.is_active = newStatus
success(newStatus ? '已启用此关联提供商' : '已停用此关联提供商')
// 刷新路由数据
modelDetailDrawerRef.value?.refreshRoutingData?.()
} catch (err: any) {
showError(parseApiError(err, '更新状态失败'))
}
@@ -1212,7 +1107,7 @@ async function confirmDeleteProviderImplementation(provider: any) {
}
const confirmed = await confirmDanger(
`确定要删除 ${provider.name} 的模型关联吗?\n\n模型: ${provider.target_model}\n\n此操作不可恢复!`,
`确定要删除 ${provider.name} 的模型关联吗?\n\n此操作不可恢复`,
'删除关联提供商'
)
if (!confirmed) return
@@ -1221,10 +1116,12 @@ async function confirmDeleteProviderImplementation(provider: any) {
const { deleteModel } = await import('@/api/endpoints')
await deleteModel(provider.id, provider.model_id)
success(`已删除 ${provider.name} 的模型实现`)
// 重新加载 Provider 列表
// 同步更新 selectedModelProviders 确保状态一致
if (selectedModel.value) {
await loadModelProviders(selectedModel.value.id)
}
// 刷新路由数据
modelDetailDrawerRef.value?.refreshRoutingData?.()
} catch (err: any) {
showError(parseApiError(err, '删除模型失败'))
}

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter
from .catalog import router as catalog_router
from .external import router as external_router
from .global_models import router as global_models_router
from .routing import router as routing_router
router = APIRouter(prefix="/api/admin/models", tags=["Admin - Models"])
@@ -14,3 +15,4 @@ router = APIRouter(prefix="/api/admin/models", tags=["Admin - Models"])
router.include_router(catalog_router)
router.include_router(global_models_router)
router.include_router(external_router)
router.include_router(routing_router)

View File

@@ -0,0 +1,388 @@
"""
GlobalModel 请求链路预览 API
提供模型的请求链路信息,包括:
- 请求会流向哪些提供商
- 每个提供商的优先级和负载均衡配置
- 模型名称映射关系
- Key 的并发配置和健康状态
"""
from dataclasses import dataclass
from typing import Dict, List, Optional
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session, selectinload
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.pipeline import ApiRequestPipeline
from src.database import get_db
from src.models.database import (
GlobalModel,
Model,
ProviderAPIKey,
ProviderEndpoint,
)
from src.services.cache.aware_scheduler import CacheAwareScheduler
from src.services.system.config import SystemConfigService
router = APIRouter(prefix="/global", tags=["Admin - Global Models"])
pipeline = ApiRequestPipeline()
# ========== Response Models ==========
class RoutingKeyInfo(BaseModel):
"""Key 路由信息"""
id: str
name: str
masked_key: str = Field("", description="脱敏的 API Key")
internal_priority: int = Field(..., description="Key 内部优先级")
global_priority: Optional[int] = Field(None, description="全局 Key 优先级")
rpm_limit: Optional[int] = Field(None, description="RPM 限制null 表示自适应")
is_adaptive: bool = Field(False, description="是否为自适应 RPM 模式")
effective_rpm: Optional[int] = Field(None, description="有效 RPM 限制")
cache_ttl_minutes: int = Field(0, description="缓存 TTL分钟")
health_score: float = Field(100.0, description="健康度分数")
is_active: bool
api_formats: List[str] = Field(default_factory=list, description="支持的 API 格式")
# 熔断状态
circuit_breaker_open: bool = Field(False, description="熔断器是否打开")
circuit_breaker_formats: List[str] = Field(default_factory=list, description="熔断的 API 格式列表")
model_config = ConfigDict(from_attributes=True)
class RoutingEndpointInfo(BaseModel):
"""Endpoint 路由信息"""
id: str
api_format: str
base_url: str
custom_path: Optional[str] = None
is_active: bool
keys: List[RoutingKeyInfo] = Field(default_factory=list)
total_keys: int = 0
active_keys: int = 0
model_config = ConfigDict(from_attributes=True)
class RoutingModelMapping(BaseModel):
"""模型名称映射信息"""
name: str = Field(..., description="映射名称")
priority: int = Field(..., description="优先级(数字越小优先级越高)")
api_formats: Optional[List[str]] = Field(None, description="作用域(适用的 API 格式)")
class RoutingProviderInfo(BaseModel):
"""Provider 路由信息"""
id: str
name: str
model_id: str = Field(..., description="Model IDGlobalModel 与 Provider 的关联记录 ID")
provider_priority: int = Field(..., description="提供商优先级(数字越小优先级越高)")
billing_type: Optional[str] = Field(None, description="计费类型")
monthly_quota_usd: Optional[float] = Field(None, description="月额度(美元)")
monthly_used_usd: Optional[float] = Field(None, description="已用额度(美元)")
is_active: bool
# 模型映射信息
provider_model_name: str = Field(..., description="提供商侧的模型名称")
model_mappings: List[RoutingModelMapping] = Field(
default_factory=list, description="模型名称映射列表"
)
model_is_active: bool = Field(True, description="Model 是否活跃")
# Endpoint 和 Key 信息
endpoints: List[RoutingEndpointInfo] = Field(default_factory=list)
total_endpoints: int = 0
active_endpoints: int = 0
model_config = ConfigDict(from_attributes=True)
class ModelRoutingPreviewResponse(BaseModel):
"""模型请求链路预览响应"""
global_model_id: str
global_model_name: str
display_name: str
is_active: bool
# 链路信息
providers: List[RoutingProviderInfo] = Field(
default_factory=list, description="按优先级排序的提供商列表"
)
total_providers: int = 0
active_providers: int = 0
# 调度配置
scheduling_mode: str = Field("cache_affinity", description="调度模式")
priority_mode: str = Field("provider", description="优先级模式")
model_config = ConfigDict(from_attributes=True)
# ========== API Endpoints ==========
@router.get("/{global_model_id}/routing", response_model=ModelRoutingPreviewResponse)
async def get_model_routing_preview(
request: Request,
global_model_id: str,
db: Session = Depends(get_db),
) -> ModelRoutingPreviewResponse:
"""
获取模型请求链路预览
查看指定 GlobalModel 的完整请求链路信息,包括:
- 关联的所有提供商及其优先级
- 每个提供商的模型名称映射配置
- Endpoint 和 Key 的详细配置
- 负载均衡和调度策略
**路径参数**:
- `global_model_id`: GlobalModel ID
**返回字段**:
- `global_model_id`: GlobalModel ID
- `global_model_name`: 模型名称
- `display_name`: 显示名称
- `is_active`: 是否活跃
- `providers`: 按优先级排序的提供商列表,每个包含:
- `id`: Provider ID
- `name`: Provider 名称
- `provider_priority`: 提供商优先级
- `provider_model_name`: 提供商侧的模型名称
- `model_mappings`: 模型名称映射列表
- `endpoints`: Endpoint 列表,每个包含 Key 信息
- `scheduling_mode`: 调度模式cache_affinity, fixed_order, load_balance
- `priority_mode`: 优先级模式provider, global_key
"""
adapter = AdminGetModelRoutingPreviewAdapter(global_model_id=global_model_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ========== Adapters ==========
@dataclass
class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
"""获取模型请求链路预览"""
global_model_id: str
async def handle(self, context) -> ModelRoutingPreviewResponse: # type: ignore[override]
db = context.db
# 获取 GlobalModel
global_model = (
db.query(GlobalModel).filter(GlobalModel.id == self.global_model_id).first()
)
if not global_model:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="GlobalModel not found")
# 获取所有关联的 Model包含 Provider 信息)
models = (
db.query(Model)
.options(selectinload(Model.provider))
.filter(Model.global_model_id == global_model.id)
.all()
)
# 获取所有相关的 Provider ID
provider_ids = [m.provider_id for m in models if m.provider_id]
# 批量获取 Provider 的 Endpoints
endpoints_by_provider: Dict[str, List[ProviderEndpoint]] = {}
if provider_ids:
endpoints = (
db.query(ProviderEndpoint)
.filter(ProviderEndpoint.provider_id.in_(provider_ids))
.all()
)
for ep in endpoints:
if ep.provider_id not in endpoints_by_provider:
endpoints_by_provider[ep.provider_id] = []
endpoints_by_provider[ep.provider_id].append(ep)
# 批量获取 Provider 的 Keys
keys_by_provider: Dict[str, List[ProviderAPIKey]] = {}
if provider_ids:
keys = (
db.query(ProviderAPIKey)
.filter(ProviderAPIKey.provider_id.in_(provider_ids))
.all()
)
for key in keys:
if key.provider_id not in keys_by_provider:
keys_by_provider[key.provider_id] = []
keys_by_provider[key.provider_id].append(key)
# 构建 Provider 路由信息
provider_infos: List[RoutingProviderInfo] = []
for model in models:
provider = model.provider
if not provider:
continue
# 获取模型映射
model_mappings = []
if model.provider_model_mappings:
for mapping in model.provider_model_mappings:
model_mappings.append(
RoutingModelMapping(
name=mapping.get("name", ""),
priority=mapping.get("priority", 0),
api_formats=mapping.get("api_formats"),
)
)
# 获取 Endpoints
provider_endpoints = endpoints_by_provider.get(provider.id, [])
provider_keys = keys_by_provider.get(provider.id, [])
# 按 api_format 组织 Keys
keys_by_endpoint: Dict[str, List[ProviderAPIKey]] = {}
for key in provider_keys:
# 每个 Key 可能支持多个 api_formats
for fmt in key.api_formats or []:
if fmt not in keys_by_endpoint:
keys_by_endpoint[fmt] = []
keys_by_endpoint[fmt].append(key)
endpoint_infos = []
for ep in provider_endpoints:
# 获取该 Endpoint 格式对应的 Keys
ep_keys = keys_by_endpoint.get(ep.api_format or "", [])
# 按优先级排序
ep_keys.sort(key=lambda k: (k.global_priority or 999, k.internal_priority or 0))
key_infos = []
for key in ep_keys:
# 计算有效 RPM
effective_rpm = key.rpm_limit
is_adaptive = key.rpm_limit is None
if is_adaptive and key.learned_rpm_limit:
effective_rpm = key.learned_rpm_limit
# 从 health_by_format 获取健康度
health_score = 100.0
if key.health_by_format and ep.api_format:
format_health = key.health_by_format.get(ep.api_format, {})
health_score = format_health.get("health_score", 100.0)
# 生成脱敏 SK先解密再脱敏
masked_key = ""
if key.api_key:
from src.core.crypto import CryptoService
crypto = CryptoService()
try:
decrypted_key = crypto.decrypt(key.api_key, silent=True)
except Exception:
# 解密失败时使用加密后的值(可能是未加密的旧数据)
decrypted_key = key.api_key
if len(decrypted_key) > 8:
masked_key = f"{decrypted_key[:4]}***{decrypted_key[-4:]}"
else:
masked_key = f"{decrypted_key[:2]}***"
# 检查熔断状态
circuit_breaker_open = False
circuit_breaker_formats: List[str] = []
if key.circuit_breaker_by_format:
for fmt, cb_state in key.circuit_breaker_by_format.items():
if isinstance(cb_state, dict) and cb_state.get("open"):
circuit_breaker_open = True
circuit_breaker_formats.append(fmt)
key_infos.append(
RoutingKeyInfo(
id=key.id or "",
name=key.name or "",
masked_key=masked_key,
internal_priority=key.internal_priority or 0,
global_priority=key.global_priority,
rpm_limit=key.rpm_limit,
is_adaptive=is_adaptive,
effective_rpm=effective_rpm,
cache_ttl_minutes=key.cache_ttl_minutes or 0,
health_score=health_score,
is_active=bool(key.is_active),
api_formats=key.api_formats or [],
circuit_breaker_open=circuit_breaker_open,
circuit_breaker_formats=circuit_breaker_formats,
)
)
active_keys = sum(1 for k in key_infos if k.is_active)
endpoint_infos.append(
RoutingEndpointInfo(
id=ep.id or "",
api_format=ep.api_format or "",
base_url=ep.base_url or "",
custom_path=ep.custom_path,
is_active=bool(ep.is_active),
keys=key_infos,
total_keys=len(key_infos),
active_keys=active_keys,
)
)
# 按 APIFormat 枚举定义的顺序排序 Endpoints
from src.core.enums import APIFormat
format_order = {fmt.value: i for i, fmt in enumerate(APIFormat)}
endpoint_infos.sort(key=lambda e: format_order.get(e.api_format, 999))
active_endpoints = sum(1 for e in endpoint_infos if e.is_active)
provider_infos.append(
RoutingProviderInfo(
id=provider.id,
name=provider.name,
model_id=model.id,
provider_priority=provider.provider_priority,
billing_type=provider.billing_type,
monthly_quota_usd=provider.monthly_quota_usd,
monthly_used_usd=provider.monthly_used_usd,
is_active=bool(provider.is_active),
provider_model_name=model.provider_model_name,
model_mappings=model_mappings,
model_is_active=bool(model.is_active),
endpoints=endpoint_infos,
total_endpoints=len(endpoint_infos),
active_endpoints=active_endpoints,
)
)
# 按 provider_priority 排序
provider_infos.sort(key=lambda p: p.provider_priority)
active_providers = sum(1 for p in provider_infos if p.is_active and p.model_is_active)
# 从数据库获取当前调度配置
scheduling_mode = SystemConfigService.get_config(
db,
"scheduling_mode",
CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY,
) or CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY
priority_mode = SystemConfigService.get_config(
db,
"provider_priority_mode",
CacheAwareScheduler.PRIORITY_MODE_PROVIDER,
) or CacheAwareScheduler.PRIORITY_MODE_PROVIDER
return ModelRoutingPreviewResponse(
global_model_id=global_model.id,
global_model_name=global_model.name,
display_name=global_model.display_name,
is_active=bool(global_model.is_active),
providers=provider_infos,
total_providers=len(provider_infos),
active_providers=active_providers,
scheduling_mode=scheduling_mode,
priority_mode=priority_mode,
)