mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-13 04:58:28 +08:00
feat(ui): 新增模型请求链路预览功能
- 添加后端 API 获取 GlobalModel 的请求链路信息 (/api/admin/models/global/{id}/routing)
- 新增 RoutingTab 组件展示模型的请求链路树状结构
- 支持全局 Key 优先和提供商优先两种模式的可视化
- 重构批量模型管理对话框为单列勾选模式
- 修复 errorParser.ts 字符串拼接格式问题
- 若干 Vue 模板格式优化
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,4 +21,5 @@ export {
|
||||
deleteGlobalModel,
|
||||
batchAssignToProviders,
|
||||
getGlobalModelProviders,
|
||||
getGlobalModelRoutingPreview,
|
||||
} from './endpoints/global-models'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' : ''"
|
||||
/>
|
||||
</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>
|
||||
<!-- 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"
|
||||
/>
|
||||
</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)
|
||||
|
||||
1014
frontend/src/features/models/components/RoutingTab.vue
Normal file
1014
frontend/src/features/models/components/RoutingTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -418,6 +418,7 @@
|
||||
|
||||
<!-- 模型详情抽屉 -->
|
||||
<ModelDetailDrawer
|
||||
ref="modelDetailDrawerRef"
|
||||
:model="selectedModel"
|
||||
:open="!!selectedModel"
|
||||
:providers="selectedModelProviders"
|
||||
@@ -437,235 +438,125 @@
|
||||
<!-- 批量添加关联提供商对话框 -->
|
||||
<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 class="border rounded-lg overflow-hidden">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div
|
||||
v-if="loadingProviderOptions"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<div
|
||||
v-if="loadingProviderOptions"
|
||||
class="flex items-center justify-center h-full"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
|
||||
<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"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium">提供商</span>
|
||||
<span class="text-xs text-muted-foreground">({{ filteredBatchProviders.length }})</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline shrink-0"
|
||||
@click="toggleAllBatchProviders"
|
||||
>
|
||||
{{ isAllBatchProvidersSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1 p-2">
|
||||
<div
|
||||
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)"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||||
:class="isBatchProviderSelected(provider.id) ? 'bg-primary border-primary' : ''"
|
||||
>
|
||||
<Check
|
||||
v-if="isBatchProviderSelected(provider.id)"
|
||||
class="w-3 h-3 text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium 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
|
||||
v-else-if="availableProvidersForBatchAdd.length === 0"
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||
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">
|
||||
所有 Provider 均已关联
|
||||
{{ batchProviderSearchQuery ? '无匹配结果' : '暂无可用提供商' }}
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ selectedModelProviders.length }} 个
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<div
|
||||
v-if="selectedModelProviders.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">
|
||||
暂无关联提供商
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="p-2 space-y-1"
|
||||
>
|
||||
<!-- 已存在的(可选中删除) -->
|
||||
<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
|
||||
/>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="closeBatchAddProvidersDialog"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<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 isAllLeftSelected = computed(() => {
|
||||
return availableProvidersForBatchAdd.value.length > 0 &&
|
||||
selectedLeftProviderIds.value.length === availableProvidersForBatchAdd.value.length
|
||||
})
|
||||
|
||||
// 是否全选了右侧
|
||||
const isAllRightSelected = computed(() => {
|
||||
return selectedModelProviders.value.length > 0 &&
|
||||
selectedRightProviderIds.value.length === selectedModelProviders.value.length
|
||||
})
|
||||
|
||||
// 切换左侧选择
|
||||
function toggleLeftSelection(providerId: string) {
|
||||
const index = selectedLeftProviderIds.value.indexOf(providerId)
|
||||
if (index === -1) {
|
||||
selectedLeftProviderIds.value.push(providerId)
|
||||
} else {
|
||||
selectedLeftProviderIds.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换右侧选择(只能选新增的)
|
||||
function toggleRightSelection(providerId: string) {
|
||||
const index = selectedRightProviderIds.value.indexOf(providerId)
|
||||
if (index === -1) {
|
||||
selectedRightProviderIds.value.push(providerId)
|
||||
} else {
|
||||
selectedRightProviderIds.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选左侧
|
||||
function toggleSelectAllLeft() {
|
||||
if (isAllLeftSelected.value) {
|
||||
selectedLeftProviderIds.value = []
|
||||
} else {
|
||||
selectedLeftProviderIds.value = availableProvidersForBatchAdd.value.map(p => p.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选右侧
|
||||
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
|
||||
|
||||
try {
|
||||
submittingBatchAddProviders.value = true
|
||||
|
||||
const result = await batchAssignToProviders(selectedModel.value.id, {
|
||||
provider_ids: selectedLeftProviderIds.value,
|
||||
create_models: true
|
||||
})
|
||||
|
||||
if (result.success.length > 0) {
|
||||
success(`成功添加 ${result.success.length} 个 Provider`)
|
||||
// 过滤后的提供商列表
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
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 添加失败')
|
||||
}
|
||||
|
||||
// 清空左侧选择,刷新右侧列表和外层表格
|
||||
selectedLeftProviderIds.value = []
|
||||
await loadModelProviders(selectedModel.value.id)
|
||||
// 刷新外层模型列表以更新 provider_count
|
||||
await loadGlobalModels()
|
||||
} catch (err: any) {
|
||||
showError(parseApiError(err, '批量添加失败'), '错误')
|
||||
} finally {
|
||||
submittingBatchAddProviders.value = false
|
||||
}
|
||||
// 检查提供商是否已选中
|
||||
function isBatchProviderSelected(providerId: string): boolean {
|
||||
return selectedBatchProviderIds.value.has(providerId)
|
||||
}
|
||||
|
||||
// 批量移除选中的 Provider(直接调用 API)
|
||||
async function batchRemoveSelectedProviders() {
|
||||
if (!selectedModel.value || selectedRightProviderIds.value.length === 0) return
|
||||
// 是否全选
|
||||
const isAllBatchProvidersSelected = computed(() => {
|
||||
if (filteredBatchProviders.value.length === 0) return false
|
||||
return filteredBatchProviders.value.every(p => isBatchProviderSelected(p.id))
|
||||
})
|
||||
|
||||
// 计算待添加的提供商
|
||||
const batchProvidersToAdd = computed(() => {
|
||||
const toAdd: string[] = []
|
||||
for (const id of selectedBatchProviderIds.value) {
|
||||
if (!initialBatchProviderIds.value.has(id)) {
|
||||
toAdd.push(id)
|
||||
}
|
||||
}
|
||||
return toAdd
|
||||
})
|
||||
|
||||
// 计算待移除的提供商
|
||||
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 {
|
||||
selectedBatchProviderIds.value.add(providerId)
|
||||
}
|
||||
selectedBatchProviderIds.value = new Set(selectedBatchProviderIds.value)
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
function toggleAllBatchProviders() {
|
||||
const allIds = filteredBatchProviders.value.map(p => p.id)
|
||||
if (isAllBatchProvidersSelected.value) {
|
||||
for (const id of allIds) {
|
||||
selectedBatchProviderIds.value.delete(id)
|
||||
}
|
||||
} else {
|
||||
for (const id of allIds) {
|
||||
selectedBatchProviderIds.value.add(id)
|
||||
}
|
||||
}
|
||||
selectedBatchProviderIds.value = new Set(selectedBatchProviderIds.value)
|
||||
}
|
||||
|
||||
// 同步初始选择状态
|
||||
function syncBatchProviderSelection() {
|
||||
const existingIds = new Set(selectedModelProviders.value.map((p: any) => p.id))
|
||||
selectedBatchProviderIds.value = new Set(existingIds)
|
||||
initialBatchProviderIds.value = new Set(existingIds)
|
||||
}
|
||||
|
||||
// 保存变更
|
||||
async function saveBatchProviderChanges() {
|
||||
if (!hasBatchProviderChanges.value || submittingBatchProviders.value || !selectedModel.value) return
|
||||
|
||||
submittingBatchProviders.value = true
|
||||
try {
|
||||
submittingBatchRemoveProviders.value = true
|
||||
const { deleteModel } = await import('@/api/endpoints')
|
||||
let totalSuccess = 0
|
||||
const allErrors: string[] = []
|
||||
|
||||
let successCount = 0
|
||||
const errors: 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
|
||||
})
|
||||
|
||||
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, '删除失败')}`)
|
||||
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 (successCount > 0) {
|
||||
success(`成功移除 ${successCount} 个 Provider`)
|
||||
// 添加提供商
|
||||
if (batchProvidersToAdd.value.length > 0) {
|
||||
const result = await batchAssignToProviders(selectedModel.value.id, {
|
||||
provider_ids: batchProvidersToAdd.value,
|
||||
create_models: true
|
||||
})
|
||||
totalSuccess += result.success.length
|
||||
if (result.errors.length > 0) {
|
||||
allErrors.push(...result.errors.map(e => e.error))
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
showError(errors.join('\n'), '部分 Provider 移除失败')
|
||||
if (totalSuccess > 0) {
|
||||
success(`成功处理 ${totalSuccess} 个提供商`)
|
||||
}
|
||||
|
||||
// 清空右侧选择,刷新列表和外层表格
|
||||
selectedRightProviderIds.value = []
|
||||
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 {
|
||||
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, '删除模型失败'))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
388
src/api/admin/models/routing.py
Normal file
388
src/api/admin/models/routing.py
Normal 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 ID(GlobalModel 与 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,
|
||||
)
|
||||
Reference in New Issue
Block a user