diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index b6d4574b6..bdde38f65 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -56,6 +56,7 @@ import { registerTotalUsersGauge } from './otel/gauges/total-users' import { registerTtsPoolGauge } from './otel/gauges/tts-pool' import { createAdminRoutes } from './routes/admin' import { createAdminUiRoutes } from './routes/admin-ui' +import { createAdminCapabilityAliasRoutes } from './routes/admin/capability-aliases' import { createAdminRouterConfigRoutes } from './routes/admin/config/router' import { createAdminFluxGrantsRoutes } from './routes/admin/flux-grants' import { createAdminProviderCatalogRoutes } from './routes/admin/provider-catalog' @@ -420,6 +421,14 @@ export async function buildApp(deps: AppDeps) { service: deps.voicePackService, })) + /** + * Admin product capability alias curation routes. + */ + .route('/api/admin/capability-aliases', createAdminCapabilityAliasRoutes({ + configKV: deps.configKV, + service: deps.providerCatalogService, + })) + /** * Admin provider catalog curation routes. */ diff --git a/apps/server/src/routes/admin/capability-aliases/index.ts b/apps/server/src/routes/admin/capability-aliases/index.ts new file mode 100644 index 000000000..60312ba94 --- /dev/null +++ b/apps/server/src/routes/admin/capability-aliases/index.ts @@ -0,0 +1,112 @@ +import type { Context } from 'hono' +import type { GenericSchema, InferOutput } from 'valibot' + +import type { ConfigKVService } from '../../../services/adapters/config-kv' +import type { ProviderCatalogService } from '../../../services/domain/provider-catalog' +import type { HonoEnv } from '../../../types/hono' + +import { Hono } from 'hono' +import { boolean, integer, maxLength, minValue, number, object, optional, picklist, pipe, safeParse, string } from 'valibot' + +import { adminGuard } from '../../../middlewares/admin-guard' +import { authGuard } from '../../../middlewares/auth' +import { createBadRequestError, createNotFoundError } from '../../../utils/error' + +const SurfaceSchema = picklist(['llm', 'asr']) + +const AliasUpdateBodySchema = object({ + displayName: optional(pipe(string(), maxLength(120))), + enabled: optional(boolean()), + displayOrder: optional(pipe(number(), integer(), minValue(0))), + fallbackEnabled: optional(boolean()), + loadBalancingEnabled: optional(boolean()), +}) + +const AliasRouteUpdateBodySchema = object({ + enabled: optional(boolean()), + pool: optional(picklist(['primary', 'fallback'])), + weight: optional(pipe(number(), integer(), minValue(1))), + displayOrder: optional(pipe(number(), integer(), minValue(0))), +}) + +export interface AdminCapabilityAliasRoutesDeps { + configKV: ConfigKVService + service: ProviderCatalogService +} + +function parseIssues(issues: Array<{ path?: Array<{ key: unknown }>, message: string }>) { + return issues.map(i => ({ + path: i.path?.map(p => p.key).join('.'), + message: i.message, + })) +} + +async function readJson(c: Context): Promise { + const raw = await c.req.json().catch(() => null) + if (raw == null) + throw createBadRequestError('Request body must be JSON', 'INVALID_BODY') + return raw +} + +async function readBody(c: Context, schema: S): Promise> { + const parsed = safeParse(schema, await readJson(c)) + if (!parsed.success) + throw createBadRequestError('Invalid request body', 'INVALID_BODY', parseIssues(parsed.issues)) + return parsed.output +} + +async function syncAliasesFromConfig(deps: AdminCapabilityAliasRoutesDeps, surface: 'llm' | 'asr') { + const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG') + if (surface === 'llm') { + const defaultModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL') + const modelIds = [ + defaultModel, + ...Object.keys(config.llm.models).sort().filter(modelId => modelId !== defaultModel), + ] + return await deps.service.syncAliasesFromRouterConfig({ surface, modelIds }) + } + + return await deps.service.syncAliasesFromRouterConfig({ + surface, + modelIds: Object.keys(config.asr?.models ?? {}).sort(), + }) +} + +/** + * Admin routes for product capability aliases. + * + * Mounted at `/api/admin/capability-aliases`. These routes curate product + * choices that clients can request, such as LLM `auto` or ASR `auto`. Real + * provider inventory stays in the provider catalog. + */ +export function createAdminCapabilityAliasRoutes(deps: AdminCapabilityAliasRoutesDeps) { + return new Hono() + .use('*', authGuard) + .use('*', adminGuard) + .get('/', async (c) => { + const rawSurface = c.req.query('surface') + const parsed = rawSurface ? safeParse(SurfaceSchema, rawSurface) : null + if (parsed && !parsed.success) + throw createBadRequestError('Invalid surface', 'INVALID_QUERY', parseIssues(parsed.issues)) + + return c.json(await deps.service.listAliases(parsed?.success ? parsed.output : undefined)) + }) + .post('/sync', async (c) => { + const body = await readBody(c, object({ surface: SurfaceSchema })) + return c.json({ aliases: await syncAliasesFromConfig(deps, body.surface) }) + }) + .patch('/:id', async (c) => { + const body = await readBody(c, AliasUpdateBodySchema) + const updated = await deps.service.updateAlias(c.req.param('id'), body) + if (!updated) + throw createNotFoundError('Capability alias not found') + return c.json(updated) + }) + .patch('/routes/:id', async (c) => { + const body = await readBody(c, AliasRouteUpdateBodySchema) + const updated = await deps.service.updateAliasRoute(c.req.param('id'), body) + if (!updated) + throw createNotFoundError('Capability alias route not found') + return c.json(updated) + }) +} diff --git a/apps/server/src/routes/admin/capability-aliases/route.test.ts b/apps/server/src/routes/admin/capability-aliases/route.test.ts new file mode 100644 index 000000000..8c5491ec4 --- /dev/null +++ b/apps/server/src/routes/admin/capability-aliases/route.test.ts @@ -0,0 +1,146 @@ +import type { ConfigKVService } from '../../../services/adapters/config-kv' +import type { ProviderCatalogService } from '../../../services/domain/provider-catalog' +import type { HonoEnv } from '../../../types/hono' + +import { Hono } from 'hono' +import { describe, expect, it, vi } from 'vitest' + +import { createAdminCapabilityAliasRoutes } from '.' +import { ApiError } from '../../../utils/error' + +interface MockUser { + id: string + email: string + role?: string | null +} + +const ADMIN: MockUser = { id: 'admin-1', email: 'admin@example.com', role: 'admin' } + +function createConfigKV(): ConfigKVService { + return { + getOrThrow: vi.fn(async (key: string) => { + if (key === 'DEFAULT_CHAT_MODEL') + return 'chat-default' + if (key === 'LLM_ROUTER_CONFIG') { + return { + llm: { + models: { + 'chat-default': { upstreams: [] }, + 'chat-fallback': { upstreams: [] }, + }, + }, + tts: { models: {} }, + asr: { models: { 'aliyun/asr-primary': { provider: 'aliyun-nls', upstreams: [] } } }, + } + } + throw new ApiError(503, 'CONFIG_NOT_SET', 'Service configuration is incomplete') + }), + getOptional: vi.fn(async () => null), + get: vi.fn(), + set: vi.fn(), + } as unknown as ConfigKVService +} + +function createService(): ProviderCatalogService { + return { + syncAliasesFromRouterConfig: vi.fn(async () => []), + listAliases: vi.fn(async () => []), + resolveEnabledAlias: vi.fn(), + updateAlias: vi.fn(async (_id, input) => ({ id: 'alias-1', ...input })), + updateAliasRoute: vi.fn(async (_id, input) => ({ id: 'route-1', ...input })), + } as unknown as ProviderCatalogService +} + +function createTestApp(input: { + user: MockUser | null + configKV?: ConfigKVService + service?: ProviderCatalogService +}) { + return new Hono() + .use('*', async (c, next) => { + c.set('user', input.user as HonoEnv['Variables']['user']) + await next() + }) + .route('/api/admin/capability-aliases', createAdminCapabilityAliasRoutes({ + configKV: input.configKV ?? createConfigKV(), + service: input.service ?? createService(), + })) + .onError((err, c) => { + if (err instanceof ApiError) + return c.json({ error: err.errorCode, details: err.details }, err.statusCode) + return c.json({ error: 'internal', message: (err as Error).message }, 500) + }) +} + +function jsonRequest(app: Hono, method: string, path: string, body?: unknown) { + return app.request(path, { + method, + headers: { 'content-type': 'application/json' }, + body: body == null ? undefined : JSON.stringify(body), + }) +} + +describe('admin capability alias routes', () => { + it('returns 401 when unauthenticated', async () => { + const service = createService() + const app = createTestApp({ user: null, service }) + const res = await jsonRequest(app, 'GET', '/api/admin/capability-aliases') + + expect(res.status).toBe(401) + expect(service.listAliases).not.toHaveBeenCalled() + }) + + it('syncs LLM aliases from router config with the default model first', async () => { + const service = createService() + const app = createTestApp({ user: ADMIN, service }) + + const res = await jsonRequest(app, 'POST', '/api/admin/capability-aliases/sync', { + surface: 'llm', + }) + + expect(res.status).toBe(200) + expect(service.syncAliasesFromRouterConfig).toHaveBeenCalledWith({ + surface: 'llm', + modelIds: ['chat-default', 'chat-fallback'], + }) + }) + + it('syncs ASR aliases from router config', async () => { + const service = createService() + const app = createTestApp({ user: ADMIN, service }) + + const res = await jsonRequest(app, 'POST', '/api/admin/capability-aliases/sync', { + surface: 'asr', + }) + + expect(res.status).toBe(200) + expect(service.syncAliasesFromRouterConfig).toHaveBeenCalledWith({ + surface: 'asr', + modelIds: ['aliyun/asr-primary'], + }) + }) + + it('maps missing aliases to 404 on update', async () => { + const service = createService() + vi.mocked(service.updateAlias).mockResolvedValueOnce(null) + const app = createTestApp({ user: ADMIN, service }) + + const res = await jsonRequest(app, 'PATCH', '/api/admin/capability-aliases/missing', { + enabled: false, + }) + + expect(res.status).toBe(404) + }) + + it('updates alias routes separately from aliases', async () => { + const service = createService() + const app = createTestApp({ user: ADMIN, service }) + + const res = await jsonRequest(app, 'PATCH', '/api/admin/capability-aliases/routes/route-1', { + pool: 'fallback', + }) + + expect(res.status).toBe(200) + expect(service.updateAliasRoute).toHaveBeenCalledWith('route-1', { pool: 'fallback' }) + }) +}) diff --git a/apps/server/src/routes/admin/provider-catalog/index.ts b/apps/server/src/routes/admin/provider-catalog/index.ts index 5c6b4104d..cf7e7c83d 100644 --- a/apps/server/src/routes/admin/provider-catalog/index.ts +++ b/apps/server/src/routes/admin/provider-catalog/index.ts @@ -9,7 +9,7 @@ import type { HonoEnv } from '../../../types/hono' import { Buffer } from 'node:buffer' import { Hono } from 'hono' -import { any, array, boolean, integer, maxLength, minValue, nullable, number, object, optional, picklist, pipe, record, safeParse, string } from 'valibot' +import { any, array, boolean, integer, maxLength, minValue, nullable, number, object, optional, pipe, record, safeParse, string } from 'valibot' import { adminGuard } from '../../../middlewares/admin-guard' import { authGuard } from '../../../middlewares/auth' @@ -18,23 +18,6 @@ import { createBadGatewayError, createBadRequestError, createNotFoundError } fro const DEFAULT_PREVIEW_TEXT = 'Hello, this is an AIRI voice preview.' -const SurfaceSchema = picklist(['llm', 'asr']) - -const AliasUpdateBodySchema = object({ - displayName: optional(pipe(string(), maxLength(120))), - enabled: optional(boolean()), - displayOrder: optional(pipe(number(), integer(), minValue(0))), - fallbackEnabled: optional(boolean()), - loadBalancingEnabled: optional(boolean()), -}) - -const AliasRouteUpdateBodySchema = object({ - enabled: optional(boolean()), - pool: optional(picklist(['primary', 'fallback'])), - weight: optional(pipe(number(), integer(), minValue(1))), - displayOrder: optional(pipe(number(), integer(), minValue(0))), -}) - const TtsModelUpdateBodySchema = object({ displayName: optional(pipe(string(), maxLength(120))), enabled: optional(boolean()), @@ -91,23 +74,6 @@ async function readBody(c: Context, schema: S) return parsed.output } -async function syncAliasesFromConfig(deps: AdminProviderCatalogRoutesDeps, surface: 'llm' | 'asr') { - const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG') - if (surface === 'llm') { - const defaultModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL') - const modelIds = [ - defaultModel, - ...Object.keys(config.llm.models).sort().filter(modelId => modelId !== defaultModel), - ] - return await deps.service.syncAliasesFromRouterConfig({ surface, modelIds }) - } - - return await deps.service.syncAliasesFromRouterConfig({ - surface, - modelIds: Object.keys(config.asr?.models ?? {}).sort(), - }) -} - async function syncTtsModelsFromConfig(deps: AdminProviderCatalogRoutesDeps) { const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG') return await deps.service.syncTtsModelsFromRouterConfig({ @@ -124,40 +90,14 @@ async function syncTtsModelsFromConfig(deps: AdminProviderCatalogRoutesDeps) { * Admin routes for provider catalog curation. * * Mounted at `/api/admin/provider-catalog`. These routes curate only the - * catalog state: enabled flags, display order, capability aliases, and TTS - * voice metadata. Real upstream URLs, credentials, and provider fallback - * config stay owned by `LLM_ROUTER_CONFIG`. + * catalog state: enabled flags, display order, and TTS voice metadata. Real + * upstream URLs, credentials, and provider fallback config stay owned by + * `LLM_ROUTER_CONFIG`. */ export function createAdminProviderCatalogRoutes(deps: AdminProviderCatalogRoutesDeps) { return new Hono() .use('*', authGuard) .use('*', adminGuard) - .get('/aliases', async (c) => { - const rawSurface = c.req.query('surface') - const parsed = rawSurface ? safeParse(SurfaceSchema, rawSurface) : null - if (parsed && !parsed.success) - throw createBadRequestError('Invalid surface', 'INVALID_QUERY', parseIssues(parsed.issues)) - - return c.json(await deps.service.listAliases(parsed?.success ? parsed.output : undefined)) - }) - .post('/aliases/sync', async (c) => { - const body = await readBody(c, object({ surface: SurfaceSchema })) - return c.json({ aliases: await syncAliasesFromConfig(deps, body.surface) }) - }) - .patch('/aliases/:id', async (c) => { - const body = await readBody(c, AliasUpdateBodySchema) - const updated = await deps.service.updateAlias(c.req.param('id'), body) - if (!updated) - throw createNotFoundError('Capability alias not found') - return c.json(updated) - }) - .patch('/alias-routes/:id', async (c) => { - const body = await readBody(c, AliasRouteUpdateBodySchema) - const updated = await deps.service.updateAliasRoute(c.req.param('id'), body) - if (!updated) - throw createNotFoundError('Capability alias route not found') - return c.json(updated) - }) .get('/tts/models', async (c) => { return c.json(await deps.service.listTtsModels()) }) diff --git a/apps/server/src/routes/admin/provider-catalog/route.test.ts b/apps/server/src/routes/admin/provider-catalog/route.test.ts index eddbf00ad..6fa23bfad 100644 --- a/apps/server/src/routes/admin/provider-catalog/route.test.ts +++ b/apps/server/src/routes/admin/provider-catalog/route.test.ts @@ -140,10 +140,10 @@ describe('admin provider catalog routes', () => { it('returns 401 when unauthenticated', async () => { const service = createService() const app = createTestApp({ user: null, service }) - const res = await jsonRequest(app, 'GET', '/api/admin/provider-catalog/aliases') + const res = await jsonRequest(app, 'GET', '/api/admin/provider-catalog/tts/models') expect(res.status).toBe(401) - expect(service.listAliases).not.toHaveBeenCalled() + expect(service.listTtsModels).not.toHaveBeenCalled() }) it('syncs TTS voices from the provider into the provider catalog', async () => { diff --git a/apps/ui-admin/src/App.vue b/apps/ui-admin/src/App.vue index d0c93f51d..4ecaa30cf 100644 --- a/apps/ui-admin/src/App.vue +++ b/apps/ui-admin/src/App.vue @@ -23,7 +23,7 @@ const navItems = [ { to: '/users', icon: 'i-lucide-users', label: 'Users' }, { to: '/flux', icon: 'i-lucide-coins', label: 'Flux' }, { to: '/llm-router', icon: 'i-lucide-route', label: 'LLM Router' }, - { to: '/providers', icon: 'i-lucide-network', label: 'Providers' }, + { to: '/capability-aliases', icon: 'i-lucide-route', label: 'Capability Aliases' }, { to: '/tts', icon: 'i-lucide-audio-lines', label: 'TTS' }, { to: '/voice-packs', icon: 'i-lucide-volume-2', label: 'Voice Packs' }, ] diff --git a/apps/ui-admin/src/main.ts b/apps/ui-admin/src/main.ts index ddb4b18fa..4a7817a0a 100644 --- a/apps/ui-admin/src/main.ts +++ b/apps/ui-admin/src/main.ts @@ -6,10 +6,10 @@ import { createRouter, createWebHistory } from 'vue-router' import { Toaster } from 'vue-sonner' import App from './App.vue' +import CapabilityAliasesPage from './pages/CapabilityAliasesPage.vue' import FluxPage from './pages/FluxPage.vue' import LlmRouterPage from './pages/LlmRouterPage.vue' import OverviewPage from './pages/OverviewPage.vue' -import ProviderCatalogPage from './pages/ProviderCatalogPage.vue' import TtsCatalogPage from './pages/TtsCatalogPage.vue' import UsersPage from './pages/UsersPage.vue' import VoicePackFormPage from './pages/VoicePackFormPage.vue' @@ -28,7 +28,7 @@ const router = createRouter({ { path: '/users', component: UsersPage }, { path: '/flux', component: FluxPage }, { path: '/llm-router', component: LlmRouterPage }, - { path: '/providers', component: ProviderCatalogPage }, + { path: '/capability-aliases', component: CapabilityAliasesPage }, { path: '/tts', component: TtsCatalogPage }, { path: '/voice-packs', component: VoicePacksPage }, { path: '/voice-packs/new', name: 'voice-pack-new', component: VoicePackFormPage }, diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts index 4ab6085ce..b30e9e802 100644 --- a/apps/ui-admin/src/modules/api.ts +++ b/apps/ui-admin/src/modules/api.ts @@ -501,20 +501,20 @@ export const adminApi = { }), capabilityAliases: (surface?: CapabilityAliasSurface) => { const suffix = surface ? `?surface=${encodeURIComponent(surface)}` : '' - return adminFetch(`/provider-catalog/aliases${suffix}`) + return adminFetch(`/capability-aliases${suffix}`) }, syncCapabilityAliases: (surface: CapabilityAliasSurface) => - adminFetch<{ aliases: CapabilityAlias[] }>('/provider-catalog/aliases/sync', { + adminFetch<{ aliases: CapabilityAlias[] }>('/capability-aliases/sync', { method: 'POST', body: JSON.stringify({ surface }), }), updateCapabilityAlias: (id: string, body: Partial>) => - adminFetch(`/provider-catalog/aliases/${encodeURIComponent(id)}`, { + adminFetch(`/capability-aliases/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(body), }), updateCapabilityAliasRoute: (id: string, body: Partial>) => - adminFetch(`/provider-catalog/alias-routes/${encodeURIComponent(id)}`, { + adminFetch(`/capability-aliases/routes/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(body), }), diff --git a/apps/ui-admin/src/pages/ProviderCatalogPage.vue b/apps/ui-admin/src/pages/CapabilityAliasesPage.vue similarity index 96% rename from apps/ui-admin/src/pages/ProviderCatalogPage.vue rename to apps/ui-admin/src/pages/CapabilityAliasesPage.vue index fa6077f69..93f19c9ac 100644 --- a/apps/ui-admin/src/pages/ProviderCatalogPage.vue +++ b/apps/ui-admin/src/pages/CapabilityAliasesPage.vue @@ -26,7 +26,7 @@ async function loadAliases() { aliases.value = await adminApi.capabilityAliases(surface.value) } catch (error) { - toast.error(errorMessageFromUnknown(error, 'Failed to load provider catalog')) + toast.error(errorMessageFromUnknown(error, 'Failed to load capability aliases')) } finally { loading.value = false @@ -37,11 +37,11 @@ async function syncAliases() { syncing.value = true try { await adminApi.syncCapabilityAliases(surface.value) - toast.success('Provider aliases synced') + toast.success('Capability aliases synced') await loadAliases() } catch (error) { - toast.error(errorMessageFromUnknown(error, 'Failed to sync provider aliases')) + toast.error(errorMessageFromUnknown(error, 'Failed to sync capability aliases')) } finally { syncing.value = false @@ -83,10 +83,10 @@ function formatDate(value: string): string {

- Provider Catalog + Capability Aliases

- Product aliases for official LLM and ASR capabilities. + Product choices for LLM and ASR requests.

@@ -120,7 +120,7 @@ function formatDate(value: string): string {
- Loading provider catalog + Loading capability aliases