Merge upstream/main

This commit is contained in:
ZheFox
2026-05-18 13:11:11 +08:00
124 changed files with 11698 additions and 581 deletions

View File

@@ -0,0 +1,186 @@
import client from './client'
import type { RoutingDecisionTrace } from '@/features/routing/utils/routingTrace'
import type {
RoutingGroupConfig,
RoutingRulePhase,
} from '@/features/routing/utils/routingPolicy'
export type RoutingBindingSubjectType = 'user' | 'api_key' | 'user_group'
export interface RoutingGroupRecord {
id: string
name: string
description?: string | null
enabled: boolean
is_system_default: boolean
config_json: RoutingGroupConfig
version: number
created_at: number
updated_at: number
published_at?: number | null
}
export interface RoutingGroupListResponse {
items: RoutingGroupRecord[]
total: number
}
export interface RoutingGroupVersionRecord {
id: string
group_id: string
version: number
config_json: RoutingGroupConfig
created_at: number
created_by?: string | null
}
export interface RoutingGroupVersionListResponse {
items: RoutingGroupVersionRecord[]
total: number
}
export interface RoutingGroupBindingRecord {
id: string
group_id: string
subject_type: RoutingBindingSubjectType
subject_id: string
is_default: boolean
allow_explicit_select: boolean
created_at: number
updated_at: number
}
export interface RoutingGroupBindingListResponse {
items: RoutingGroupBindingRecord[]
total: number
}
export interface RoutingGroupCreateRequest {
id?: string
name: string
description?: string | null
enabled?: boolean
is_system_default?: boolean
config_json?: RoutingGroupConfig
}
export interface RoutingGroupUpdateRequest {
name?: string
description?: string | null
enabled?: boolean
is_system_default?: boolean
config_json?: RoutingGroupConfig
version?: number
published_at?: number | null
}
export interface RoutingGroupBindingCreateRequest {
id?: string
group_id: string
subject_type: RoutingBindingSubjectType
subject_id: string
is_default?: boolean
allow_explicit_select?: boolean
}
export interface RoutingGroupBindingUpdateRequest {
group_id?: string
subject_type?: RoutingBindingSubjectType
subject_id?: string
is_default?: boolean
allow_explicit_select?: boolean
}
export interface RoutingDryRunRequest {
model: string
resolved_model?: string
api_format?: string
user_id?: string
api_key_id?: string
headers?: Record<string, string>
body?: unknown
phase?: RoutingRulePhase
}
export interface RoutingDryRunResponse {
group: RoutingGroupRecord
policy: unknown
trace_seed: RoutingDecisionTrace
patch_summary: unknown
mutated_body: unknown
mutated_headers: Record<string, string>
candidate_preview: unknown
}
export async function listRoutingGroups(): Promise<RoutingGroupListResponse> {
const response = await client.get<RoutingGroupListResponse>('/api/admin/routing/groups')
return response.data
}
export async function getRoutingGroup(groupId: string): Promise<RoutingGroupRecord> {
const response = await client.get<RoutingGroupRecord>(`/api/admin/routing/groups/${groupId}`)
return response.data
}
export async function createRoutingGroup(data: RoutingGroupCreateRequest): Promise<RoutingGroupRecord> {
const response = await client.post<RoutingGroupRecord>('/api/admin/routing/groups', data)
return response.data
}
export async function updateRoutingGroup(
groupId: string,
data: RoutingGroupUpdateRequest
): Promise<RoutingGroupRecord> {
const response = await client.patch<RoutingGroupRecord>(`/api/admin/routing/groups/${groupId}`, data)
return response.data
}
export async function deleteRoutingGroup(groupId: string): Promise<void> {
await client.delete(`/api/admin/routing/groups/${groupId}`)
}
export async function publishRoutingGroup(groupId: string): Promise<RoutingGroupRecord> {
const response = await client.post<RoutingGroupRecord>(`/api/admin/routing/groups/${groupId}/publish`)
return response.data
}
export async function listRoutingGroupVersions(groupId: string): Promise<RoutingGroupVersionListResponse> {
const response = await client.get<RoutingGroupVersionListResponse>(`/api/admin/routing/groups/${groupId}/versions`)
return response.data
}
export async function dryRunRoutingGroup(
groupId: string,
data: RoutingDryRunRequest
): Promise<RoutingDryRunResponse> {
const response = await client.post<RoutingDryRunResponse>(`/api/admin/routing/groups/${groupId}/dry-run`, data)
return response.data
}
export async function listRoutingGroupBindings(params?: {
group_id?: string
subject_type?: RoutingBindingSubjectType
subject_id?: string
}): Promise<RoutingGroupBindingListResponse> {
const response = await client.get<RoutingGroupBindingListResponse>('/api/admin/routing/bindings', { params })
return response.data
}
export async function createRoutingGroupBinding(
data: RoutingGroupBindingCreateRequest
): Promise<RoutingGroupBindingRecord> {
const response = await client.post<RoutingGroupBindingRecord>('/api/admin/routing/bindings', data)
return response.data
}
export async function updateRoutingGroupBinding(
bindingId: string,
data: RoutingGroupBindingUpdateRequest
): Promise<RoutingGroupBindingRecord> {
const response = await client.patch<RoutingGroupBindingRecord>(`/api/admin/routing/bindings/${bindingId}`, data)
return response.data
}
export async function deleteRoutingGroupBinding(bindingId: string): Promise<void> {
await client.delete(`/api/admin/routing/bindings/${bindingId}`)
}

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_ROUTING_POLICY_MODEL,
createEmptyModelPolicy,
createEmptyRoutingGroupConfig,
getDefaultModelPolicy,
getModelScheduling,
modelSchedulingRuleId,
normalizeRoutingGroupConfig,
setDefaultPoolPriorityOverrides,
setDefaultProviderPriorityOverrides,
upsertModelSchedulingRule,
upsertModelPolicy,
} from '../utils/routingPolicy'
import { sortCandidateTraces, summarizeRoutingTrace, type RoutingDecisionTrace } from '../utils/routingTrace'
describe('routingPolicy', () => {
it('normalizes partial configs with stable defaults', () => {
const config = normalizeRoutingGroupConfig({
allowed_models: ['gpt-5'],
})
expect(config.default_policy.priority_mode).toBe('provider')
expect(config.default_policy.scheduling_mode).toBe('cache_affinity')
expect(config.allowed_models).toEqual(['gpt-5'])
})
it('upserts model policies by model name', () => {
const config = createEmptyRoutingGroupConfig()
const next = upsertModelPolicy(config, {
...createEmptyModelPolicy('gpt-5'),
allowed_providers: ['provider-a'],
})
expect(next.model_policies).toHaveLength(1)
expect(next.model_policies[0].allowed_providers).toEqual(['provider-a'])
})
it('stores default priority overrides on the wildcard model policy', () => {
const config = upsertModelPolicy(createEmptyRoutingGroupConfig(), createEmptyModelPolicy('gpt-5'))
const next = setDefaultProviderPriorityOverrides(config, {
'provider-a': 0,
'provider-b': 2,
})
const policy = getDefaultModelPolicy(next)
expect(policy.model).toBe(DEFAULT_ROUTING_POLICY_MODEL)
expect(next.model_policies.map(item => item.model)).toEqual([DEFAULT_ROUTING_POLICY_MODEL, 'gpt-5'])
expect(policy.provider_priority_overrides).toEqual({
'provider-a': 0,
'provider-b': 2,
})
})
it('stores pool priority overrides separately from key overrides', () => {
const next = setDefaultPoolPriorityOverrides(createEmptyRoutingGroupConfig(), {
'provider-pool': 3,
})
const policy = getDefaultModelPolicy(next)
expect(policy.pool_priority_overrides).toEqual({
'provider-pool': 3,
})
expect(policy.key_priority_overrides).toEqual({})
})
it('stores per-model scheduling as generated routing rules', () => {
const next = upsertModelSchedulingRule(createEmptyRoutingGroupConfig(), 'gpt-5', {
priority_mode: 'global_key',
scheduling_mode: 'fixed_order',
})
expect(next.rules).toHaveLength(1)
expect(next.rules[0].id).toBe(modelSchedulingRuleId('gpt-5'))
expect(next.rules[0].conditions).toEqual({
field: 'model',
op: 'eq',
value: 'gpt-5',
})
expect(getModelScheduling(next, 'gpt-5')).toMatchObject({
priority_mode: 'global_key',
scheduling_mode: 'fixed_order',
})
})
})
describe('routingTrace', () => {
it('sorts candidate traces by selected order', () => {
const sorted = sortCandidateTraces([
candidate('provider-b', 2),
candidate('provider-a', 1),
])
expect(sorted.map(item => item.provider_id)).toEqual(['provider-a', 'provider-b'])
})
it('summarizes trace metadata', () => {
const trace: RoutingDecisionTrace = {
group_id: 'group-a',
group_version: 3,
selection_source: 'explicit',
selected_rules: ['rule-a'],
original_model: 'gpt-5',
resolved_model: 'gpt-5',
client_api_format: 'openai:chat',
global_candidates: [candidate('provider-a', 0)],
pool_expansion: [],
runtime_facts: {},
}
expect(summarizeRoutingTrace(trace)).toContain('分组: group-a')
expect(summarizeRoutingTrace(trace)).toContain('候选: 1')
})
})
function candidate(providerId: string, selectedOrder: number) {
return {
candidate_kind: 'provider' as const,
provider_id: providerId,
endpoint_id: `${providerId}-endpoint`,
model_id: 'model-a',
key_id: `${providerId}-key`,
selected_order: selectedOrder,
ranking_vector: {
provider_priority_before: selectedOrder,
provider_priority_after: selectedOrder,
key_priority_before: selectedOrder,
key_priority_after: selectedOrder,
},
}
}

