feat(server): add official provider catalog management
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
CREATE TABLE "official_provider_alias_routes" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"alias_id" text NOT NULL,
|
||||
"router_model_id" text NOT NULL,
|
||||
"pool" text DEFAULT 'primary' NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"weight" integer DEFAULT 1 NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "official_provider_aliases" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"surface" text NOT NULL,
|
||||
"alias_id" text NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"fallback_enabled" boolean DEFAULT true NOT NULL,
|
||||
"load_balancing_enabled" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "official_tts_models" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"router_model_id" text NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"last_synced_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "official_tts_voices" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"tts_model_id" text NOT NULL,
|
||||
"provider_voice_id" text NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"languages" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"labels" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"preview_audio_url" text,
|
||||
"source" text DEFAULT 'provider-sync' NOT NULL,
|
||||
"last_synced_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "official_provider_alias_routes" ADD CONSTRAINT "official_provider_alias_routes_alias_id_official_provider_aliases_id_fk" FOREIGN KEY ("alias_id") REFERENCES "public"."official_provider_aliases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "official_tts_voices" ADD CONSTRAINT "official_tts_voices_tts_model_id_official_tts_models_id_fk" FOREIGN KEY ("tts_model_id") REFERENCES "public"."official_tts_models"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "official_provider_alias_routes_alias_model_pool_uidx" ON "official_provider_alias_routes" USING btree ("alias_id","router_model_id","pool");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "official_provider_aliases_surface_alias_uidx" ON "official_provider_aliases" USING btree ("surface","alias_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "official_tts_models_router_model_uidx" ON "official_tts_models" USING btree ("router_model_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "official_tts_voices_model_voice_uidx" ON "official_tts_voices" USING btree ("tts_model_id","provider_voice_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
||||
"when": 1782847276369,
|
||||
"tag": "0016_tired_dagger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1782912836523,
|
||||
"tag": "0017_nappy_dagger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -52,6 +52,7 @@ function createTestDeps() {
|
||||
ttsMeter: {} as any,
|
||||
requestLogService: {} as any,
|
||||
voicePackService: {} as any,
|
||||
officialCatalogService: {} as any,
|
||||
productEventService: {
|
||||
track: vi.fn(async () => undefined),
|
||||
countDistinctUsersByFeature: vi.fn(async () => []),
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { ChatService } from './services/domain/chats'
|
||||
import type { FluxService } from './services/domain/flux'
|
||||
import type { FluxTransactionService } from './services/domain/flux-transaction'
|
||||
import type { LlmRouterService } from './services/domain/llm-router'
|
||||
import type { OfficialCatalogService } from './services/domain/official-catalog'
|
||||
import type { ProductEventService } from './services/domain/product-events'
|
||||
import type { ProviderService } from './services/domain/providers'
|
||||
import type { RequestLogService } from './services/domain/request-log'
|
||||
@@ -57,6 +58,7 @@ import { createAdminRoutes } from './routes/admin'
|
||||
import { createAdminUiRoutes } from './routes/admin-ui'
|
||||
import { createAdminRouterConfigRoutes } from './routes/admin/config/router'
|
||||
import { createAdminFluxGrantsRoutes } from './routes/admin/flux-grants'
|
||||
import { createAdminOfficialCatalogRoutes } from './routes/admin/official-catalog'
|
||||
import { createAdminUsersRoutes } from './routes/admin/users'
|
||||
import { createAdminVoicePackRoutes } from './routes/admin/voice-packs'
|
||||
import { createAudioSpeechWsHandlers } from './routes/audio-speech-ws'
|
||||
@@ -82,6 +84,7 @@ import { createChatService } from './services/domain/chats'
|
||||
import { createFluxService } from './services/domain/flux'
|
||||
import { createFluxTransactionService } from './services/domain/flux-transaction'
|
||||
import { createConcurrencyLedger, createConfigSyncSubscriber, createLlmRouterService } from './services/domain/llm-router'
|
||||
import { createOfficialCatalogService } from './services/domain/official-catalog'
|
||||
import { createProductEventService } from './services/domain/product-events'
|
||||
import { createProviderService } from './services/domain/providers'
|
||||
import { createRequestLogService } from './services/domain/request-log'
|
||||
@@ -117,6 +120,7 @@ interface AppDeps {
|
||||
otel: OtelInstance | null
|
||||
userDeletionService: UserDeletionService
|
||||
llmRouter: LlmRouterService
|
||||
officialCatalogService: OfficialCatalogService
|
||||
}
|
||||
|
||||
export async function buildApp(deps: AppDeps) {
|
||||
@@ -249,6 +253,7 @@ export async function buildApp(deps: AppDeps) {
|
||||
productEventService: deps.productEventService,
|
||||
ttsMeter: deps.ttsMeter,
|
||||
llmRouter: deps.llmRouter,
|
||||
officialCatalogService: deps.officialCatalogService,
|
||||
voicePackService: deps.voicePackService,
|
||||
genAi: deps.otel?.genAi,
|
||||
revenue: deps.otel?.revenue,
|
||||
@@ -414,6 +419,15 @@ export async function buildApp(deps: AppDeps) {
|
||||
service: deps.voicePackService,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Admin official provider catalog curation routes.
|
||||
*/
|
||||
.route('/api/admin/official-catalog', createAdminOfficialCatalogRoutes({
|
||||
configKV: deps.configKV,
|
||||
llmRouter: deps.llmRouter,
|
||||
service: deps.officialCatalogService,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Admin LLM router config seeding/patching. Single entry point for
|
||||
* writing `LLM_ROUTER_CONFIG`, `UNSPEECH_UPSTREAM`, and the
|
||||
@@ -661,6 +675,11 @@ export async function createApp() {
|
||||
build: ({ dependsOn }) => createVoicePackService(dependsOn.db),
|
||||
})
|
||||
|
||||
const officialCatalogService = injeca.provide('services:officialCatalog', {
|
||||
dependsOn: { db },
|
||||
build: ({ dependsOn }) => createOfficialCatalogService(dependsOn.db),
|
||||
})
|
||||
|
||||
const billingService = injeca.provide('services:billing', {
|
||||
dependsOn: { db, redis, configKV, otel },
|
||||
build: ({ dependsOn }) => createBillingService(dependsOn.db, dependsOn.redis, dependsOn.configKV, dependsOn.otel?.revenue),
|
||||
@@ -771,6 +790,7 @@ export async function createApp() {
|
||||
otel,
|
||||
userDeletionService,
|
||||
llmRouter,
|
||||
officialCatalogService,
|
||||
ttsConcurrencyLedger,
|
||||
})
|
||||
// Register the cluster-wide ObservableGauges for sessions / users. Each
|
||||
@@ -815,6 +835,7 @@ export async function createApp() {
|
||||
otel: resolved.otel,
|
||||
userDeletionService: resolved.userDeletionService,
|
||||
llmRouter: resolved.llmRouter,
|
||||
officialCatalogService: resolved.officialCatalogService,
|
||||
})
|
||||
|
||||
logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started')
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { Context } from 'hono'
|
||||
import type { GenericSchema, InferOutput } from 'valibot'
|
||||
|
||||
import type { ConfigKVService } from '../../../services/adapters/config-kv'
|
||||
import type { LlmRouterService } from '../../../services/domain/llm-router'
|
||||
import type { OfficialCatalogService } from '../../../services/domain/official-catalog'
|
||||
import type { HonoEnv } from '../../../types/hono'
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { any, array, boolean, integer, maxLength, minValue, nullable, number, object, optional, picklist, pipe, record, safeParse, string } from 'valibot'
|
||||
|
||||
import { adminGuard } from '../../../middlewares/admin-guard'
|
||||
import { authGuard } from '../../../middlewares/auth'
|
||||
import { normalizeProviderVoiceForCatalog } from '../../../services/domain/official-catalog/provider-voices'
|
||||
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))),
|
||||
})
|
||||
|
||||
const TtsModelUpdateBodySchema = object({
|
||||
displayName: optional(pipe(string(), maxLength(120))),
|
||||
enabled: optional(boolean()),
|
||||
displayOrder: optional(pipe(number(), integer(), minValue(0))),
|
||||
})
|
||||
|
||||
const LanguageSchema = object({
|
||||
code: pipe(string(), maxLength(32)),
|
||||
title: optional(pipe(string(), maxLength(80))),
|
||||
})
|
||||
|
||||
const TtsVoiceUpdateBodySchema = object({
|
||||
displayName: optional(pipe(string(), maxLength(120))),
|
||||
enabled: optional(boolean()),
|
||||
displayOrder: optional(pipe(number(), integer(), minValue(0))),
|
||||
languages: optional(array(LanguageSchema)),
|
||||
labels: optional(record(string(), any())),
|
||||
previewAudioUrl: optional(nullable(pipe(string(), maxLength(2048)))),
|
||||
})
|
||||
|
||||
const TtsVoiceSyncBodySchema = object({
|
||||
routerModelId: pipe(string(), maxLength(160)),
|
||||
})
|
||||
|
||||
export interface AdminOfficialCatalogRoutesDeps {
|
||||
configKV: ConfigKVService
|
||||
llmRouter: LlmRouterService
|
||||
service: OfficialCatalogService
|
||||
}
|
||||
|
||||
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<HonoEnv>): Promise<unknown> {
|
||||
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<S extends GenericSchema>(c: Context<HonoEnv>, schema: S): Promise<InferOutput<S>> {
|
||||
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: AdminOfficialCatalogRoutesDeps, 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: AdminOfficialCatalogRoutesDeps) {
|
||||
const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG')
|
||||
return await deps.service.syncTtsModelsFromRouterConfig({
|
||||
models: Object.fromEntries(
|
||||
Object.entries(config.tts.models).map(([routerModelId, model]) => [
|
||||
routerModelId,
|
||||
{ provider: model.provider },
|
||||
]),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin routes for the official provider catalog.
|
||||
*
|
||||
* Mounted at `/api/admin/official-catalog`. These routes curate only the
|
||||
* product catalog state: enabled flags, display order, aliases, and TTS voice
|
||||
* metadata. Real upstream URLs, credentials, and provider fallback config stay
|
||||
* owned by `LLM_ROUTER_CONFIG`.
|
||||
*/
|
||||
export function createAdminOfficialCatalogRoutes(deps: AdminOfficialCatalogRoutesDeps) {
|
||||
return new Hono<HonoEnv>()
|
||||
.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('Official 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('Official alias route not found')
|
||||
return c.json(updated)
|
||||
})
|
||||
.get('/tts/models', async (c) => {
|
||||
return c.json(await deps.service.listTtsModels())
|
||||
})
|
||||
.post('/tts/models/sync', async (c) => {
|
||||
return c.json({ models: await syncTtsModelsFromConfig(deps) })
|
||||
})
|
||||
.patch('/tts/models/:id', async (c) => {
|
||||
const body = await readBody(c, TtsModelUpdateBodySchema)
|
||||
const updated = await deps.service.updateTtsModel(c.req.param('id'), body)
|
||||
if (!updated)
|
||||
throw createNotFoundError('Official TTS model not found')
|
||||
return c.json(updated)
|
||||
})
|
||||
.get('/tts/voices', async (c) => {
|
||||
const routerModelId = c.req.query('model')
|
||||
if (!routerModelId)
|
||||
throw createBadRequestError('model query is required', 'MISSING_MODEL')
|
||||
return c.json(await deps.service.listTtsVoices(routerModelId))
|
||||
})
|
||||
.post('/tts/voices/sync', async (c) => {
|
||||
const body = await readBody(c, TtsVoiceSyncBodySchema)
|
||||
await syncTtsModelsFromConfig(deps)
|
||||
const providerVoices = await deps.llmRouter.listTtsVoices(body.routerModelId)
|
||||
const voices = providerVoices.map(normalizeProviderVoiceForCatalog).filter(voice => voice != null)
|
||||
const synced = await deps.service.syncTtsVoices({ routerModelId: body.routerModelId, voices })
|
||||
return c.json({ voices: synced, syncedCount: synced.length })
|
||||
})
|
||||
.patch('/tts/voices/:id', async (c) => {
|
||||
const body = await readBody(c, TtsVoiceUpdateBodySchema)
|
||||
const updated = await deps.service.updateTtsVoice(c.req.param('id'), body)
|
||||
if (!updated)
|
||||
throw createNotFoundError('Official TTS voice not found')
|
||||
return c.json(updated)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ConfigKVService } from '../../../services/adapters/config-kv'
|
||||
import type { LlmRouterService } from '../../../services/domain/llm-router'
|
||||
import type { OfficialCatalogService } from '../../../services/domain/official-catalog'
|
||||
import type { HonoEnv } from '../../../types/hono'
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createAdminOfficialCatalogRoutes } 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: [] } } },
|
||||
tts: { models: { 'microsoft/v1': { provider: 'azure', upstreams: [] } } },
|
||||
asr: { models: { auto: { 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 createLlmRouter(): LlmRouterService {
|
||||
return {
|
||||
route: vi.fn(),
|
||||
routeTts: vi.fn(),
|
||||
listTtsVoices: vi.fn(async () => [
|
||||
{ id: 'en-US-AvaMultilingualNeural', name: 'Ava', previewUrl: 'https://example.com/ava.mp3' },
|
||||
]),
|
||||
invalidateConfig: vi.fn(),
|
||||
invalidateTtsVoicesCache: vi.fn(),
|
||||
} as unknown as LlmRouterService
|
||||
}
|
||||
|
||||
function createService(): OfficialCatalogService {
|
||||
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 })),
|
||||
syncTtsModelsFromRouterConfig: vi.fn(async () => []),
|
||||
listTtsModels: vi.fn(async () => []),
|
||||
listEnabledTtsModels: vi.fn(async () => []),
|
||||
updateTtsModel: vi.fn(async (_id, input) => ({ id: 'model-1', ...input })),
|
||||
assertTtsModelEnabled: vi.fn(),
|
||||
syncTtsVoices: vi.fn(async (input: Parameters<OfficialCatalogService['syncTtsVoices']>[0]) => input.voices.map((voice, index) => ({
|
||||
id: `voice-${index}`,
|
||||
providerVoiceId: voice.id,
|
||||
displayName: voice.name ?? voice.id,
|
||||
enabled: false,
|
||||
}))),
|
||||
listTtsVoices: vi.fn(async () => []),
|
||||
listEnabledTtsVoices: vi.fn(async () => []),
|
||||
updateTtsVoice: vi.fn(async (_id, input) => ({ id: 'voice-1', ...input })),
|
||||
assertTtsVoiceEnabled: vi.fn(),
|
||||
} as unknown as OfficialCatalogService
|
||||
}
|
||||
|
||||
function createTestApp(input: {
|
||||
user: MockUser | null
|
||||
configKV?: ConfigKVService
|
||||
llmRouter?: LlmRouterService
|
||||
service?: OfficialCatalogService
|
||||
}) {
|
||||
return new Hono<HonoEnv>()
|
||||
.use('*', async (c, next) => {
|
||||
c.set('user', input.user as HonoEnv['Variables']['user'])
|
||||
await next()
|
||||
})
|
||||
.route('/api/admin/official-catalog', createAdminOfficialCatalogRoutes({
|
||||
configKV: input.configKV ?? createConfigKV(),
|
||||
llmRouter: input.llmRouter ?? createLlmRouter(),
|
||||
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<HonoEnv>, 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 official 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/official-catalog/aliases')
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(service.listAliases).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncs TTS voices from the provider into the official catalog', async () => {
|
||||
const service = createService()
|
||||
const llmRouter = createLlmRouter()
|
||||
const app = createTestApp({ user: ADMIN, service, llmRouter })
|
||||
|
||||
const res = await jsonRequest(app, 'POST', '/api/admin/official-catalog/tts/voices/sync', {
|
||||
routerModelId: 'microsoft/v1',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(service.syncTtsModelsFromRouterConfig).toHaveBeenCalledWith({
|
||||
models: { 'microsoft/v1': { provider: 'azure' } },
|
||||
})
|
||||
expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('microsoft/v1')
|
||||
expect(service.syncTtsVoices).toHaveBeenCalledWith({
|
||||
routerModelId: 'microsoft/v1',
|
||||
voices: [{
|
||||
id: 'en-US-AvaMultilingualNeural',
|
||||
name: 'Ava',
|
||||
languages: undefined,
|
||||
labels: undefined,
|
||||
previewAudioUrl: 'https://example.com/ava.mp3',
|
||||
}],
|
||||
})
|
||||
expect(await res.json()).toMatchObject({ syncedCount: 1 })
|
||||
})
|
||||
|
||||
it('maps missing catalog rows to 404 on update', async () => {
|
||||
const service = createService()
|
||||
vi.mocked(service.updateTtsModel).mockResolvedValueOnce(null)
|
||||
const app = createTestApp({ user: ADMIN, service })
|
||||
|
||||
const res = await jsonRequest(app, 'PATCH', '/api/admin/official-catalog/tts/models/missing', {
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -40,11 +40,8 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
|
||||
const billingPolicy = await billing.authorizeChat(input.userId)
|
||||
|
||||
const body = input.body
|
||||
let requestModel = typeof body.model === 'string' && body.model.length > 0 ? body.model : 'auto'
|
||||
|
||||
if (requestModel === 'auto') {
|
||||
requestModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL')
|
||||
}
|
||||
const requestedAlias = typeof body.model === 'string' && body.model.length > 0 ? body.model : 'auto'
|
||||
const requestModel = await resolveChatModelAlias(deps, requestedAlias)
|
||||
|
||||
const stream = !!body.stream
|
||||
logger.withFields({
|
||||
@@ -201,6 +198,23 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveChatModelAlias(deps: V1RouteDeps, aliasId: string): Promise<string> {
|
||||
const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG')
|
||||
const defaultModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL')
|
||||
const modelIds = [
|
||||
defaultModel,
|
||||
...Object.keys(config.llm.models).sort().filter(modelId => modelId !== defaultModel),
|
||||
]
|
||||
await deps.officialCatalogService.syncAliasesFromRouterConfig({
|
||||
surface: 'llm',
|
||||
modelIds,
|
||||
})
|
||||
|
||||
const alias = await deps.officialCatalogService.resolveEnabledAlias('llm', aliasId)
|
||||
const primary = alias.routes.find(route => route.pool === 'primary')
|
||||
return (primary ?? alias.routes[0]).routerModelId
|
||||
}
|
||||
|
||||
function streamChatCompletion(input: {
|
||||
deps: V1RouteDeps
|
||||
response: Response
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { V1RouteDeps } from '../../types'
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { ofetch } from 'ofetch'
|
||||
|
||||
import { catalogVoiceResponse, normalizeProviderVoiceForCatalog } from '../../../../../services/domain/official-catalog/provider-voices'
|
||||
import { createBadGatewayError, createBadRequestError, createServiceUnavailableError } from '../../../../../utils/error'
|
||||
|
||||
const VOICE_PACK_MODEL_ID = 'voice-pack'
|
||||
@@ -63,13 +64,19 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
|
||||
return Response.json({ voices: voicePacks.map(voicePackCatalogVoice), recommended: {} })
|
||||
}
|
||||
|
||||
const voices = await deps.llmRouter.listTtsVoices(model)
|
||||
await deps.officialCatalogService.assertTtsModelEnabled(model)
|
||||
const providerVoices = await deps.llmRouter.listTtsVoices(model)
|
||||
await deps.officialCatalogService.syncTtsVoices({
|
||||
routerModelId: model,
|
||||
voices: providerVoices.map(normalizeProviderVoiceForCatalog).filter(voice => voice != null),
|
||||
})
|
||||
const voices = await deps.officialCatalogService.listEnabledTtsVoices(model)
|
||||
const recommended = (await deps.configKV.getOptional('DEFAULT_TTS_VOICES'))?.[model] ?? {}
|
||||
// Debug level: high-frequency catalog poll from UI selectors, no
|
||||
// billing / user-facing side effect — useful only when debugging
|
||||
// voice-picker drift, never as a permanent audit trail line.
|
||||
logger.withFields({ model, voiceCount: voices.length, voicePackCount: voicePacks.length }).debug('list tts voices')
|
||||
return Response.json({ voices, recommended })
|
||||
return Response.json({ voices: voices.map(catalogVoiceResponse), recommended })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,11 +162,19 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
|
||||
// tolerates `undefined`. `getOrThrow` already throws on missing entries,
|
||||
// so by this line we know `config` is present — the `?.` here is purely
|
||||
// a TS narrowing aid.
|
||||
const modelIds = Object.keys(config?.tts?.models ?? {}).sort()
|
||||
await deps.officialCatalogService.syncTtsModelsFromRouterConfig({
|
||||
models: Object.fromEntries(
|
||||
Object.entries(config?.tts?.models ?? {}).map(([routerModelId, model]) => [
|
||||
routerModelId,
|
||||
{ provider: model.provider },
|
||||
]),
|
||||
),
|
||||
})
|
||||
const models = await deps.officialCatalogService.listEnabledTtsModels()
|
||||
return Response.json({
|
||||
models: [
|
||||
{ id: VOICE_PACK_MODEL_ID, name: 'Voice Pack', description: 'Server-curated voices' },
|
||||
...modelIds.map(id => ({ id, name: id })),
|
||||
...models.map(model => ({ id: model.routerModelId, name: model.displayName })),
|
||||
],
|
||||
default: defaultModel,
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ export function speechGeneration(deps: V1RouteDeps): GatewayCallback<'speech.gen
|
||||
genAi: deps.genAi,
|
||||
llmRouter: deps.llmRouter,
|
||||
llmTracing: deps.llmTracing,
|
||||
officialCatalogService: deps.officialCatalogService,
|
||||
productEventService: deps.productEventService,
|
||||
requestLogService: deps.requestLogService,
|
||||
ttsMeter: deps.ttsMeter,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BillingService } from '../../../services/domain/billing/billing-se
|
||||
import type { FluxService } from '../../../services/domain/flux'
|
||||
import type { LlmRouterService } from '../../../services/domain/llm-router'
|
||||
import type { ChatGenerationTrace, TtsGenerationTrace } from '../../../services/domain/llm-tracing'
|
||||
import type { OfficialCatalogService } from '../../../services/domain/official-catalog'
|
||||
import type { ProductEventService } from '../../../services/domain/product-events'
|
||||
import type { RequestLogService } from '../../../services/domain/request-log'
|
||||
import type { VoicePackService } from '../../../services/domain/voice-packs'
|
||||
@@ -46,6 +47,10 @@ function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVServic
|
||||
TTS_DEBT_TTL_SECONDS: 86400,
|
||||
DEFAULT_CHAT_MODEL: 'openai/gpt-5-mini',
|
||||
DEFAULT_TTS_MODEL: 'tts-1',
|
||||
LLM_ROUTER_CONFIG: {
|
||||
llm: { models: { 'openai/gpt-5-mini': { upstreams: [] } } },
|
||||
tts: { models: {} },
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
return {
|
||||
@@ -155,6 +160,143 @@ function createMockVoicePackService(impl?: Partial<VoicePackService>): VoicePack
|
||||
} as unknown as VoicePackService
|
||||
}
|
||||
|
||||
function createMockOfficialCatalogService(impl?: Partial<OfficialCatalogService>): OfficialCatalogService {
|
||||
let syncedAliasRoutes: Array<{
|
||||
id: string
|
||||
aliasId: string
|
||||
routerModelId: string
|
||||
pool: 'primary' | 'fallback'
|
||||
enabled: boolean
|
||||
weight: number
|
||||
displayOrder: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}> = []
|
||||
let syncedModels: Awaited<ReturnType<OfficialCatalogService['syncTtsModelsFromRouterConfig']>> = []
|
||||
const syncedVoicesByModel = new Map<string, Awaited<ReturnType<OfficialCatalogService['syncTtsVoices']>>>()
|
||||
|
||||
return {
|
||||
syncAliasesFromRouterConfig: vi.fn(async (input: Parameters<OfficialCatalogService['syncAliasesFromRouterConfig']>[0]) => {
|
||||
const { surface, modelIds } = input
|
||||
syncedAliasRoutes = Array.from(new Set(modelIds)).map((routerModelId, index) => ({
|
||||
id: `alias-route-${index}`,
|
||||
aliasId: 'alias-auto',
|
||||
routerModelId,
|
||||
pool: 'primary',
|
||||
enabled: true,
|
||||
weight: 1,
|
||||
displayOrder: index,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
return [{
|
||||
id: 'alias-auto',
|
||||
surface,
|
||||
aliasId: 'auto',
|
||||
displayName: 'Auto',
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
fallbackEnabled: true,
|
||||
loadBalancingEnabled: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}]
|
||||
}),
|
||||
listAliases: vi.fn(async () => []),
|
||||
resolveEnabledAlias: vi.fn(async (surface, aliasId) => ({
|
||||
id: `alias-${aliasId}`,
|
||||
surface,
|
||||
aliasId,
|
||||
displayName: aliasId,
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
fallbackEnabled: true,
|
||||
loadBalancingEnabled: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
routes: aliasId === 'auto'
|
||||
? syncedAliasRoutes
|
||||
: [{
|
||||
id: `alias-route-${aliasId}`,
|
||||
aliasId: `alias-${aliasId}`,
|
||||
routerModelId: aliasId,
|
||||
pool: 'primary',
|
||||
enabled: true,
|
||||
weight: 1,
|
||||
displayOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
})),
|
||||
syncTtsModelsFromRouterConfig: vi.fn(async (input: Parameters<OfficialCatalogService['syncTtsModelsFromRouterConfig']>[0]) => {
|
||||
const { models } = input
|
||||
syncedModels = Object.entries(models).sort(([a], [b]) => a.localeCompare(b)).map(([routerModelId, model], index) => ({
|
||||
id: `tts-model-${index}`,
|
||||
routerModelId,
|
||||
provider: model.provider,
|
||||
displayName: routerModelId,
|
||||
enabled: true,
|
||||
displayOrder: index,
|
||||
lastSyncedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
return syncedModels
|
||||
}),
|
||||
listTtsModels: vi.fn(async () => []),
|
||||
listEnabledTtsModels: vi.fn(async () => syncedModels),
|
||||
assertTtsModelEnabled: vi.fn(async routerModelId => ({
|
||||
id: 'tts-model-1',
|
||||
routerModelId,
|
||||
provider: 'azure',
|
||||
displayName: routerModelId,
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
lastSyncedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
syncTtsVoices: vi.fn(async (input: Parameters<OfficialCatalogService['syncTtsVoices']>[0]) => {
|
||||
const { routerModelId, voices } = input
|
||||
const syncedVoices = voices.map((voice, index) => ({
|
||||
id: `tts-voice-${index}`,
|
||||
ttsModelId: 'tts-model-1',
|
||||
providerVoiceId: voice.id,
|
||||
displayName: voice.name ?? voice.id,
|
||||
enabled: true,
|
||||
displayOrder: index,
|
||||
languages: voice.languages ?? [],
|
||||
labels: voice.labels ?? {},
|
||||
previewAudioUrl: voice.previewAudioUrl ?? null,
|
||||
source: 'provider-sync' as const,
|
||||
lastSyncedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
syncedVoicesByModel.set(routerModelId, syncedVoices)
|
||||
return syncedVoices
|
||||
}),
|
||||
listTtsVoices: vi.fn(async () => []),
|
||||
listEnabledTtsVoices: vi.fn(async routerModelId => syncedVoicesByModel.get(routerModelId) ?? []),
|
||||
assertTtsVoiceEnabled: vi.fn(async (_routerModelId, providerVoiceId) => ({
|
||||
id: 'tts-voice-1',
|
||||
ttsModelId: 'tts-model-1',
|
||||
providerVoiceId,
|
||||
displayName: providerVoiceId,
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
languages: [],
|
||||
labels: {},
|
||||
previewAudioUrl: null,
|
||||
source: 'provider-sync',
|
||||
lastSyncedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
...impl,
|
||||
} as OfficialCatalogService
|
||||
}
|
||||
|
||||
function createTestApp(
|
||||
fluxService: FluxService,
|
||||
configKV: ConfigKVService,
|
||||
@@ -165,6 +307,7 @@ function createTestApp(
|
||||
llmTracing = createMockLlmTracing(),
|
||||
productEventService = createMockProductEventService(),
|
||||
voicePackService = createMockVoicePackService(),
|
||||
officialCatalogService = createMockOfficialCatalogService(),
|
||||
) {
|
||||
const { openaiRoutes, audioRoutes } = createV1Routes({
|
||||
fluxService,
|
||||
@@ -175,6 +318,7 @@ function createTestApp(
|
||||
ttsMeter: ttsMeter ?? createMockTtsMeter(),
|
||||
llmRouter: llmRouter ?? createMockLlmRouter(),
|
||||
voicePackService,
|
||||
officialCatalogService,
|
||||
genAi: null,
|
||||
revenue: null,
|
||||
rateLimitMetrics: null,
|
||||
@@ -454,7 +598,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass through non-auto model as-is', async () => {
|
||||
it('resolves an enabled non-auto model alias through the official catalog', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -479,6 +623,76 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects disabled LLM aliases before upstream routing', async () => {
|
||||
const route = vi.fn(async () => new Response('{}', { status: 200 }))
|
||||
const officialCatalogService = createMockOfficialCatalogService({
|
||||
resolveEnabledAlias: vi.fn(async () => {
|
||||
throw new ApiError(400, 'OFFICIAL_ALIAS_DISABLED', 'Official provider alias is disabled')
|
||||
}),
|
||||
})
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
createMockLlmRouter({ route }),
|
||||
createMockLlmTracing(),
|
||||
createMockProductEventService(),
|
||||
createMockVoicePackService(),
|
||||
officialCatalogService,
|
||||
)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', messages: [] }),
|
||||
}),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json() as { error?: string }
|
||||
expect(body.error).toBe('OFFICIAL_ALIAS_DISABLED')
|
||||
expect(route).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects missing LLM aliases before upstream routing', async () => {
|
||||
const route = vi.fn(async () => new Response('{}', { status: 200 }))
|
||||
const officialCatalogService = createMockOfficialCatalogService({
|
||||
resolveEnabledAlias: vi.fn(async () => {
|
||||
throw new ApiError(400, 'OFFICIAL_ALIAS_NOT_FOUND', 'Official provider alias is not configured')
|
||||
}),
|
||||
})
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
createMockLlmRouter({ route }),
|
||||
createMockLlmTracing(),
|
||||
createMockProductEventService(),
|
||||
createMockVoicePackService(),
|
||||
officialCatalogService,
|
||||
)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/openai/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'deepseek', messages: [] }),
|
||||
}),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json() as { error?: string }
|
||||
expect(body.error).toBe('OFFICIAL_ALIAS_NOT_FOUND')
|
||||
expect(route).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records Langfuse chat generation with the router-resolved upstream model', async () => {
|
||||
const llmRouter = createMockLlmRouter({
|
||||
route: vi.fn(async (_req, ctx) => {
|
||||
@@ -540,7 +754,11 @@ describe('v1CompletionsRoutes', () => {
|
||||
|
||||
it('should return 503 when config keys are missing', async () => {
|
||||
const configKV = createMockConfigKV()
|
||||
configKV.getOptional = vi.fn(async () => null)
|
||||
configKV.getOrThrow = vi.fn(async (key: string) => {
|
||||
if (key === 'LLM_ROUTER_CONFIG')
|
||||
throw new ApiError(503, 'CONFIG_NOT_SET', 'Service configuration is incomplete')
|
||||
return createMockConfigKV().getOrThrow(key as never)
|
||||
})
|
||||
|
||||
const app = createTestApp(createMockFluxService(), configKV)
|
||||
|
||||
@@ -685,6 +903,81 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects disabled official TTS models before billing or upstream routing', async () => {
|
||||
const routeTts = vi.fn(async () => new Response(new Uint8Array([1]), { status: 200 }))
|
||||
const ttsMeter = createMockTtsMeter()
|
||||
const officialCatalogService = createMockOfficialCatalogService({
|
||||
assertTtsModelEnabled: vi.fn(async () => {
|
||||
throw new ApiError(400, 'OFFICIAL_MODEL_DISABLED', 'Official TTS model is disabled')
|
||||
}),
|
||||
})
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({ DEFAULT_TTS_MODEL: 'microsoft/v1' }),
|
||||
undefined,
|
||||
undefined,
|
||||
ttsMeter,
|
||||
createMockLlmRouter({ routeTts }),
|
||||
createMockLlmTracing(),
|
||||
createMockProductEventService(),
|
||||
createMockVoicePackService(),
|
||||
officialCatalogService,
|
||||
)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', input: 'test', voice: 'alloy' }),
|
||||
}),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json() as { error?: string }
|
||||
expect(body.error).toBe('OFFICIAL_MODEL_DISABLED')
|
||||
expect(ttsMeter.assertCanAfford).not.toHaveBeenCalled()
|
||||
expect(routeTts).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects disabled official TTS voices before billing or upstream routing', async () => {
|
||||
const routeTts = vi.fn(async () => new Response(new Uint8Array([1]), { status: 200 }))
|
||||
const ttsMeter = createMockTtsMeter()
|
||||
const officialCatalogService = createMockOfficialCatalogService({
|
||||
assertTtsVoiceEnabled: vi.fn(async () => {
|
||||
throw new ApiError(400, 'OFFICIAL_VOICE_DISABLED', 'Official TTS voice is disabled')
|
||||
}),
|
||||
})
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({ DEFAULT_TTS_MODEL: 'microsoft/v1' }),
|
||||
undefined,
|
||||
undefined,
|
||||
ttsMeter,
|
||||
createMockLlmRouter({ routeTts }),
|
||||
createMockLlmTracing(),
|
||||
createMockProductEventService(),
|
||||
createMockVoicePackService(),
|
||||
officialCatalogService,
|
||||
)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'auto', input: 'test', voice: 'alloy' }),
|
||||
}),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json() as { error?: string }
|
||||
expect(body.error).toBe('OFFICIAL_VOICE_DISABLED')
|
||||
expect(officialCatalogService.assertTtsVoiceEnabled).toHaveBeenCalledWith('microsoft/v1', 'alloy')
|
||||
expect(ttsMeter.assertCanAfford).not.toHaveBeenCalled()
|
||||
expect(routeTts).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* POST /api/v1/audio/speech { "model": "voice-pack", "voice": "friendly-azure" }
|
||||
@@ -1398,8 +1691,23 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { voices: typeof voices, recommended: Record<string, string> }
|
||||
expect(data.voices).toEqual(voices)
|
||||
const data = await res.json() as { voices: Array<Record<string, unknown>>, recommended: Record<string, string> }
|
||||
expect(data.voices).toEqual([
|
||||
{
|
||||
id: 'en-US-JennyNeural',
|
||||
name: 'Jenny',
|
||||
languages: [],
|
||||
labels: {},
|
||||
previewAudioUrl: null,
|
||||
},
|
||||
{
|
||||
id: 'en-US-AvaMultilingualNeural',
|
||||
name: 'Ava',
|
||||
languages: [],
|
||||
labels: {},
|
||||
previewAudioUrl: null,
|
||||
},
|
||||
])
|
||||
expect(data.recommended).toEqual({ 'en-US': 'en-US-AvaMultilingualNeural' })
|
||||
expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('microsoft/v1')
|
||||
})
|
||||
@@ -1517,10 +1825,53 @@ describe('v1CompletionsRoutes', () => {
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { voices: Array<Record<string, unknown>> }
|
||||
expect(data.voices).toEqual([
|
||||
{ id: 'en-US-AvaMultilingualNeural', name: 'Ava', languages: [{ code: 'en-US', title: 'English' }] },
|
||||
{
|
||||
id: 'en-US-AvaMultilingualNeural',
|
||||
name: 'Ava',
|
||||
languages: [{ code: 'en-US', title: 'English' }],
|
||||
labels: {},
|
||||
previewAudioUrl: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('hides provider voices that are not enabled in the official catalog', async () => {
|
||||
const llmRouter = createMockLlmRouter({
|
||||
listTtsVoices: vi.fn(async () => [
|
||||
{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' },
|
||||
]) as any,
|
||||
})
|
||||
const officialCatalogService = createMockOfficialCatalogService({
|
||||
listEnabledTtsVoices: vi.fn(async () => []),
|
||||
})
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
llmRouter,
|
||||
createMockLlmTracing(),
|
||||
createMockProductEventService(),
|
||||
createMockVoicePackService(),
|
||||
officialCatalogService,
|
||||
)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/audio/voices?model=microsoft/v1', { method: 'GET' }),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { voices: Array<Record<string, unknown>> }
|
||||
expect(data.voices).toEqual([])
|
||||
expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('microsoft/v1')
|
||||
expect(officialCatalogService.syncTtsVoices).toHaveBeenCalledWith({
|
||||
routerModelId: 'microsoft/v1',
|
||||
voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava', languages: undefined, labels: undefined, previewAudioUrl: null }],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty recommended map when the resolved model has no bucket', async () => {
|
||||
const llmRouter = createMockLlmRouter({
|
||||
listTtsVoices: vi.fn(async () => []) as any,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FluxMeter } from '../../../services/domain/billing/flux-meter'
|
||||
import type { FluxService } from '../../../services/domain/flux'
|
||||
import type { LlmRouterService } from '../../../services/domain/llm-router'
|
||||
import type { ChatGenerationTrace, TtsGenerationTrace } from '../../../services/domain/llm-tracing'
|
||||
import type { OfficialCatalogService } from '../../../services/domain/official-catalog'
|
||||
import type { ProductEventService } from '../../../services/domain/product-events'
|
||||
import type { RequestLogService } from '../../../services/domain/request-log'
|
||||
import type { VoicePackService } from '../../../services/domain/voice-packs'
|
||||
@@ -25,6 +26,7 @@ export interface V1RouteDeps {
|
||||
ttsMeter: FluxMeter
|
||||
llmRouter: LlmRouterService
|
||||
voicePackService: VoicePackService
|
||||
officialCatalogService: OfficialCatalogService
|
||||
genAi?: GenAiMetrics | null
|
||||
revenue?: RevenueMetrics | null
|
||||
rateLimitMetrics?: RateLimitMetrics | null
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './chats'
|
||||
export * from './flux'
|
||||
export * from './flux-transaction'
|
||||
export * from './llm-request-log'
|
||||
export * from './official-catalog'
|
||||
export * from './product-events'
|
||||
export * from './providers'
|
||||
export * from './stripe'
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
||||
|
||||
import { boolean, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
|
||||
|
||||
import { nanoid } from '../utils/id'
|
||||
|
||||
export type OfficialCatalogSurface = 'llm' | 'asr'
|
||||
export type OfficialCatalogRoutePool = 'primary' | 'fallback'
|
||||
|
||||
export interface OfficialTtsVoiceLanguage {
|
||||
code: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type OfficialTtsVoiceLabels = Record<string, unknown>
|
||||
|
||||
export const officialProviderAliases = pgTable(
|
||||
'official_provider_aliases',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
surface: text('surface').notNull().$type<OfficialCatalogSurface>(),
|
||||
aliasId: text('alias_id').notNull(),
|
||||
displayName: text('display_name').notNull(),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
fallbackEnabled: boolean('fallback_enabled').notNull().default(true),
|
||||
loadBalancingEnabled: boolean('load_balancing_enabled').notNull().default(false),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
table => [
|
||||
uniqueIndex('official_provider_aliases_surface_alias_uidx').on(table.surface, table.aliasId),
|
||||
],
|
||||
)
|
||||
|
||||
export const officialProviderAliasRoutes = pgTable(
|
||||
'official_provider_alias_routes',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
aliasId: text('alias_id').notNull().references(() => officialProviderAliases.id, { onDelete: 'cascade' }),
|
||||
routerModelId: text('router_model_id').notNull(),
|
||||
pool: text('pool').notNull().$type<OfficialCatalogRoutePool>().default('primary'),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
weight: integer('weight').notNull().default(1),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
table => [
|
||||
uniqueIndex('official_provider_alias_routes_alias_model_pool_uidx').on(table.aliasId, table.routerModelId, table.pool),
|
||||
],
|
||||
)
|
||||
|
||||
export const officialTtsModels = pgTable(
|
||||
'official_tts_models',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
routerModelId: text('router_model_id').notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
displayName: text('display_name').notNull(),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
lastSyncedAt: timestamp('last_synced_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
table => [
|
||||
uniqueIndex('official_tts_models_router_model_uidx').on(table.routerModelId),
|
||||
],
|
||||
)
|
||||
|
||||
export const officialTtsVoices = pgTable(
|
||||
'official_tts_voices',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
ttsModelId: text('tts_model_id').notNull().references(() => officialTtsModels.id, { onDelete: 'cascade' }),
|
||||
providerVoiceId: text('provider_voice_id').notNull(),
|
||||
displayName: text('display_name').notNull(),
|
||||
enabled: boolean('enabled').notNull().default(false),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
languages: jsonb('languages').notNull().$type<OfficialTtsVoiceLanguage[]>().default([]),
|
||||
labels: jsonb('labels').notNull().$type<OfficialTtsVoiceLabels>().default({}),
|
||||
previewAudioUrl: text('preview_audio_url'),
|
||||
source: text('source').notNull().default('provider-sync'),
|
||||
lastSyncedAt: timestamp('last_synced_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
table => [
|
||||
uniqueIndex('official_tts_voices_model_voice_uidx').on(table.ttsModelId, table.providerVoiceId),
|
||||
],
|
||||
)
|
||||
|
||||
export type OfficialProviderAlias = InferSelectModel<typeof officialProviderAliases>
|
||||
export type NewOfficialProviderAlias = InferInsertModel<typeof officialProviderAliases>
|
||||
export type OfficialProviderAliasRoute = InferSelectModel<typeof officialProviderAliasRoutes>
|
||||
export type NewOfficialProviderAliasRoute = InferInsertModel<typeof officialProviderAliasRoutes>
|
||||
export type OfficialTtsModel = InferSelectModel<typeof officialTtsModels>
|
||||
export type NewOfficialTtsModel = InferInsertModel<typeof officialTtsModels>
|
||||
export type OfficialTtsVoice = InferSelectModel<typeof officialTtsVoices>
|
||||
export type NewOfficialTtsVoice = InferInsertModel<typeof officialTtsVoices>
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { Database } from '../../../libs/db'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createOfficialCatalogService } from '.'
|
||||
import { mockDB } from '../../../libs/mock-db'
|
||||
import { officialProviderAliases, officialProviderAliasRoutes, officialTtsModels, officialTtsVoices } from '../../../schemas/official-catalog'
|
||||
import { ApiError } from '../../../utils/error'
|
||||
|
||||
import * as schema from '../../../schemas'
|
||||
|
||||
describe('officialCatalogService', () => {
|
||||
let db: Database
|
||||
let service: ReturnType<typeof createOfficialCatalogService>
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await mockDB(schema)
|
||||
service = createOfficialCatalogService(db)
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(officialProviderAliasRoutes)
|
||||
await db.delete(officialProviderAliases)
|
||||
await db.delete(officialTtsVoices)
|
||||
await db.delete(officialTtsModels)
|
||||
})
|
||||
|
||||
it('syncs the default LLM auto alias and runtime model routes as enabled', async () => {
|
||||
const aliases = await service.syncAliasesFromRouterConfig({
|
||||
surface: 'llm',
|
||||
modelIds: ['chat-b', 'chat-a'],
|
||||
})
|
||||
|
||||
expect(aliases).toHaveLength(1)
|
||||
expect(aliases[0]).toMatchObject({
|
||||
surface: 'llm',
|
||||
aliasId: 'auto',
|
||||
displayName: 'Auto',
|
||||
enabled: true,
|
||||
fallbackEnabled: true,
|
||||
loadBalancingEnabled: false,
|
||||
})
|
||||
|
||||
const resolved = await service.resolveEnabledAlias('llm', 'auto')
|
||||
expect(resolved.routes.map(route => route.routerModelId)).toEqual(['chat-b', 'chat-a'])
|
||||
expect(resolved.routes.every(route => route.enabled)).toBe(true)
|
||||
expect(resolved.routes.every(route => route.pool === 'primary')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves alias and route curation across repeated syncs', async () => {
|
||||
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a'] })
|
||||
const [alias] = await db.select().from(officialProviderAliases)
|
||||
const [route] = await db.select().from(officialProviderAliasRoutes)
|
||||
|
||||
await db.update(officialProviderAliases)
|
||||
.set({ enabled: false, displayName: 'Custom Auto', displayOrder: 5 })
|
||||
.where(eq(officialProviderAliases.id, alias.id))
|
||||
await db.update(officialProviderAliasRoutes)
|
||||
.set({ enabled: false, displayOrder: 9 })
|
||||
.where(eq(officialProviderAliasRoutes.id, route.id))
|
||||
|
||||
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a', 'chat-b'] })
|
||||
const aliases = await service.listAliases('llm')
|
||||
const preservedRoute = aliases[0].routes.find(item => item.routerModelId === 'chat-a')
|
||||
const newRoute = aliases[0].routes.find(item => item.routerModelId === 'chat-b')
|
||||
|
||||
expect(aliases[0]).toMatchObject({ enabled: false, displayName: 'Custom Auto', displayOrder: 5 })
|
||||
expect(preservedRoute).toMatchObject({ enabled: false, displayOrder: 9 })
|
||||
expect(newRoute).toMatchObject({ enabled: true, displayOrder: 1 })
|
||||
})
|
||||
|
||||
it('syncs runtime TTS models as enabled but preserves admin display fields', async () => {
|
||||
const first = await service.syncTtsModelsFromRouterConfig({
|
||||
models: {
|
||||
'alibaba/cosyvoice-v2': { provider: 'dashscope-cosyvoice' },
|
||||
},
|
||||
})
|
||||
await db.update(officialTtsModels)
|
||||
.set({ enabled: false, displayName: 'Curated CosyVoice', displayOrder: 7 })
|
||||
.where(eq(officialTtsModels.id, first[0].id))
|
||||
|
||||
await service.syncTtsModelsFromRouterConfig({
|
||||
models: {
|
||||
'alibaba/cosyvoice-v2': { provider: 'dashscope-cosyvoice' },
|
||||
'microsoft/v1': { provider: 'azure' },
|
||||
},
|
||||
})
|
||||
|
||||
const models = await service.listTtsModels()
|
||||
expect(models.map(model => model.routerModelId)).toEqual(['alibaba/cosyvoice-v2', 'microsoft/v1'])
|
||||
expect(models.find(model => model.routerModelId === 'alibaba/cosyvoice-v2')).toMatchObject({
|
||||
enabled: false,
|
||||
displayName: 'Curated CosyVoice',
|
||||
displayOrder: 7,
|
||||
provider: 'dashscope-cosyvoice',
|
||||
})
|
||||
expect(models.find(model => model.routerModelId === 'microsoft/v1')).toMatchObject({
|
||||
enabled: true,
|
||||
displayName: 'microsoft/v1',
|
||||
provider: 'azure',
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs provider voices as disabled by default and preserves curation on resync', async () => {
|
||||
await service.syncTtsModelsFromRouterConfig({
|
||||
models: { 'microsoft/v1': { provider: 'azure' } },
|
||||
})
|
||||
|
||||
const first = await service.syncTtsVoices({
|
||||
routerModelId: 'microsoft/v1',
|
||||
voices: [{
|
||||
id: 'en-US-AvaMultilingualNeural',
|
||||
name: 'Ava',
|
||||
languages: [{ code: 'en-US', title: 'English' }],
|
||||
labels: { gender: 'female' },
|
||||
previewAudioUrl: 'https://example.com/ava.mp3',
|
||||
}],
|
||||
})
|
||||
expect(first[0]).toMatchObject({
|
||||
providerVoiceId: 'en-US-AvaMultilingualNeural',
|
||||
displayName: 'Ava',
|
||||
enabled: false,
|
||||
previewAudioUrl: 'https://example.com/ava.mp3',
|
||||
})
|
||||
|
||||
await db.update(officialTtsVoices)
|
||||
.set({
|
||||
enabled: true,
|
||||
displayName: 'Curated Ava',
|
||||
displayOrder: 3,
|
||||
previewAudioUrl: 'https://example.com/manual.mp3',
|
||||
})
|
||||
.where(eq(officialTtsVoices.id, first[0].id))
|
||||
|
||||
await service.syncTtsVoices({
|
||||
routerModelId: 'microsoft/v1',
|
||||
voices: [{
|
||||
id: 'en-US-AvaMultilingualNeural',
|
||||
name: 'Ava from provider',
|
||||
languages: [{ code: 'en-US', title: 'English US' }],
|
||||
labels: { gender: 'Female' },
|
||||
previewAudioUrl: 'https://example.com/provider-new.mp3',
|
||||
}],
|
||||
})
|
||||
|
||||
const voices = await service.listTtsVoices('microsoft/v1')
|
||||
expect(voices[0]).toMatchObject({
|
||||
enabled: true,
|
||||
displayName: 'Curated Ava',
|
||||
displayOrder: 3,
|
||||
previewAudioUrl: 'https://example.com/manual.mp3',
|
||||
labels: { gender: 'Female' },
|
||||
languages: [{ code: 'en-US', title: 'English US' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('lists and gates only enabled TTS models and voices', async () => {
|
||||
const [model] = await service.syncTtsModelsFromRouterConfig({
|
||||
models: { 'microsoft/v1': { provider: 'azure' } },
|
||||
})
|
||||
const [voice] = await service.syncTtsVoices({
|
||||
routerModelId: 'microsoft/v1',
|
||||
voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }],
|
||||
})
|
||||
|
||||
expect(await service.listEnabledTtsModels()).toHaveLength(1)
|
||||
expect(await service.listEnabledTtsVoices('microsoft/v1')).toEqual([])
|
||||
|
||||
await db.update(officialTtsVoices)
|
||||
.set({ enabled: true })
|
||||
.where(eq(officialTtsVoices.id, voice.id))
|
||||
expect((await service.listEnabledTtsVoices('microsoft/v1')).map(item => item.providerVoiceId)).toEqual(['en-US-AvaMultilingualNeural'])
|
||||
|
||||
await db.update(officialTtsModels)
|
||||
.set({ enabled: false })
|
||||
.where(eq(officialTtsModels.id, model.id))
|
||||
|
||||
await expect(service.assertTtsModelEnabled('microsoft/v1')).rejects.toMatchObject({
|
||||
errorCode: 'OFFICIAL_MODEL_DISABLED',
|
||||
})
|
||||
await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'en-US-AvaMultilingualNeural')).rejects.toMatchObject({
|
||||
errorCode: 'OFFICIAL_MODEL_DISABLED',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws structured errors for missing or disabled aliases and voices', async () => {
|
||||
await expect(service.resolveEnabledAlias('llm', 'auto')).rejects.toMatchObject({
|
||||
errorCode: 'OFFICIAL_ALIAS_NOT_FOUND',
|
||||
})
|
||||
|
||||
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a'] })
|
||||
const [alias] = await db.select().from(officialProviderAliases)
|
||||
await db.update(officialProviderAliases)
|
||||
.set({ enabled: false })
|
||||
.where(eq(officialProviderAliases.id, alias.id))
|
||||
|
||||
await expect(service.resolveEnabledAlias('llm', 'auto')).rejects.toMatchObject({
|
||||
errorCode: 'OFFICIAL_ALIAS_DISABLED',
|
||||
})
|
||||
|
||||
await service.syncTtsModelsFromRouterConfig({ models: { 'microsoft/v1': { provider: 'azure' } } })
|
||||
await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'missing')).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'missing')).rejects.toMatchObject({
|
||||
errorCode: 'OFFICIAL_VOICE_NOT_FOUND',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,416 @@
|
||||
import type { Database } from '../../../libs/db'
|
||||
import type {
|
||||
OfficialCatalogRoutePool,
|
||||
OfficialCatalogSurface,
|
||||
OfficialProviderAlias,
|
||||
OfficialProviderAliasRoute,
|
||||
OfficialTtsModel,
|
||||
OfficialTtsVoice,
|
||||
OfficialTtsVoiceLabels,
|
||||
OfficialTtsVoiceLanguage,
|
||||
} from '../../../schemas/official-catalog'
|
||||
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm'
|
||||
|
||||
import {
|
||||
officialProviderAliases,
|
||||
officialProviderAliasRoutes,
|
||||
officialTtsModels,
|
||||
officialTtsVoices,
|
||||
} from '../../../schemas/official-catalog'
|
||||
import { createBadRequestError } from '../../../utils/error'
|
||||
|
||||
const DEFAULT_ALIAS_ID = 'auto'
|
||||
|
||||
export interface OfficialTtsModelSyncInput {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export interface OfficialTtsVoiceSyncInput {
|
||||
id: string
|
||||
name?: string
|
||||
languages?: OfficialTtsVoiceLanguage[]
|
||||
labels?: OfficialTtsVoiceLabels
|
||||
previewAudioUrl?: string | null
|
||||
}
|
||||
|
||||
export interface OfficialProviderAliasWithRoutes extends OfficialProviderAlias {
|
||||
routes: OfficialProviderAliasRoute[]
|
||||
}
|
||||
|
||||
export interface OfficialProviderAliasUpdateInput {
|
||||
displayName?: string
|
||||
enabled?: boolean
|
||||
displayOrder?: number
|
||||
fallbackEnabled?: boolean
|
||||
loadBalancingEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface OfficialProviderAliasRouteUpdateInput {
|
||||
enabled?: boolean
|
||||
pool?: OfficialCatalogRoutePool
|
||||
weight?: number
|
||||
displayOrder?: number
|
||||
}
|
||||
|
||||
export interface OfficialTtsModelUpdateInput {
|
||||
displayName?: string
|
||||
enabled?: boolean
|
||||
displayOrder?: number
|
||||
}
|
||||
|
||||
export interface OfficialTtsVoiceUpdateInput {
|
||||
displayName?: string
|
||||
enabled?: boolean
|
||||
displayOrder?: number
|
||||
languages?: OfficialTtsVoiceLanguage[]
|
||||
labels?: OfficialTtsVoiceLabels
|
||||
previewAudioUrl?: string | null
|
||||
}
|
||||
|
||||
function defaultAliasDisplayName(surface: OfficialCatalogSurface, aliasId: string): string {
|
||||
if (aliasId !== DEFAULT_ALIAS_ID)
|
||||
return aliasId
|
||||
return surface === 'llm' ? 'Auto' : 'Auto Transcription'
|
||||
}
|
||||
|
||||
function nextOrder(rows: Array<{ displayOrder: number }>): number {
|
||||
if (rows.length === 0)
|
||||
return 0
|
||||
return Math.max(...rows.map(row => row.displayOrder)) + 1
|
||||
}
|
||||
|
||||
function catalogError(message: string, errorCode: string, details?: unknown) {
|
||||
return createBadRequestError(message, errorCode, details)
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns AIRI's official product catalog.
|
||||
*
|
||||
* The router config still owns real provider URLs, keys, and fallback
|
||||
* mechanics. This service owns what users can see and what requests may use.
|
||||
* Public list endpoints and gateway request gates should both call this
|
||||
* service so UI hiding and handwritten request validation cannot drift.
|
||||
*/
|
||||
export function createOfficialCatalogService(db: Database) {
|
||||
async function findAlias(surface: OfficialCatalogSurface, aliasId: string) {
|
||||
return await db.query.officialProviderAliases.findFirst({
|
||||
where: and(
|
||||
eq(officialProviderAliases.surface, surface),
|
||||
eq(officialProviderAliases.aliasId, aliasId),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureAlias(surface: OfficialCatalogSurface, aliasId: string) {
|
||||
const existing = await findAlias(surface, aliasId)
|
||||
if (existing)
|
||||
return existing
|
||||
|
||||
const existingAliases = await db.query.officialProviderAliases.findMany({
|
||||
where: eq(officialProviderAliases.surface, surface),
|
||||
})
|
||||
const [created] = await db.insert(officialProviderAliases).values({
|
||||
surface,
|
||||
aliasId,
|
||||
displayName: defaultAliasDisplayName(surface, aliasId),
|
||||
enabled: true,
|
||||
displayOrder: nextOrder(existingAliases),
|
||||
fallbackEnabled: true,
|
||||
loadBalancingEnabled: false,
|
||||
}).returning()
|
||||
return created
|
||||
}
|
||||
|
||||
async function syncAliasRoute(input: {
|
||||
aliasRowId: string
|
||||
routerModelId: string
|
||||
pool: OfficialCatalogRoutePool
|
||||
order: number
|
||||
}) {
|
||||
const existing = await db.query.officialProviderAliasRoutes.findFirst({
|
||||
where: and(
|
||||
eq(officialProviderAliasRoutes.aliasId, input.aliasRowId),
|
||||
eq(officialProviderAliasRoutes.routerModelId, input.routerModelId),
|
||||
eq(officialProviderAliasRoutes.pool, input.pool),
|
||||
),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(officialProviderAliasRoutes)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(officialProviderAliasRoutes.id, existing.id))
|
||||
.returning()
|
||||
return updated
|
||||
}
|
||||
|
||||
const [created] = await db.insert(officialProviderAliasRoutes).values({
|
||||
aliasId: input.aliasRowId,
|
||||
routerModelId: input.routerModelId,
|
||||
pool: input.pool,
|
||||
enabled: true,
|
||||
weight: 1,
|
||||
displayOrder: input.order,
|
||||
}).returning()
|
||||
return created
|
||||
}
|
||||
|
||||
return {
|
||||
async syncAliasesFromRouterConfig(input: {
|
||||
surface: OfficialCatalogSurface
|
||||
modelIds: string[]
|
||||
}) {
|
||||
const alias = await ensureAlias(input.surface, DEFAULT_ALIAS_ID)
|
||||
const uniqueModelIds = Array.from(new Set(input.modelIds))
|
||||
for (const [index, routerModelId] of uniqueModelIds.entries()) {
|
||||
await syncAliasRoute({
|
||||
aliasRowId: alias.id,
|
||||
routerModelId,
|
||||
pool: 'primary',
|
||||
order: index,
|
||||
})
|
||||
}
|
||||
|
||||
return await db.query.officialProviderAliases.findMany({
|
||||
where: eq(officialProviderAliases.surface, input.surface),
|
||||
orderBy: [asc(officialProviderAliases.displayOrder), asc(officialProviderAliases.aliasId)],
|
||||
})
|
||||
},
|
||||
|
||||
async listAliases(surface?: OfficialCatalogSurface): Promise<OfficialProviderAliasWithRoutes[]> {
|
||||
const aliases = await db.query.officialProviderAliases.findMany({
|
||||
where: surface ? eq(officialProviderAliases.surface, surface) : undefined,
|
||||
orderBy: [asc(officialProviderAliases.displayOrder), asc(officialProviderAliases.aliasId)],
|
||||
})
|
||||
if (aliases.length === 0)
|
||||
return []
|
||||
|
||||
const routes = await db.query.officialProviderAliasRoutes.findMany({
|
||||
where: inArray(officialProviderAliasRoutes.aliasId, aliases.map(alias => alias.id)),
|
||||
orderBy: [asc(officialProviderAliasRoutes.displayOrder), asc(officialProviderAliasRoutes.routerModelId)],
|
||||
})
|
||||
return aliases.map(alias => ({
|
||||
...alias,
|
||||
routes: routes.filter(route => route.aliasId === alias.id),
|
||||
}))
|
||||
},
|
||||
|
||||
async updateAlias(id: string, input: OfficialProviderAliasUpdateInput): Promise<OfficialProviderAlias | null> {
|
||||
const [updated] = await db.update(officialProviderAliases)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(officialProviderAliases.id, id))
|
||||
.returning()
|
||||
return updated ?? null
|
||||
},
|
||||
|
||||
async updateAliasRoute(id: string, input: OfficialProviderAliasRouteUpdateInput): Promise<OfficialProviderAliasRoute | null> {
|
||||
const [updated] = await db.update(officialProviderAliasRoutes)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(officialProviderAliasRoutes.id, id))
|
||||
.returning()
|
||||
return updated ?? null
|
||||
},
|
||||
|
||||
async resolveEnabledAlias(surface: OfficialCatalogSurface, aliasId: string): Promise<OfficialProviderAliasWithRoutes> {
|
||||
const alias = await findAlias(surface, aliasId)
|
||||
if (!alias) {
|
||||
throw catalogError('Official provider alias is not configured', 'OFFICIAL_ALIAS_NOT_FOUND', { surface, aliasId })
|
||||
}
|
||||
if (!alias.enabled) {
|
||||
throw catalogError('Official provider alias is disabled', 'OFFICIAL_ALIAS_DISABLED', { surface, aliasId })
|
||||
}
|
||||
|
||||
const routes = await db.query.officialProviderAliasRoutes.findMany({
|
||||
where: and(
|
||||
eq(officialProviderAliasRoutes.aliasId, alias.id),
|
||||
eq(officialProviderAliasRoutes.enabled, true),
|
||||
),
|
||||
orderBy: [asc(officialProviderAliasRoutes.displayOrder), asc(officialProviderAliasRoutes.routerModelId)],
|
||||
})
|
||||
if (routes.length === 0) {
|
||||
throw catalogError('Official provider alias has no enabled route', 'OFFICIAL_ALIAS_ROUTE_NOT_FOUND', { surface, aliasId })
|
||||
}
|
||||
|
||||
return { ...alias, routes }
|
||||
},
|
||||
|
||||
async syncTtsModelsFromRouterConfig(input: {
|
||||
models: Record<string, OfficialTtsModelSyncInput>
|
||||
}) {
|
||||
const existingModels = await db.query.officialTtsModels.findMany()
|
||||
const existingByRouterModel = new Map(existingModels.map(model => [model.routerModelId, model]))
|
||||
const synced: OfficialTtsModel[] = []
|
||||
const now = new Date()
|
||||
|
||||
for (const [routerModelId, model] of Object.entries(input.models).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const existing = existingByRouterModel.get(routerModelId)
|
||||
if (existing) {
|
||||
const [updated] = await db.update(officialTtsModels)
|
||||
.set({
|
||||
provider: model.provider,
|
||||
lastSyncedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(officialTtsModels.id, existing.id))
|
||||
.returning()
|
||||
synced.push(updated)
|
||||
continue
|
||||
}
|
||||
|
||||
const [created] = await db.insert(officialTtsModels).values({
|
||||
routerModelId,
|
||||
provider: model.provider,
|
||||
displayName: routerModelId,
|
||||
enabled: true,
|
||||
displayOrder: nextOrder([...existingModels, ...synced]),
|
||||
lastSyncedAt: now,
|
||||
}).returning()
|
||||
synced.push(created)
|
||||
}
|
||||
|
||||
return synced
|
||||
},
|
||||
|
||||
async listTtsModels(): Promise<OfficialTtsModel[]> {
|
||||
return await db.query.officialTtsModels.findMany({
|
||||
orderBy: [asc(officialTtsModels.displayOrder), asc(officialTtsModels.routerModelId)],
|
||||
})
|
||||
},
|
||||
|
||||
async updateTtsModel(id: string, input: OfficialTtsModelUpdateInput): Promise<OfficialTtsModel | null> {
|
||||
const [updated] = await db.update(officialTtsModels)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(officialTtsModels.id, id))
|
||||
.returning()
|
||||
return updated ?? null
|
||||
},
|
||||
|
||||
async listEnabledTtsModels(): Promise<OfficialTtsModel[]> {
|
||||
return await db.query.officialTtsModels.findMany({
|
||||
where: eq(officialTtsModels.enabled, true),
|
||||
orderBy: [asc(officialTtsModels.displayOrder), asc(officialTtsModels.routerModelId)],
|
||||
})
|
||||
},
|
||||
|
||||
async assertTtsModelEnabled(routerModelId: string): Promise<OfficialTtsModel> {
|
||||
const model = await db.query.officialTtsModels.findFirst({
|
||||
where: eq(officialTtsModels.routerModelId, routerModelId),
|
||||
})
|
||||
if (!model) {
|
||||
throw catalogError('Official TTS model is not configured', 'OFFICIAL_MODEL_NOT_FOUND', { model: routerModelId })
|
||||
}
|
||||
if (!model.enabled) {
|
||||
throw catalogError('Official TTS model is disabled', 'OFFICIAL_MODEL_DISABLED', { model: routerModelId })
|
||||
}
|
||||
return model
|
||||
},
|
||||
|
||||
async syncTtsVoices(input: {
|
||||
routerModelId: string
|
||||
voices: OfficialTtsVoiceSyncInput[]
|
||||
}) {
|
||||
const model = await db.query.officialTtsModels.findFirst({
|
||||
where: eq(officialTtsModels.routerModelId, input.routerModelId),
|
||||
})
|
||||
if (!model) {
|
||||
throw catalogError('Official TTS model is not configured', 'OFFICIAL_MODEL_NOT_FOUND', { model: input.routerModelId })
|
||||
}
|
||||
const existingVoices = await db.query.officialTtsVoices.findMany({
|
||||
where: eq(officialTtsVoices.ttsModelId, model.id),
|
||||
})
|
||||
const existingByVoiceId = new Map(existingVoices.map(voice => [voice.providerVoiceId, voice]))
|
||||
const synced: OfficialTtsVoice[] = []
|
||||
const now = new Date()
|
||||
|
||||
for (const voice of input.voices) {
|
||||
const existing = existingByVoiceId.get(voice.id)
|
||||
if (existing) {
|
||||
const [updated] = await db.update(officialTtsVoices)
|
||||
.set({
|
||||
languages: voice.languages ?? existing.languages,
|
||||
labels: voice.labels ?? existing.labels,
|
||||
lastSyncedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(officialTtsVoices.id, existing.id))
|
||||
.returning()
|
||||
synced.push(updated)
|
||||
continue
|
||||
}
|
||||
|
||||
const [created] = await db.insert(officialTtsVoices).values({
|
||||
ttsModelId: model.id,
|
||||
providerVoiceId: voice.id,
|
||||
displayName: voice.name ?? voice.id,
|
||||
enabled: false,
|
||||
displayOrder: nextOrder([...existingVoices, ...synced]),
|
||||
languages: voice.languages ?? [],
|
||||
labels: voice.labels ?? {},
|
||||
previewAudioUrl: voice.previewAudioUrl ?? null,
|
||||
source: 'provider-sync',
|
||||
lastSyncedAt: now,
|
||||
}).returning()
|
||||
synced.push(created)
|
||||
}
|
||||
|
||||
return synced
|
||||
},
|
||||
|
||||
async listTtsVoices(routerModelId: string): Promise<OfficialTtsVoice[]> {
|
||||
const model = await db.query.officialTtsModels.findFirst({
|
||||
where: eq(officialTtsModels.routerModelId, routerModelId),
|
||||
})
|
||||
if (!model)
|
||||
return []
|
||||
|
||||
return await db.query.officialTtsVoices.findMany({
|
||||
where: eq(officialTtsVoices.ttsModelId, model.id),
|
||||
orderBy: [asc(officialTtsVoices.displayOrder), asc(officialTtsVoices.providerVoiceId)],
|
||||
})
|
||||
},
|
||||
|
||||
async updateTtsVoice(id: string, input: OfficialTtsVoiceUpdateInput): Promise<OfficialTtsVoice | null> {
|
||||
const [updated] = await db.update(officialTtsVoices)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(officialTtsVoices.id, id))
|
||||
.returning()
|
||||
return updated ?? null
|
||||
},
|
||||
|
||||
async listEnabledTtsVoices(routerModelId: string): Promise<OfficialTtsVoice[]> {
|
||||
const model = await this.assertTtsModelEnabled(routerModelId)
|
||||
return await db.query.officialTtsVoices.findMany({
|
||||
where: and(
|
||||
eq(officialTtsVoices.ttsModelId, model.id),
|
||||
eq(officialTtsVoices.enabled, true),
|
||||
),
|
||||
orderBy: [asc(officialTtsVoices.displayOrder), asc(officialTtsVoices.providerVoiceId)],
|
||||
})
|
||||
},
|
||||
|
||||
async assertTtsVoiceEnabled(routerModelId: string, providerVoiceId: string): Promise<OfficialTtsVoice> {
|
||||
const model = await this.assertTtsModelEnabled(routerModelId)
|
||||
const voice = await db.query.officialTtsVoices.findFirst({
|
||||
where: and(
|
||||
eq(officialTtsVoices.ttsModelId, model.id),
|
||||
eq(officialTtsVoices.providerVoiceId, providerVoiceId),
|
||||
),
|
||||
})
|
||||
if (!voice) {
|
||||
throw catalogError('Official TTS voice is not configured for this model', 'OFFICIAL_VOICE_NOT_FOUND', {
|
||||
model: routerModelId,
|
||||
voice: providerVoiceId,
|
||||
})
|
||||
}
|
||||
if (!voice.enabled) {
|
||||
throw catalogError('Official TTS voice is disabled', 'OFFICIAL_VOICE_DISABLED', {
|
||||
model: routerModelId,
|
||||
voice: providerVoiceId,
|
||||
})
|
||||
}
|
||||
return voice
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type OfficialCatalogService = ReturnType<typeof createOfficialCatalogService>
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { OfficialTtsVoice, OfficialTtsVoiceLabels, OfficialTtsVoiceLanguage } from '../../../schemas/official-catalog'
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value !== 'object' || value == null || Array.isArray(value))
|
||||
return undefined
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function asLanguageList(value: unknown): OfficialTtsVoiceLanguage[] | undefined {
|
||||
if (!Array.isArray(value))
|
||||
return undefined
|
||||
|
||||
const languages = value.flatMap((item) => {
|
||||
const record = asRecord(item)
|
||||
const code = asOptionalString(record?.code)
|
||||
if (!code)
|
||||
return []
|
||||
const title = asOptionalString(record?.title)
|
||||
return [{ code, ...(title ? { title } : {}) }]
|
||||
})
|
||||
return languages.length > 0 ? languages : undefined
|
||||
}
|
||||
|
||||
function asLabels(value: unknown): OfficialTtsVoiceLabels | undefined {
|
||||
const record = asRecord(value)
|
||||
return record ? { ...record } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a provider-specific voice object into the official catalog sync shape.
|
||||
*
|
||||
* Before:
|
||||
* - `{ id: "en-US-AvaMultilingualNeural", name: "Ava", previewUrl: "https://..." }`
|
||||
*
|
||||
* After:
|
||||
* - `{ id: "en-US-AvaMultilingualNeural", name: "Ava", previewAudioUrl: "https://..." }`
|
||||
*/
|
||||
export function normalizeProviderVoiceForCatalog(value: unknown) {
|
||||
const record = asRecord(value)
|
||||
const id = asOptionalString(record?.id)
|
||||
if (!id)
|
||||
return null
|
||||
|
||||
return {
|
||||
id,
|
||||
name: asOptionalString(record?.name),
|
||||
languages: asLanguageList(record?.languages),
|
||||
labels: asLabels(record?.labels),
|
||||
previewAudioUrl: asOptionalString(record?.previewAudioUrl) ?? asOptionalString(record?.previewUrl) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function catalogVoiceResponse(voice: OfficialTtsVoice) {
|
||||
return {
|
||||
id: voice.providerVoiceId,
|
||||
name: voice.displayName,
|
||||
languages: voice.languages,
|
||||
labels: voice.labels,
|
||||
previewAudioUrl: voice.previewAudioUrl,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { FluxMeter } from '../billing/flux-meter'
|
||||
import type { FluxService } from '../flux'
|
||||
import type { LlmRouterService } from '../llm-router'
|
||||
import type { startTtsGeneration, TtsGenerationTrace } from '../llm-tracing'
|
||||
import type { OfficialCatalogService } from '../official-catalog'
|
||||
import type { ProductEventService } from '../product-events'
|
||||
import type { RequestLogService } from '../request-log'
|
||||
import type { VoicePackService } from '../voice-packs'
|
||||
@@ -49,6 +50,7 @@ export interface OpenAiSpeechServiceDeps {
|
||||
ttsMeter: FluxMeter
|
||||
llmRouter: LlmRouterService
|
||||
voicePackService: VoicePackService
|
||||
officialCatalogService: OfficialCatalogService
|
||||
productEventService: ProductEventService
|
||||
genAi?: GenAiMetrics | null
|
||||
llmTracing: {
|
||||
@@ -106,6 +108,10 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
|
||||
if (requestModel === 'auto')
|
||||
requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL')
|
||||
const routedVoice = voicePackRequest.voice ?? requestVoice
|
||||
await deps.officialCatalogService.assertTtsModelEnabled(requestModel)
|
||||
if (!voicePackRequest.voicePackId && routedVoice)
|
||||
await deps.officialCatalogService.assertTtsVoiceEnabled(requestModel, routedVoice)
|
||||
|
||||
const voiceMetadata = ttsVoiceMetadata({
|
||||
voice: requestVoice,
|
||||
voicePackId: voicePackRequest.voicePackId,
|
||||
|
||||
@@ -23,6 +23,8 @@ 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: '/tts', icon: 'i-lucide-audio-lines', label: 'TTS' },
|
||||
{ to: '/voice-packs', icon: 'i-lucide-volume-2', label: 'Voice Packs' },
|
||||
]
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import App from './App.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'
|
||||
import VoicePacksPage from './pages/VoicePacksPage.vue'
|
||||
@@ -26,6 +28,8 @@ const router = createRouter({
|
||||
{ path: '/users', component: UsersPage },
|
||||
{ path: '/flux', component: FluxPage },
|
||||
{ path: '/llm-router', component: LlmRouterPage },
|
||||
{ path: '/providers', component: ProviderCatalogPage },
|
||||
{ path: '/tts', component: TtsCatalogPage },
|
||||
{ path: '/voice-packs', component: VoicePacksPage },
|
||||
{ path: '/voice-packs/new', name: 'voice-pack-new', component: VoicePackFormPage },
|
||||
{ path: '/voice-packs/:id/edit', name: 'voice-pack-edit', component: VoicePackFormPage },
|
||||
|
||||
@@ -239,6 +239,63 @@ export interface SpeechTestPayload {
|
||||
}
|
||||
}
|
||||
|
||||
export type OfficialCatalogSurface = 'llm' | 'asr'
|
||||
export type OfficialCatalogRoutePool = 'primary' | 'fallback'
|
||||
|
||||
export interface OfficialProviderAliasRoute {
|
||||
id: string
|
||||
aliasId: string
|
||||
routerModelId: string
|
||||
pool: OfficialCatalogRoutePool
|
||||
enabled: boolean
|
||||
weight: number
|
||||
displayOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface OfficialProviderAlias {
|
||||
id: string
|
||||
surface: OfficialCatalogSurface
|
||||
aliasId: string
|
||||
displayName: string
|
||||
enabled: boolean
|
||||
displayOrder: number
|
||||
fallbackEnabled: boolean
|
||||
loadBalancingEnabled: boolean
|
||||
routes: OfficialProviderAliasRoute[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface OfficialTtsModel {
|
||||
id: string
|
||||
routerModelId: string
|
||||
provider: string
|
||||
displayName: string
|
||||
enabled: boolean
|
||||
displayOrder: number
|
||||
lastSyncedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface OfficialTtsVoice {
|
||||
id: string
|
||||
ttsModelId: string
|
||||
providerVoiceId: string
|
||||
displayName: string
|
||||
enabled: boolean
|
||||
displayOrder: number
|
||||
languages: Array<{ code: string, title?: string }>
|
||||
labels: Record<string, unknown>
|
||||
previewAudioUrl: string | null
|
||||
source: 'provider-sync' | 'manual'
|
||||
lastSyncedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export class AdminApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -442,4 +499,47 @@ export const adminApi = {
|
||||
adminFetch<VoicePack>(`/voice-packs/${encodeURIComponent(id)}/disable`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
officialAliases: (surface?: OfficialCatalogSurface) => {
|
||||
const suffix = surface ? `?surface=${encodeURIComponent(surface)}` : ''
|
||||
return adminFetch<OfficialProviderAlias[]>(`/official-catalog/aliases${suffix}`)
|
||||
},
|
||||
syncOfficialAliases: (surface: OfficialCatalogSurface) =>
|
||||
adminFetch<{ aliases: OfficialProviderAlias[] }>('/official-catalog/aliases/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ surface }),
|
||||
}),
|
||||
updateOfficialAlias: (id: string, body: Partial<Pick<OfficialProviderAlias, 'displayName' | 'enabled' | 'displayOrder' | 'fallbackEnabled' | 'loadBalancingEnabled'>>) =>
|
||||
adminFetch<OfficialProviderAlias>(`/official-catalog/aliases/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
updateOfficialAliasRoute: (id: string, body: Partial<Pick<OfficialProviderAliasRoute, 'enabled' | 'pool' | 'weight' | 'displayOrder'>>) =>
|
||||
adminFetch<OfficialProviderAliasRoute>(`/official-catalog/alias-routes/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
officialTtsModels: () => adminFetch<OfficialTtsModel[]>('/official-catalog/tts/models'),
|
||||
syncOfficialTtsModels: () =>
|
||||
adminFetch<{ models: OfficialTtsModel[] }>('/official-catalog/tts/models/sync', {
|
||||
method: 'POST',
|
||||
}),
|
||||
updateOfficialTtsModel: (id: string, body: Partial<Pick<OfficialTtsModel, 'displayName' | 'enabled' | 'displayOrder'>>) =>
|
||||
adminFetch<OfficialTtsModel>(`/official-catalog/tts/models/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
officialTtsVoices: (model: string) => {
|
||||
const query = new URLSearchParams({ model })
|
||||
return adminFetch<OfficialTtsVoice[]>(`/official-catalog/tts/voices?${query.toString()}`)
|
||||
},
|
||||
syncOfficialTtsVoices: (routerModelId: string) =>
|
||||
adminFetch<{ voices: OfficialTtsVoice[], syncedCount: number }>('/official-catalog/tts/voices/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ routerModelId }),
|
||||
}),
|
||||
updateOfficialTtsVoice: (id: string, body: Partial<Pick<OfficialTtsVoice, 'displayName' | 'enabled' | 'displayOrder' | 'languages' | 'labels' | 'previewAudioUrl'>>) =>
|
||||
adminFetch<OfficialTtsVoice>(`/official-catalog/tts/voices/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import type { OfficialCatalogSurface, OfficialProviderAlias, OfficialProviderAliasRoute } from '../modules/api'
|
||||
|
||||
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { computed, onMounted, ref, shallowRef } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { adminApi } from '../modules/api'
|
||||
|
||||
const aliases = shallowRef<OfficialProviderAlias[]>([])
|
||||
const surface = ref<OfficialCatalogSurface>('llm')
|
||||
const loading = shallowRef(false)
|
||||
const syncing = shallowRef(false)
|
||||
|
||||
const enabledCount = computed(() => aliases.value.filter(alias => alias.enabled).length)
|
||||
const routeCount = computed(() => aliases.value.reduce((total, alias) => total + alias.routes.length, 0))
|
||||
|
||||
onMounted(() => {
|
||||
void loadAliases()
|
||||
})
|
||||
|
||||
async function loadAliases() {
|
||||
loading.value = true
|
||||
try {
|
||||
aliases.value = await adminApi.officialAliases(surface.value)
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to load provider catalog'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAliases() {
|
||||
syncing.value = true
|
||||
try {
|
||||
await adminApi.syncOfficialAliases(surface.value)
|
||||
toast.success('Provider aliases synced')
|
||||
await loadAliases()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to sync provider aliases'))
|
||||
}
|
||||
finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAlias(alias: OfficialProviderAlias, patch: Partial<Pick<OfficialProviderAlias, 'displayName' | 'enabled' | 'displayOrder' | 'fallbackEnabled' | 'loadBalancingEnabled'>>) {
|
||||
try {
|
||||
const updated = await adminApi.updateOfficialAlias(alias.id, patch)
|
||||
aliases.value = aliases.value.map(item => item.id === updated.id ? { ...item, ...updated, routes: item.routes } : item)
|
||||
toast.success('Alias updated')
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to update alias'))
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRoute(alias: OfficialProviderAlias, route: OfficialProviderAliasRoute, patch: Partial<Pick<OfficialProviderAliasRoute, 'enabled' | 'pool' | 'weight' | 'displayOrder'>>) {
|
||||
try {
|
||||
const updated = await adminApi.updateOfficialAliasRoute(route.id, patch)
|
||||
aliases.value = aliases.value.map(item => item.id === alias.id
|
||||
? { ...item, routes: item.routes.map(existing => existing.id === updated.id ? updated : existing) }
|
||||
: item)
|
||||
toast.success('Route updated')
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to update route'))
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['space-y-4']">
|
||||
<section :class="['panel', 'overflow-hidden']">
|
||||
<div :class="['flex', 'flex-col', 'gap-3', 'border-b', 'border-neutral-200', 'px-5', 'py-4', 'md:flex-row', 'md:items-center', 'md:justify-between']">
|
||||
<div>
|
||||
<h2 :class="['text-sm', 'font-semibold']">
|
||||
Provider Catalog
|
||||
</h2>
|
||||
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
|
||||
Product aliases for official LLM and ASR capabilities.
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||
<div :class="['inline-flex', 'h-8', 'overflow-hidden', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'text-sm']">
|
||||
<button
|
||||
:class="['px-3', surface === 'llm' ? 'bg-neutral-900 text-white' : 'text-neutral-600']"
|
||||
type="button"
|
||||
@click="surface = 'llm'; loadAliases()"
|
||||
>
|
||||
LLM
|
||||
</button>
|
||||
<button
|
||||
:class="['px-3', surface === 'asr' ? 'bg-neutral-900 text-white' : 'text-neutral-600']"
|
||||
type="button"
|
||||
@click="surface = 'asr'; loadAliases()"
|
||||
>
|
||||
ASR
|
||||
</button>
|
||||
</div>
|
||||
<span :class="['badge', 'badge-green']">
|
||||
<span :class="['i-lucide-check-circle-2']" />
|
||||
{{ enabledCount }} enabled
|
||||
</span>
|
||||
<span :class="['badge', 'badge-amber']">
|
||||
<span :class="['i-lucide-route']" />
|
||||
{{ routeCount }} routes
|
||||
</span>
|
||||
<Button :disabled="syncing" :icon="syncing ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-refresh-cw'" label="Sync" size="sm" variant="secondary" @click="syncAliases" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && aliases.length === 0" :class="['empty-state']">
|
||||
<span :class="['i-lucide-loader-2', 'animate-spin', 'text-2xl']" />
|
||||
Loading provider catalog
|
||||
</div>
|
||||
|
||||
<div v-else-if="aliases.length > 0" :class="['divide-y', 'divide-neutral-200']">
|
||||
<article v-for="alias in aliases" :key="alias.id" :class="['px-5', 'py-4']">
|
||||
<div :class="['grid', 'gap-3', 'lg:grid-cols-[minmax(0,1fr)_auto]']">
|
||||
<div :class="['min-w-0']">
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||
<span :class="['font-mono', 'text-xs', 'text-neutral-500']">{{ alias.aliasId }}</span>
|
||||
<input
|
||||
:class="['h-8', 'w-56', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']"
|
||||
:value="alias.displayName"
|
||||
@change="event => updateAlias(alias, { displayName: (event.target as HTMLInputElement).value })"
|
||||
>
|
||||
<span :class="['badge', alias.enabled ? 'badge-green' : 'badge-amber']">
|
||||
<span :class="[alias.enabled ? 'i-lucide-check-circle-2' : 'i-lucide-pause-circle']" />
|
||||
{{ alias.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
<div :class="['mt-2', 'text-xs', 'text-neutral-500']">
|
||||
Updated {{ formatDate(alias.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-3']">
|
||||
<label :class="['flex', 'items-center', 'gap-2', 'text-sm']">
|
||||
<input :checked="alias.enabled" type="checkbox" @change="event => updateAlias(alias, { enabled: (event.target as HTMLInputElement).checked })">
|
||||
Visible
|
||||
</label>
|
||||
<label :class="['flex', 'items-center', 'gap-2', 'text-sm']">
|
||||
<input :checked="alias.fallbackEnabled" type="checkbox" @change="event => updateAlias(alias, { fallbackEnabled: (event.target as HTMLInputElement).checked })">
|
||||
Fallback
|
||||
</label>
|
||||
<label :class="['flex', 'items-center', 'gap-2', 'text-sm']">
|
||||
<input :checked="alias.loadBalancingEnabled" type="checkbox" @change="event => updateAlias(alias, { loadBalancingEnabled: (event.target as HTMLInputElement).checked })">
|
||||
Balance
|
||||
</label>
|
||||
<input
|
||||
:class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']"
|
||||
min="0"
|
||||
type="number"
|
||||
:value="alias.displayOrder"
|
||||
@change="event => updateAlias(alias, { displayOrder: Number((event.target as HTMLInputElement).value) })"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table :class="['table', 'mt-4']">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Router model</th>
|
||||
<th>Pool</th>
|
||||
<th>Weight</th>
|
||||
<th>Order</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="route in alias.routes" :key="route.id">
|
||||
<td :class="['font-mono', 'text-xs']">
|
||||
{{ route.routerModelId }}
|
||||
</td>
|
||||
<td>
|
||||
<select :class="['h-8', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" :value="route.pool" @change="event => updateRoute(alias, route, { pool: (event.target as HTMLSelectElement).value as 'primary' | 'fallback' })">
|
||||
<option value="primary">
|
||||
primary
|
||||
</option>
|
||||
<option value="fallback">
|
||||
fallback
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input :class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" min="1" type="number" :value="route.weight" @change="event => updateRoute(alias, route, { weight: Number((event.target as HTMLInputElement).value) })">
|
||||
</td>
|
||||
<td>
|
||||
<input :class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" min="0" type="number" :value="route.displayOrder" @change="event => updateRoute(alias, route, { displayOrder: Number((event.target as HTMLInputElement).value) })">
|
||||
</td>
|
||||
<td>
|
||||
<label :class="['flex', 'items-center', 'gap-2', 'text-sm']">
|
||||
<input :checked="route.enabled" type="checkbox" @change="event => updateRoute(alias, route, { enabled: (event.target as HTMLInputElement).checked })">
|
||||
{{ route.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else :class="['empty-state']">
|
||||
<span :class="['i-lucide-route-off', 'text-2xl']" />
|
||||
No aliases configured
|
||||
<Button icon="i-lucide-refresh-cw" label="Sync aliases" size="sm" variant="secondary" @click="syncAliases" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import type { OfficialTtsModel, OfficialTtsVoice } from '../modules/api'
|
||||
|
||||
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { computed, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { adminApi } from '../modules/api'
|
||||
|
||||
const models = shallowRef<OfficialTtsModel[]>([])
|
||||
const voices = shallowRef<OfficialTtsVoice[]>([])
|
||||
const selectedModel = ref('')
|
||||
const loadingModels = shallowRef(false)
|
||||
const loadingVoices = shallowRef(false)
|
||||
const syncingModels = shallowRef(false)
|
||||
const syncingVoices = shallowRef(false)
|
||||
|
||||
const enabledModels = computed(() => models.value.filter(model => model.enabled).length)
|
||||
const enabledVoices = computed(() => voices.value.filter(voice => voice.enabled).length)
|
||||
const disabledVoices = computed(() => voices.value.length - enabledVoices.value)
|
||||
const selectedModelRow = computed(() => models.value.find(model => model.routerModelId === selectedModel.value) ?? null)
|
||||
|
||||
watch(selectedModel, () => {
|
||||
if (selectedModel.value)
|
||||
void loadVoices()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadModels()
|
||||
})
|
||||
|
||||
async function loadModels() {
|
||||
loadingModels.value = true
|
||||
try {
|
||||
models.value = await adminApi.officialTtsModels()
|
||||
if (!selectedModel.value && models.value[0])
|
||||
selectedModel.value = models.value[0].routerModelId
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to load TTS models'))
|
||||
}
|
||||
finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVoices() {
|
||||
if (!selectedModel.value)
|
||||
return
|
||||
|
||||
loadingVoices.value = true
|
||||
try {
|
||||
voices.value = await adminApi.officialTtsVoices(selectedModel.value)
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to load TTS voices'))
|
||||
}
|
||||
finally {
|
||||
loadingVoices.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncModels() {
|
||||
syncingModels.value = true
|
||||
try {
|
||||
await adminApi.syncOfficialTtsModels()
|
||||
toast.success('TTS models synced')
|
||||
await loadModels()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to sync TTS models'))
|
||||
}
|
||||
finally {
|
||||
syncingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncVoices() {
|
||||
if (!selectedModel.value)
|
||||
return
|
||||
|
||||
syncingVoices.value = true
|
||||
try {
|
||||
const result = await adminApi.syncOfficialTtsVoices(selectedModel.value)
|
||||
toast.success(`${result.syncedCount} voices synced`)
|
||||
await loadVoices()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to sync TTS voices'))
|
||||
}
|
||||
finally {
|
||||
syncingVoices.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateModel(model: OfficialTtsModel, patch: Partial<Pick<OfficialTtsModel, 'displayName' | 'enabled' | 'displayOrder'>>) {
|
||||
try {
|
||||
const updated = await adminApi.updateOfficialTtsModel(model.id, patch)
|
||||
models.value = models.value.map(item => item.id === updated.id ? updated : item)
|
||||
toast.success('TTS model updated')
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to update TTS model'))
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVoice(voice: OfficialTtsVoice, patch: Partial<Pick<OfficialTtsVoice, 'displayName' | 'enabled' | 'displayOrder' | 'previewAudioUrl'>>) {
|
||||
try {
|
||||
const updated = await adminApi.updateOfficialTtsVoice(voice.id, patch)
|
||||
voices.value = voices.value.map(item => item.id === updated.id ? updated : item)
|
||||
toast.success('TTS voice updated')
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to update TTS voice'))
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value)
|
||||
return 'Never'
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
|
||||
}
|
||||
|
||||
function languageSummary(voice: OfficialTtsVoice): string {
|
||||
if (!voice.languages.length)
|
||||
return 'Not set'
|
||||
return voice.languages.map(language => language.title ?? language.code).join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['grid', 'gap-4', 'xl:grid-cols-[360px_minmax(0,1fr)]']">
|
||||
<section :class="['panel', 'overflow-hidden']">
|
||||
<div :class="['flex', 'items-center', 'justify-between', 'gap-3', 'border-b', 'border-neutral-200', 'px-5', 'py-4']">
|
||||
<div>
|
||||
<h2 :class="['text-sm', 'font-semibold']">
|
||||
TTS Models
|
||||
</h2>
|
||||
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
|
||||
Official speech models visible to clients.
|
||||
</p>
|
||||
</div>
|
||||
<Button :disabled="syncingModels" :icon="syncingModels ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-refresh-cw'" label="Sync" size="sm" variant="secondary" @click="syncModels" />
|
||||
</div>
|
||||
|
||||
<div :class="['flex', 'gap-2', 'border-b', 'border-neutral-200', 'px-5', 'py-3']">
|
||||
<span :class="['badge', 'badge-green']">
|
||||
<span :class="['i-lucide-check-circle-2']" />
|
||||
{{ enabledModels }} enabled
|
||||
</span>
|
||||
<span :class="['badge', 'badge-amber']">
|
||||
<span :class="['i-lucide-volume-2']" />
|
||||
{{ models.length }} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingModels && models.length === 0" :class="['empty-state']">
|
||||
<span :class="['i-lucide-loader-2', 'animate-spin', 'text-2xl']" />
|
||||
Loading TTS models
|
||||
</div>
|
||||
|
||||
<div v-else-if="models.length > 0" :class="['divide-y', 'divide-neutral-200']">
|
||||
<button
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:class="['w-full', 'px-5', 'py-4', 'text-left', 'transition-colors', selectedModel === model.routerModelId ? 'bg-neutral-100' : 'hover:bg-neutral-50']"
|
||||
type="button"
|
||||
@click="selectedModel = model.routerModelId"
|
||||
>
|
||||
<div :class="['flex', 'items-center', 'justify-between', 'gap-3']">
|
||||
<div :class="['min-w-0']">
|
||||
<div :class="['truncate', 'text-sm', 'font-medium']">
|
||||
{{ model.displayName }}
|
||||
</div>
|
||||
<div :class="['mt-1', 'truncate', 'font-mono', 'text-xs', 'text-neutral-500']">
|
||||
{{ model.routerModelId }}
|
||||
</div>
|
||||
</div>
|
||||
<span :class="['badge', model.enabled ? 'badge-green' : 'badge-amber']">
|
||||
{{ model.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
<div :class="['mt-3', 'grid', 'grid-cols-[1fr_auto]', 'gap-2']">
|
||||
<input :class="['h-8', 'min-w-0', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" :value="model.displayName" @change="event => updateModel(model, { displayName: (event.target as HTMLInputElement).value })">
|
||||
<input :class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" min="0" type="number" :value="model.displayOrder" @change="event => updateModel(model, { displayOrder: Number((event.target as HTMLInputElement).value) })">
|
||||
</div>
|
||||
<label :class="['mt-3', 'flex', 'items-center', 'gap-2', 'text-sm']">
|
||||
<input :checked="model.enabled" type="checkbox" @change="event => updateModel(model, { enabled: (event.target as HTMLInputElement).checked })">
|
||||
Visible to clients
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else :class="['empty-state']">
|
||||
<span :class="['i-lucide-volume-x', 'text-2xl']" />
|
||||
No TTS models synced
|
||||
<Button icon="i-lucide-refresh-cw" label="Sync models" size="sm" variant="secondary" @click="syncModels" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :class="['panel', 'overflow-hidden']">
|
||||
<div :class="['flex', 'flex-col', 'gap-3', 'border-b', 'border-neutral-200', 'px-5', 'py-4', 'md:flex-row', 'md:items-center', 'md:justify-between']">
|
||||
<div>
|
||||
<h2 :class="['text-sm', 'font-semibold']">
|
||||
TTS Voices
|
||||
</h2>
|
||||
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
|
||||
{{ selectedModelRow ? selectedModelRow.routerModelId : 'Select a model to manage voices.' }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||
<select v-model="selectedModel" :class="['h-8', 'max-w-72', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']">
|
||||
<option v-for="model in models" :key="model.id" :value="model.routerModelId">
|
||||
{{ model.displayName }}
|
||||
</option>
|
||||
</select>
|
||||
<span :class="['badge', 'badge-green']">
|
||||
{{ enabledVoices }} enabled
|
||||
</span>
|
||||
<span :class="['badge', disabledVoices > 0 ? 'badge-amber' : 'badge-green']">
|
||||
{{ disabledVoices }} disabled
|
||||
</span>
|
||||
<Button :disabled="!selectedModel || syncingVoices" :icon="syncingVoices ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-download'" label="Pull voices" size="sm" variant="secondary" @click="syncVoices" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingVoices && voices.length === 0" :class="['empty-state']">
|
||||
<span :class="['i-lucide-loader-2', 'animate-spin', 'text-2xl']" />
|
||||
Loading TTS voices
|
||||
</div>
|
||||
|
||||
<table v-else-if="voices.length > 0" :class="['table']">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voice</th>
|
||||
<th>Languages</th>
|
||||
<th>Preview</th>
|
||||
<th>Order</th>
|
||||
<th>Status</th>
|
||||
<th>Synced</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="voice in voices" :key="voice.id">
|
||||
<td>
|
||||
<input :class="['h-8', 'w-full', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" :value="voice.displayName" @change="event => updateVoice(voice, { displayName: (event.target as HTMLInputElement).value })">
|
||||
<div :class="['mt-1', 'font-mono', 'text-xs', 'text-neutral-500']">
|
||||
{{ voice.providerVoiceId }}
|
||||
</div>
|
||||
</td>
|
||||
<td :class="['max-w-56', 'text-xs', 'text-neutral-600']">
|
||||
{{ languageSummary(voice) }}
|
||||
</td>
|
||||
<td>
|
||||
<input :class="['h-8', 'w-full', 'min-w-48', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" :value="voice.previewAudioUrl ?? ''" placeholder="https://..." @change="event => updateVoice(voice, { previewAudioUrl: (event.target as HTMLInputElement).value || null })">
|
||||
</td>
|
||||
<td>
|
||||
<input :class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" min="0" type="number" :value="voice.displayOrder" @change="event => updateVoice(voice, { displayOrder: Number((event.target as HTMLInputElement).value) })">
|
||||
</td>
|
||||
<td>
|
||||
<label :class="['flex', 'items-center', 'gap-2', 'text-sm']">
|
||||
<input :checked="voice.enabled" type="checkbox" @change="event => updateVoice(voice, { enabled: (event.target as HTMLInputElement).checked })">
|
||||
{{ voice.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</label>
|
||||
</td>
|
||||
<td :class="['text-xs', 'text-neutral-500']">
|
||||
{{ formatDate(voice.lastSyncedAt) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else :class="['empty-state']">
|
||||
<span :class="['i-lucide-mic-off', 'text-2xl']" />
|
||||
No voices synced for this model
|
||||
<Button :disabled="!selectedModel" icon="i-lucide-download" label="Pull provider voices" size="sm" variant="secondary" @click="syncVoices" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
# Official Provider Catalog Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build an official catalog layer that controls AIRI's official LLM aliases, TTS models, TTS voices, and ASR aliases for both public listing and gateway request authorization.
|
||||
|
||||
**Architecture:** Add database-backed catalog tables and a focused `official-catalog` domain service. Public routes and gateway operations read the same service so UI visibility and handwritten request authorization cannot drift. Admin UI writes catalog state; `LLM_ROUTER_CONFIG` remains the source for real provider/key routing.
|
||||
|
||||
**Tech Stack:** TypeScript, Hono, Drizzle ORM, Valibot, Vue 3 `<script setup>`, Pinia-light admin API module, Vitest, pnpm workspace filters.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Do not create commits during implementation.
|
||||
- Preserve existing dirty user changes.
|
||||
- Use `@moeru/std` / existing error helpers for structured errors.
|
||||
- Public listing and request execution must both enforce catalog `enabled` state.
|
||||
- Existing router config and encrypted keys remain separate from catalog.
|
||||
- Existing configured LLM/TTS/ASR models synced from runtime config default to enabled.
|
||||
- TTS voices synced from providers default to disabled.
|
||||
- Voice preview generation is out of scope for v1; store only provider/manual preview URLs.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Catalog Schema and Service
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/server/src/schemas/official-catalog.ts`
|
||||
- Modify: `apps/server/src/schemas/index.ts`
|
||||
- Create: `apps/server/src/services/domain/official-catalog/index.ts`
|
||||
- Test: `apps/server/src/services/domain/official-catalog/index.test.ts`
|
||||
- Create: `apps/server/drizzle/0016_official_provider_catalog.sql`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `createOfficialCatalogService(db, deps)` with methods:
|
||||
- `syncAliasesFromRouterConfig(input: { surface: 'llm' | 'asr', modelIds: string[] }): Promise<OfficialProviderAlias[]>`
|
||||
- `syncTtsModelsFromRouterConfig(input: { models: Record<string, { provider: string }> }): Promise<OfficialTtsModel[]>`
|
||||
- `syncTtsVoices(input: { routerModelId: string, voices: OfficialTtsVoiceSyncInput[] }): Promise<OfficialTtsVoice[]>`
|
||||
- `listEnabledTtsModels(): Promise<OfficialTtsModel[]>`
|
||||
- `listEnabledTtsVoices(routerModelId: string): Promise<OfficialTtsVoice[]>`
|
||||
- `resolveEnabledAlias(surface, aliasId): Promise<OfficialProviderAliasWithRoutes>`
|
||||
- `assertTtsModelEnabled(routerModelId): Promise<OfficialTtsModel>`
|
||||
- `assertTtsVoiceEnabled(routerModelId, providerVoiceId): Promise<OfficialTtsVoice>`
|
||||
|
||||
- [ ] Write service tests first for sync defaults, repeated sync preservation, enabled listing, alias lookup, and TTS voice gate errors.
|
||||
- [ ] Add schema tables for aliases, alias routes, TTS models, and TTS voices.
|
||||
- [ ] Implement service with explicit methods instead of leaking Drizzle query details into routes.
|
||||
- [ ] Add manual migration SQL matching the schema.
|
||||
- [ ] Run `pnpm exec vitest run apps/server/src/services/domain/official-catalog/index.test.ts`.
|
||||
|
||||
### Task 2: Public TTS Listing and Request Gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/routes/openai/v1/types.ts`
|
||||
- Modify: `apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts`
|
||||
- Modify: `apps/server/src/routes/openai/v1/operations/speech-generation/index.ts`
|
||||
- Modify: `apps/server/src/app.ts`
|
||||
- Test: `apps/server/src/routes/openai/v1/route.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `OfficialCatalogService`
|
||||
- Produces: public TTS model and voice lists filtered by enabled catalog rows.
|
||||
|
||||
- [ ] Add failing route tests: disabled model hidden, disabled voice hidden, disabled model rejected in speech generation, disabled voice rejected in speech generation.
|
||||
- [ ] Inject `officialCatalogService` into `V1RouteDeps`.
|
||||
- [ ] In `listSpeechModels`, sync runtime TTS models then return enabled catalog rows in display order.
|
||||
- [ ] In `listVoices`, fetch provider voices for sync, sync them as disabled-by-default, then return only enabled catalog voices while preserving `recommended`.
|
||||
- [ ] In `speechGeneration`, validate model and voice against catalog after resolving `auto`.
|
||||
- [ ] Run focused server route tests.
|
||||
|
||||
### Task 3: LLM Alias Gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/routes/openai/v1/operations/chat-completions/index.ts`
|
||||
- Test: `apps/server/src/routes/openai/v1/route.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `officialCatalogService.resolveEnabledAlias('llm', aliasId)`
|
||||
- Produces: chat requests use client-visible alias and route to enabled alias primary target.
|
||||
|
||||
- [ ] Add failing tests: `auto` alias disabled rejects; missing alias rejects; enabled `auto` resolves to a real router model.
|
||||
- [ ] Keep v1 client-visible model as alias (`auto` by default).
|
||||
- [ ] Resolve alias before billing telemetry uses the real router model, while product analytics may retain alias in metadata.
|
||||
- [ ] Run focused route tests.
|
||||
|
||||
### Task 4: Admin API
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/server/src/routes/admin/official-catalog/index.ts`
|
||||
- Create: `apps/server/src/routes/admin/official-catalog/route.test.ts`
|
||||
- Modify: `apps/server/src/app.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `OfficialCatalogService`, `LlmRouterService`, `ConfigKVService`
|
||||
- Produces:
|
||||
- `GET /api/admin/official-catalog/aliases`
|
||||
- `POST /api/admin/official-catalog/aliases/sync`
|
||||
- `PATCH /api/admin/official-catalog/aliases/:id`
|
||||
- `PATCH /api/admin/official-catalog/aliases/:id/routes`
|
||||
- `GET /api/admin/official-catalog/tts/models`
|
||||
- `POST /api/admin/official-catalog/tts/models/sync`
|
||||
- `PATCH /api/admin/official-catalog/tts/models/:id`
|
||||
- `GET /api/admin/official-catalog/tts/models/:id/voices`
|
||||
- `POST /api/admin/official-catalog/tts/models/:id/voices/sync`
|
||||
- `PATCH /api/admin/official-catalog/tts/voices/:id`
|
||||
|
||||
- [ ] Add auth/admin guard tests following existing voice-pack route tests.
|
||||
- [ ] Implement Valibot schemas for patch bodies.
|
||||
- [ ] Implement sync endpoints from runtime config and provider voice catalog.
|
||||
- [ ] Mount route under `/api/admin/official-catalog`.
|
||||
- [ ] Run admin route tests.
|
||||
|
||||
### Task 5: Admin API Client and Forms
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/ui-admin/src/modules/api.ts`
|
||||
- Create: `apps/ui-admin/src/pages/ProvidersPage.vue`
|
||||
- Create: `apps/ui-admin/src/pages/TtsCatalogPage.vue`
|
||||
- Modify: `apps/ui-admin/src/main.ts`
|
||||
- Modify: `apps/ui-admin/src/App.vue`
|
||||
- Test: `apps/ui-admin/src/pages/ProvidersPage.test.ts`
|
||||
- Test: `apps/ui-admin/src/pages/TtsCatalogPage.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: admin official catalog endpoints.
|
||||
- Produces: operator can sync aliases/models/voices, toggle enabled state, edit names/order/preview URLs.
|
||||
|
||||
- [ ] Add TypeScript interfaces and admin API methods.
|
||||
- [ ] Add Providers page for v1 `auto` alias and route pool visibility.
|
||||
- [ ] Add TTS page for model list and selected model voices.
|
||||
- [ ] Add sidebar nav entries: Providers and TTS; keep Voice Packs separate.
|
||||
- [ ] Add focused Vue tests around sync and toggle calls.
|
||||
- [ ] Run `pnpm exec vitest run apps/ui-admin/src/pages/ProvidersPage.test.ts apps/ui-admin/src/pages/TtsCatalogPage.test.ts`.
|
||||
|
||||
### Task 6: Voice Pack Candidate Filtering
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/ui-admin/src/pages/VoicePackFormPage.vue`
|
||||
- Test: `apps/ui-admin/src/pages/VoicePackFormPage.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: enabled official TTS models and voices from public/admin catalog.
|
||||
- Produces: Voice Pack admin cannot create new packs from disabled catalog rows.
|
||||
|
||||
- [ ] Add tests showing disabled voices are not offered as candidates.
|
||||
- [ ] Update catalog loading to use filtered public endpoints or admin enabled list.
|
||||
- [ ] Keep editing existing packs resilient if a historical voice was later disabled.
|
||||
- [ ] Run focused Voice Pack tests.
|
||||
|
||||
### Task 7: Verification Sweep
|
||||
|
||||
**Files:**
|
||||
- All touched files.
|
||||
|
||||
- [ ] Run server focused tests:
|
||||
`pnpm exec vitest run apps/server/src/services/domain/official-catalog/index.test.ts apps/server/src/routes/admin/official-catalog/route.test.ts apps/server/src/routes/openai/v1/route.test.ts`
|
||||
- [ ] Run admin focused tests:
|
||||
`pnpm exec vitest run apps/ui-admin/src/pages/ProvidersPage.test.ts apps/ui-admin/src/pages/TtsCatalogPage.test.ts apps/ui-admin/src/pages/VoicePackFormPage.test.ts`
|
||||
- [ ] Run typechecks:
|
||||
`pnpm -F @proj-airi/server typecheck`
|
||||
`pnpm -F @proj-airi/ui-admin typecheck`
|
||||
- [ ] Run targeted eslint on changed files.
|
||||
- [ ] Report any unrelated pre-existing failures separately.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: LLM alias, TTS catalog, ASR-compatible alias structure, admin pages, strict request gate, sync defaults, and v2 preview generation deferral are represented.
|
||||
- Placeholder scan: no TBD/TODO implementation placeholders are required by this plan; task details use exact paths and behavior.
|
||||
- Type consistency: service names and route dependencies are consistent across tasks.
|
||||
@@ -0,0 +1,303 @@
|
||||
<title>AIRI Official Provider Catalog PRD</title>
|
||||
|
||||
<h1>背景</h1>
|
||||
<p>AIRI 现在的官方 LLM / TTS / ASR 能力主要由运行时路由配置驱动。LLM 和 ASR 对客户端基本表现为 <code>auto</code>,TTS 模型和声线则来自 <code>LLM_ROUTER_CONFIG</code>、provider catalog 和 <code>DEFAULT_TTS_VOICES</code> 推荐配置。</p>
|
||||
<p>这套机制能跑通请求,但缺少一个产品层的官方能力目录。管理员不能统一控制主站展示哪些官方模型、声线是否启用、展示顺序,也不能阻止用户通过手写请求绕过前端 UI 直接调用底层 provider/model/voice。</p>
|
||||
|
||||
<callout emoji="✅" background-color="light-green" border-color="green">
|
||||
<p><b>核心结论:</b>新增独立的 Official Provider Catalog。主站展示和 gateway 请求都必须经过 catalog 白名单。用户选择的是产品能力 alias 或 Voice Pack,不是底层供应商细节。</p>
|
||||
</callout>
|
||||
|
||||
<h1>目标</h1>
|
||||
<ul>
|
||||
<li>在 admin 面板新增官方 Provider / TTS 管理能力。</li>
|
||||
<li>让主站官方 LLM、TTS、ASR 展示内容由 catalog 控制,而不是直接暴露底层 router 配置。</li>
|
||||
<li>支持 LLM alias,例如 v1 只开放 <code>auto</code>,后续可扩展 <code>fast</code>、<code>reasoning</code>、<code>deepseek</code>。</li>
|
||||
<li>支持 TTS model 和 voice 的启用、禁用、排序、展示名、语言、标签和预览 URL 管理。</li>
|
||||
<li>支持一键拉取 TTS provider 声线;新拉取声线默认禁用,需要管理员手动启用。</li>
|
||||
<li>请求进入 gateway 前二次校验 catalog。禁用或不存在的 alias/model/voice 必须报错,不能直通底层 provider。</li>
|
||||
</ul>
|
||||
|
||||
<h1>非目标</h1>
|
||||
<ul>
|
||||
<li>一期不做声线预览音频自动生成。</li>
|
||||
<li>一期不新增对象存储或 CDN adapter。</li>
|
||||
<li>一期不做人群灰度、租户级配置或 A/B 实验。</li>
|
||||
<li>一期不重做现有 LLM Router 的密钥加密、fallback 真实执行逻辑。</li>
|
||||
<li>一期不把 Voice Pack 替换成 raw voice 选择;Voice Pack 仍是面向用户的 TTS 产品能力抽象。</li>
|
||||
</ul>
|
||||
|
||||
<h1>产品原则</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th background-color="light-gray">原则</th>
|
||||
<th background-color="light-gray">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>用户选产品能力</td>
|
||||
<td>客户端看到 <code>auto</code>、未来的 <code>fast</code> / <code>reasoning</code>、Voice Pack,而不是真实 provider/model/key。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Catalog 是展示白名单</td>
|
||||
<td>主站只展示 catalog 中 enabled 的 alias/model/voice。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Catalog 也是请求白名单</td>
|
||||
<td>用户绕过 UI 手写 disabled 或不存在的 model/voice,请求必须失败。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>路由配置不等于产品目录</td>
|
||||
<td><code>LLM_ROUTER_CONFIG</code> 负责真实路由和 key,Official Catalog 负责产品可见性、排序和 alias。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h1>用户角色</h1>
|
||||
<ul>
|
||||
<li><b>管理员:</b>配置官方能力、启停模型和声线、同步 provider 声线、调整展示顺序。</li>
|
||||
<li><b>普通用户:</b>在主站选择可用的官方能力,不需要理解 provider、model、voice 的真实路由。</li>
|
||||
<li><b>系统:</b>在展示和请求执行前读取 catalog,保证禁用项不可见且不可调用。</li>
|
||||
</ul>
|
||||
|
||||
<h1>一期范围</h1>
|
||||
<h2>LLM Alias</h2>
|
||||
<p>v1 只开放一个默认 alias:<code>auto</code>。表和 API 按多 alias 设计,后续可以扩展更多产品能力。</p>
|
||||
<ul>
|
||||
<li>alias 有 <code>enabled</code> 状态。禁用后客户端不展示,请求也不能使用。</li>
|
||||
<li>alias 可配置展示名和排序。</li>
|
||||
<li>alias 下配置 primary pool 和 fallback pool。</li>
|
||||
<li>alias 支持 fallback 开关和负载均衡开关。</li>
|
||||
<li>真实候选 provider/model 从 <code>LLM_ROUTER_CONFIG.llm.models</code> 同步进管理候选池。</li>
|
||||
</ul>
|
||||
|
||||
<h2>TTS Model 和 Voice</h2>
|
||||
<ul>
|
||||
<li>TTS 单独开 admin 页面,和 Voice Packs 并列。</li>
|
||||
<li>TTS model 从 <code>LLM_ROUTER_CONFIG.tts.models</code> 同步。</li>
|
||||
<li>现有运行配置同步出的 TTS model 默认启用,避免上线后突然不可用。</li>
|
||||
<li>管理员可以启用、禁用、排序、重命名 TTS model。</li>
|
||||
<li>每个 TTS model 下管理 voice catalog。</li>
|
||||
<li>支持一键从对应 provider 拉取声线。</li>
|
||||
<li>新拉取声线默认禁用。</li>
|
||||
<li>voice 支持展示名、语言、标签、排序、预览 URL。</li>
|
||||
<li>主站 <code>/api/v1/audio/voices</code> 只返回 enabled voices。</li>
|
||||
</ul>
|
||||
|
||||
<h2>ASR</h2>
|
||||
<ul>
|
||||
<li>ASR 不直接暴露底层 provider 细节。</li>
|
||||
<li>v1 可以只保留 <code>auto</code>。</li>
|
||||
<li>真实候选从 <code>LLM_ROUTER_CONFIG.asr.models</code> 同步。</li>
|
||||
<li>请求 ASR 前校验 alias/model 是否启用。</li>
|
||||
</ul>
|
||||
|
||||
<h1>Admin 信息架构</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th background-color="light-gray">菜单</th>
|
||||
<th background-color="light-gray">用途</th>
|
||||
<th background-color="light-gray">一期能力</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Providers</td>
|
||||
<td>管理 LLM / ASR alias。</td>
|
||||
<td>查看和编辑 <code>auto</code>;同步真实 router model;配置 primary/fallback pool。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>TTS</td>
|
||||
<td>管理官方 TTS model 和 voice catalog。</td>
|
||||
<td>同步模型、拉取声线、启停、排序、编辑显示信息。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Voice Packs</td>
|
||||
<td>管理面向用户的 TTS 产品预设。</td>
|
||||
<td>继续保留现有页面,但候选 model/voice 应来自 enabled catalog。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>LLM Router</td>
|
||||
<td>管理真实路由、key、fallback 底层配置。</td>
|
||||
<td>继续负责真实 provider/model/key 写入,不负责主站展示白名单。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h1>数据模型</h1>
|
||||
<p>具体表名实现时可按 repo 命名规范调整,但职责边界保持如下。</p>
|
||||
|
||||
<h2><code>official_provider_aliases</code></h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th background-color="light-gray">字段</th>
|
||||
<th background-color="light-gray">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>id</code></td><td>主键。</td></tr>
|
||||
<tr><td><code>surface</code></td><td><code>llm</code> 或 <code>asr</code>。</td></tr>
|
||||
<tr><td><code>alias_id</code></td><td>客户端可见 alias,例如 <code>auto</code>。</td></tr>
|
||||
<tr><td><code>display_name</code></td><td>展示名称。</td></tr>
|
||||
<tr><td><code>enabled</code></td><td>是否展示和允许请求。</td></tr>
|
||||
<tr><td><code>display_order</code></td><td>展示排序。</td></tr>
|
||||
<tr><td><code>fallback_enabled</code></td><td>是否启用 fallback pool。</td></tr>
|
||||
<tr><td><code>load_balancing_enabled</code></td><td>是否启用 primary pool 负载均衡。</td></tr>
|
||||
<tr><td><code>created_at / updated_at</code></td><td>创建和更新时间。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><code>official_provider_alias_routes</code></h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th background-color="light-gray">字段</th>
|
||||
<th background-color="light-gray">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>alias_id</code></td><td>关联 alias。</td></tr>
|
||||
<tr><td><code>router_model_id</code></td><td>真实 <code>LLM_ROUTER_CONFIG</code> model key。</td></tr>
|
||||
<tr><td><code>pool</code></td><td><code>primary</code> 或 <code>fallback</code>。</td></tr>
|
||||
<tr><td><code>enabled</code></td><td>该真实候选是否参与路由。</td></tr>
|
||||
<tr><td><code>weight</code></td><td>负载均衡权重,v1 可先保留默认值。</td></tr>
|
||||
<tr><td><code>display_order</code></td><td>管理面排序。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><code>official_tts_models</code></h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th background-color="light-gray">字段</th>
|
||||
<th background-color="light-gray">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>id</code></td><td>主键。</td></tr>
|
||||
<tr><td><code>router_model_id</code></td><td>真实 TTS model key,例如 <code>alibaba/cosyvoice-v2</code>。</td></tr>
|
||||
<tr><td><code>provider</code></td><td>底层 provider,例如 <code>dashscope-cosyvoice</code>、<code>azure</code>、<code>stepfun</code>。</td></tr>
|
||||
<tr><td><code>display_name</code></td><td>展示名。</td></tr>
|
||||
<tr><td><code>enabled</code></td><td>是否展示和允许请求。</td></tr>
|
||||
<tr><td><code>display_order</code></td><td>展示排序。</td></tr>
|
||||
<tr><td><code>last_synced_at</code></td><td>最近一次从 router config 同步时间。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><code>official_tts_voices</code></h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th background-color="light-gray">字段</th>
|
||||
<th background-color="light-gray">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>id</code></td><td>主键。</td></tr>
|
||||
<tr><td><code>tts_model_id</code></td><td>关联 <code>official_tts_models</code>。</td></tr>
|
||||
<tr><td><code>provider_voice_id</code></td><td>provider 返回的真实 voice id。</td></tr>
|
||||
<tr><td><code>display_name</code></td><td>展示名。</td></tr>
|
||||
<tr><td><code>enabled</code></td><td>是否展示和允许请求。</td></tr>
|
||||
<tr><td><code>display_order</code></td><td>展示排序。</td></tr>
|
||||
<tr><td><code>languages</code></td><td>语言列表,JSON。</td></tr>
|
||||
<tr><td><code>labels</code></td><td>provider labels,JSON。</td></tr>
|
||||
<tr><td><code>preview_audio_url</code></td><td>provider 返回或 admin 手动填写的预览 URL。</td></tr>
|
||||
<tr><td><code>source</code></td><td><code>provider-sync</code> 或 <code>manual</code>。</td></tr>
|
||||
<tr><td><code>last_synced_at</code></td><td>最近一次拉取声线时间。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h1>Admin API</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th background-color="light-gray">接口</th>
|
||||
<th background-color="light-gray">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>GET /api/admin/official-catalog/aliases</code></td><td>列出 LLM / ASR aliases。</td></tr>
|
||||
<tr><td><code>POST /api/admin/official-catalog/aliases/sync</code></td><td>从 <code>LLM_ROUTER_CONFIG</code> 同步真实候选。</td></tr>
|
||||
<tr><td><code>PATCH /api/admin/official-catalog/aliases/:id</code></td><td>更新 alias 展示、启用、fallback、负载均衡设置。</td></tr>
|
||||
<tr><td><code>PATCH /api/admin/official-catalog/aliases/:id/routes</code></td><td>更新 alias primary/fallback pool。</td></tr>
|
||||
<tr><td><code>GET /api/admin/official-catalog/tts/models</code></td><td>列出 TTS models。</td></tr>
|
||||
<tr><td><code>POST /api/admin/official-catalog/tts/models/sync</code></td><td>从 router config 同步 TTS models。</td></tr>
|
||||
<tr><td><code>PATCH /api/admin/official-catalog/tts/models/:id</code></td><td>更新 TTS model 启用、排序和展示名。</td></tr>
|
||||
<tr><td><code>GET /api/admin/official-catalog/tts/models/:id/voices</code></td><td>列出某个 model 下的 voices。</td></tr>
|
||||
<tr><td><code>POST /api/admin/official-catalog/tts/models/:id/voices/sync</code></td><td>从 provider 拉取 voices,新 voice 默认禁用。</td></tr>
|
||||
<tr><td><code>PATCH /api/admin/official-catalog/tts/voices/:id</code></td><td>更新 voice 启用、排序、展示名、语言、标签和预览 URL。</td></tr>
|
||||
<tr><td><code>POST /api/admin/official-catalog/tts/voices/bulk</code></td><td>批量启用、禁用或排序 voices。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h1>Public API 调整</h1>
|
||||
<ul>
|
||||
<li><code>GET /api/v1/audio/models</code>:只返回 enabled TTS models,按 admin 排序。</li>
|
||||
<li><code>GET /api/v1/audio/voices?model=...</code>:只返回该 model 下 enabled voices。</li>
|
||||
<li><code>POST /api/v1/openai/chat/completions</code>:先把请求 model 当 alias 校验和解析。disabled / missing alias 直接报错。</li>
|
||||
<li><code>POST /api/v1/audio/speech</code>:校验 TTS model enabled,再校验 voice 属于该 model 且 enabled。</li>
|
||||
<li>ASR route:校验 ASR alias/model enabled 后再进入真实转写链路。</li>
|
||||
</ul>
|
||||
|
||||
<h1>Gateway 校验规则</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th background-color="light-gray">场景</th>
|
||||
<th background-color="light-gray">处理</th>
|
||||
<th background-color="light-gray">错误码</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>LLM alias 不存在</td><td>拒绝请求。</td><td><code>OFFICIAL_ALIAS_NOT_FOUND</code></td></tr>
|
||||
<tr><td>LLM alias 禁用</td><td>拒绝请求。</td><td><code>OFFICIAL_ALIAS_DISABLED</code></td></tr>
|
||||
<tr><td>TTS model 不存在或未同步</td><td>拒绝请求。</td><td><code>OFFICIAL_MODEL_NOT_FOUND</code></td></tr>
|
||||
<tr><td>TTS model 禁用</td><td>拒绝请求。</td><td><code>OFFICIAL_MODEL_DISABLED</code></td></tr>
|
||||
<tr><td>TTS voice 不存在于该 model</td><td>拒绝请求。</td><td><code>OFFICIAL_VOICE_NOT_FOUND</code></td></tr>
|
||||
<tr><td>TTS voice 禁用</td><td>拒绝请求。</td><td><code>OFFICIAL_VOICE_DISABLED</code></td></tr>
|
||||
<tr><td>ASR alias/model 禁用</td><td>拒绝请求。</td><td><code>OFFICIAL_ALIAS_DISABLED</code> 或 <code>OFFICIAL_MODEL_DISABLED</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h1>同步策略</h1>
|
||||
<ul>
|
||||
<li>现有运行配置同步出的 LLM/TTS/ASR model 默认启用,避免上线后把已有能力突然关闭。</li>
|
||||
<li>TTS 声线拉取后默认禁用,必须管理员手动启用。</li>
|
||||
<li>重复同步时保留管理员已经改过的 <code>enabled</code>、<code>display_order</code>、<code>display_name</code>、<code>preview_audio_url</code>。</li>
|
||||
<li>provider 不再返回的旧 voice 不自动删除,可标记为 stale 或保留 <code>last_synced_at</code> 供 admin 判断。</li>
|
||||
<li>同步失败必须展示明确错误,不写入半成品批次。</li>
|
||||
</ul>
|
||||
|
||||
<h1>验收标准</h1>
|
||||
<checkbox done="false">Admin 可以看到 Providers 页面,默认存在 enabled 的 LLM <code>auto</code> alias。</checkbox>
|
||||
<checkbox done="false">Admin 可以同步 LLM / ASR router model 候选,并配置 alias primary/fallback pool。</checkbox>
|
||||
<checkbox done="false">Admin 可以看到 TTS 页面,能同步 TTS models。</checkbox>
|
||||
<checkbox done="false">Admin 可以对某个 TTS model 一键拉取 voices;新 voice 默认 disabled。</checkbox>
|
||||
<checkbox done="false">Admin 启用 voice 后,主站 <code>/api/v1/audio/voices</code> 才返回该 voice。</checkbox>
|
||||
<checkbox done="false">禁用 TTS model 后,主站模型列表不展示,请求该 model 报错。</checkbox>
|
||||
<checkbox done="false">禁用 TTS voice 后,主站声线列表不展示,请求该 voice 报错。</checkbox>
|
||||
<checkbox done="false">禁用 LLM alias 后,客户端不展示,请求该 alias 报错。</checkbox>
|
||||
<checkbox done="false">Voice Pack 创建/编辑页面的候选 model 和 voice 不包含 disabled catalog 项。</checkbox>
|
||||
<checkbox done="false">保留现有 router config 写入和 key 加密逻辑,不把密钥暴露给 catalog API 或 admin UI。</checkbox>
|
||||
|
||||
<h1>二期</h1>
|
||||
<ul>
|
||||
<li>一键生成缺失 voice preview 音频。</li>
|
||||
<li>新增对象存储/CDN adapter,保存生成的预览音频。</li>
|
||||
<li>alias 灰度开放和人群分组。</li>
|
||||
<li>更完整的 alias 权重负载均衡 UI。</li>
|
||||
<li>Catalog 变更审计日志。</li>
|
||||
<li>provider 质量、成本、延迟指标回显。</li>
|
||||
</ul>
|
||||
|
||||
<h1>实现提示</h1>
|
||||
<ul>
|
||||
<li>Catalog service 应独立于 LLM Router service。Router 负责真实转发,Catalog 负责产品白名单和 alias 解析。</li>
|
||||
<li>请求校验要放在 server 侧,不只靠 admin 或主站 UI。</li>
|
||||
<li>Public catalog endpoint 和 gateway 校验应复用同一个 domain service,避免展示和请求规则分叉。</li>
|
||||
<li>新增测试应覆盖展示过滤、请求拦截、同步默认值、重复同步保留 admin 修改。</li>
|
||||
</ul>
|
||||
Reference in New Issue
Block a user