mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +00:00
Add composite group route registry
This commit is contained in:
@@ -7,6 +7,10 @@ import { apiClient } from '../client'
|
||||
import type {
|
||||
AdminGroup,
|
||||
GroupPlatform,
|
||||
CompositeModelRoute,
|
||||
CompositeModelRouteInput,
|
||||
CompositeRoutePreviewRequest,
|
||||
CompositeRouteDecision,
|
||||
CreateGroupRequest,
|
||||
UpdateGroupRequest,
|
||||
PaginatedResponse
|
||||
@@ -263,6 +267,55 @@ export async function getGroupApiKeys(
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listCompositeRoutes(id: number): Promise<CompositeModelRoute[]> {
|
||||
const { data } = await apiClient.get<CompositeModelRoute[]>(`/admin/groups/${id}/composite-routes`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createCompositeRoute(
|
||||
id: number,
|
||||
route: CompositeModelRouteInput
|
||||
): Promise<CompositeModelRoute> {
|
||||
const { data } = await apiClient.post<CompositeModelRoute>(
|
||||
`/admin/groups/${id}/composite-routes`,
|
||||
route
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateCompositeRoute(
|
||||
id: number,
|
||||
routeId: number,
|
||||
route: CompositeModelRouteInput
|
||||
): Promise<CompositeModelRoute> {
|
||||
const { data } = await apiClient.put<CompositeModelRoute>(
|
||||
`/admin/groups/${id}/composite-routes/${routeId}`,
|
||||
route
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteCompositeRoute(
|
||||
id: number,
|
||||
routeId: number
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(
|
||||
`/admin/groups/${id}/composite-routes/${routeId}`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function previewCompositeRoute(
|
||||
id: number,
|
||||
request: CompositeRoutePreviewRequest
|
||||
): Promise<CompositeRouteDecision> {
|
||||
const { data } = await apiClient.post<CompositeRouteDecision>(
|
||||
`/admin/groups/${id}/composite-routes/preview`,
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate multiplier entry for a user in a group
|
||||
*/
|
||||
@@ -423,6 +476,11 @@ export const groupsAPI = {
|
||||
toggleStatus,
|
||||
getStats,
|
||||
getGroupApiKeys,
|
||||
listCompositeRoutes,
|
||||
createCompositeRoute,
|
||||
updateCompositeRoute,
|
||||
deleteCompositeRoute,
|
||||
previewCompositeRoute,
|
||||
getGroupRateMultipliers,
|
||||
clearGroupRateMultipliers,
|
||||
batchSetGroupRateMultipliers,
|
||||
|
||||
@@ -1015,6 +1015,55 @@ export default {
|
||||
selectAll: 'Select all',
|
||||
invertSelection: 'Invert'
|
||||
},
|
||||
compositeRoutes: {
|
||||
action: 'Routes',
|
||||
title: 'Composite Routes',
|
||||
titleWithGroup: 'Composite Routes: {name}',
|
||||
routes: 'Saved Routes',
|
||||
empty: 'No composite routes configured',
|
||||
publicModel: 'Public Model',
|
||||
target: 'Target',
|
||||
scope: 'Scope',
|
||||
priority: 'Priority',
|
||||
addRoute: 'Add Route',
|
||||
editRoute: 'Edit Route',
|
||||
matchType: 'Match',
|
||||
endpoint: 'Endpoint',
|
||||
targetPlatform: 'Target Platform',
|
||||
upstreamModel: 'Upstream Model',
|
||||
notes: 'Notes',
|
||||
enabled: 'Enabled',
|
||||
preview: 'Preview',
|
||||
matched: 'Matched',
|
||||
notMatched: 'No Match',
|
||||
publicModelRequired: 'Public model is required',
|
||||
routeCreated: 'Composite route created',
|
||||
routeUpdated: 'Composite route updated',
|
||||
routeDeleted: 'Composite route deleted',
|
||||
failedToLoad: 'Failed to load composite routes',
|
||||
failedToSave: 'Failed to save composite route',
|
||||
failedToDelete: 'Failed to delete composite route',
|
||||
failedToPreview: 'Failed to preview composite route',
|
||||
deleteConfirm: 'Delete this composite route?',
|
||||
endpoints: {
|
||||
any: 'Any',
|
||||
messages: 'Messages',
|
||||
countTokens: 'Count Tokens',
|
||||
responses: 'Responses',
|
||||
chatCompletions: 'Chat Completions',
|
||||
embeddings: 'Embeddings',
|
||||
images: 'Images',
|
||||
gemini: 'Gemini Native'
|
||||
},
|
||||
match: {
|
||||
exact: 'Exact',
|
||||
prefix: 'Prefix'
|
||||
},
|
||||
sources: {
|
||||
route: 'Route',
|
||||
detector: 'Detector'
|
||||
}
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code Client Restriction',
|
||||
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
|
||||
|
||||
@@ -1012,6 +1012,55 @@ export default {
|
||||
selectAll: '全选',
|
||||
invertSelection: '反选'
|
||||
},
|
||||
compositeRoutes: {
|
||||
action: '路由',
|
||||
title: 'Composite 路由',
|
||||
titleWithGroup: 'Composite 路由:{name}',
|
||||
routes: '已保存路由',
|
||||
empty: '暂无 Composite 路由',
|
||||
publicModel: '公开模型',
|
||||
target: '目标',
|
||||
scope: '范围',
|
||||
priority: '优先级',
|
||||
addRoute: '添加路由',
|
||||
editRoute: '编辑路由',
|
||||
matchType: '匹配方式',
|
||||
endpoint: '端点',
|
||||
targetPlatform: '目标平台',
|
||||
upstreamModel: '上游模型',
|
||||
notes: '备注',
|
||||
enabled: '启用',
|
||||
preview: '预览',
|
||||
matched: '已匹配',
|
||||
notMatched: '未匹配',
|
||||
publicModelRequired: '请输入公开模型',
|
||||
routeCreated: 'Composite 路由已创建',
|
||||
routeUpdated: 'Composite 路由已更新',
|
||||
routeDeleted: 'Composite 路由已删除',
|
||||
failedToLoad: '加载 Composite 路由失败',
|
||||
failedToSave: '保存 Composite 路由失败',
|
||||
failedToDelete: '删除 Composite 路由失败',
|
||||
failedToPreview: '预览 Composite 路由失败',
|
||||
deleteConfirm: '确定删除此 Composite 路由?',
|
||||
endpoints: {
|
||||
any: '任意',
|
||||
messages: 'Messages',
|
||||
countTokens: 'Count Tokens',
|
||||
responses: 'Responses',
|
||||
chatCompletions: 'Chat Completions',
|
||||
embeddings: 'Embeddings',
|
||||
images: 'Images',
|
||||
gemini: 'Gemini 原生'
|
||||
},
|
||||
match: {
|
||||
exact: '精确',
|
||||
prefix: '前缀'
|
||||
},
|
||||
sources: {
|
||||
route: '路由',
|
||||
detector: '内置识别'
|
||||
}
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code 客户端限制',
|
||||
tooltip:
|
||||
|
||||
@@ -589,6 +589,63 @@ export interface ModelsListConfig {
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export type CompositeRouteMatchType = 'exact' | 'prefix'
|
||||
|
||||
export type CompositeRouteEndpoint =
|
||||
| 'any'
|
||||
| 'messages'
|
||||
| 'count_tokens'
|
||||
| 'responses'
|
||||
| 'chat_completions'
|
||||
| 'embeddings'
|
||||
| 'images'
|
||||
| 'gemini'
|
||||
|
||||
export type CompositeRouteSource = 'route' | 'detector' | string
|
||||
|
||||
export interface CompositeModelRoute {
|
||||
id: number
|
||||
group_id: number
|
||||
public_model: string
|
||||
match_type: CompositeRouteMatchType
|
||||
target_platform: Exclude<GroupPlatform, 'composite'>
|
||||
upstream_model: string
|
||||
endpoint: CompositeRouteEndpoint
|
||||
priority: number
|
||||
enabled: boolean
|
||||
notes: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface CompositeModelRouteInput {
|
||||
public_model: string
|
||||
match_type: CompositeRouteMatchType
|
||||
target_platform: Exclude<GroupPlatform, 'composite'>
|
||||
upstream_model?: string
|
||||
endpoint: CompositeRouteEndpoint
|
||||
priority?: number
|
||||
enabled?: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface CompositeRoutePreviewRequest {
|
||||
model: string
|
||||
endpoint: CompositeRouteEndpoint
|
||||
}
|
||||
|
||||
export interface CompositeRouteDecision {
|
||||
matched: boolean
|
||||
source: CompositeRouteSource
|
||||
group_id: number
|
||||
public_model: string
|
||||
target_platform: Exclude<GroupPlatform, 'composite'> | ''
|
||||
upstream_model: string
|
||||
endpoint: CompositeRouteEndpoint
|
||||
route?: CompositeModelRoute
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: number
|
||||
user_id: number
|
||||
|
||||
@@ -386,6 +386,16 @@
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.platform === 'composite'"
|
||||
@click="handleCompositeRoutes(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-cyan-600 dark:hover:bg-dark-700 dark:hover:text-cyan-400"
|
||||
>
|
||||
<Icon name="swap" size="sm" />
|
||||
<span class="text-xs">{{
|
||||
t("admin.groups.compositeRoutes.action")
|
||||
}}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleRateMultipliers(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-purple-600 dark:hover:bg-dark-700 dark:hover:text-purple-400"
|
||||
@@ -3578,6 +3588,362 @@
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Composite Routes Modal -->
|
||||
<BaseDialog
|
||||
:show="showCompositeRoutesModal"
|
||||
:title="
|
||||
compositeRoutesGroup
|
||||
? t('admin.groups.compositeRoutes.titleWithGroup', {
|
||||
name: compositeRoutesGroup.name,
|
||||
})
|
||||
: t('admin.groups.compositeRoutes.title')
|
||||
"
|
||||
width="wide"
|
||||
@close="closeCompositeRoutesModal"
|
||||
>
|
||||
<div class="grid gap-5 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
||||
<section class="min-w-0">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.groups.compositeRoutes.routes") }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="compositeRoutesLoading"
|
||||
@click="loadCompositeRoutes"
|
||||
>
|
||||
<Icon
|
||||
name="refresh"
|
||||
size="sm"
|
||||
:class="compositeRoutesLoading ? 'animate-spin' : ''"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600"
|
||||
>
|
||||
<div
|
||||
v-if="compositeRoutesLoading"
|
||||
class="flex h-36 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="compositeRoutes.length === 0"
|
||||
class="flex h-36 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t("admin.groups.compositeRoutes.empty") }}
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-dark-600">
|
||||
<thead class="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:bg-dark-800 dark:text-gray-400">
|
||||
<tr>
|
||||
<th class="px-3 py-2">
|
||||
{{ t("admin.groups.compositeRoutes.publicModel") }}
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
{{ t("admin.groups.compositeRoutes.target") }}
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
{{ t("admin.groups.compositeRoutes.scope") }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right">
|
||||
{{ t("admin.groups.columns.actions") }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||
<tr
|
||||
v-for="route in compositeRoutes"
|
||||
:key="route.id"
|
||||
:class="!route.enabled && 'opacity-60'"
|
||||
>
|
||||
<td class="max-w-[15rem] px-3 py-2">
|
||||
<div class="break-all font-medium text-gray-900 dark:text-white">
|
||||
{{ route.public_model }}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span class="badge badge-gray">{{
|
||||
compositeRouteMatchLabel(route.match_type)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="!route.enabled"
|
||||
class="badge badge-danger"
|
||||
>
|
||||
{{ t("admin.accounts.status.inactive") }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-1.5 text-gray-900 dark:text-white">
|
||||
<PlatformIcon :platform="route.target_platform" size="xs" />
|
||||
<span>{{ formatCompositePlatform(route.target_platform) }}</span>
|
||||
</div>
|
||||
<div class="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ route.upstream_model || route.public_model }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="text-gray-700 dark:text-gray-300">
|
||||
{{ formatCompositeEndpoint(route.endpoint) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.groups.compositeRoutes.priority") }}:
|
||||
{{ route.priority }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||
:title="t('common.edit')"
|
||||
@click="editCompositeRoute(route)"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('common.delete')"
|
||||
@click="deleteCompositeRoute(route)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-5">
|
||||
<form class="space-y-3" @submit.prevent="saveCompositeRoute">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{
|
||||
compositeRouteEditingId
|
||||
? t("admin.groups.compositeRoutes.editRoute")
|
||||
: t("admin.groups.compositeRoutes.addRoute")
|
||||
}}
|
||||
</h3>
|
||||
<button
|
||||
v-if="compositeRouteEditingId"
|
||||
type="button"
|
||||
class="text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
@click="resetCompositeRouteForm"
|
||||
>
|
||||
{{ t("common.cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.compositeRoutes.publicModel")
|
||||
}}</label>
|
||||
<input
|
||||
v-model.trim="compositeRouteForm.public_model"
|
||||
type="text"
|
||||
class="input"
|
||||
required
|
||||
placeholder="openrouter/gpt-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.compositeRoutes.matchType")
|
||||
}}</label>
|
||||
<Select
|
||||
v-model="compositeRouteForm.match_type"
|
||||
:options="compositeRouteMatchOptions"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.compositeRoutes.endpoint")
|
||||
}}</label>
|
||||
<Select
|
||||
v-model="compositeRouteForm.endpoint"
|
||||
:options="compositeRouteEndpointOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.compositeRoutes.targetPlatform")
|
||||
}}</label>
|
||||
<Select
|
||||
v-model="compositeRouteForm.target_platform"
|
||||
:options="compositeRoutePlatformOptions"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.compositeRoutes.priority")
|
||||
}}</label>
|
||||
<input
|
||||
v-model.number="compositeRouteForm.priority"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.compositeRoutes.upstreamModel")
|
||||
}}</label>
|
||||
<input
|
||||
v-model.trim="compositeRouteForm.upstream_model"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="gpt-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.compositeRoutes.notes")
|
||||
}}</label>
|
||||
<textarea
|
||||
v-model.trim="compositeRouteForm.notes"
|
||||
rows="2"
|
||||
class="input"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
v-model="compositeRouteForm.enabled"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
|
||||
/>
|
||||
{{ t("admin.groups.compositeRoutes.enabled") }}
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="compositeRouteSaving"
|
||||
>
|
||||
<Icon
|
||||
v-if="!compositeRouteSaving"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ compositeRouteEditingId ? t("common.update") : t("common.create") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.groups.compositeRoutes.preview") }}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
v-model.trim="compositePreviewModel"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="openrouter/gpt-5"
|
||||
@keyup.enter="previewCompositeRoute"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
v-model="compositePreviewEndpoint"
|
||||
:options="compositeRouteEndpointOptions"
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="compositePreviewLoading || !compositePreviewModel"
|
||||
@click="previewCompositeRoute"
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="compositePreviewDecision"
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
compositePreviewDecision.matched
|
||||
? 'badge-success'
|
||||
: 'badge-danger',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
compositePreviewDecision.matched
|
||||
? t("admin.groups.compositeRoutes.matched")
|
||||
: t("admin.groups.compositeRoutes.notMatched")
|
||||
}}
|
||||
</span>
|
||||
<span class="badge badge-gray">
|
||||
{{
|
||||
compositeRouteSourceLabel(
|
||||
compositePreviewDecision.source,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="compositePreviewDecision.matched"
|
||||
class="space-y-1 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div>
|
||||
{{ t("admin.groups.compositeRoutes.targetPlatform") }}:
|
||||
{{
|
||||
formatCompositePlatform(
|
||||
compositePreviewDecision.target_platform,
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="break-all">
|
||||
{{ t("admin.groups.compositeRoutes.upstreamModel") }}:
|
||||
{{ compositePreviewDecision.upstream_model }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ compositePreviewDecision.reason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="closeCompositeRoutesModal"
|
||||
>
|
||||
{{ t("common.close") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Group Rate Multipliers Modal -->
|
||||
<GroupRateMultipliersModal
|
||||
:show="showRateMultipliersModal"
|
||||
@@ -3602,7 +3968,16 @@ import { useI18n } from "vue-i18n";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useOnboardingStore } from "@/stores/onboarding";
|
||||
import { adminAPI } from "@/api/admin";
|
||||
import type { AdminGroup, GroupPlatform, SubscriptionType } from "@/types";
|
||||
import type {
|
||||
AdminGroup,
|
||||
CompositeModelRoute,
|
||||
CompositeModelRouteInput,
|
||||
CompositeRouteDecision,
|
||||
CompositeRouteEndpoint,
|
||||
CompositeRouteMatchType,
|
||||
GroupPlatform,
|
||||
SubscriptionType,
|
||||
} from "@/types";
|
||||
import type { Column } from "@/components/common/types";
|
||||
import AppLayout from "@/components/layout/AppLayout.vue";
|
||||
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
|
||||
@@ -3854,6 +4229,45 @@ const platformFilterOptions = computed(() => [
|
||||
{ value: "composite", label: "Composite" },
|
||||
]);
|
||||
|
||||
const compositeRoutePlatformOptions = computed(() => [
|
||||
{ value: "anthropic", label: "Anthropic" },
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "antigravity", label: "Antigravity" },
|
||||
{ value: "grok", label: "Grok" },
|
||||
]);
|
||||
|
||||
const compositeRouteEndpointOptions = computed(() => [
|
||||
{ value: "any", label: t("admin.groups.compositeRoutes.endpoints.any") },
|
||||
{
|
||||
value: "messages",
|
||||
label: t("admin.groups.compositeRoutes.endpoints.messages"),
|
||||
},
|
||||
{
|
||||
value: "count_tokens",
|
||||
label: t("admin.groups.compositeRoutes.endpoints.countTokens"),
|
||||
},
|
||||
{
|
||||
value: "responses",
|
||||
label: t("admin.groups.compositeRoutes.endpoints.responses"),
|
||||
},
|
||||
{
|
||||
value: "chat_completions",
|
||||
label: t("admin.groups.compositeRoutes.endpoints.chatCompletions"),
|
||||
},
|
||||
{
|
||||
value: "embeddings",
|
||||
label: t("admin.groups.compositeRoutes.endpoints.embeddings"),
|
||||
},
|
||||
{ value: "images", label: t("admin.groups.compositeRoutes.endpoints.images") },
|
||||
{ value: "gemini", label: t("admin.groups.compositeRoutes.endpoints.gemini") },
|
||||
]);
|
||||
|
||||
const compositeRouteMatchOptions = computed(() => [
|
||||
{ value: "exact", label: t("admin.groups.compositeRoutes.match.exact") },
|
||||
{ value: "prefix", label: t("admin.groups.compositeRoutes.match.prefix") },
|
||||
]);
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
{ value: "active", label: t("admin.accounts.status.active") },
|
||||
{ value: "inactive", label: t("admin.accounts.status.inactive") },
|
||||
@@ -4030,6 +4444,38 @@ const rateMultipliersGroup = ref<AdminGroup | null>(null);
|
||||
const showRPMOverridesModal = ref(false);
|
||||
const rpmOverridesGroup = ref<AdminGroup | null>(null);
|
||||
const sortableGroups = ref<AdminGroup[]>([]);
|
||||
type ConcreteGroupPlatform = Exclude<GroupPlatform, "composite">;
|
||||
type CompositeRouteFormState = {
|
||||
public_model: string;
|
||||
match_type: CompositeRouteMatchType;
|
||||
target_platform: ConcreteGroupPlatform;
|
||||
upstream_model: string;
|
||||
endpoint: CompositeRouteEndpoint;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const showCompositeRoutesModal = ref(false);
|
||||
const compositeRoutesGroup = ref<AdminGroup | null>(null);
|
||||
const compositeRoutes = ref<CompositeModelRoute[]>([]);
|
||||
const compositeRoutesLoading = ref(false);
|
||||
const compositeRouteSaving = ref(false);
|
||||
const compositeRouteEditingId = ref<number | null>(null);
|
||||
const compositePreviewModel = ref("");
|
||||
const compositePreviewEndpoint = ref<CompositeRouteEndpoint>("any");
|
||||
const compositePreviewLoading = ref(false);
|
||||
const compositePreviewDecision = ref<CompositeRouteDecision | null>(null);
|
||||
const compositeRouteForm = reactive<CompositeRouteFormState>({
|
||||
public_model: "",
|
||||
match_type: "exact",
|
||||
target_platform: "openai",
|
||||
upstream_model: "",
|
||||
endpoint: "any",
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
notes: "",
|
||||
});
|
||||
const createMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
|
||||
const editMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
|
||||
const createModelsListState = reactive(createInitialModelsListState());
|
||||
@@ -5247,6 +5693,188 @@ const handleDuplicate = async (group: AdminGroup) => {
|
||||
}
|
||||
};
|
||||
|
||||
const compositeRouteMatchLabel = (matchType: CompositeRouteMatchType) =>
|
||||
compositeRouteMatchOptions.value.find((option) => option.value === matchType)
|
||||
?.label || matchType;
|
||||
|
||||
const formatCompositeEndpoint = (endpoint: CompositeRouteEndpoint) =>
|
||||
compositeRouteEndpointOptions.value.find((option) => option.value === endpoint)
|
||||
?.label || endpoint;
|
||||
|
||||
const formatCompositePlatform = (platform: string) => {
|
||||
if (!platform) return "—";
|
||||
return t(`admin.groups.platforms.${platform}`);
|
||||
};
|
||||
|
||||
const compositeRouteSourceLabel = (source: string) => {
|
||||
if (source === "route") return t("admin.groups.compositeRoutes.sources.route");
|
||||
if (source === "detector") {
|
||||
return t("admin.groups.compositeRoutes.sources.detector");
|
||||
}
|
||||
return source || "—";
|
||||
};
|
||||
|
||||
const resetCompositeRouteForm = () => {
|
||||
compositeRouteEditingId.value = null;
|
||||
compositeRouteForm.public_model = "";
|
||||
compositeRouteForm.match_type = "exact";
|
||||
compositeRouteForm.target_platform = "openai";
|
||||
compositeRouteForm.upstream_model = "";
|
||||
compositeRouteForm.endpoint = "any";
|
||||
compositeRouteForm.priority = 100;
|
||||
compositeRouteForm.enabled = true;
|
||||
compositeRouteForm.notes = "";
|
||||
};
|
||||
|
||||
const toCompositeRouteInput = (): CompositeModelRouteInput => ({
|
||||
public_model: compositeRouteForm.public_model.trim(),
|
||||
match_type: compositeRouteForm.match_type,
|
||||
target_platform: compositeRouteForm.target_platform,
|
||||
upstream_model: compositeRouteForm.upstream_model.trim(),
|
||||
endpoint: compositeRouteForm.endpoint,
|
||||
priority: Number(compositeRouteForm.priority) || 100,
|
||||
enabled: compositeRouteForm.enabled,
|
||||
notes: compositeRouteForm.notes.trim(),
|
||||
});
|
||||
|
||||
const loadCompositeRoutes = async () => {
|
||||
if (!compositeRoutesGroup.value) return;
|
||||
compositeRoutesLoading.value = true;
|
||||
try {
|
||||
const routes = await adminAPI.groups.listCompositeRoutes(
|
||||
compositeRoutesGroup.value.id,
|
||||
);
|
||||
compositeRoutes.value = routes.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
return a.id - b.id;
|
||||
});
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
t("admin.groups.compositeRoutes.failedToLoad"),
|
||||
);
|
||||
console.error("Error loading composite routes:", error);
|
||||
} finally {
|
||||
compositeRoutesLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompositeRoutes = async (group: AdminGroup) => {
|
||||
compositeRoutesGroup.value = group;
|
||||
compositePreviewModel.value = "";
|
||||
compositePreviewEndpoint.value = "any";
|
||||
compositePreviewDecision.value = null;
|
||||
resetCompositeRouteForm();
|
||||
showCompositeRoutesModal.value = true;
|
||||
await loadCompositeRoutes();
|
||||
};
|
||||
|
||||
const closeCompositeRoutesModal = () => {
|
||||
showCompositeRoutesModal.value = false;
|
||||
compositeRoutesGroup.value = null;
|
||||
compositeRoutes.value = [];
|
||||
compositePreviewDecision.value = null;
|
||||
resetCompositeRouteForm();
|
||||
};
|
||||
|
||||
const editCompositeRoute = (route: CompositeModelRoute) => {
|
||||
compositeRouteEditingId.value = route.id;
|
||||
compositeRouteForm.public_model = route.public_model;
|
||||
compositeRouteForm.match_type = route.match_type;
|
||||
compositeRouteForm.target_platform = route.target_platform;
|
||||
compositeRouteForm.upstream_model = route.upstream_model;
|
||||
compositeRouteForm.endpoint = route.endpoint;
|
||||
compositeRouteForm.priority = route.priority || 100;
|
||||
compositeRouteForm.enabled = route.enabled;
|
||||
compositeRouteForm.notes = route.notes || "";
|
||||
};
|
||||
|
||||
const saveCompositeRoute = async () => {
|
||||
if (!compositeRoutesGroup.value) return;
|
||||
if (!compositeRouteForm.public_model.trim()) {
|
||||
appStore.showError(t("admin.groups.compositeRoutes.publicModelRequired"));
|
||||
return;
|
||||
}
|
||||
compositeRouteSaving.value = true;
|
||||
try {
|
||||
const payload = toCompositeRouteInput();
|
||||
if (compositeRouteEditingId.value) {
|
||||
await adminAPI.groups.updateCompositeRoute(
|
||||
compositeRoutesGroup.value.id,
|
||||
compositeRouteEditingId.value,
|
||||
payload,
|
||||
);
|
||||
appStore.showSuccess(t("admin.groups.compositeRoutes.routeUpdated"));
|
||||
} else {
|
||||
await adminAPI.groups.createCompositeRoute(
|
||||
compositeRoutesGroup.value.id,
|
||||
payload,
|
||||
);
|
||||
appStore.showSuccess(t("admin.groups.compositeRoutes.routeCreated"));
|
||||
}
|
||||
resetCompositeRouteForm();
|
||||
await loadCompositeRoutes();
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
t("admin.groups.compositeRoutes.failedToSave"),
|
||||
);
|
||||
console.error("Error saving composite route:", error);
|
||||
} finally {
|
||||
compositeRouteSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCompositeRoute = async (route: CompositeModelRoute) => {
|
||||
if (!compositeRoutesGroup.value) return;
|
||||
if (!window.confirm(t("admin.groups.compositeRoutes.deleteConfirm"))) return;
|
||||
try {
|
||||
await adminAPI.groups.deleteCompositeRoute(
|
||||
compositeRoutesGroup.value.id,
|
||||
route.id,
|
||||
);
|
||||
if (compositeRouteEditingId.value === route.id) {
|
||||
resetCompositeRouteForm();
|
||||
}
|
||||
appStore.showSuccess(t("admin.groups.compositeRoutes.routeDeleted"));
|
||||
await loadCompositeRoutes();
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
t("admin.groups.compositeRoutes.failedToDelete"),
|
||||
);
|
||||
console.error("Error deleting composite route:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const previewCompositeRoute = async () => {
|
||||
if (!compositeRoutesGroup.value || !compositePreviewModel.value.trim()) {
|
||||
return;
|
||||
}
|
||||
compositePreviewLoading.value = true;
|
||||
try {
|
||||
compositePreviewDecision.value = await adminAPI.groups.previewCompositeRoute(
|
||||
compositeRoutesGroup.value.id,
|
||||
{
|
||||
model: compositePreviewModel.value.trim(),
|
||||
endpoint: compositePreviewEndpoint.value,
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
t("admin.groups.compositeRoutes.failedToPreview"),
|
||||
);
|
||||
console.error("Error previewing composite route:", error);
|
||||
} finally {
|
||||
compositePreviewLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (group: AdminGroup) => {
|
||||
deletingGroup.value = group;
|
||||
showDeleteDialog.value = true;
|
||||
|
||||
Reference in New Issue
Block a user