View File

@@ -0,0 +1,39 @@
<template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<input
v-model="draft.model"
class="h-10 rounded-md border border-border bg-background px-3 text-sm"
placeholder="模型"
>
<input
v-model="draft.api_format"
class="h-10 rounded-md border border-border bg-background px-3 text-sm"
placeholder="API 格式"
>
</div>
<RoutingTraceViewer
v-if="trace"
:trace="trace"
/>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import RoutingTraceViewer from './RoutingTraceViewer.vue'
import type { RoutingDecisionTrace } from '../utils/routingTrace'
const props = defineProps<{
trace?: RoutingDecisionTrace | null
model?: string
apiFormat?: string
}>()
const draft = reactive({
model: props.model ?? '',
api_format: props.apiFormat ?? 'openai:chat',
})
</script>

View File

@@ -0,0 +1,53 @@
<template>
<section class="space-y-4">
<div class="grid gap-3">
<label class="space-y-1 text-sm">
<span class="text-muted-foreground">允许模型</span>
<input
v-model="allowedModelsText"
class="h-10 w-full rounded-md border border-border bg-background px-3 text-sm"
placeholder="gpt-5, claude-sonnet-*"
>
</label>
</div>
<RoutingModelPolicyEditor
:model-policies="config.model_policies"
@update:model-policies="updateModelPolicies"
/>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import RoutingModelPolicyEditor from './RoutingModelPolicyEditor.vue'
import { normalizeRoutingGroupConfig, type RoutingGroupConfig, type RoutingModelPolicy } from '../utils/routingPolicy'
const props = defineProps<{
config: RoutingGroupConfig
}>()
const emit = defineEmits<{
'update:config': [value: RoutingGroupConfig]
}>()
const config = computed(() => normalizeRoutingGroupConfig(props.config))
const allowedModelsText = computed({
get: () => config.value.allowed_models.join(', '),
set: value => {
emit('update:config', {
...config.value,
allowed_models: value.split(',').map(item => item.trim()).filter(Boolean),
})
},
})
function updateModelPolicies(modelPolicies: RoutingModelPolicy[]) {
emit('update:config', {
...config.value,
model_policies: modelPolicies,
})
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="space-y-3">
<div
v-for="group in groups"
:key="group.id"
class="rounded-lg border border-border/60 bg-background px-4 py-3"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-foreground">
{{ group.name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ group.description || '未填写描述' }}
</p>
</div>
<span class="shrink-0 rounded-md border px-2 py-1 text-xs text-muted-foreground">
v{{ group.version }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
export interface RoutingGroupListItem {
id: string
name: string
description?: string | null
enabled: boolean
version: number
}
defineProps<{
groups: RoutingGroupListItem[]
}>()
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-medium">
模型策略
</h3>
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-xs"
@click="addPolicy"
>
添加
</button>
</div>
<div
v-for="(policy, index) in draftPolicies"
:key="`${policy.model}-${index}`"
class="grid gap-3 rounded-lg border border-border/60 p-3 sm:grid-cols-[1fr_1fr_auto]"
>
<input
v-model="policy.model"
class="h-9 rounded-md border border-border bg-background px-3 text-sm"
placeholder="模型"
@change="commit"
>
<input
:value="policy.allowed_providers.join(', ')"
class="h-9 rounded-md border border-border bg-background px-3 text-sm"
placeholder="允许 Provider"
@change="event => updateProviders(index, event)"
>
<button
type="button"
class="rounded-md border border-border px-3 text-xs text-muted-foreground"
@click="removePolicy(index)"
>
删除
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { createEmptyModelPolicy, type RoutingModelPolicy } from '../utils/routingPolicy'
const props = defineProps<{
modelPolicies: RoutingModelPolicy[]
}>()
const emit = defineEmits<{
'update:model-policies': [value: RoutingModelPolicy[]]
}>()
const draftPolicies = ref<RoutingModelPolicy[]>(props.modelPolicies.map(policy => ({ ...policy })))
watch(() => props.modelPolicies, value => {
draftPolicies.value = value.map(policy => ({ ...policy }))
})
function addPolicy() {
draftPolicies.value.push(createEmptyModelPolicy())
commit()
}
function removePolicy(index: number) {
draftPolicies.value.splice(index, 1)
commit()
}
function updateProviders(index: number, event: Event) {
const target = event.target as HTMLInputElement
draftPolicies.value[index].allowed_providers = target.value.split(',').map(item => item.trim()).filter(Boolean)
commit()
}
function commit() {
emit('update:model-policies', draftPolicies.value.map(policy => ({ ...policy })))
}
</script>

View File

@@ -0,0 +1,884 @@
<template>
<section class="space-y-4">
<div
v-if="showPriorityMode || showSchedulingMode"
class="grid gap-3"
:class="showPriorityMode ? 'lg:grid-cols-[1fr_1.4fr]' : ''"
>
<div
v-if="showPriorityMode"
class="space-y-1 text-sm"
>
<span class="text-muted-foreground">优先级模式</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="effectivePriorityMode === 'provider'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updatePriorityMode('provider')"
>
<Layers class="h-4 w-4" />
Provider
</button>
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="effectivePriorityMode === 'global_key'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updatePriorityMode('global_key')"
>
<Key class="h-4 w-4" />
Key
</button>
</div>
</div>
<div
v-if="showSchedulingMode"
class="space-y-1 text-sm"
>
<span class="text-muted-foreground">调度策略</span>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="effectiveSchedulingMode === mode.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateSchedulingMode(mode.value)"
>
{{ mode.label }}
</button>
</div>
</div>
</div>
<div class="rounded-lg border border-border/60">
<div class="flex flex-col gap-3 border-b border-border/60 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="text-sm font-medium">
{{ effectivePriorityMode === 'provider' ? '提供商排序' : 'Key 排序' }}
</h3>
<p class="mt-1 text-xs text-muted-foreground">
{{ subtitle }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
v-if="effectivePriorityMode === 'global_key'"
v-model="selectedApiFormat"
class="h-9 min-w-[180px] rounded-md border border-border bg-background px-3 text-sm"
>
<option
v-for="format in apiFormats"
:key="format"
:value="format"
>
{{ formatLabel(format) }}
</option>
</select>
<button
type="button"
class="inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-xs"
@click="refresh"
>
<RefreshCw
class="h-3.5 w-3.5"
:class="{ 'animate-spin': loading }"
/>
刷新
</button>
<button
type="button"
class="h-9 rounded-md border border-border px-3 text-xs text-muted-foreground"
@click="clearActiveOverrides"
>
清空排序
</button>
</div>
</div>
<div class="min-h-[180px] max-h-[420px] overflow-y-auto p-3">
<div
v-if="loading"
class="py-10 text-center text-sm text-muted-foreground"
>
正在加载
</div>
<div
v-else-if="loadError"
class="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
>
{{ loadError }}
</div>
<div
v-else-if="effectivePriorityMode === 'provider'"
class="space-y-2"
>
<div
v-if="providerRows.length === 0"
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground"
>
暂无 Provider
</div>
<div
v-for="(row, index) in providerRows"
v-else
:key="row.id"
class="group grid items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_76px_minmax(0,1fr)_auto]"
:class="draggedProviderId === row.id
? 'border-primary/50 bg-primary/5 shadow-sm'
: dragOverProviderId === row.id
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:bg-muted/30'"
draggable="true"
@dragstart="handleProviderDragStart(row.id, $event)"
@dragend="handleProviderDragEnd"
@dragover.prevent="handleProviderDragOver(row.id)"
@dragleave="handleProviderDragLeave"
@drop="handleProviderDrop(row.id)"
>
<div class="cursor-grab rounded p-1 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground active:cursor-grabbing">
<GripVertical class="h-4 w-4" />
</div>
<div class="flex items-center gap-1">
<button
type="button"
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === 0"
@click="moveProvider(row.id, -1)"
>
<ArrowUp class="h-4 w-4" />
</button>
<button
type="button"
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === providerRows.length - 1"
@click="moveProvider(row.id, 1)"
>
<ArrowDown class="h-4 w-4" />
</button>
</div>
<input
:value="row.priority"
type="number"
min="0"
class="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
@change="event => setProviderPriority(row.id, event)"
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{{ row.name }}</span>
<span
v-if="row.kind === 'pool'"
class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary"
>
Pool
</span>
<span
v-if="!row.is_active"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
停用
</span>
</div>
<div class="mt-0.5 truncate text-xs text-muted-foreground">
{{ row.id }}
</div>
</div>
<div class="hidden max-w-[240px] flex-wrap justify-end gap-1 sm:flex">
<span
v-for="format in row.api_formats.slice(0, 3)"
:key="format"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{{ format }}
</span>
</div>
</div>
</div>
<div
v-else
class="space-y-2"
>
<div
v-if="keyRows.length === 0"
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground"
>
暂无 Key
</div>
<div
v-for="(row, index) in keyRows"
v-else
:key="row.id"
class="group grid items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_76px_minmax(0,1fr)_auto]"
:class="draggedKeyId === row.id
? 'border-primary/50 bg-primary/5 shadow-sm'
: dragOverKeyId === row.id
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:bg-muted/30'"
draggable="true"
@dragstart="handleKeyDragStart(row.id, $event)"
@dragend="handleKeyDragEnd"
@dragover.prevent="handleKeyDragOver(row.id)"
@dragleave="handleKeyDragLeave"
@drop="handleKeyDrop(row.id)"
>
<div class="cursor-grab rounded p-1 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground active:cursor-grabbing">
<GripVertical class="h-4 w-4" />
</div>
<div class="flex items-center gap-1">
<button
type="button"
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === 0"
@click="moveKey(row.id, -1)"
>
<ArrowUp class="h-4 w-4" />
</button>
<button
type="button"
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === keyRows.length - 1"
@click="moveKey(row.id, 1)"
>
<ArrowDown class="h-4 w-4" />
</button>
</div>
<input
:value="row.priority"
type="number"
min="0"
class="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
@change="event => setKeyPriority(row.id, event)"
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{{ row.name }}</span>
<span
v-if="!row.is_active"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
停用
</span>
</div>
<div class="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{{ row.masked }} · {{ row.provider_name }}
</div>
</div>
<div class="hidden max-w-[240px] flex-wrap justify-end gap-1 sm:flex">
<span
v-for="format in row.api_formats.slice(0, 3)"
:key="format"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{{ format }}
</span>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ArrowDown, ArrowUp, GripVertical, Key, Layers, RefreshCw } from 'lucide-vue-next'
import client from '@/api/client'
import {
getProvidersSummary,
type ProviderWithEndpointsSummary,
} from '@/api/endpoints'
import { formatApiFormat, normalizeApiFormatAlias, sortApiFormats } from '@/api/endpoints/types/api-format'
import { parseApiError } from '@/utils/errorParser'
import {
DEFAULT_ROUTING_POLICY_MODEL,
getDefaultModelPolicy,
getModelPolicy,
normalizeRoutingGroupConfig,
setModelKeyPriorityOverrides,
setModelPoolPriorityOverrides,
setModelProviderPriorityOverrides,
type RoutingDefaultPolicy,
type RoutingGroupConfig,
type RoutingPriorityMode,
type RoutingSchedulingMode,
} from '../utils/routingPolicy'
interface ProviderPriorityRow {
id: string
name: string
is_active: boolean
api_formats: string[]
priority: number
}
interface KeyPriorityRow {
id: string
kind: 'key' | 'pool'
target_id: string
name: string
masked: string
is_active: boolean
api_formats: string[]
priority: number
provider_id: string
provider_name: string
pool_key_count?: number
pool_active_key_count?: number
}
interface GlobalKeySource {
id: string
provider_id: string
provider_name: string
name: string
api_key_masked: string
internal_priority: number
global_priority_by_format: Record<string, number> | null
is_active: boolean
provider_active: boolean
api_formats: string[]
api_format: string
health_score: number | null
request_count: number
}
const props = defineProps<{
config: RoutingGroupConfig
model?: string
priorityMode?: RoutingPriorityMode
schedulingMode?: RoutingSchedulingMode
showPriorityMode?: boolean
showSchedulingMode?: boolean
subtitle?: string
}>()
const emit = defineEmits<{
'update:config': [value: RoutingGroupConfig]
'update:priority-mode': [value: RoutingPriorityMode]
'update:scheduling-mode': [value: RoutingSchedulingMode]
}>()
const schedulingModes: Array<{ value: RoutingDefaultPolicy['scheduling_mode']; label: string }> = [
{ value: 'cache_affinity', label: '缓存亲和' },
{ value: 'load_balance', label: '负载均衡' },
{ value: 'fixed_order', label: '固定顺序' },
]
const providers = ref<ProviderWithEndpointsSummary[]>([])
const keysByFormat = ref<Record<string, GlobalKeySource[]>>({})
const selectedApiFormat = ref('')
const loadingProviders = ref(false)
const loadingKeys = ref(false)
const loadError = ref<string | null>(null)
const draggedProviderId = ref<string | null>(null)
const dragOverProviderId = ref<string | null>(null)
const draggedKeyId = ref<string | null>(null)
const dragOverKeyId = ref<string | null>(null)
const config = computed(() => normalizeRoutingGroupConfig(props.config))
const targetModel = computed(() => props.model?.trim() || DEFAULT_ROUTING_POLICY_MODEL)
const targetModelPolicy = computed(() => targetModel.value === DEFAULT_ROUTING_POLICY_MODEL
? getDefaultModelPolicy(config.value)
: getModelPolicy(config.value, targetModel.value))
const showPriorityMode = computed(() => props.showPriorityMode !== false)
const showSchedulingMode = computed(() => props.showSchedulingMode !== false)
const effectivePriorityMode = computed(() => props.priorityMode ?? config.value.default_policy.priority_mode)
const effectiveSchedulingMode = computed(() => props.schedulingMode ?? config.value.default_policy.scheduling_mode)
const subtitle = computed(() => props.subtitle ?? '默认作用于全部模型')
const loading = computed(() => loadingProviders.value || loadingKeys.value)
const apiFormats = computed(() => sortApiFormats(Object.keys(keysByFormat.value)))
const providerById = computed(() => {
const map = new Map<string, ProviderWithEndpointsSummary>()
for (const provider of providers.value) {
map.set(provider.id, provider)
}
return map
})
const providerIdByName = computed(() => {
const map = new Map<string, string>()
for (const provider of providers.value) {
if (!map.has(provider.name)) {
map.set(provider.name, provider.id)
}
}
return map
})
const poolProviderIds = computed(() => {
const set = new Set<string>()
for (const provider of providers.value) {
if (provider.pool_advanced) {
set.add(provider.id)
}
}
return set
})
const providerRows = computed<ProviderPriorityRow[]>(() => {
const overrides = targetModelPolicy.value.provider_priority_overrides
return providers.value
.map(provider => ({
id: provider.id,
name: provider.name,
is_active: provider.is_active,
api_formats: provider.api_formats ?? [],
priority: priorityValue(overrides[provider.id], provider.provider_priority),
}))
.sort(comparePriorityRows)
})
const keyRows = computed<KeyPriorityRow[]>(() => {
const format = selectedApiFormat.value
const keyOverrides = targetModelPolicy.value.key_priority_overrides
const poolOverrides = targetModelPolicy.value.pool_priority_overrides
const normalRows: KeyPriorityRow[] = []
const poolGroups = new Map<string, GlobalKeySource[]>()
for (const key of keysByFormat.value[format] ?? []) {
const providerId = resolveProviderId(key)
if (isPoolManagedProvider(providerId)) {
if (!poolGroups.has(providerId)) {
poolGroups.set(providerId, [])
}
poolGroups.get(providerId)?.push(key)
continue
}
normalRows.push({
id: key.id,
kind: 'key',
target_id: key.id,
name: key.name,
masked: key.api_key_masked,
is_active: key.is_active && key.provider_active,
api_formats: key.api_formats,
priority: priorityValue(keyOverrides[key.id], fallbackKeyPriority(key, format)),
provider_id: providerId,
provider_name: key.provider_name,
})
}
const poolRows = Array.from(poolGroups.entries()).map(([providerId, keys]) =>
buildPoolRow(format, providerId, keys, poolOverrides)
)
return [...normalRows, ...poolRows].sort(comparePriorityRows)
})
watch(effectivePriorityMode, mode => {
if (mode === 'global_key') {
void loadGlobalKeys()
}
})
watch(apiFormats, formats => {
if (!formats.includes(selectedApiFormat.value)) {
selectedApiFormat.value = formats[0] ?? ''
}
})
onMounted(() => {
void (async () => {
await loadProviders()
if (effectivePriorityMode.value === 'global_key') {
await loadGlobalKeys()
}
})()
})
function updateConfig(value: RoutingGroupConfig): void {
emit('update:config', normalizeRoutingGroupConfig(value))
}
function updateDefaultPolicy(patch: Partial<RoutingDefaultPolicy>): void {
updateConfig({
...config.value,
default_policy: {
...config.value.default_policy,
...patch,
},
})
}
function updatePriorityMode(mode: RoutingPriorityMode): void {
if (props.priorityMode != null) {
emit('update:priority-mode', mode)
return
}
updateDefaultPolicy({ priority_mode: mode })
}
function updateSchedulingMode(mode: RoutingSchedulingMode): void {
if (props.schedulingMode != null) {
emit('update:scheduling-mode', mode)
return
}
updateDefaultPolicy({ scheduling_mode: mode })
}
async function refresh(): Promise<void> {
if (effectivePriorityMode.value === 'provider') {
await loadProviders()
} else {
await loadProviders()
await loadGlobalKeys(true)
}
}
async function loadProviders(): Promise<void> {
loadingProviders.value = true
loadError.value = null
try {
const response = await getProvidersSummary({ page: 1, page_size: 9999 })
providers.value = response.items
} catch (err) {
loadError.value = parseApiError(err, '加载 Provider 失败')
providers.value = []
} finally {
loadingProviders.value = false
}
}
async function loadGlobalKeys(force = false): Promise<void> {
if (!force && Object.keys(keysByFormat.value).length > 0) return
loadingKeys.value = true
loadError.value = null
try {
const response = await client.get<Record<string, Record<string, unknown>[]>>(
'/api/admin/endpoints/keys/grouped-by-format',
)
const next: Record<string, GlobalKeySource[]> = {}
for (const [rawFormat, rawKeys] of Object.entries(response.data ?? {})) {
const format = normalizeFormat(rawFormat)
if (!format) continue
next[format] = normalizeGlobalKeys(format, rawKeys)
}
keysByFormat.value = next
if (!selectedApiFormat.value || !Object.keys(next).includes(selectedApiFormat.value)) {
selectedApiFormat.value = sortApiFormats(Object.keys(next))[0] ?? ''
}
} catch (err) {
loadError.value = parseApiError(err, '加载全局 Key 失败')
} finally {
loadingKeys.value = false
}
}
function setProviderPriority(providerId: string, event: Event): void {
const priority = readPriorityInput(event)
if (priority == null) return
updateProviderOverrides({
...targetModelPolicy.value.provider_priority_overrides,
[providerId]: priority,
})
}
function moveProvider(providerId: string, direction: -1 | 1): void {
const rows = moveRow(providerRows.value, providerId, direction)
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
}
function updateProviderOverrides(overrides: Record<string, number>): void {
updateConfig(setModelProviderPriorityOverrides(config.value, targetModel.value, overrides))
}
function setKeyPriority(keyId: string, event: Event): void {
const priority = readPriorityInput(event)
if (priority == null) return
const row = keyRows.value.find(item => item.id === keyId)
if (!row) return
if (row.kind === 'pool') {
updatePoolOverrides({
...targetModelPolicy.value.pool_priority_overrides,
[row.target_id]: priority,
})
} else {
updateKeyOverrides({
...targetModelPolicy.value.key_priority_overrides,
[row.target_id]: priority,
})
}
}
function moveKey(keyId: string, direction: -1 | 1): void {
const rows = moveRow(keyRows.value, keyId, direction)
updateVisibleKeyAndPoolOverrides(rows)
}
function updateKeyOverrides(overrides: Record<string, number>): void {
updateConfig(setModelKeyPriorityOverrides(config.value, targetModel.value, overrides))
}
function updatePoolOverrides(overrides: Record<string, number>): void {
updateConfig(setModelPoolPriorityOverrides(config.value, targetModel.value, overrides))
}
function updateKeyAndPoolOverrides(
keyOverrides: Record<string, number>,
poolOverrides: Record<string, number>,
): void {
const next = setModelPoolPriorityOverrides(
setModelKeyPriorityOverrides(config.value, targetModel.value, keyOverrides),
targetModel.value,
poolOverrides,
)
updateConfig(next)
}
function updateVisibleKeyAndPoolOverrides(rows: KeyPriorityRow[]): void {
const keyOverrides = { ...targetModelPolicy.value.key_priority_overrides }
const poolOverrides = { ...targetModelPolicy.value.pool_priority_overrides }
for (const row of keyRows.value) {
if (row.kind === 'pool') {
delete poolOverrides[row.target_id]
} else {
delete keyOverrides[row.target_id]
}
}
rows.forEach((row, index) => {
if (row.kind === 'pool') {
poolOverrides[row.target_id] = index
} else {
keyOverrides[row.target_id] = index
}
})
updateKeyAndPoolOverrides(keyOverrides, poolOverrides)
}
function handleProviderDragStart(providerId: string, event: DragEvent): void {
draggedProviderId.value = providerId
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', providerId)
}
}
function handleProviderDragEnd(): void {
draggedProviderId.value = null
dragOverProviderId.value = null
}
function handleProviderDragOver(providerId: string): void {
dragOverProviderId.value = providerId
}
function handleProviderDragLeave(): void {
dragOverProviderId.value = null
}
function handleProviderDrop(providerId: string): void {
const draggedId = draggedProviderId.value
if (!draggedId || draggedId === providerId) {
handleProviderDragEnd()
return
}
const rows = reorderRows(providerRows.value, draggedId, providerId)
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
handleProviderDragEnd()
}
function handleKeyDragStart(keyId: string, event: DragEvent): void {
draggedKeyId.value = keyId
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', keyId)
}
}
function handleKeyDragEnd(): void {
draggedKeyId.value = null
dragOverKeyId.value = null
}
function handleKeyDragOver(keyId: string): void {
dragOverKeyId.value = keyId
}
function handleKeyDragLeave(): void {
dragOverKeyId.value = null
}
function handleKeyDrop(keyId: string): void {
const draggedId = draggedKeyId.value
if (!draggedId || draggedId === keyId) {
handleKeyDragEnd()
return
}
const rows = reorderRows(keyRows.value, draggedId, keyId)
updateVisibleKeyAndPoolOverrides(rows)
handleKeyDragEnd()
}
function clearActiveOverrides(): void {
if (effectivePriorityMode.value === 'provider') {
updateProviderOverrides({})
} else {
updateVisibleKeyAndPoolOverrides([])
}
}
function moveRow<T extends { id: string }>(rows: T[], id: string, direction: -1 | 1): T[] {
const next = [...rows]
const index = next.findIndex(row => row.id === id)
const targetIndex = index + direction
if (index < 0 || targetIndex < 0 || targetIndex >= next.length) {
return next
}
const [item] = next.splice(index, 1)
next.splice(targetIndex, 0, item)
return next
}
function reorderRows<T extends { id: string }>(rows: T[], draggedId: string, targetId: string): T[] {
const next = [...rows]
const fromIndex = next.findIndex(row => row.id === draggedId)
const toIndex = next.findIndex(row => row.id === targetId)
if (fromIndex < 0 || toIndex < 0) return next
const [item] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, item)
return next
}
function readPriorityInput(event: Event): number | null {
const value = Number((event.target as HTMLInputElement).value)
if (!Number.isFinite(value) || value < 0) {
return null
}
return Math.trunc(value)
}
function priorityValue(override: number | undefined, fallback: number | null | undefined): number {
if (typeof override === 'number' && Number.isFinite(override)) return override
if (typeof fallback === 'number' && Number.isFinite(fallback)) return fallback
return 0
}
function fallbackKeyPriority(key: GlobalKeySource, format: string): number {
const normalizedFormat = normalizeFormat(format)
if (normalizedFormat && typeof key.global_priority_by_format?.[normalizedFormat] === 'number') {
return key.global_priority_by_format[normalizedFormat]
}
return key.internal_priority
}
function normalizeGlobalKeys(format: string, rawKeys: Record<string, unknown>[]): GlobalKeySource[] {
const deduped = new Map<string, GlobalKeySource>()
for (const raw of rawKeys) {
const id = String(raw.id || '').trim()
if (!id) continue
const providerName = String(raw.provider_name || '')
const providerId = String(raw.provider_id || '') || providerIdByName.value.get(providerName) || ''
const priorityMap = normalizePriorityMap(raw.global_priority_by_format as Record<string, unknown> | null | undefined)
const source: GlobalKeySource = {
id,
provider_id: providerId,
provider_name: providerName || providerById.value.get(providerId)?.name || 'Unknown Provider',
name: String(raw.name || 'Unnamed Key'),
api_key_masked: String(raw.api_key_masked || '***'),
internal_priority: toNumberOrNull(raw.internal_priority) ?? 0,
global_priority_by_format: Object.keys(priorityMap).length > 0 ? priorityMap : null,
is_active: raw.is_active !== false,
provider_active: raw.provider_active !== false,
api_formats: Array.isArray(raw.api_formats) ? raw.api_formats.map(item => normalizeFormat(String(item))).filter(Boolean) : [format],
api_format: format,
health_score: toNumberOrNull(raw.health_score),
request_count: toNumberOrNull(raw.request_count) ?? 0,
}
const existing = deduped.get(id)
if (!existing) {
deduped.set(id, source)
continue
}
deduped.set(id, {
...existing,
...source,
global_priority_by_format: {
...(existing.global_priority_by_format ?? {}),
...(source.global_priority_by_format ?? {}),
},
api_formats: Array.from(new Set([...existing.api_formats, ...source.api_formats])),
})
}
return Array.from(deduped.values())
}
function buildPoolRow(
format: string,
providerId: string,
keys: GlobalKeySource[],
overrides: Record<string, number>,
): KeyPriorityRow {
const provider = providerById.value.get(providerId)
const activeKeyCount = keys.filter(key => key.is_active).length
return {
id: `pool:${providerId}:${format}`,
kind: 'pool',
target_id: providerId,
name: provider?.name || keys[0]?.provider_name || '未知 Provider',
masked: '[Pool]',
is_active: (provider?.is_active ?? keys.some(key => key.provider_active)) && activeKeyCount > 0,
api_formats: [format],
priority: priorityValue(
overrides[providerId],
provider?.pool_advanced?.global_priority ?? provider?.provider_priority ?? 999999,
),
provider_id: providerId,
provider_name: provider?.name || keys[0]?.provider_name || 'Unknown Provider',
pool_key_count: keys.length,
pool_active_key_count: activeKeyCount,
}
}
function resolveProviderId(key: Pick<GlobalKeySource, 'provider_id' | 'provider_name'>): string {
if (key.provider_id) return key.provider_id
return providerIdByName.value.get(key.provider_name) || ''
}
function isPoolManagedProvider(providerId: string): boolean {
return providerId !== '' && poolProviderIds.value.has(providerId)
}
function normalizeFormat(value: string | null | undefined): string {
return normalizeApiFormatAlias(value).trim()
}
function formatLabel(format: string): string {
return formatApiFormat(format)
}
function normalizePriorityMap(value: Record<string, unknown> | null | undefined): Record<string, number> {
if (!value) return {}
const normalized: Record<string, number> = {}
for (const [rawFormat, rawPriority] of Object.entries(value)) {
const format = normalizeFormat(rawFormat)
const priority = toNumberOrNull(rawPriority)
if (!format || priority == null) continue
normalized[format] = priority
}
return normalized
}
function toNumberOrNull(value: unknown): number | null {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : null
}
function comparePriorityRows(left: ProviderPriorityRow | KeyPriorityRow, right: ProviderPriorityRow | KeyPriorityRow): number {
return left.priority - right.priority
|| Number(right.is_active) - Number(left.is_active)
|| left.name.localeCompare(right.name)
|| left.id.localeCompare(right.id)
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="space-y-2">
<div
v-for="rule in rules"
:key="rule.id"
class="rounded-lg border border-border/60 px-3 py-2"
>
<div class="flex items-center justify-between gap-3">
<p class="truncate text-sm font-medium">
{{ rule.id }}
</p>
<span class="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
P{{ rule.priority }} / {{ rule.phase }}
</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">
{{ summarizeRule(rule) }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { summarizeRoutingCondition } from '../utils/routingConditions'
import type { RoutingRule } from '../utils/routingPolicy'
defineProps<{
rules: RoutingRule[]
}>()
function summarizeRule(rule: RoutingRule): string {
return summarizeRoutingCondition(rule.conditions as never)
}
</script>

View File

@@ -0,0 +1,43 @@
<template>
<section class="space-y-4">
<div class="rounded-lg border border-border/60 p-3">
<p
v-for="line in summary"
:key="line"
class="text-sm text-muted-foreground"
>
{{ line }}
</p>
</div>
<div class="space-y-2">
<div
v-for="candidate in candidates"
:key="`${candidate.provider_id}-${candidate.endpoint_id}-${candidate.key_id ?? 'pool'}`"
class="rounded-lg border border-border/60 px-3 py-2"
>
<div class="flex items-center justify-between gap-3">
<p class="truncate text-sm font-medium">
{{ candidateTraceLabel(candidate) }}
</p>
<span class="text-xs text-muted-foreground">
{{ candidate.skip_reason || `#${candidate.selected_order ?? '-'}` }}
</span>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { candidateTraceLabel, sortCandidateTraces, summarizeRoutingTrace, type RoutingDecisionTrace } from '../utils/routingTrace'
const props = defineProps<{
trace: RoutingDecisionTrace
}>()
const summary = computed(() => summarizeRoutingTrace(props.trace))
const candidates = computed(() => sortCandidateTraces(props.trace.global_candidates))
</script>

View File

@@ -0,0 +1,7 @@
export { default as RoutingDryRunDialog } from './RoutingDryRunDialog.vue'
export { default as RoutingGroupEditor } from './RoutingGroupEditor.vue'
export { default as RoutingGroupList } from './RoutingGroupList.vue'
export { default as RoutingModelPolicyEditor } from './RoutingModelPolicyEditor.vue'
export { default as RoutingPriorityPolicyEditor } from './RoutingPriorityPolicyEditor.vue'
export { default as RoutingRuleEditor } from './RoutingRuleEditor.vue'
export { default as RoutingTraceViewer } from './RoutingTraceViewer.vue'

View File

@@ -0,0 +1,4 @@
export * from './components'
export * from './utils/routingConditions'
export * from './utils/routingPolicy'
export * from './utils/routingTrace'

View File

@@ -0,0 +1,73 @@
export type RoutingConditionOp = 'eq' | 'ne' | 'in' | 'contains' | 'exists' | 'matches'
export interface RoutingConditionLeaf {
field: string
op: RoutingConditionOp
value?: unknown
}
export interface RoutingConditionGroup {
all?: RoutingCondition[]
any?: RoutingCondition[]
not?: RoutingCondition
}
export type RoutingCondition = RoutingConditionLeaf | RoutingConditionGroup
export const routingConditionFieldLabels: Record<string, string> = {
model: '模型',
api_format: 'API 格式',
user_id: '用户',
api_key_id: 'API Key',
}
export const routingConditionOpLabels: Record<RoutingConditionOp, string> = {
eq: '等于',
ne: '不等于',
in: '包含于',
contains: '包含',
exists: '存在',
matches: '匹配',
}
export function isConditionLeaf(condition: RoutingCondition): condition is RoutingConditionLeaf {
return typeof (condition as RoutingConditionLeaf).field === 'string'
}
export function summarizeRoutingCondition(condition: RoutingCondition): string {
if (isConditionLeaf(condition)) {
const field = routingConditionFieldLabels[condition.field] ?? condition.field
const op = routingConditionOpLabels[condition.op] ?? condition.op
return `${field} ${op} ${formatConditionValue(condition.value)}`
}
if (condition.all?.length) {
return condition.all.map(summarizeRoutingCondition).join(' 且 ')
}
if (condition.any?.length) {
return condition.any.map(summarizeRoutingCondition).join(' 或 ')
}
if (condition.not) {
return `${summarizeRoutingCondition(condition.not)}`
}
return '无条件'
}
function formatConditionValue(value: unknown): string {
if (Array.isArray(value)) {
return value.map(formatConditionValue).join(', ')
}
if (value === undefined || value === null) {
return ''
}
if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
}

View File

@@ -0,0 +1,350 @@
export type RoutingPriorityMode = 'provider' | 'global_key'
export type RoutingSchedulingMode = 'fixed_order' | 'cache_affinity' | 'load_balance'
export type RoutingRulePhase = 'client_request' | 'provider_request'
export interface RoutingDefaultPolicy {
priority_mode: RoutingPriorityMode
scheduling_mode: RoutingSchedulingMode
keep_priority_on_conversion: boolean
}
export interface RoutingPoolSchedulingPreset {
preset: string
enabled: boolean
mode?: string | null
}
export interface RoutingPoolPolicyOverride {
scheduling_presets: RoutingPoolSchedulingPreset[]
}
export interface RoutingModelPolicy {
model: string
allowed_providers: string[]
allowed_keys: string[]
provider_priority_overrides: Record<string, number>
key_priority_overrides: Record<string, number>
pool_priority_overrides: Record<string, number>
pool_policy_overrides: Record<string, RoutingPoolPolicyOverride>
}
export interface RoutingRule {
id: string
priority: number
enabled: boolean
phase: RoutingRulePhase
conditions: unknown
actions: unknown[]
stop_processing: boolean
}
export interface RoutingPredicateCondition {
field: string
op: 'eq' | 'prefix'
value: string
}
export interface RoutingSetSchedulingAction {
type: 'set_scheduling'
priority_mode: RoutingPriorityMode
scheduling_mode: RoutingSchedulingMode
}
export interface RoutingGroupConfig {
allowed_models: string[]
default_policy: RoutingDefaultPolicy
model_policies: RoutingModelPolicy[]
rules: RoutingRule[]
}
export const DEFAULT_ROUTING_POLICY_MODEL = '*'
export const MODEL_SCHEDULING_RULE_PREFIX = 'ui_model_scheduling:'
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
return {
allowed_models: [],
default_policy: {
priority_mode: 'provider',
scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false,
},
model_policies: [],
rules: [],
}
}
export function createEmptyModelPolicy(model = ''): RoutingModelPolicy {
return {
model,
allowed_providers: [],
allowed_keys: [],
provider_priority_overrides: {},
key_priority_overrides: {},
pool_priority_overrides: {},
pool_policy_overrides: {},
}
}
export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> | null | undefined): RoutingGroupConfig {
const base = createEmptyRoutingGroupConfig()
return {
allowed_models: Array.isArray(value?.allowed_models) ? [...value.allowed_models] : base.allowed_models,
default_policy: {
...base.default_policy,
...(value?.default_policy ?? {}),
},
model_policies: Array.isArray(value?.model_policies)
? value.model_policies.map(policy => ({
...createEmptyModelPolicy(policy.model),
...policy,
allowed_providers: Array.isArray(policy.allowed_providers) ? [...policy.allowed_providers] : [],
allowed_keys: Array.isArray(policy.allowed_keys) ? [...policy.allowed_keys] : [],
provider_priority_overrides: { ...(policy.provider_priority_overrides ?? {}) },
key_priority_overrides: { ...(policy.key_priority_overrides ?? {}) },
pool_priority_overrides: { ...(policy.pool_priority_overrides ?? {}) },
pool_policy_overrides: { ...(policy.pool_policy_overrides ?? {}) },
}))
: base.model_policies,
rules: Array.isArray(value?.rules) ? value.rules.map(rule => ({ ...rule })) : base.rules,
}
}
export function upsertModelPolicy(config: RoutingGroupConfig, policy: RoutingModelPolicy): RoutingGroupConfig {
const model = policy.model.trim()
if (!model) {
return normalizeRoutingGroupConfig(config)
}
const next = normalizeRoutingGroupConfig(config)
const index = next.model_policies.findIndex(item => item.model === model)
const normalizedPolicy = { ...createEmptyModelPolicy(model), ...policy, model }
if (index >= 0) {
next.model_policies[index] = normalizedPolicy
} else {
next.model_policies.push(normalizedPolicy)
}
return next
}
export function removeModelPolicy(config: RoutingGroupConfig, model: string): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
next.model_policies = next.model_policies.filter(policy => policy.model !== model)
return next
}
export function getDefaultModelPolicy(config: RoutingGroupConfig): RoutingModelPolicy {
const normalized = normalizeRoutingGroupConfig(config)
return normalized.model_policies.find(policy => policy.model === DEFAULT_ROUTING_POLICY_MODEL)
?? createEmptyModelPolicy(DEFAULT_ROUTING_POLICY_MODEL)
}
export function getModelPolicy(config: RoutingGroupConfig, model: string): RoutingModelPolicy {
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
return getDefaultModelPolicy(config)
}
const normalized = normalizeRoutingGroupConfig(config)
return normalized.model_policies.find(policy => policy.model === normalizedModel)
?? createEmptyModelPolicy(normalizedModel)
}
export function upsertDefaultModelPolicy(
config: RoutingGroupConfig,
patch: Partial<Omit<RoutingModelPolicy, 'model'>>,
): RoutingGroupConfig {
const current = getDefaultModelPolicy(config)
const next = upsertModelPolicy(config, {
...current,
...patch,
model: DEFAULT_ROUTING_POLICY_MODEL,
})
next.model_policies = [
...next.model_policies.filter(policy => policy.model === DEFAULT_ROUTING_POLICY_MODEL),
...next.model_policies.filter(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL),
]
return next
}
export function setDefaultProviderPriorityOverrides(
config: RoutingGroupConfig,
overrides: Record<string, number>,
): RoutingGroupConfig {
return upsertDefaultModelPolicy(config, {
provider_priority_overrides: normalizePriorityOverrides(overrides),
})
}
export function setDefaultKeyPriorityOverrides(
config: RoutingGroupConfig,
overrides: Record<string, number>,
): RoutingGroupConfig {
return upsertDefaultModelPolicy(config, {
key_priority_overrides: normalizePriorityOverrides(overrides),
})
}
export function setDefaultPoolPriorityOverrides(
config: RoutingGroupConfig,
overrides: Record<string, number>,
): RoutingGroupConfig {
return upsertDefaultModelPolicy(config, {
pool_priority_overrides: normalizePriorityOverrides(overrides),
})
}
export function setModelProviderPriorityOverrides(
config: RoutingGroupConfig,
model: string,
overrides: Record<string, number>,
): RoutingGroupConfig {
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
return setDefaultProviderPriorityOverrides(config, overrides)
}
return upsertModelPolicy(config, {
...getModelPolicy(config, normalizedModel),
model: normalizedModel,
provider_priority_overrides: normalizePriorityOverrides(overrides),
})
}
export function setModelKeyPriorityOverrides(
config: RoutingGroupConfig,
model: string,
overrides: Record<string, number>,
): RoutingGroupConfig {
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
return setDefaultKeyPriorityOverrides(config, overrides)
}
return upsertModelPolicy(config, {
...getModelPolicy(config, normalizedModel),
model: normalizedModel,
key_priority_overrides: normalizePriorityOverrides(overrides),
})
}
export function setModelPoolPriorityOverrides(
config: RoutingGroupConfig,
model: string,
overrides: Record<string, number>,
): RoutingGroupConfig {
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
return setDefaultPoolPriorityOverrides(config, overrides)
}
return upsertModelPolicy(config, {
...getModelPolicy(config, normalizedModel),
model: normalizedModel,
pool_priority_overrides: normalizePriorityOverrides(overrides),
})
}
export function modelSchedulingRuleId(model: string): string {
return `${MODEL_SCHEDULING_RULE_PREFIX}${encodeURIComponent(model.trim())}`
}
export function isGeneratedModelSchedulingRule(rule: RoutingRule): boolean {
return rule.id.startsWith(MODEL_SCHEDULING_RULE_PREFIX)
}
export function modelPatternCondition(model: string): RoutingPredicateCondition {
const normalizedModel = model.trim()
if (normalizedModel.endsWith('*')) {
return {
field: 'model',
op: 'prefix',
value: normalizedModel.slice(0, -1),
}
}
return {
field: 'model',
op: 'eq',
value: normalizedModel,
}
}
export function getModelScheduling(
config: RoutingGroupConfig,
model: string,
): RoutingDefaultPolicy {
const normalized = normalizeRoutingGroupConfig(config)
const rule = normalized.rules.find(rule => rule.id === modelSchedulingRuleId(model))
const action = rule?.actions.find(isSetSchedulingAction)
return {
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode,
keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion,
}
}
export function upsertModelSchedulingRule(
config: RoutingGroupConfig,
model: string,
scheduling: Pick<RoutingDefaultPolicy, 'priority_mode' | 'scheduling_mode'>,
): RoutingGroupConfig {
const normalizedModel = model.trim()
if (!normalizedModel || normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
return normalizeRoutingGroupConfig(config)
}
const next = normalizeRoutingGroupConfig(config)
const rule: RoutingRule = {
id: modelSchedulingRuleId(normalizedModel),
priority: 10_000 + next.rules.filter(isGeneratedModelSchedulingRule).length,
enabled: true,
phase: 'client_request',
conditions: modelPatternCondition(normalizedModel),
actions: [{
type: 'set_scheduling',
priority_mode: scheduling.priority_mode,
scheduling_mode: scheduling.scheduling_mode,
} satisfies RoutingSetSchedulingAction],
stop_processing: false,
}
const index = next.rules.findIndex(item => item.id === rule.id)
if (index >= 0) {
next.rules[index] = {
...next.rules[index],
...rule,
priority: next.rules[index].priority,
}
} else {
next.rules.push(rule)
}
return next
}
export function removeModelSchedulingRule(config: RoutingGroupConfig, model: string): RoutingGroupConfig {
const ruleId = modelSchedulingRuleId(model)
const next = normalizeRoutingGroupConfig(config)
next.rules = next.rules.filter(rule => rule.id !== ruleId)
return next
}
export function removeGeneratedModelSchedulingRules(config: RoutingGroupConfig): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
next.rules = next.rules.filter(rule => !isGeneratedModelSchedulingRule(rule))
return next
}
export function normalizePriorityOverrides(overrides: Record<string, number>): Record<string, number> {
const normalized: Record<string, number> = {}
for (const [rawId, rawPriority] of Object.entries(overrides)) {
const id = rawId.trim()
const priority = Math.max(0, Math.trunc(Number(rawPriority)))
if (!id || !Number.isFinite(priority)) continue
normalized[id] = priority
}
return normalized
}
function isSetSchedulingAction(action: unknown): action is RoutingSetSchedulingAction {
if (!action || typeof action !== 'object') return false
const candidate = action as Partial<RoutingSetSchedulingAction>
return candidate.type === 'set_scheduling'
}

