Add composite group route registry

This commit is contained in:
Heatherm Huang
2026-07-23 09:20:18 +08:00
parent c8d1e2e16f
commit a008b63c16
55 changed files with 7867 additions and 57 deletions
+58
View File
@@ -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:
+57
View File
@@ -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
+629 -1
View File
@@ -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;