feat(server): add official provider catalog management

This commit is contained in:
RainbowBird
2026-07-01 22:34:56 +08:00
parent 2da9d37add
commit 87baf622c9
25 changed files with 6170 additions and 14 deletions
+59
View File
@@ -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
+7
View File
@@ -120,6 +120,13 @@
"when": 1782847276369,
"tag": "0016_tired_dagger",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1782912836523,
"tag": "0017_nappy_dagger",
"breakpoints": true
}
]
}
+1
View File
@@ -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 () => []),
+21
View File
@@ -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,
+356 -5
View File
@@ -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
+1
View File
@@ -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'
+101
View File
@@ -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,