View File

@@ -0,0 +1,59 @@
export interface RoutingCandidateRankVector {
provider_priority_before: number
provider_priority_after: number
key_priority_before: number
key_priority_after: number
}
export interface RoutingCandidateTrace {
candidate_kind: 'provider' | 'pool_group'
provider_id: string
endpoint_id: string
model_id: string
key_id?: string | null
ranking_vector: RoutingCandidateRankVector
skip_reason?: string | null
selected_order?: number | null
}
export interface RoutingDecisionTrace {
group_id?: string | null
group_version?: number | null
selection_source: string
selected_rules: string[]
original_model: string
resolved_model: string
client_api_format: string
global_candidates: RoutingCandidateTrace[]
pool_expansion: unknown[]
runtime_facts: Record<string, unknown>
}
export function candidateTraceLabel(candidate: RoutingCandidateTrace): string {
const kind = candidate.candidate_kind === 'pool_group' ? '号池' : 'Provider'
const key = candidate.key_id ? ` / ${candidate.key_id}` : ''
return `${kind} ${candidate.provider_id}${key}`
}
export function summarizeRoutingTrace(trace: RoutingDecisionTrace): string[] {
const lines = [
`分组: ${trace.group_id ?? 'legacy'}`,
`来源: ${trace.selection_source}`,
`模型: ${trace.original_model} -> ${trace.resolved_model}`,
]
if (trace.selected_rules.length > 0) {
lines.push(`规则: ${trace.selected_rules.join(', ')}`)
}
lines.push(`候选: ${trace.global_candidates.length}`)
return lines
}
export function sortCandidateTraces(candidates: readonly RoutingCandidateTrace[]): RoutingCandidateTrace[] {
return [...candidates].sort((left, right) => {
const leftOrder = left.selected_order ?? Number.MAX_SAFE_INTEGER
const rightOrder = right.selected_order ?? Number.MAX_SAFE_INTEGER
return leftOrder - rightOrder
})
}

View File

@@ -696,6 +696,7 @@ const navigation = computed(() => {
{ name: '用户管理', href: '/admin/users', icon: Users },
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
{ name: '模型管理', href: '/admin/models', icon: Layers },
{ name: '调度策略', href: '/admin/routing', icon: SlidersHorizontal },
{ name: '号池管理', href: '/admin/pool', icon: Database },
{ name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '钱包管理', href: '/admin/wallets', icon: Wallet },

View File

@@ -429,6 +429,102 @@ const MOCK_ALIASES = [
{ id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
]
interface MockRoutingGroup {
id: string
name: string
description: string | null
enabled: boolean
is_system_default: boolean
config_json: Record<string, unknown>
version: number
created_at: number
updated_at: number
published_at: number | null
}
interface MockRoutingGroupVersion {
id: string
group_id: string
version: number
config_json: Record<string, unknown>
created_at: number
created_by: string | null
}
interface MockRoutingGroupBinding {
id: string
group_id: string
subject_type: 'user' | 'api_key' | 'user_group'
subject_id: string
is_default: boolean
allow_explicit_select: boolean
created_at: number
updated_at: number
}
const mockRoutingNow = Math.floor(Date.now() / 1000)
const MOCK_ROUTING_GROUPS: MockRoutingGroup[] = [
{
id: 'routing-default',
name: '默认调度策略',
description: '演示模式默认分组,保持 Provider 优先和缓存亲和',
enabled: true,
is_system_default: true,
config_json: {
allowed_models: [],
default_policy: {
priority_mode: 'provider',
scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false,
},
model_policies: [
{
model: 'gpt-5.1',
allowed_providers: ['provider-002'],
allowed_keys: [],
provider_priority_overrides: { 'provider-002': 0 },
key_priority_overrides: {},
pool_policy_overrides: {},
},
],
rules: [],
},
version: 1,
created_at: mockRoutingNow - 86400,
updated_at: mockRoutingNow - 3600,
published_at: mockRoutingNow - 3600,
},
]
const MOCK_ROUTING_GROUP_VERSIONS: MockRoutingGroupVersion[] = [
{
id: 'routing-default-v1',
group_id: 'routing-default',
version: 1,
config_json: MOCK_ROUTING_GROUPS[0].config_json,
created_at: MOCK_ROUTING_GROUPS[0].published_at ?? mockRoutingNow,
created_by: null,
},
]
const MOCK_ROUTING_GROUP_BINDINGS: MockRoutingGroupBinding[] = []
function cloneMockRoutingGroup(group: MockRoutingGroup): MockRoutingGroup {
return JSON.parse(JSON.stringify(group)) as MockRoutingGroup
}
function cloneMockRoutingVersion(version: MockRoutingGroupVersion): MockRoutingGroupVersion {
return JSON.parse(JSON.stringify(version)) as MockRoutingGroupVersion
}
function unsetOtherMockRoutingDefaults(groupId: string): void {
for (const group of MOCK_ROUTING_GROUPS) {
if (group.id !== groupId) {
group.is_system_default = false
}
}
}
function normalizeApiFormat(apiFormat: string): string {
return apiFormat.toLowerCase().replace(/_/g, ':')
}
@@ -949,6 +1045,68 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
return createMockResponse({ ...body, id: `gm-demo-${Date.now()}`, created_at: new Date().toISOString() })
},
// ========== Admin: Routing Profiles ==========
'GET /api/admin/routing/groups': async () => {
await delay()
requireAdmin()
return createMockResponse({
items: MOCK_ROUTING_GROUPS.map(cloneMockRoutingGroup),
total: MOCK_ROUTING_GROUPS.length,
})
},
'POST /api/admin/routing/groups': async (config) => {
await delay()
requireAdmin()
const body = JSON.parse(config.data || '{}') as Partial<MockRoutingGroup>
const now = Math.floor(Date.now() / 1000)
const group: MockRoutingGroup = {
id: body.id || `routing-demo-${Date.now()}`,
name: body.name || '未命名调度策略',
description: body.description ?? null,
enabled: body.enabled ?? true,
is_system_default: body.is_system_default ?? false,
config_json: body.config_json ?? {},
version: 1,
created_at: now,
updated_at: now,
published_at: null,
}
if (group.is_system_default) {
unsetOtherMockRoutingDefaults(group.id)
}
MOCK_ROUTING_GROUPS.unshift(group)
return createMockResponse(cloneMockRoutingGroup(group))
},
'GET /api/admin/routing/bindings': async () => {
await delay()
requireAdmin()
return createMockResponse({
items: MOCK_ROUTING_GROUP_BINDINGS.map(binding => ({ ...binding })),
total: MOCK_ROUTING_GROUP_BINDINGS.length,
})
},
'POST /api/admin/routing/bindings': async (config) => {
await delay()
requireAdmin()
const body = JSON.parse(config.data || '{}') as Partial<MockRoutingGroupBinding>
const now = Math.floor(Date.now() / 1000)
const binding: MockRoutingGroupBinding = {
id: body.id || `routing-binding-demo-${Date.now()}`,
group_id: body.group_id || 'routing-default',
subject_type: body.subject_type || 'api_key',
subject_id: body.subject_id || 'demo',
is_default: body.is_default ?? false,
allow_explicit_select: body.allow_explicit_select ?? false,
created_at: now,
updated_at: now,
}
MOCK_ROUTING_GROUP_BINDINGS.unshift(binding)
return createMockResponse({ ...binding })
},
// ========== Admin: Model Mappings / Aliases ==========
'GET /api/admin/models/mappings': async () => {
await delay()
@@ -2102,6 +2260,181 @@ registerDynamicRoute('POST', '/api/admin/models/global/:modelId/assign-to-provid
return createMockResponse(result)
})
registerDynamicRoute('GET', '/api/admin/routing/groups/:groupId', async (_config, params) => {
await delay()
requireAdmin()
const group = MOCK_ROUTING_GROUPS.find(item => item.id === params.groupId)
if (!group) {
throw { response: createMockResponse({ detail: '调度策略不存在' }, 404) }
}
return createMockResponse(cloneMockRoutingGroup(group))
})
registerDynamicRoute('PATCH', '/api/admin/routing/groups/:groupId', async (config, params) => {
await delay()
requireAdmin()
const index = MOCK_ROUTING_GROUPS.findIndex(item => item.id === params.groupId)
if (index < 0) {
throw { response: createMockResponse({ detail: '调度策略不存在' }, 404) }
}
const body = JSON.parse(config.data || '{}') as Partial<MockRoutingGroup>
const current = MOCK_ROUTING_GROUPS[index]
const now = Math.floor(Date.now() / 1000)
const updated: MockRoutingGroup = {
...current,
...body,
id: current.id,
config_json: body.config_json ?? current.config_json,
version: body.config_json ? current.version + 1 : (body.version ?? current.version),
updated_at: now,
}
if (updated.is_system_default) {
unsetOtherMockRoutingDefaults(updated.id)
}
MOCK_ROUTING_GROUPS[index] = updated
return createMockResponse(cloneMockRoutingGroup(updated))
})
registerDynamicRoute('DELETE', '/api/admin/routing/groups/:groupId', async (_config, params) => {
await delay()
requireAdmin()
const index = MOCK_ROUTING_GROUPS.findIndex(item => item.id === params.groupId)
if (index < 0) {
throw { response: createMockResponse({ detail: '调度策略不存在' }, 404) }
}
MOCK_ROUTING_GROUPS.splice(index, 1)
return createMockResponse({ message: '删除成功(演示模式)' })
})
registerDynamicRoute('POST', '/api/admin/routing/groups/:groupId/publish', async (_config, params) => {
await delay()
requireAdmin()
const group = MOCK_ROUTING_GROUPS.find(item => item.id === params.groupId)
if (!group) {
throw { response: createMockResponse({ detail: '调度策略不存在' }, 404) }
}
const now = Math.floor(Date.now() / 1000)
group.published_at = now
group.updated_at = now
MOCK_ROUTING_GROUP_VERSIONS.unshift({
id: `${group.id}-v${group.version}-${now}`,
group_id: group.id,
version: group.version,
config_json: group.config_json,
created_at: now,
created_by: null,
})
return createMockResponse(cloneMockRoutingGroup(group))
})
registerDynamicRoute('GET', '/api/admin/routing/groups/:groupId/versions', async (_config, params) => {
await delay()
requireAdmin()
const versions = MOCK_ROUTING_GROUP_VERSIONS
.filter(version => version.group_id === params.groupId)
.map(cloneMockRoutingVersion)
return createMockResponse({ items: versions, total: versions.length })
})
registerDynamicRoute('POST', '/api/admin/routing/groups/:groupId/dry-run', async (config, params) => {
await delay()
requireAdmin()
const group = MOCK_ROUTING_GROUPS.find(item => item.id === params.groupId)
if (!group) {
throw { response: createMockResponse({ detail: '调度策略不存在' }, 404) }
}
const body = JSON.parse(config.data || '{}') as {
model?: string
resolved_model?: string
api_format?: string
headers?: Record<string, string>
body?: unknown
}
const model = body.model || 'gpt-5.1'
const resolvedModel = body.resolved_model || model
const rules = Array.isArray(group.config_json.rules)
? group.config_json.rules as Array<{ id?: unknown; enabled?: unknown }>
: []
const selectedRules = rules
.filter(rule => rule.enabled !== false && typeof rule.id === 'string')
.map(rule => String(rule.id))
const traceSeed = {
group_id: group.id,
group_version: group.version,
selection_source: 'admin_dry_run',
selected_rules: selectedRules,
original_model: model,
resolved_model: resolvedModel,
client_api_format: body.api_format || 'openai:chat',
global_candidates: [
{
candidate_kind: 'provider',
provider_id: 'provider-002',
endpoint_id: 'ep-002',
model_id: resolvedModel,
key_id: 'ekey-003',
ranking_vector: {
provider_priority_before: 0,
provider_priority_after: 0,
key_priority_before: 0,
key_priority_after: 0,
},
skip_reason: null,
selected_order: 0,
},
],
pool_expansion: [],
runtime_facts: {
scheduler_mode: 'cache_affinity',
priority_mode: 'provider',
},
}
return createMockResponse({
group: cloneMockRoutingGroup(group),
policy: {
selected_rules: selectedRules,
ranking_overlay: {},
},
trace_seed: traceSeed,
patch_summary: { body_paths: [], header_names: [], failed_action: null },
mutated_body: body.body ?? { model },
mutated_headers: body.headers ?? {},
candidate_preview: {
status: 'policy_only',
ranking_overlay: {},
note: '演示模式候选预览',
},
})
})
registerDynamicRoute('PATCH', '/api/admin/routing/bindings/:bindingId', async (config, params) => {
await delay()
requireAdmin()
const index = MOCK_ROUTING_GROUP_BINDINGS.findIndex(item => item.id === params.bindingId)
if (index < 0) {
throw { response: createMockResponse({ detail: '调度绑定不存在' }, 404) }
}
const body = JSON.parse(config.data || '{}') as Partial<MockRoutingGroupBinding>
MOCK_ROUTING_GROUP_BINDINGS[index] = {
...MOCK_ROUTING_GROUP_BINDINGS[index],
...body,
id: MOCK_ROUTING_GROUP_BINDINGS[index].id,
updated_at: Math.floor(Date.now() / 1000),
}
return createMockResponse({ ...MOCK_ROUTING_GROUP_BINDINGS[index] })
})
registerDynamicRoute('DELETE', '/api/admin/routing/bindings/:bindingId', async (_config, params) => {
await delay()
requireAdmin()
const index = MOCK_ROUTING_GROUP_BINDINGS.findIndex(item => item.id === params.bindingId)
if (index < 0) {
throw { response: createMockResponse({ detail: '调度绑定不存在' }, 404) }
}
MOCK_ROUTING_GROUP_BINDINGS.splice(index, 1)
return createMockResponse({ message: '删除成功(演示模式)' })
})
// Endpoint Health 详情
registerDynamicRoute('GET', '/api/admin/endpoints/health/endpoint/:endpointId', async (_config, params) => {
await delay()

View File

@@ -200,6 +200,11 @@ const routes: RouteRecordRaw[] = [
name: 'ModelManagement',
component: () => importWithRetry(() => import('@/views/admin/ModelManagement.vue'))
},
{
path: 'routing',
name: 'RoutingProfiles',
component: () => importWithRetry(() => import('@/views/admin/RoutingProfiles.vue'))
},
{
path: 'health-monitor',
name: 'HealthMonitor',

View File

@@ -4,6 +4,7 @@ import { adminBillingPlansApi, epayGatewayApi } from '@/api/billing'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getPoolOverview, getPoolSchedulingPresets, listPoolKeys } from '@/api/endpoints/pool'
import { listGlobalModels } from '@/api/global-models'
import { listRoutingGroups } from '@/api/routing-profiles'
import { usersApi } from '@/api/users'
import { log } from '@/utils/logger'
@@ -50,6 +51,12 @@ const adminRouteWarmers: Record<string, () => Promise<void>> = {
),
])
},
'/admin/routing': async () => {
await Promise.allSettled([
import('@/views/admin/RoutingProfiles.vue'),
listRoutingGroups(),
])
},
'/admin/pool': async () => {
const [overviewResult] = await Promise.allSettled([
getPoolOverview({ cacheTtlMs: NAV_DATA_CACHE_TTL_MS }),

File diff suppressed because it is too large Load Diff