fix(server): harden provider catalog gating

This commit is contained in:
RainbowBird
2026-07-01 23:08:14 +08:00
parent ed8c93c77b
commit 281614d396
24 changed files with 920 additions and 793 deletions
+10 -10
View File
@@ -1,4 +1,4 @@
CREATE TABLE "official_provider_alias_routes" (
CREATE TABLE "capability_alias_routes" (
"id" text PRIMARY KEY NOT NULL,
"alias_id" text NOT NULL,
"router_model_id" text NOT NULL,
@@ -10,7 +10,7 @@ CREATE TABLE "official_provider_alias_routes" (
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "official_provider_aliases" (
CREATE TABLE "capability_aliases" (
"id" text PRIMARY KEY NOT NULL,
"surface" text NOT NULL,
"alias_id" text NOT NULL,
@@ -23,7 +23,7 @@ CREATE TABLE "official_provider_aliases" (
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "official_tts_models" (
CREATE TABLE "provider_catalog_tts_models" (
"id" text PRIMARY KEY NOT NULL,
"router_model_id" text NOT NULL,
"provider" text NOT NULL,
@@ -35,7 +35,7 @@ CREATE TABLE "official_tts_models" (
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "official_tts_voices" (
CREATE TABLE "provider_catalog_tts_voices" (
"id" text PRIMARY KEY NOT NULL,
"tts_model_id" text NOT NULL,
"provider_voice_id" text NOT NULL,
@@ -51,9 +51,9 @@ CREATE TABLE "official_tts_voices" (
"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");
ALTER TABLE "capability_alias_routes" ADD CONSTRAINT "capability_alias_routes_alias_id_capability_aliases_id_fk" FOREIGN KEY ("alias_id") REFERENCES "public"."capability_aliases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "provider_catalog_tts_voices" ADD CONSTRAINT "provider_catalog_tts_voices_tts_model_id_provider_catalog_tts_models_id_fk" FOREIGN KEY ("tts_model_id") REFERENCES "public"."provider_catalog_tts_models"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "capability_alias_routes_alias_model_pool_uidx" ON "capability_alias_routes" USING btree ("alias_id","router_model_id","pool");--> statement-breakpoint
CREATE UNIQUE INDEX "capability_aliases_surface_alias_uidx" ON "capability_aliases" USING btree ("surface","alias_id");--> statement-breakpoint
CREATE UNIQUE INDEX "provider_catalog_tts_models_router_model_uidx" ON "provider_catalog_tts_models" USING btree ("router_model_id");--> statement-breakpoint
CREATE UNIQUE INDEX "provider_catalog_tts_voices_model_voice_uidx" ON "provider_catalog_tts_voices" USING btree ("tts_model_id","provider_voice_id");
+24 -24
View File
@@ -2047,8 +2047,8 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.official_provider_alias_routes": {
"name": "official_provider_alias_routes",
"public.capability_alias_routes": {
"name": "capability_alias_routes",
"schema": "",
"columns": {
"id": {
@@ -2113,8 +2113,8 @@
}
},
"indexes": {
"official_provider_alias_routes_alias_model_pool_uidx": {
"name": "official_provider_alias_routes_alias_model_pool_uidx",
"capability_alias_routes_alias_model_pool_uidx": {
"name": "capability_alias_routes_alias_model_pool_uidx",
"columns": [
{
"expression": "alias_id",
@@ -2142,10 +2142,10 @@
}
},
"foreignKeys": {
"official_provider_alias_routes_alias_id_official_provider_aliases_id_fk": {
"name": "official_provider_alias_routes_alias_id_official_provider_aliases_id_fk",
"tableFrom": "official_provider_alias_routes",
"tableTo": "official_provider_aliases",
"capability_alias_routes_alias_id_capability_aliases_id_fk": {
"name": "capability_alias_routes_alias_id_capability_aliases_id_fk",
"tableFrom": "capability_alias_routes",
"tableTo": "capability_aliases",
"columnsFrom": [
"alias_id"
],
@@ -2162,8 +2162,8 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.official_provider_aliases": {
"name": "official_provider_aliases",
"public.capability_aliases": {
"name": "capability_aliases",
"schema": "",
"columns": {
"id": {
@@ -2234,8 +2234,8 @@
}
},
"indexes": {
"official_provider_aliases_surface_alias_uidx": {
"name": "official_provider_aliases_surface_alias_uidx",
"capability_aliases_surface_alias_uidx": {
"name": "capability_aliases_surface_alias_uidx",
"columns": [
{
"expression": "surface",
@@ -2263,8 +2263,8 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.official_tts_models": {
"name": "official_tts_models",
"public.provider_catalog_tts_models": {
"name": "provider_catalog_tts_models",
"schema": "",
"columns": {
"id": {
@@ -2327,8 +2327,8 @@
}
},
"indexes": {
"official_tts_models_router_model_uidx": {
"name": "official_tts_models_router_model_uidx",
"provider_catalog_tts_models_router_model_uidx": {
"name": "provider_catalog_tts_models_router_model_uidx",
"columns": [
{
"expression": "router_model_id",
@@ -2350,8 +2350,8 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.official_tts_voices": {
"name": "official_tts_voices",
"public.provider_catalog_tts_voices": {
"name": "provider_catalog_tts_voices",
"schema": "",
"columns": {
"id": {
@@ -2441,8 +2441,8 @@
}
},
"indexes": {
"official_tts_voices_model_voice_uidx": {
"name": "official_tts_voices_model_voice_uidx",
"provider_catalog_tts_voices_model_voice_uidx": {
"name": "provider_catalog_tts_voices_model_voice_uidx",
"columns": [
{
"expression": "tts_model_id",
@@ -2464,10 +2464,10 @@
}
},
"foreignKeys": {
"official_tts_voices_tts_model_id_official_tts_models_id_fk": {
"name": "official_tts_voices_tts_model_id_official_tts_models_id_fk",
"tableFrom": "official_tts_voices",
"tableTo": "official_tts_models",
"provider_catalog_tts_voices_tts_model_id_provider_catalog_tts_models_id_fk": {
"name": "provider_catalog_tts_voices_tts_model_id_provider_catalog_tts_models_id_fk",
"tableFrom": "provider_catalog_tts_voices",
"tableTo": "provider_catalog_tts_models",
"columnsFrom": [
"tts_model_id"
],
+1 -1
View File
@@ -52,7 +52,7 @@ function createTestDeps() {
ttsMeter: {} as any,
requestLogService: {} as any,
voicePackService: {} as any,
officialCatalogService: {} as any,
providerCatalogService: {} as any,
productEventService: {
track: vi.fn(async () => undefined),
countDistinctUsersByFeature: vi.fn(async () => []),
+13 -13
View File
@@ -16,8 +16,8 @@ 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 { ProviderCatalogService } from './services/domain/provider-catalog'
import type { ProviderService } from './services/domain/providers'
import type { RequestLogService } from './services/domain/request-log'
import type { StripeService } from './services/domain/stripe'
@@ -58,7 +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 { createAdminProviderCatalogRoutes } from './routes/admin/provider-catalog'
import { createAdminUsersRoutes } from './routes/admin/users'
import { createAdminVoicePackRoutes } from './routes/admin/voice-packs'
import { createAudioSpeechWsHandlers } from './routes/audio-speech-ws'
@@ -84,8 +84,8 @@ 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 { createProviderCatalogService } from './services/domain/provider-catalog'
import { createProviderService } from './services/domain/providers'
import { createRequestLogService } from './services/domain/request-log'
import { createStripeService } from './services/domain/stripe'
@@ -120,7 +120,7 @@ interface AppDeps {
otel: OtelInstance | null
userDeletionService: UserDeletionService
llmRouter: LlmRouterService
officialCatalogService: OfficialCatalogService
providerCatalogService: ProviderCatalogService
}
export async function buildApp(deps: AppDeps) {
@@ -230,7 +230,7 @@ export async function buildApp(deps: AppDeps) {
env: deps.env,
configKV: deps.configKV,
envelopeCrypto: deps.envelopeCrypto,
officialCatalogService: deps.officialCatalogService,
providerCatalogService: deps.providerCatalogService,
}))
// Cross-instance config invalidation. The subscriber owns its own
@@ -254,7 +254,7 @@ export async function buildApp(deps: AppDeps) {
productEventService: deps.productEventService,
ttsMeter: deps.ttsMeter,
llmRouter: deps.llmRouter,
officialCatalogService: deps.officialCatalogService,
providerCatalogService: deps.providerCatalogService,
voicePackService: deps.voicePackService,
genAi: deps.otel?.genAi,
revenue: deps.otel?.revenue,
@@ -421,12 +421,12 @@ export async function buildApp(deps: AppDeps) {
}))
/**
* Admin official provider catalog curation routes.
* Admin provider catalog curation routes.
*/
.route('/api/admin/official-catalog', createAdminOfficialCatalogRoutes({
.route('/api/admin/provider-catalog', createAdminProviderCatalogRoutes({
configKV: deps.configKV,
llmRouter: deps.llmRouter,
service: deps.officialCatalogService,
service: deps.providerCatalogService,
}))
/**
@@ -676,9 +676,9 @@ export async function createApp() {
build: ({ dependsOn }) => createVoicePackService(dependsOn.db),
})
const officialCatalogService = injeca.provide('services:officialCatalog', {
const providerCatalogService = injeca.provide('services:providerCatalog', {
dependsOn: { db },
build: ({ dependsOn }) => createOfficialCatalogService(dependsOn.db),
build: ({ dependsOn }) => createProviderCatalogService(dependsOn.db),
})
const billingService = injeca.provide('services:billing', {
@@ -791,7 +791,7 @@ export async function createApp() {
otel,
userDeletionService,
llmRouter,
officialCatalogService,
providerCatalogService,
ttsConcurrencyLedger,
})
// Register the cluster-wide ObservableGauges for sessions / users. Each
@@ -836,7 +836,7 @@ export async function createApp() {
otel: resolved.otel,
userDeletionService: resolved.userDeletionService,
llmRouter: resolved.llmRouter,
officialCatalogService: resolved.officialCatalogService,
providerCatalogService: resolved.providerCatalogService,
})
logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started')
@@ -3,7 +3,7 @@ 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 { ProviderCatalogService } from '../../../services/domain/provider-catalog'
import type { HonoEnv } from '../../../types/hono'
import { Buffer } from 'node:buffer'
@@ -13,7 +13,7 @@ import { any, array, boolean, integer, maxLength, minValue, nullable, number, ob
import { adminGuard } from '../../../middlewares/admin-guard'
import { authGuard } from '../../../middlewares/auth'
import { normalizeProviderVoiceForCatalog } from '../../../services/domain/official-catalog/provider-voices'
import { normalizeProviderVoiceForCatalog } from '../../../services/domain/provider-catalog/provider-voices'
import { createBadGatewayError, createBadRequestError, createNotFoundError } from '../../../utils/error'
const DEFAULT_PREVIEW_TEXT = 'Hello, this is an AIRI voice preview.'
@@ -64,10 +64,10 @@ const TtsVoicePreviewBodySchema = object({
responseFormat: optional(pipe(string(), maxLength(24))),
})
export interface AdminOfficialCatalogRoutesDeps {
export interface AdminProviderCatalogRoutesDeps {
configKV: ConfigKVService
llmRouter: LlmRouterService
service: OfficialCatalogService
service: ProviderCatalogService
}
function parseIssues(issues: Array<{ path?: Array<{ key: unknown }>, message: string }>) {
@@ -91,7 +91,7 @@ async function readBody<S extends GenericSchema>(c: Context<HonoEnv>, schema: S)
return parsed.output
}
async function syncAliasesFromConfig(deps: AdminOfficialCatalogRoutesDeps, surface: 'llm' | 'asr') {
async function syncAliasesFromConfig(deps: AdminProviderCatalogRoutesDeps, surface: 'llm' | 'asr') {
const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG')
if (surface === 'llm') {
const defaultModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL')
@@ -108,7 +108,7 @@ async function syncAliasesFromConfig(deps: AdminOfficialCatalogRoutesDeps, surfa
})
}
async function syncTtsModelsFromConfig(deps: AdminOfficialCatalogRoutesDeps) {
async function syncTtsModelsFromConfig(deps: AdminProviderCatalogRoutesDeps) {
const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG')
return await deps.service.syncTtsModelsFromRouterConfig({
models: Object.fromEntries(
@@ -121,14 +121,14 @@ async function syncTtsModelsFromConfig(deps: AdminOfficialCatalogRoutesDeps) {
}
/**
* Admin routes for the official provider catalog.
* Admin routes for provider catalog curation.
*
* 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`.
* Mounted at `/api/admin/provider-catalog`. These routes curate only the
* catalog state: enabled flags, display order, capability aliases, and TTS
* voice metadata. Real upstream URLs, credentials, and provider fallback
* config stay owned by `LLM_ROUTER_CONFIG`.
*/
export function createAdminOfficialCatalogRoutes(deps: AdminOfficialCatalogRoutesDeps) {
export function createAdminProviderCatalogRoutes(deps: AdminProviderCatalogRoutesDeps) {
return new Hono<HonoEnv>()
.use('*', authGuard)
.use('*', adminGuard)
@@ -148,14 +148,14 @@ export function createAdminOfficialCatalogRoutes(deps: AdminOfficialCatalogRoute
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')
throw createNotFoundError('Capability alias not found')
return c.json(updated)
})
.patch('/alias-routes/:id', async (c) => {
const body = await readBody(c, AliasRouteUpdateBodySchema)
const updated = await deps.service.updateAliasRoute(c.req.param('id'), body)
if (!updated)
throw createNotFoundError('Official alias route not found')
throw createNotFoundError('Capability alias route not found')
return c.json(updated)
})
.get('/tts/models', async (c) => {
@@ -168,7 +168,7 @@ export function createAdminOfficialCatalogRoutes(deps: AdminOfficialCatalogRoute
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')
throw createNotFoundError('Provider catalog TTS model not found')
return c.json(updated)
})
.get('/tts/voices', async (c) => {
@@ -189,7 +189,7 @@ export function createAdminOfficialCatalogRoutes(deps: AdminOfficialCatalogRoute
const body = await readBody(c, TtsVoicePreviewBodySchema)
const row = await deps.service.getTtsVoiceWithModel(c.req.param('id'))
if (!row)
throw createNotFoundError('Official TTS voice not found')
throw createNotFoundError('Provider catalog TTS voice not found')
const response = await deps.llmRouter.routeTts({
modelName: row.model.routerModelId,
@@ -207,7 +207,7 @@ export function createAdminOfficialCatalogRoutes(deps: AdminOfficialCatalogRoute
const previewAudioUrl = `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`
const updated = await deps.service.updateTtsVoice(row.voice.id, { previewAudioUrl })
if (!updated)
throw createNotFoundError('Official TTS voice not found')
throw createNotFoundError('Provider catalog TTS voice not found')
return c.json({
voice: updated,
@@ -219,7 +219,7 @@ export function createAdminOfficialCatalogRoutes(deps: AdminOfficialCatalogRoute
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')
throw createNotFoundError('Provider catalog TTS voice not found')
return c.json(updated)
})
}
@@ -1,12 +1,12 @@
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 { ProviderCatalogService } from '../../../services/domain/provider-catalog'
import type { HonoEnv } from '../../../types/hono'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { createAdminOfficialCatalogRoutes } from '.'
import { createAdminProviderCatalogRoutes } from '.'
import { ApiError } from '../../../utils/error'
interface MockUser {
@@ -52,7 +52,7 @@ function createLlmRouter(): LlmRouterService {
} as unknown as LlmRouterService
}
function createService(): OfficialCatalogService {
function createService(): ProviderCatalogService {
return {
syncAliasesFromRouterConfig: vi.fn(async () => []),
listAliases: vi.fn(async () => []),
@@ -64,7 +64,7 @@ function createService(): OfficialCatalogService {
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) => ({
syncTtsVoices: vi.fn(async (input: Parameters<ProviderCatalogService['syncTtsVoices']>[0]) => input.voices.map((voice, index) => ({
id: `voice-${index}`,
providerVoiceId: voice.id,
displayName: voice.name ?? voice.id,
@@ -102,21 +102,21 @@ function createService(): OfficialCatalogService {
})),
updateTtsVoice: vi.fn(async (_id, input) => ({ id: 'voice-1', ...input })),
assertTtsVoiceEnabled: vi.fn(),
} as unknown as OfficialCatalogService
} as unknown as ProviderCatalogService
}
function createTestApp(input: {
user: MockUser | null
configKV?: ConfigKVService
llmRouter?: LlmRouterService
service?: OfficialCatalogService
service?: ProviderCatalogService
}) {
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({
.route('/api/admin/provider-catalog', createAdminProviderCatalogRoutes({
configKV: input.configKV ?? createConfigKV(),
llmRouter: input.llmRouter ?? createLlmRouter(),
service: input.service ?? createService(),
@@ -136,22 +136,22 @@ function jsonRequest(app: Hono<HonoEnv>, method: string, path: string, body?: un
})
}
describe('admin official catalog routes', () => {
describe('admin provider catalog routes', () => {
it('returns 401 when unauthenticated', async () => {
const service = createService()
const app = createTestApp({ user: null, service })
const res = await jsonRequest(app, 'GET', '/api/admin/official-catalog/aliases')
const res = await jsonRequest(app, 'GET', '/api/admin/provider-catalog/aliases')
expect(res.status).toBe(401)
expect(service.listAliases).not.toHaveBeenCalled()
})
it('syncs TTS voices from the provider into the official catalog', async () => {
it('syncs TTS voices from the provider into the provider 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', {
const res = await jsonRequest(app, 'POST', '/api/admin/provider-catalog/tts/voices/sync', {
routerModelId: 'microsoft/v1',
})
@@ -178,7 +178,7 @@ describe('admin official catalog routes', () => {
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', {
const res = await jsonRequest(app, 'PATCH', '/api/admin/provider-catalog/tts/models/missing', {
enabled: false,
})
@@ -190,7 +190,7 @@ describe('admin official catalog routes', () => {
const llmRouter = createLlmRouter()
const app = createTestApp({ user: ADMIN, service, llmRouter })
const res = await jsonRequest(app, 'POST', '/api/admin/official-catalog/tts/voices/voice-1/preview', {
const res = await jsonRequest(app, 'POST', '/api/admin/provider-catalog/tts/voices/voice-1/preview', {
text: 'Preview this voice.',
})
@@ -291,6 +291,9 @@ export function createSessionState(
return
startFrameAccepted = true
void dialUpstream()
}).catch((err) => {
log.withError(err).error('streaming tts start validation failed unexpectedly')
closeWithError(1011, 'streaming_tts_start_validation_failed')
})
return
}
@@ -722,6 +725,10 @@ function streamingVoicesURL(restBaseURL: string, resourceId: string): string | n
try {
const url = new URL(restBaseURL)
url.pathname = '/api/voices'
// NOTICE: The streaming websocket path is currently backed only by the
// Volcengine Unspeech adapter. If another streaming provider is added,
// thread provider identity through the start-frame validation path instead
// of deriving it from the model id here.
url.search = new URLSearchParams({ provider: 'volcengine', model: resourceId }).toString()
return url.toString()
}
@@ -1,5 +1,5 @@
import type { RouterConfig } from '../../services/domain/llm-router/types'
import type { OfficialCatalogService } from '../../services/domain/official-catalog'
import type { ProviderCatalogService } from '../../services/domain/provider-catalog'
import { Buffer } from 'node:buffer'
@@ -22,7 +22,7 @@ function createRouterConfig(overrides?: Partial<RouterConfig>): RouterConfig {
}
}
function createOfficialCatalogService(routeModelId = 'auto'): OfficialCatalogService {
function createProviderCatalogService(routeModelId = 'auto'): ProviderCatalogService {
return {
syncAliasesFromRouterConfig: vi.fn(async () => []),
resolveEnabledAlias: vi.fn(async () => ({
@@ -48,7 +48,7 @@ function createOfficialCatalogService(routeModelId = 'auto'): OfficialCatalogSer
updatedAt: new Date(),
}],
})),
} as unknown as OfficialCatalogService
} as unknown as ProviderCatalogService
}
describe('resolveOfficialAliyunNlsCredentials', () => {
@@ -123,12 +123,12 @@ describe('resolveOfficialAliyunNlsCredentials', () => {
},
},
})
const officialCatalogService = createOfficialCatalogService('aliyun/asr-primary')
const providerCatalogService = createProviderCatalogService('aliyun/asr-primary')
const credentials = await resolveOfficialAliyunNlsCredentialsFromConfig({
configKV: { getOptional: vi.fn(async () => routerConfig) } as never,
envelopeCrypto: envelope,
officialCatalogService,
providerCatalogService,
})
expect(credentials).toMatchObject({
@@ -136,18 +136,15 @@ describe('resolveOfficialAliyunNlsCredentials', () => {
accessKeySecret: 'secret',
appKey: 'app',
})
expect(officialCatalogService.syncAliasesFromRouterConfig).toHaveBeenCalledWith({
surface: 'asr',
modelIds: ['aliyun/asr-primary'],
})
expect(officialCatalogService.resolveEnabledAlias).toHaveBeenCalledWith('asr', 'auto')
expect(providerCatalogService.syncAliasesFromRouterConfig).not.toHaveBeenCalled()
expect(providerCatalogService.resolveEnabledAlias).toHaveBeenCalledWith('asr', 'auto')
})
it('rejects disabled official ASR aliases before credentials are used', async () => {
it('rejects disabled ASR capability aliases before credentials are used', async () => {
const envelope = createEnvelopeCrypto({ masterKey: Buffer.alloc(32, 7) })
const officialCatalogService = createOfficialCatalogService()
vi.mocked(officialCatalogService.resolveEnabledAlias).mockRejectedValueOnce(
new ApiError(400, 'OFFICIAL_ALIAS_DISABLED', 'Official provider alias is disabled'),
const providerCatalogService = createProviderCatalogService()
vi.mocked(providerCatalogService.resolveEnabledAlias).mockRejectedValueOnce(
new ApiError(400, 'CAPABILITY_ALIAS_DISABLED', 'Capability alias is disabled'),
)
const routerConfig = createRouterConfig({
asr: {
@@ -166,10 +163,10 @@ describe('resolveOfficialAliyunNlsCredentials', () => {
await expect(resolveOfficialAliyunNlsCredentialsFromConfig({
configKV: { getOptional: vi.fn(async () => routerConfig) } as never,
envelopeCrypto: envelope,
officialCatalogService,
providerCatalogService,
})).rejects.toMatchObject({
statusCode: 400,
errorCode: 'OFFICIAL_ALIAS_DISABLED',
errorCode: 'CAPABILITY_ALIAS_DISABLED',
})
})
})
@@ -4,7 +4,7 @@ import type { AuthInstance } from '../../libs/auth'
import type { Env } from '../../libs/env'
import type { ConfigKVService } from '../../services/adapters/config-kv'
import type { RouterConfig } from '../../services/domain/llm-router/types'
import type { OfficialCatalogService } from '../../services/domain/official-catalog'
import type { ProviderCatalogService } from '../../services/domain/provider-catalog'
import type { EnvelopeCrypto } from '../../utils/envelope-crypto'
import { resolveRequestAuth } from '../../libs/request-auth'
@@ -88,19 +88,13 @@ export function resolveOfficialAliyunNlsCredentials(
export async function resolveOfficialAliyunNlsCredentialsFromConfig(input: {
configKV: ConfigKVService
envelopeCrypto: EnvelopeCrypto
officialCatalogService: OfficialCatalogService
providerCatalogService: ProviderCatalogService
}) {
const routerConfig = await input.configKV.getOptional('LLM_ROUTER_CONFIG')
const modelIds = Object.keys(routerConfig?.asr?.models ?? {}).sort()
if (modelIds.length === 0)
if (Object.keys(routerConfig?.asr?.models ?? {}).length === 0)
return null
await input.officialCatalogService.syncAliasesFromRouterConfig({
surface: 'asr',
modelIds,
})
const alias = await input.officialCatalogService.resolveEnabledAlias('asr', OFFICIAL_ASR_MODEL_NAME)
const alias = await input.providerCatalogService.resolveEnabledAlias('asr', OFFICIAL_ASR_MODEL_NAME)
const primary = alias.routes.find(route => route.pool === 'primary')
const modelName = (primary ?? alias.routes[0]).routerModelId
const credentials = resolveOfficialAliyunNlsCredentials(routerConfig, input.envelopeCrypto, modelName)
@@ -127,7 +121,7 @@ export function createAudioTranscriptionStreamHandler(input: {
env: Env
configKV: ConfigKVService
envelopeCrypto: EnvelopeCrypto
officialCatalogService: OfficialCatalogService
providerCatalogService: ProviderCatalogService
}) {
return async function handleAudioTranscriptionStream(c: Context) {
const session = await resolveRequestAuth(
@@ -140,7 +134,7 @@ export function createAudioTranscriptionStreamHandler(input: {
const credentials = await resolveOfficialAliyunNlsCredentialsFromConfig(input)
if (!credentials)
throw createServiceUnavailableError('Official ASR transcription is not configured in LLM_ROUTER_CONFIG.asr.models.auto', 'CONFIG_NOT_SET')
throw createServiceUnavailableError('Official ASR transcription is not configured in the ASR capability catalog', 'CONFIG_NOT_SET')
const audioStream = c.req.raw.body
if (!audioStream)
@@ -1,4 +1,4 @@
import type { OfficialProviderAliasRoute } from '../../../../../schemas/official-catalog'
import type { CapabilityAliasRoute } from '../../../../../schemas/provider-catalog'
import type { UsageInfo } from '../../../../../services/domain/billing/billing'
import type { GatewayCallback } from '../../gateway'
import type { V1RouteDeps } from '../../types'
@@ -214,18 +214,7 @@ interface ChatModelAliasPlan {
}
async function resolveChatModelAliasPlan(deps: V1RouteDeps, aliasId: string): Promise<ChatModelAliasPlan> {
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 alias = await deps.providerCatalogService.resolveEnabledAlias('llm', aliasId)
const primaryRoutes = alias.routes.filter(route => route.pool === 'primary')
const fallbackRoutes = alias.fallbackEnabled
? alias.routes.filter(route => route.pool === 'fallback')
@@ -236,7 +225,7 @@ async function resolveChatModelAliasPlan(deps: V1RouteDeps, aliasId: string): Pr
const routedModelIds = uniqueModelIds([...orderedPrimaryRoutes, ...fallbackRoutes])
if (routedModelIds.length === 0) {
throw createBadRequestError('Official provider alias has no enabled route', 'OFFICIAL_ALIAS_ROUTE_NOT_FOUND', {
throw createBadRequestError('Capability alias has no enabled route', 'CAPABILITY_ALIAS_ROUTE_NOT_FOUND', {
surface: 'llm',
aliasId,
})
@@ -277,7 +266,7 @@ async function routeChatAliasCandidates(input: {
throw lastError
}
function weightedRouteOrder(routes: OfficialProviderAliasRoute[]): OfficialProviderAliasRoute[] {
function weightedRouteOrder(routes: CapabilityAliasRoute[]): CapabilityAliasRoute[] {
if (routes.length <= 1)
return routes
@@ -300,7 +289,7 @@ function weightedRouteOrder(routes: OfficialProviderAliasRoute[]): OfficialProvi
]
}
function uniqueModelIds(routes: OfficialProviderAliasRoute[]): string[] {
function uniqueModelIds(routes: CapabilityAliasRoute[]): string[] {
return Array.from(new Set(routes.map(route => route.routerModelId)))
}
@@ -4,7 +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 { catalogVoiceResponse } from '../../../../../services/domain/provider-catalog/provider-voices'
import { createBadGatewayError, createBadRequestError, createServiceUnavailableError } from '../../../../../utils/error'
const VOICE_PACK_MODEL_ID = 'voice-pack'
@@ -64,13 +64,7 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
return Response.json({ voices: voicePacks.map(voicePackCatalogVoice), recommended: {} })
}
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 voices = await deps.providerCatalogService.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
@@ -151,32 +145,17 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp
}
async function listSpeechModels() {
// Surface the concrete TTS models the operator has configured. The UI
// should select an explicit model id so voice catalog requests stay
// model-scoped instead of hiding behind DEFAULT_TTS_MODEL. The `default`
// field lets the initial client selection mirror the same server-side
// alias that `/audio/speech` uses for `model: "auto"`.
const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG')
const defaultModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL')
// `LLM_ROUTER_CONFIG` is `optional()` at the schema, so its inferred type
// 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.
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()
const models = await deps.providerCatalogService.listEnabledTtsModels()
const publicDefaultModel = models.some(model => model.routerModelId === defaultModel)
? defaultModel
: null
return Response.json({
models: [
{ id: VOICE_PACK_MODEL_ID, name: 'Voice Pack', description: 'Server-curated voices' },
...models.map(model => ({ id: model.routerModelId, name: model.displayName })),
],
default: defaultModel,
default: publicDefaultModel,
})
}
@@ -17,7 +17,7 @@ export function speechGeneration(deps: V1RouteDeps): GatewayCallback<'speech.gen
genAi: deps.genAi,
llmRouter: deps.llmRouter,
llmTracing: deps.llmTracing,
officialCatalogService: deps.officialCatalogService,
providerCatalogService: deps.providerCatalogService,
productEventService: deps.productEventService,
requestLogService: deps.requestLogService,
ttsMeter: deps.ttsMeter,
+226 -77
View File
@@ -3,8 +3,8 @@ 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 { ProviderCatalogService } from '../../../services/domain/provider-catalog'
import type { RequestLogService } from '../../../services/domain/request-log'
import type { VoicePackService } from '../../../services/domain/voice-packs'
import type { HonoEnv } from '../../../types/hono'
@@ -160,7 +160,7 @@ function createMockVoicePackService(impl?: Partial<VoicePackService>): VoicePack
} as unknown as VoicePackService
}
function createMockOfficialCatalogService(impl?: Partial<OfficialCatalogService>): OfficialCatalogService {
function createMockProviderCatalogService(impl?: Partial<ProviderCatalogService>): ProviderCatalogService {
let syncedAliasRoutes: Array<{
id: string
aliasId: string
@@ -172,11 +172,11 @@ function createMockOfficialCatalogService(impl?: Partial<OfficialCatalogService>
createdAt: Date
updatedAt: Date
}> = []
let syncedModels: Awaited<ReturnType<OfficialCatalogService['syncTtsModelsFromRouterConfig']>> = []
const syncedVoicesByModel = new Map<string, Awaited<ReturnType<OfficialCatalogService['syncTtsVoices']>>>()
let syncedModels: Awaited<ReturnType<ProviderCatalogService['syncTtsModelsFromRouterConfig']>> = []
const syncedVoicesByModel = new Map<string, Awaited<ReturnType<ProviderCatalogService['syncTtsVoices']>>>()
return {
syncAliasesFromRouterConfig: vi.fn(async (input: Parameters<OfficialCatalogService['syncAliasesFromRouterConfig']>[0]) => {
syncAliasesFromRouterConfig: vi.fn(async (input: Parameters<ProviderCatalogService['syncAliasesFromRouterConfig']>[0]) => {
const { surface, modelIds } = input
syncedAliasRoutes = Array.from(new Set(modelIds)).map((routerModelId, index) => ({
id: `alias-route-${index}`,
@@ -215,7 +215,19 @@ function createMockOfficialCatalogService(impl?: Partial<OfficialCatalogService>
createdAt: new Date(),
updatedAt: new Date(),
routes: aliasId === 'auto'
? syncedAliasRoutes
? (syncedAliasRoutes.length > 0
? syncedAliasRoutes
: [{
id: 'alias-route-auto',
aliasId: 'alias-auto',
routerModelId: 'openai/gpt-5-mini',
pool: 'primary',
enabled: true,
weight: 1,
displayOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
}])
: [{
id: `alias-route-${aliasId}`,
aliasId: `alias-${aliasId}`,
@@ -228,7 +240,7 @@ function createMockOfficialCatalogService(impl?: Partial<OfficialCatalogService>
updatedAt: new Date(),
}],
})),
syncTtsModelsFromRouterConfig: vi.fn(async (input: Parameters<OfficialCatalogService['syncTtsModelsFromRouterConfig']>[0]) => {
syncTtsModelsFromRouterConfig: vi.fn(async (input: Parameters<ProviderCatalogService['syncTtsModelsFromRouterConfig']>[0]) => {
const { models } = input
syncedModels = Object.entries(models).sort(([a], [b]) => a.localeCompare(b)).map(([routerModelId, model], index) => ({
id: `tts-model-${index}`,
@@ -256,7 +268,7 @@ function createMockOfficialCatalogService(impl?: Partial<OfficialCatalogService>
createdAt: new Date(),
updatedAt: new Date(),
})),
syncTtsVoices: vi.fn(async (input: Parameters<OfficialCatalogService['syncTtsVoices']>[0]) => {
syncTtsVoices: vi.fn(async (input: Parameters<ProviderCatalogService['syncTtsVoices']>[0]) => {
const { routerModelId, voices } = input
const syncedVoices = voices.map((voice, index) => ({
id: `tts-voice-${index}`,
@@ -295,7 +307,7 @@ function createMockOfficialCatalogService(impl?: Partial<OfficialCatalogService>
updatedAt: new Date(),
})),
...impl,
} as OfficialCatalogService
} as ProviderCatalogService
}
function createTestApp(
@@ -308,7 +320,7 @@ function createTestApp(
llmTracing = createMockLlmTracing(),
productEventService = createMockProductEventService(),
voicePackService = createMockVoicePackService(),
officialCatalogService = createMockOfficialCatalogService(),
providerCatalogService = createMockProviderCatalogService(),
) {
const { openaiRoutes, audioRoutes } = createV1Routes({
fluxService,
@@ -319,7 +331,7 @@ function createTestApp(
ttsMeter: ttsMeter ?? createMockTtsMeter(),
llmRouter: llmRouter ?? createMockLlmRouter(),
voicePackService,
officialCatalogService,
providerCatalogService,
genAi: null,
revenue: null,
rateLimitMetrics: null,
@@ -571,15 +583,24 @@ describe('v1CompletionsRoutes', () => {
)
})
it('should resolve "auto" model to DEFAULT_CHAT_MODEL from config', async () => {
it('resolves "auto" model through the capability alias catalog', async () => {
globalThis.fetch = vi.fn(async () => new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const providerCatalogService = createMockProviderCatalogService()
const app = createTestApp(
createMockFluxService(),
createMockConfigKV({ DEFAULT_CHAT_MODEL: 'anthropic/claude-sonnet' }),
undefined,
undefined,
undefined,
undefined,
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
providerCatalogService,
)
await app.fetch(
@@ -594,12 +615,13 @@ describe('v1CompletionsRoutes', () => {
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://mock-gateway/chat/completions',
expect.objectContaining({
body: expect.stringContaining('"model":"anthropic/claude-sonnet"'),
body: expect.stringContaining('"model":"openai/gpt-5-mini"'),
}),
)
expect(providerCatalogService.syncAliasesFromRouterConfig).not.toHaveBeenCalled()
})
it('resolves an enabled non-auto model alias through the official catalog', async () => {
it('resolves an enabled non-auto model alias through the provider catalog', async () => {
globalThis.fetch = vi.fn(async () => new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
@@ -626,9 +648,9 @@ describe('v1CompletionsRoutes', () => {
it('rejects disabled LLM aliases before upstream routing', async () => {
const route = vi.fn(async () => new Response('{}', { status: 200 }))
const officialCatalogService = createMockOfficialCatalogService({
const providerCatalogService = createMockProviderCatalogService({
resolveEnabledAlias: vi.fn(async () => {
throw new ApiError(400, 'OFFICIAL_ALIAS_DISABLED', 'Official provider alias is disabled')
throw new ApiError(400, 'CAPABILITY_ALIAS_DISABLED', 'Capability alias is disabled')
}),
})
const app = createTestApp(
@@ -641,7 +663,7 @@ describe('v1CompletionsRoutes', () => {
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
officialCatalogService,
providerCatalogService,
)
const res = await app.fetch(
@@ -655,15 +677,15 @@ describe('v1CompletionsRoutes', () => {
expect(res.status).toBe(400)
const body = await res.json() as { error?: string }
expect(body.error).toBe('OFFICIAL_ALIAS_DISABLED')
expect(body.error).toBe('CAPABILITY_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({
const providerCatalogService = createMockProviderCatalogService({
resolveEnabledAlias: vi.fn(async () => {
throw new ApiError(400, 'OFFICIAL_ALIAS_NOT_FOUND', 'Official provider alias is not configured')
throw new ApiError(400, 'CAPABILITY_ALIAS_NOT_FOUND', 'Capability alias is not configured')
}),
})
const app = createTestApp(
@@ -676,7 +698,7 @@ describe('v1CompletionsRoutes', () => {
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
officialCatalogService,
providerCatalogService,
)
const res = await app.fetch(
@@ -690,7 +712,7 @@ describe('v1CompletionsRoutes', () => {
expect(res.status).toBe(400)
const body = await res.json() as { error?: string }
expect(body.error).toBe('OFFICIAL_ALIAS_NOT_FOUND')
expect(body.error).toBe('CAPABILITY_ALIAS_NOT_FOUND')
expect(route).not.toHaveBeenCalled()
})
@@ -708,7 +730,7 @@ describe('v1CompletionsRoutes', () => {
})
})
const now = new Date()
const officialCatalogService = createMockOfficialCatalogService({
const providerCatalogService = createMockProviderCatalogService({
resolveEnabledAlias: vi.fn(async () => ({
id: 'alias-auto',
surface: 'llm' as const,
@@ -742,7 +764,7 @@ describe('v1CompletionsRoutes', () => {
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
officialCatalogService,
providerCatalogService,
)
const res = await app.fetch(
@@ -765,7 +787,7 @@ describe('v1CompletionsRoutes', () => {
throw new ApiError(502, 'BAD_GATEWAY', 'primary exhausted')
})
const now = new Date()
const officialCatalogService = createMockOfficialCatalogService({
const providerCatalogService = createMockProviderCatalogService({
resolveEnabledAlias: vi.fn(async () => ({
id: 'alias-auto',
surface: 'llm' as const,
@@ -799,7 +821,7 @@ describe('v1CompletionsRoutes', () => {
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
officialCatalogService,
providerCatalogService,
)
const res = await app.fetch(
@@ -829,7 +851,7 @@ describe('v1CompletionsRoutes', () => {
})
})
const now = new Date()
const officialCatalogService = createMockOfficialCatalogService({
const providerCatalogService = createMockProviderCatalogService({
resolveEnabledAlias: vi.fn(async () => ({
id: 'alias-auto',
surface: 'llm' as const,
@@ -863,7 +885,7 @@ describe('v1CompletionsRoutes', () => {
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
officialCatalogService,
providerCatalogService,
)
try {
@@ -951,8 +973,24 @@ describe('v1CompletionsRoutes', () => {
throw new ApiError(503, 'CONFIG_NOT_SET', 'Service configuration is incomplete')
return createMockConfigKV().getOrThrow(key as never)
})
const providerCatalogService = createMockProviderCatalogService({
resolveEnabledAlias: vi.fn(async () => {
throw new ApiError(503, 'CONFIG_NOT_SET', 'Service configuration is incomplete')
}),
})
const app = createTestApp(createMockFluxService(), configKV)
const app = createTestApp(
createMockFluxService(),
configKV,
undefined,
undefined,
undefined,
undefined,
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
providerCatalogService,
)
const res = await app.fetch(
new Request('http://localhost/api/v1/openai/chat/completions', {
@@ -1095,12 +1133,12 @@ describe('v1CompletionsRoutes', () => {
)
})
it('rejects disabled official TTS models before billing or upstream routing', async () => {
it('rejects disabled provider catalog 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({
const providerCatalogService = createMockProviderCatalogService({
assertTtsModelEnabled: vi.fn(async () => {
throw new ApiError(400, 'OFFICIAL_MODEL_DISABLED', 'Official TTS model is disabled')
throw new ApiError(400, 'PROVIDER_CATALOG_TTS_MODEL_DISABLED', 'Provider catalog TTS model is disabled')
}),
})
const app = createTestApp(
@@ -1113,7 +1151,7 @@ describe('v1CompletionsRoutes', () => {
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
officialCatalogService,
providerCatalogService,
)
const res = await app.fetch(
@@ -1127,17 +1165,17 @@ describe('v1CompletionsRoutes', () => {
expect(res.status).toBe(400)
const body = await res.json() as { error?: string }
expect(body.error).toBe('OFFICIAL_MODEL_DISABLED')
expect(body.error).toBe('PROVIDER_CATALOG_TTS_MODEL_DISABLED')
expect(ttsMeter.assertCanAfford).not.toHaveBeenCalled()
expect(routeTts).not.toHaveBeenCalled()
})
it('rejects disabled official TTS voices before billing or upstream routing', async () => {
it('rejects disabled provider catalog 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({
const providerCatalogService = createMockProviderCatalogService({
assertTtsVoiceEnabled: vi.fn(async () => {
throw new ApiError(400, 'OFFICIAL_VOICE_DISABLED', 'Official TTS voice is disabled')
throw new ApiError(400, 'PROVIDER_CATALOG_TTS_VOICE_DISABLED', 'Provider catalog TTS voice is disabled')
}),
})
const app = createTestApp(
@@ -1150,7 +1188,7 @@ describe('v1CompletionsRoutes', () => {
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
officialCatalogService,
providerCatalogService,
)
const res = await app.fetch(
@@ -1164,8 +1202,8 @@ describe('v1CompletionsRoutes', () => {
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(body.error).toBe('PROVIDER_CATALOG_TTS_VOICE_DISABLED')
expect(providerCatalogService.assertTtsVoiceEnabled).toHaveBeenCalledWith('microsoft/v1', 'alloy')
expect(ttsMeter.assertCanAfford).not.toHaveBeenCalled()
expect(routeTts).not.toHaveBeenCalled()
})
@@ -1687,6 +1725,32 @@ describe('v1CompletionsRoutes', () => {
describe('gET /api/v1/audio/models', () => {
it('exposes Voice Pack beside every configured tts model id', async () => {
const providerCatalogService = createMockProviderCatalogService({
listEnabledTtsModels: vi.fn(async () => [
{
id: 'tts-model-aliyun',
routerModelId: 'alibaba/cosyvoice-v2',
provider: 'dashscope-cosyvoice',
displayName: 'alibaba/cosyvoice-v2',
enabled: true,
displayOrder: 0,
lastSyncedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: 'tts-model-azure',
routerModelId: 'microsoft/v1',
provider: 'azure',
displayName: 'microsoft/v1',
enabled: true,
displayOrder: 1,
lastSyncedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
},
]),
})
const app = createTestApp(
createMockFluxService(),
createMockConfigKV({
@@ -1701,6 +1765,14 @@ describe('v1CompletionsRoutes', () => {
},
},
}),
undefined,
undefined,
undefined,
undefined,
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
providerCatalogService,
)
const res = await app.fetch(
@@ -1721,6 +1793,7 @@ describe('v1CompletionsRoutes', () => {
description: 'Server-curated voices',
})
expect(data.default).toBe('microsoft/v1')
expect(providerCatalogService.syncTtsModelsFromRouterConfig).not.toHaveBeenCalled()
})
it('keeps the Voice Pack model entry when no tts models are configured', async () => {
@@ -1868,6 +1941,40 @@ describe('v1CompletionsRoutes', () => {
const llmRouter = createMockLlmRouter({
listTtsVoices: vi.fn(async () => voices) as any,
})
const providerCatalogService = createMockProviderCatalogService({
listEnabledTtsVoices: vi.fn(async () => [
{
id: 'tts-voice-jenny',
ttsModelId: 'tts-model-azure',
providerVoiceId: 'en-US-JennyNeural',
displayName: 'Jenny',
enabled: true,
displayOrder: 0,
languages: [],
labels: {},
previewAudioUrl: 'https://example.com/jenny.mp3',
source: 'provider-sync',
lastSyncedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: 'tts-voice-ava',
ttsModelId: 'tts-model-azure',
providerVoiceId: 'en-US-AvaMultilingualNeural',
displayName: 'Ava',
enabled: true,
displayOrder: 1,
languages: [],
labels: {},
previewAudioUrl: null,
source: 'provider-sync',
lastSyncedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
},
]),
})
const configKV = createMockConfigKV({
DEFAULT_TTS_VOICES: {
'microsoft/v1': { 'en-US': 'en-US-AvaMultilingualNeural' },
@@ -1875,7 +1982,18 @@ describe('v1CompletionsRoutes', () => {
},
})
const app = createTestApp(createMockFluxService(), configKV, undefined, undefined, undefined, llmRouter)
const app = createTestApp(
createMockFluxService(),
configKV,
undefined,
undefined,
undefined,
llmRouter,
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
providerCatalogService,
)
const res = await app.fetch(
new Request('http://localhost/api/v1/audio/voices?model=microsoft/v1', { method: 'GET' }),
@@ -1884,24 +2002,22 @@ describe('v1CompletionsRoutes', () => {
expect(res.status).toBe(200)
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: {},
preview_audio_url: 'https://example.com/jenny.mp3',
},
{
id: 'en-US-AvaMultilingualNeural',
name: 'Ava',
languages: [],
labels: {},
preview_audio_url: undefined,
},
])
expect(data.voices[0]).toEqual({
id: 'en-US-JennyNeural',
name: 'Jenny',
languages: [],
labels: {},
preview_audio_url: 'https://example.com/jenny.mp3',
})
expect(data.voices[1]).toMatchObject({
id: 'en-US-AvaMultilingualNeural',
name: 'Ava',
languages: [],
labels: {},
})
expect(data.voices[1]).not.toHaveProperty('preview_audio_url')
expect(data.recommended).toEqual({ 'en-US': 'en-US-AvaMultilingualNeural' })
expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('microsoft/v1')
expect(llmRouter.listTtsVoices).not.toHaveBeenCalled()
})
it('lists enabled Voice Packs from the Voice Pack model without upstream details', async () => {
@@ -1997,6 +2113,23 @@ describe('v1CompletionsRoutes', () => {
updatedAt: new Date(),
}]),
})
const providerCatalogService = createMockProviderCatalogService({
listEnabledTtsVoices: vi.fn(async () => [{
id: 'tts-voice-ava',
ttsModelId: 'tts-model-azure',
providerVoiceId: 'en-US-AvaMultilingualNeural',
displayName: 'Ava',
enabled: true,
displayOrder: 0,
languages: [{ code: 'en-US', title: 'English' }],
labels: {},
previewAudioUrl: null,
source: 'provider-sync',
lastSyncedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
}]),
})
const app = createTestApp(
createMockFluxService(),
createMockConfigKV({ DEFAULT_TTS_VOICES: { 'microsoft/v1': { 'en-US': 'en-US-AvaMultilingualNeural' } } }),
@@ -2007,6 +2140,7 @@ describe('v1CompletionsRoutes', () => {
createMockLlmTracing(),
createMockProductEventService(),
voicePackService,
providerCatalogService,
)
const res = await app.fetch(
@@ -2022,18 +2156,18 @@ describe('v1CompletionsRoutes', () => {
name: 'Ava',
languages: [{ code: 'en-US', title: 'English' }],
labels: {},
preview_audio_url: undefined,
},
])
expect(llmRouter.listTtsVoices).not.toHaveBeenCalled()
})
it('hides provider voices that are not enabled in the official catalog', async () => {
it('hides provider voices that are not enabled in the provider catalog', async () => {
const llmRouter = createMockLlmRouter({
listTtsVoices: vi.fn(async () => [
{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' },
]) as any,
})
const officialCatalogService = createMockOfficialCatalogService({
const providerCatalogService = createMockProviderCatalogService({
listEnabledTtsVoices: vi.fn(async () => []),
})
const app = createTestApp(
@@ -2046,7 +2180,7 @@ describe('v1CompletionsRoutes', () => {
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
officialCatalogService,
providerCatalogService,
)
const res = await app.fetch(
@@ -2057,11 +2191,8 @@ describe('v1CompletionsRoutes', () => {
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 }],
})
expect(llmRouter.listTtsVoices).not.toHaveBeenCalled()
expect(providerCatalogService.syncTtsVoices).not.toHaveBeenCalled()
})
it('returns an empty recommended map when the resolved model has no bucket', async () => {
@@ -2087,26 +2218,44 @@ describe('v1CompletionsRoutes', () => {
})
it('uses the explicit ?model= query when provided instead of DEFAULT_TTS_MODEL', async () => {
const llmRouter = createMockLlmRouter({
listTtsVoices: vi.fn(async (model: string) => [{ id: `${model}-v`, name: model } as any]) as any,
})
const providerCatalogService = createMockProviderCatalogService()
const app = createTestApp(createMockFluxService(), createMockConfigKV(), undefined, undefined, undefined, llmRouter)
const app = createTestApp(
createMockFluxService(),
createMockConfigKV(),
undefined,
undefined,
undefined,
undefined,
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
providerCatalogService,
)
await app.fetch(new Request('http://localhost/api/v1/audio/voices?model=alibaba/cosyvoice-v1'), { user: testUser } as any)
expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('alibaba/cosyvoice-v1')
expect(providerCatalogService.listEnabledTtsVoices).toHaveBeenCalledWith('alibaba/cosyvoice-v1')
})
it('resolves `auto` model to configKV DEFAULT_TTS_MODEL', async () => {
const llmRouter = createMockLlmRouter({
listTtsVoices: vi.fn(async () => []) as any,
})
const providerCatalogService = createMockProviderCatalogService()
const configKV = createMockConfigKV({ DEFAULT_TTS_MODEL: 'microsoft/v1' })
const app = createTestApp(createMockFluxService(), configKV, undefined, undefined, undefined, llmRouter)
const app = createTestApp(
createMockFluxService(),
configKV,
undefined,
undefined,
undefined,
undefined,
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService(),
providerCatalogService,
)
await app.fetch(new Request('http://localhost/api/v1/audio/voices?model=auto'), { user: testUser } as any)
expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('microsoft/v1')
expect(providerCatalogService.listEnabledTtsVoices).toHaveBeenCalledWith('microsoft/v1')
})
it('returns 400 MISSING_MODEL when ?model= is omitted (no implicit fallback)', async () => {
+2 -2
View File
@@ -5,8 +5,8 @@ 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 { ProviderCatalogService } from '../../../services/domain/provider-catalog'
import type { RequestLogService } from '../../../services/domain/request-log'
import type { VoicePackService } from '../../../services/domain/voice-packs'
@@ -26,7 +26,7 @@ export interface V1RouteDeps {
ttsMeter: FluxMeter
llmRouter: LlmRouterService
voicePackService: VoicePackService
officialCatalogService: OfficialCatalogService
providerCatalogService: ProviderCatalogService
genAi?: GenAiMetrics | null
revenue?: RevenueMetrics | null
rateLimitMetrics?: RateLimitMetrics | null
+1 -1
View File
@@ -4,8 +4,8 @@ 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 './provider-catalog'
export * from './providers'
export * from './stripe'
export * from './user-character'
@@ -4,21 +4,21 @@ import { boolean, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from '
import { nanoid } from '../utils/id'
export type OfficialCatalogSurface = 'llm' | 'asr'
export type OfficialCatalogRoutePool = 'primary' | 'fallback'
export type CapabilityAliasSurface = 'llm' | 'asr'
export type CapabilityAliasRoutePool = 'primary' | 'fallback'
export interface OfficialTtsVoiceLanguage {
export interface ProviderCatalogTtsVoiceLanguage {
code: string
title?: string
}
export type OfficialTtsVoiceLabels = Record<string, unknown>
export type ProviderCatalogTtsVoiceLabels = Record<string, unknown>
export const officialProviderAliases = pgTable(
'official_provider_aliases',
export const capabilityAliases = pgTable(
'capability_aliases',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
surface: text('surface').notNull().$type<OfficialCatalogSurface>(),
surface: text('surface').notNull().$type<CapabilityAliasSurface>(),
aliasId: text('alias_id').notNull(),
displayName: text('display_name').notNull(),
enabled: boolean('enabled').notNull().default(true),
@@ -29,17 +29,17 @@ export const officialProviderAliases = pgTable(
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
table => [
uniqueIndex('official_provider_aliases_surface_alias_uidx').on(table.surface, table.aliasId),
uniqueIndex('capability_aliases_surface_alias_uidx').on(table.surface, table.aliasId),
],
)
export const officialProviderAliasRoutes = pgTable(
'official_provider_alias_routes',
export const capabilityAliasRoutes = pgTable(
'capability_alias_routes',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
aliasId: text('alias_id').notNull().references(() => officialProviderAliases.id, { onDelete: 'cascade' }),
aliasId: text('alias_id').notNull().references(() => capabilityAliases.id, { onDelete: 'cascade' }),
routerModelId: text('router_model_id').notNull(),
pool: text('pool').notNull().$type<OfficialCatalogRoutePool>().default('primary'),
pool: text('pool').notNull().$type<CapabilityAliasRoutePool>().default('primary'),
enabled: boolean('enabled').notNull().default(true),
weight: integer('weight').notNull().default(1),
displayOrder: integer('display_order').notNull().default(0),
@@ -47,12 +47,12 @@ export const officialProviderAliasRoutes = pgTable(
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
table => [
uniqueIndex('official_provider_alias_routes_alias_model_pool_uidx').on(table.aliasId, table.routerModelId, table.pool),
uniqueIndex('capability_alias_routes_alias_model_pool_uidx').on(table.aliasId, table.routerModelId, table.pool),
],
)
export const officialTtsModels = pgTable(
'official_tts_models',
export const providerCatalogTtsModels = pgTable(
'provider_catalog_tts_models',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
routerModelId: text('router_model_id').notNull(),
@@ -65,21 +65,21 @@ export const officialTtsModels = pgTable(
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
table => [
uniqueIndex('official_tts_models_router_model_uidx').on(table.routerModelId),
uniqueIndex('provider_catalog_tts_models_router_model_uidx').on(table.routerModelId),
],
)
export const officialTtsVoices = pgTable(
'official_tts_voices',
export const providerCatalogTtsVoices = pgTable(
'provider_catalog_tts_voices',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
ttsModelId: text('tts_model_id').notNull().references(() => officialTtsModels.id, { onDelete: 'cascade' }),
ttsModelId: text('tts_model_id').notNull().references(() => providerCatalogTtsModels.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({}),
languages: jsonb('languages').notNull().$type<ProviderCatalogTtsVoiceLanguage[]>().default([]),
labels: jsonb('labels').notNull().$type<ProviderCatalogTtsVoiceLabels>().default({}),
previewAudioUrl: text('preview_audio_url'),
source: text('source').notNull().default('provider-sync'),
lastSyncedAt: timestamp('last_synced_at'),
@@ -87,15 +87,15 @@ export const officialTtsVoices = pgTable(
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
table => [
uniqueIndex('official_tts_voices_model_voice_uidx').on(table.ttsModelId, table.providerVoiceId),
uniqueIndex('provider_catalog_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>
export type CapabilityAlias = InferSelectModel<typeof capabilityAliases>
export type NewCapabilityAlias = InferInsertModel<typeof capabilityAliases>
export type CapabilityAliasRoute = InferSelectModel<typeof capabilityAliasRoutes>
export type NewCapabilityAliasRoute = InferInsertModel<typeof capabilityAliasRoutes>
export type ProviderCatalogTtsModel = InferSelectModel<typeof providerCatalogTtsModels>
export type NewProviderCatalogTtsModel = InferInsertModel<typeof providerCatalogTtsModels>
export type ProviderCatalogTtsVoice = InferSelectModel<typeof providerCatalogTtsVoices>
export type NewProviderCatalogTtsVoice = InferInsertModel<typeof providerCatalogTtsVoices>
@@ -1,437 +0,0 @@
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 OfficialTtsVoiceWithModel {
model: OfficialTtsModel
voice: OfficialTtsVoice
}
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 getTtsVoiceWithModel(id: string): Promise<OfficialTtsVoiceWithModel | null> {
const voice = await db.query.officialTtsVoices.findFirst({
where: eq(officialTtsVoices.id, id),
})
if (!voice)
return null
const model = await db.query.officialTtsModels.findFirst({
where: eq(officialTtsModels.id, voice.ttsModelId),
})
if (!model)
return null
return { model, voice }
},
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>
@@ -4,8 +4,8 @@ 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 { ProviderCatalogService } from '../provider-catalog'
import type { RequestLogService } from '../request-log'
import type { VoicePackService } from '../voice-packs'
@@ -50,7 +50,7 @@ export interface OpenAiSpeechServiceDeps {
ttsMeter: FluxMeter
llmRouter: LlmRouterService
voicePackService: VoicePackService
officialCatalogService: OfficialCatalogService
providerCatalogService: ProviderCatalogService
productEventService: ProductEventService
genAi?: GenAiMetrics | null
llmTracing: {
@@ -108,9 +108,9 @@ 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)
await deps.providerCatalogService.assertTtsModelEnabled(requestModel)
if (!voicePackRequest.voicePackId && routedVoice)
await deps.officialCatalogService.assertTtsVoiceEnabled(requestModel, routedVoice)
await deps.providerCatalogService.assertTtsVoiceEnabled(requestModel, routedVoice)
const voiceMetadata = ttsVoiceMetadata({
voice: requestVoice,
@@ -3,27 +3,27 @@ import type { Database } from '../../../libs/db'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { createOfficialCatalogService } from '.'
import { createProviderCatalogService } from '.'
import { mockDB } from '../../../libs/mock-db'
import { officialProviderAliases, officialProviderAliasRoutes, officialTtsModels, officialTtsVoices } from '../../../schemas/official-catalog'
import { capabilityAliases, capabilityAliasRoutes, providerCatalogTtsModels, providerCatalogTtsVoices } from '../../../schemas/provider-catalog'
import { ApiError } from '../../../utils/error'
import * as schema from '../../../schemas'
describe('officialCatalogService', () => {
describe('providerCatalogService', () => {
let db: Database
let service: ReturnType<typeof createOfficialCatalogService>
let service: ReturnType<typeof createProviderCatalogService>
beforeAll(async () => {
db = await mockDB(schema)
service = createOfficialCatalogService(db)
service = createProviderCatalogService(db)
})
beforeEach(async () => {
await db.delete(officialProviderAliasRoutes)
await db.delete(officialProviderAliases)
await db.delete(officialTtsVoices)
await db.delete(officialTtsModels)
await db.delete(capabilityAliasRoutes)
await db.delete(capabilityAliases)
await db.delete(providerCatalogTtsVoices)
await db.delete(providerCatalogTtsModels)
})
it('syncs the default LLM auto alias and runtime model routes as enabled', async () => {
@@ -50,15 +50,15 @@ describe('officialCatalogService', () => {
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)
const [alias] = await db.select().from(capabilityAliases)
const [route] = await db.select().from(capabilityAliasRoutes)
await db.update(officialProviderAliases)
await db.update(capabilityAliases)
.set({ enabled: false, displayName: 'Custom Auto', displayOrder: 5 })
.where(eq(officialProviderAliases.id, alias.id))
await db.update(officialProviderAliasRoutes)
.where(eq(capabilityAliases.id, alias.id))
await db.update(capabilityAliasRoutes)
.set({ enabled: false, displayOrder: 9 })
.where(eq(officialProviderAliasRoutes.id, route.id))
.where(eq(capabilityAliasRoutes.id, route.id))
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a', 'chat-b'] })
const aliases = await service.listAliases('llm')
@@ -76,9 +76,9 @@ describe('officialCatalogService', () => {
'alibaba/cosyvoice-v2': { provider: 'dashscope-cosyvoice' },
},
})
await db.update(officialTtsModels)
await db.update(providerCatalogTtsModels)
.set({ enabled: false, displayName: 'Curated CosyVoice', displayOrder: 7 })
.where(eq(officialTtsModels.id, first[0].id))
.where(eq(providerCatalogTtsModels.id, first[0].id))
await service.syncTtsModelsFromRouterConfig({
models: {
@@ -124,14 +124,14 @@ describe('officialCatalogService', () => {
previewAudioUrl: 'https://example.com/ava.mp3',
})
await db.update(officialTtsVoices)
await db.update(providerCatalogTtsVoices)
.set({
enabled: true,
displayName: 'Curated Ava',
displayOrder: 3,
previewAudioUrl: 'https://example.com/manual.mp3',
})
.where(eq(officialTtsVoices.id, first[0].id))
.where(eq(providerCatalogTtsVoices.id, first[0].id))
await service.syncTtsVoices({
routerModelId: 'microsoft/v1',
@@ -167,42 +167,42 @@ describe('officialCatalogService', () => {
expect(await service.listEnabledTtsModels()).toHaveLength(1)
expect(await service.listEnabledTtsVoices('microsoft/v1')).toEqual([])
await db.update(officialTtsVoices)
await db.update(providerCatalogTtsVoices)
.set({ enabled: true })
.where(eq(officialTtsVoices.id, voice.id))
.where(eq(providerCatalogTtsVoices.id, voice.id))
expect((await service.listEnabledTtsVoices('microsoft/v1')).map(item => item.providerVoiceId)).toEqual(['en-US-AvaMultilingualNeural'])
await db.update(officialTtsModels)
await db.update(providerCatalogTtsModels)
.set({ enabled: false })
.where(eq(officialTtsModels.id, model.id))
.where(eq(providerCatalogTtsModels.id, model.id))
await expect(service.assertTtsModelEnabled('microsoft/v1')).rejects.toMatchObject({
errorCode: 'OFFICIAL_MODEL_DISABLED',
errorCode: 'PROVIDER_CATALOG_TTS_MODEL_DISABLED',
})
await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'en-US-AvaMultilingualNeural')).rejects.toMatchObject({
errorCode: 'OFFICIAL_MODEL_DISABLED',
errorCode: 'PROVIDER_CATALOG_TTS_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',
errorCode: 'CAPABILITY_ALIAS_NOT_FOUND',
})
await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a'] })
const [alias] = await db.select().from(officialProviderAliases)
await db.update(officialProviderAliases)
const [alias] = await db.select().from(capabilityAliases)
await db.update(capabilityAliases)
.set({ enabled: false })
.where(eq(officialProviderAliases.id, alias.id))
.where(eq(capabilityAliases.id, alias.id))
await expect(service.resolveEnabledAlias('llm', 'auto')).rejects.toMatchObject({
errorCode: 'OFFICIAL_ALIAS_DISABLED',
errorCode: 'CAPABILITY_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',
errorCode: 'PROVIDER_CATALOG_TTS_VOICE_NOT_FOUND',
})
})
})
@@ -0,0 +1,444 @@
import type { Database } from '../../../libs/db'
import type {
CapabilityAlias,
CapabilityAliasRoute,
CapabilityAliasRoutePool,
CapabilityAliasSurface,
ProviderCatalogTtsModel,
ProviderCatalogTtsVoice,
ProviderCatalogTtsVoiceLabels,
ProviderCatalogTtsVoiceLanguage,
} from '../../../schemas/provider-catalog'
import { and, asc, eq, inArray } from 'drizzle-orm'
import {
capabilityAliases,
capabilityAliasRoutes,
providerCatalogTtsModels,
providerCatalogTtsVoices,
} from '../../../schemas/provider-catalog'
import { createBadRequestError } from '../../../utils/error'
const DEFAULT_ALIAS_ID = 'auto'
export interface ProviderCatalogTtsModelSyncInput {
provider: string
}
export interface ProviderCatalogTtsVoiceSyncInput {
id: string
name?: string
languages?: ProviderCatalogTtsVoiceLanguage[]
labels?: ProviderCatalogTtsVoiceLabels
previewAudioUrl?: string | null
}
export interface CapabilityAliasWithRoutes extends CapabilityAlias {
routes: CapabilityAliasRoute[]
}
export interface ProviderCatalogTtsVoiceWithModel {
model: ProviderCatalogTtsModel
voice: ProviderCatalogTtsVoice
}
export interface CapabilityAliasUpdateInput {
displayName?: string
enabled?: boolean
displayOrder?: number
fallbackEnabled?: boolean
loadBalancingEnabled?: boolean
}
export interface CapabilityAliasRouteUpdateInput {
enabled?: boolean
pool?: CapabilityAliasRoutePool
weight?: number
displayOrder?: number
}
export interface ProviderCatalogTtsModelUpdateInput {
displayName?: string
enabled?: boolean
displayOrder?: number
}
export interface ProviderCatalogTtsVoiceUpdateInput {
displayName?: string
enabled?: boolean
displayOrder?: number
languages?: ProviderCatalogTtsVoiceLanguage[]
labels?: ProviderCatalogTtsVoiceLabels
previewAudioUrl?: string | null
}
function defaultAliasDisplayName(surface: CapabilityAliasSurface, 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 provider catalog curation state.
*
* The router config still owns real provider URLs, keys, and fallback
* mechanics. Capability aliases and provider model or voice rows decide what
* users can see and what gateway 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 createProviderCatalogService(db: Database) {
async function findAlias(surface: CapabilityAliasSurface, aliasId: string) {
return await db.query.capabilityAliases.findFirst({
where: and(
eq(capabilityAliases.surface, surface),
eq(capabilityAliases.aliasId, aliasId),
),
})
}
async function ensureAlias(surface: CapabilityAliasSurface, aliasId: string) {
const existing = await findAlias(surface, aliasId)
if (existing)
return existing
const existingAliases = await db.query.capabilityAliases.findMany({
where: eq(capabilityAliases.surface, surface),
})
const [created] = await db.insert(capabilityAliases).values({
surface,
aliasId,
displayName: defaultAliasDisplayName(surface, aliasId),
enabled: true,
displayOrder: nextOrder(existingAliases),
fallbackEnabled: true,
loadBalancingEnabled: false,
}).onConflictDoNothing({
target: [capabilityAliases.surface, capabilityAliases.aliasId],
}).returning()
const alias = created ?? await findAlias(surface, aliasId)
if (!alias)
throw catalogError('Capability alias could not be synced', 'CAPABILITY_ALIAS_SYNC_FAILED', { surface, aliasId })
return alias
}
async function syncAliasRoute(input: {
aliasRowId: string
routerModelId: string
pool: CapabilityAliasRoutePool
order: number
}) {
const existing = await db.query.capabilityAliasRoutes.findFirst({
where: and(
eq(capabilityAliasRoutes.aliasId, input.aliasRowId),
eq(capabilityAliasRoutes.routerModelId, input.routerModelId),
eq(capabilityAliasRoutes.pool, input.pool),
),
})
if (existing)
return existing
const [created] = await db.insert(capabilityAliasRoutes).values({
aliasId: input.aliasRowId,
routerModelId: input.routerModelId,
pool: input.pool,
enabled: true,
weight: 1,
displayOrder: input.order,
}).onConflictDoNothing({
target: [
capabilityAliasRoutes.aliasId,
capabilityAliasRoutes.routerModelId,
capabilityAliasRoutes.pool,
],
}).returning()
const route = created ?? await db.query.capabilityAliasRoutes.findFirst({
where: and(
eq(capabilityAliasRoutes.aliasId, input.aliasRowId),
eq(capabilityAliasRoutes.routerModelId, input.routerModelId),
eq(capabilityAliasRoutes.pool, input.pool),
),
})
if (!route) {
throw catalogError('Capability alias route could not be synced', 'CAPABILITY_ALIAS_ROUTE_SYNC_FAILED', {
routerModelId: input.routerModelId,
pool: input.pool,
})
}
return route
}
return {
async syncAliasesFromRouterConfig(input: {
surface: CapabilityAliasSurface
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.capabilityAliases.findMany({
where: eq(capabilityAliases.surface, input.surface),
orderBy: [asc(capabilityAliases.displayOrder), asc(capabilityAliases.aliasId)],
})
},
async listAliases(surface?: CapabilityAliasSurface): Promise<CapabilityAliasWithRoutes[]> {
const aliases = await db.query.capabilityAliases.findMany({
where: surface ? eq(capabilityAliases.surface, surface) : undefined,
orderBy: [asc(capabilityAliases.displayOrder), asc(capabilityAliases.aliasId)],
})
if (aliases.length === 0)
return []
const routes = await db.query.capabilityAliasRoutes.findMany({
where: inArray(capabilityAliasRoutes.aliasId, aliases.map(alias => alias.id)),
orderBy: [asc(capabilityAliasRoutes.displayOrder), asc(capabilityAliasRoutes.routerModelId)],
})
return aliases.map(alias => ({
...alias,
routes: routes.filter(route => route.aliasId === alias.id),
}))
},
async updateAlias(id: string, input: CapabilityAliasUpdateInput): Promise<CapabilityAlias | null> {
const [updated] = await db.update(capabilityAliases)
.set({ ...input, updatedAt: new Date() })
.where(eq(capabilityAliases.id, id))
.returning()
return updated ?? null
},
async updateAliasRoute(id: string, input: CapabilityAliasRouteUpdateInput): Promise<CapabilityAliasRoute | null> {
const [updated] = await db.update(capabilityAliasRoutes)
.set({ ...input, updatedAt: new Date() })
.where(eq(capabilityAliasRoutes.id, id))
.returning()
return updated ?? null
},
async resolveEnabledAlias(surface: CapabilityAliasSurface, aliasId: string): Promise<CapabilityAliasWithRoutes> {
const alias = await findAlias(surface, aliasId)
if (!alias) {
throw catalogError('Capability alias is not configured', 'CAPABILITY_ALIAS_NOT_FOUND', { surface, aliasId })
}
if (!alias.enabled) {
throw catalogError('Capability alias is disabled', 'CAPABILITY_ALIAS_DISABLED', { surface, aliasId })
}
const routes = await db.query.capabilityAliasRoutes.findMany({
where: and(
eq(capabilityAliasRoutes.aliasId, alias.id),
eq(capabilityAliasRoutes.enabled, true),
),
orderBy: [asc(capabilityAliasRoutes.displayOrder), asc(capabilityAliasRoutes.routerModelId)],
})
if (routes.length === 0) {
throw catalogError('Capability alias has no enabled route', 'CAPABILITY_ALIAS_ROUTE_NOT_FOUND', { surface, aliasId })
}
return { ...alias, routes }
},
async syncTtsModelsFromRouterConfig(input: {
models: Record<string, ProviderCatalogTtsModelSyncInput>
}) {
const existingModels = await db.query.providerCatalogTtsModels.findMany()
const synced: ProviderCatalogTtsModel[] = []
const now = new Date()
for (const [routerModelId, model] of Object.entries(input.models).sort(([a], [b]) => a.localeCompare(b))) {
const [syncedModel] = await db.insert(providerCatalogTtsModels).values({
routerModelId,
provider: model.provider,
displayName: routerModelId,
enabled: true,
displayOrder: nextOrder([...existingModels, ...synced]),
lastSyncedAt: now,
}).onConflictDoUpdate({
target: providerCatalogTtsModels.routerModelId,
set: {
provider: model.provider,
lastSyncedAt: now,
updatedAt: now,
},
}).returning()
synced.push(syncedModel)
}
return synced
},
async listTtsModels(): Promise<ProviderCatalogTtsModel[]> {
return await db.query.providerCatalogTtsModels.findMany({
orderBy: [asc(providerCatalogTtsModels.displayOrder), asc(providerCatalogTtsModels.routerModelId)],
})
},
async updateTtsModel(id: string, input: ProviderCatalogTtsModelUpdateInput): Promise<ProviderCatalogTtsModel | null> {
const [updated] = await db.update(providerCatalogTtsModels)
.set({ ...input, updatedAt: new Date() })
.where(eq(providerCatalogTtsModels.id, id))
.returning()
return updated ?? null
},
async listEnabledTtsModels(): Promise<ProviderCatalogTtsModel[]> {
return await db.query.providerCatalogTtsModels.findMany({
where: eq(providerCatalogTtsModels.enabled, true),
orderBy: [asc(providerCatalogTtsModels.displayOrder), asc(providerCatalogTtsModels.routerModelId)],
})
},
async assertTtsModelEnabled(routerModelId: string): Promise<ProviderCatalogTtsModel> {
const model = await db.query.providerCatalogTtsModels.findFirst({
where: eq(providerCatalogTtsModels.routerModelId, routerModelId),
})
if (!model) {
throw catalogError('Provider catalog TTS model is not configured', 'PROVIDER_CATALOG_TTS_MODEL_NOT_FOUND', { model: routerModelId })
}
if (!model.enabled) {
throw catalogError('Provider catalog TTS model is disabled', 'PROVIDER_CATALOG_TTS_MODEL_DISABLED', { model: routerModelId })
}
return model
},
async syncTtsVoices(input: {
routerModelId: string
voices: ProviderCatalogTtsVoiceSyncInput[]
}) {
const model = await db.query.providerCatalogTtsModels.findFirst({
where: eq(providerCatalogTtsModels.routerModelId, input.routerModelId),
})
if (!model) {
throw catalogError('Provider catalog TTS model is not configured', 'PROVIDER_CATALOG_TTS_MODEL_NOT_FOUND', { model: input.routerModelId })
}
const existingVoices = await db.query.providerCatalogTtsVoices.findMany({
where: eq(providerCatalogTtsVoices.ttsModelId, model.id),
})
const existingByVoiceId = new Map(existingVoices.map(voice => [voice.providerVoiceId, voice]))
const synced: ProviderCatalogTtsVoice[] = []
const now = new Date()
for (const voice of input.voices) {
const existing = existingByVoiceId.get(voice.id)
const [syncedVoice] = await db.insert(providerCatalogTtsVoices).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,
}).onConflictDoUpdate({
target: [providerCatalogTtsVoices.ttsModelId, providerCatalogTtsVoices.providerVoiceId],
set: {
languages: voice.languages ?? existing?.languages ?? [],
labels: voice.labels ?? existing?.labels ?? {},
lastSyncedAt: now,
updatedAt: now,
},
}).returning()
synced.push(syncedVoice)
}
return synced
},
async listTtsVoices(routerModelId: string): Promise<ProviderCatalogTtsVoice[]> {
const model = await db.query.providerCatalogTtsModels.findFirst({
where: eq(providerCatalogTtsModels.routerModelId, routerModelId),
})
if (!model)
return []
return await db.query.providerCatalogTtsVoices.findMany({
where: eq(providerCatalogTtsVoices.ttsModelId, model.id),
orderBy: [asc(providerCatalogTtsVoices.displayOrder), asc(providerCatalogTtsVoices.providerVoiceId)],
})
},
async getTtsVoiceWithModel(id: string): Promise<ProviderCatalogTtsVoiceWithModel | null> {
const voice = await db.query.providerCatalogTtsVoices.findFirst({
where: eq(providerCatalogTtsVoices.id, id),
})
if (!voice)
return null
const model = await db.query.providerCatalogTtsModels.findFirst({
where: eq(providerCatalogTtsModels.id, voice.ttsModelId),
})
if (!model)
return null
return { model, voice }
},
async updateTtsVoice(id: string, input: ProviderCatalogTtsVoiceUpdateInput): Promise<ProviderCatalogTtsVoice | null> {
const [updated] = await db.update(providerCatalogTtsVoices)
.set({ ...input, updatedAt: new Date() })
.where(eq(providerCatalogTtsVoices.id, id))
.returning()
return updated ?? null
},
async listEnabledTtsVoices(routerModelId: string): Promise<ProviderCatalogTtsVoice[]> {
const model = await this.assertTtsModelEnabled(routerModelId)
return await db.query.providerCatalogTtsVoices.findMany({
where: and(
eq(providerCatalogTtsVoices.ttsModelId, model.id),
eq(providerCatalogTtsVoices.enabled, true),
),
orderBy: [asc(providerCatalogTtsVoices.displayOrder), asc(providerCatalogTtsVoices.providerVoiceId)],
})
},
async assertTtsVoiceEnabled(routerModelId: string, providerVoiceId: string): Promise<ProviderCatalogTtsVoice> {
const model = await this.assertTtsModelEnabled(routerModelId)
const voice = await db.query.providerCatalogTtsVoices.findFirst({
where: and(
eq(providerCatalogTtsVoices.ttsModelId, model.id),
eq(providerCatalogTtsVoices.providerVoiceId, providerVoiceId),
),
})
if (!voice) {
throw catalogError('Provider catalog TTS voice is not configured for this model', 'PROVIDER_CATALOG_TTS_VOICE_NOT_FOUND', {
model: routerModelId,
voice: providerVoiceId,
})
}
if (!voice.enabled) {
throw catalogError('Provider catalog TTS voice is disabled', 'PROVIDER_CATALOG_TTS_VOICE_DISABLED', {
model: routerModelId,
voice: providerVoiceId,
})
}
return voice
},
}
}
export type ProviderCatalogService = ReturnType<typeof createProviderCatalogService>
@@ -1,4 +1,4 @@
import type { OfficialTtsVoice, OfficialTtsVoiceLabels, OfficialTtsVoiceLanguage } from '../../../schemas/official-catalog'
import type { ProviderCatalogTtsVoice, ProviderCatalogTtsVoiceLabels, ProviderCatalogTtsVoiceLanguage } from '../../../schemas/provider-catalog'
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'object' || value == null || Array.isArray(value))
@@ -10,7 +10,7 @@ function asOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function asLanguageList(value: unknown): OfficialTtsVoiceLanguage[] | undefined {
function asLanguageList(value: unknown): ProviderCatalogTtsVoiceLanguage[] | undefined {
if (!Array.isArray(value))
return undefined
@@ -25,13 +25,13 @@ function asLanguageList(value: unknown): OfficialTtsVoiceLanguage[] | undefined
return languages.length > 0 ? languages : undefined
}
function asLabels(value: unknown): OfficialTtsVoiceLabels | undefined {
function asLabels(value: unknown): ProviderCatalogTtsVoiceLabels | undefined {
const record = asRecord(value)
return record ? { ...record } : undefined
}
/**
* Normalizes a provider-specific voice object into the official catalog sync shape.
* Normalizes a provider-specific voice object into the provider catalog sync shape.
*
* Before:
* - `{ id: "en-US-AvaMultilingualNeural", name: "Ava", previewUrl: "https://..." }`
@@ -54,12 +54,17 @@ export function normalizeProviderVoiceForCatalog(value: unknown) {
}
}
export function catalogVoiceResponse(voice: OfficialTtsVoice) {
export function catalogVoiceResponse(voice: ProviderCatalogTtsVoice) {
// NOTICE: Admin-generated previews may temporarily live as data URIs until
// object storage is wired. Public voice catalogs stay lightweight and only
// expose provider or storage URLs.
const previewAudioUrl = voice.previewAudioUrl?.startsWith('data:') ? undefined : voice.previewAudioUrl
return {
id: voice.providerVoiceId,
name: voice.displayName,
languages: voice.languages,
labels: voice.labels,
preview_audio_url: voice.previewAudioUrl ?? undefined,
preview_audio_url: previewAudioUrl ?? undefined,
}
}
+30 -30
View File
@@ -239,14 +239,14 @@ export interface SpeechTestPayload {
}
}
export type OfficialCatalogSurface = 'llm' | 'asr'
export type OfficialCatalogRoutePool = 'primary' | 'fallback'
export type CapabilityAliasSurface = 'llm' | 'asr'
export type CapabilityAliasRoutePool = 'primary' | 'fallback'
export interface OfficialProviderAliasRoute {
export interface CapabilityAliasRoute {
id: string
aliasId: string
routerModelId: string
pool: OfficialCatalogRoutePool
pool: CapabilityAliasRoutePool
enabled: boolean
weight: number
displayOrder: number
@@ -254,21 +254,21 @@ export interface OfficialProviderAliasRoute {
updatedAt: string
}
export interface OfficialProviderAlias {
export interface CapabilityAlias {
id: string
surface: OfficialCatalogSurface
surface: CapabilityAliasSurface
aliasId: string
displayName: string
enabled: boolean
displayOrder: number
fallbackEnabled: boolean
loadBalancingEnabled: boolean
routes: OfficialProviderAliasRoute[]
routes: CapabilityAliasRoute[]
createdAt: string
updatedAt: string
}
export interface OfficialTtsModel {
export interface ProviderCatalogTtsModel {
id: string
routerModelId: string
provider: string
@@ -280,7 +280,7 @@ export interface OfficialTtsModel {
updatedAt: string
}
export interface OfficialTtsVoice {
export interface ProviderCatalogTtsVoice {
id: string
ttsModelId: string
providerVoiceId: string
@@ -499,51 +499,51 @@ export const adminApi = {
adminFetch<VoicePack>(`/voice-packs/${encodeURIComponent(id)}/disable`, {
method: 'POST',
}),
officialAliases: (surface?: OfficialCatalogSurface) => {
capabilityAliases: (surface?: CapabilityAliasSurface) => {
const suffix = surface ? `?surface=${encodeURIComponent(surface)}` : ''
return adminFetch<OfficialProviderAlias[]>(`/official-catalog/aliases${suffix}`)
return adminFetch<CapabilityAlias[]>(`/provider-catalog/aliases${suffix}`)
},
syncOfficialAliases: (surface: OfficialCatalogSurface) =>
adminFetch<{ aliases: OfficialProviderAlias[] }>('/official-catalog/aliases/sync', {
syncCapabilityAliases: (surface: CapabilityAliasSurface) =>
adminFetch<{ aliases: CapabilityAlias[] }>('/provider-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)}`, {
updateCapabilityAlias: (id: string, body: Partial<Pick<CapabilityAlias, 'displayName' | 'enabled' | 'displayOrder' | 'fallbackEnabled' | 'loadBalancingEnabled'>>) =>
adminFetch<CapabilityAlias>(`/provider-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)}`, {
updateCapabilityAliasRoute: (id: string, body: Partial<Pick<CapabilityAliasRoute, 'enabled' | 'pool' | 'weight' | 'displayOrder'>>) =>
adminFetch<CapabilityAliasRoute>(`/provider-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', {
providerCatalogTtsModels: () => adminFetch<ProviderCatalogTtsModel[]>('/provider-catalog/tts/models'),
syncProviderCatalogTtsModels: () =>
adminFetch<{ models: ProviderCatalogTtsModel[] }>('/provider-catalog/tts/models/sync', {
method: 'POST',
}),
updateOfficialTtsModel: (id: string, body: Partial<Pick<OfficialTtsModel, 'displayName' | 'enabled' | 'displayOrder'>>) =>
adminFetch<OfficialTtsModel>(`/official-catalog/tts/models/${encodeURIComponent(id)}`, {
updateProviderCatalogTtsModel: (id: string, body: Partial<Pick<ProviderCatalogTtsModel, 'displayName' | 'enabled' | 'displayOrder'>>) =>
adminFetch<ProviderCatalogTtsModel>(`/provider-catalog/tts/models/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
officialTtsVoices: (model: string) => {
providerCatalogTtsVoices: (model: string) => {
const query = new URLSearchParams({ model })
return adminFetch<OfficialTtsVoice[]>(`/official-catalog/tts/voices?${query.toString()}`)
return adminFetch<ProviderCatalogTtsVoice[]>(`/provider-catalog/tts/voices?${query.toString()}`)
},
syncOfficialTtsVoices: (routerModelId: string) =>
adminFetch<{ voices: OfficialTtsVoice[], syncedCount: number }>('/official-catalog/tts/voices/sync', {
syncProviderCatalogTtsVoices: (routerModelId: string) =>
adminFetch<{ voices: ProviderCatalogTtsVoice[], syncedCount: number }>('/provider-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)}`, {
updateProviderCatalogTtsVoice: (id: string, body: Partial<Pick<ProviderCatalogTtsVoice, 'displayName' | 'enabled' | 'displayOrder' | 'languages' | 'labels' | 'previewAudioUrl'>>) =>
adminFetch<ProviderCatalogTtsVoice>(`/provider-catalog/tts/voices/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
generateOfficialTtsVoicePreview: (id: string, body: { text?: string, responseFormat?: string } = {}) =>
adminFetch<{ voice: OfficialTtsVoice, contentType: string, byteLength: number }>(`/official-catalog/tts/voices/${encodeURIComponent(id)}/preview`, {
generateProviderCatalogTtsVoicePreview: (id: string, body: { text?: string, responseFormat?: string } = {}) =>
adminFetch<{ voice: ProviderCatalogTtsVoice, contentType: string, byteLength: number }>(`/provider-catalog/tts/voices/${encodeURIComponent(id)}/preview`, {
method: 'POST',
body: JSON.stringify(body),
}),
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { OfficialCatalogSurface, OfficialProviderAlias, OfficialProviderAliasRoute } from '../modules/api'
import type { CapabilityAlias, CapabilityAliasRoute, CapabilityAliasSurface } from '../modules/api'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
import { Button } from '@proj-airi/ui'
@@ -8,8 +8,8 @@ import { toast } from 'vue-sonner'
import { adminApi } from '../modules/api'
const aliases = shallowRef<OfficialProviderAlias[]>([])
const surface = ref<OfficialCatalogSurface>('llm')
const aliases = shallowRef<CapabilityAlias[]>([])
const surface = ref<CapabilityAliasSurface>('llm')
const loading = shallowRef(false)
const syncing = shallowRef(false)
@@ -23,7 +23,7 @@ onMounted(() => {
async function loadAliases() {
loading.value = true
try {
aliases.value = await adminApi.officialAliases(surface.value)
aliases.value = await adminApi.capabilityAliases(surface.value)
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to load provider catalog'))
@@ -36,7 +36,7 @@ async function loadAliases() {
async function syncAliases() {
syncing.value = true
try {
await adminApi.syncOfficialAliases(surface.value)
await adminApi.syncCapabilityAliases(surface.value)
toast.success('Provider aliases synced')
await loadAliases()
}
@@ -48,9 +48,9 @@ async function syncAliases() {
}
}
async function updateAlias(alias: OfficialProviderAlias, patch: Partial<Pick<OfficialProviderAlias, 'displayName' | 'enabled' | 'displayOrder' | 'fallbackEnabled' | 'loadBalancingEnabled'>>) {
async function updateAlias(alias: CapabilityAlias, patch: Partial<Pick<CapabilityAlias, 'displayName' | 'enabled' | 'displayOrder' | 'fallbackEnabled' | 'loadBalancingEnabled'>>) {
try {
const updated = await adminApi.updateOfficialAlias(alias.id, patch)
const updated = await adminApi.updateCapabilityAlias(alias.id, patch)
aliases.value = aliases.value.map(item => item.id === updated.id ? { ...item, ...updated, routes: item.routes } : item)
toast.success('Alias updated')
}
@@ -59,9 +59,9 @@ async function updateAlias(alias: OfficialProviderAlias, patch: Partial<Pick<Off
}
}
async function updateRoute(alias: OfficialProviderAlias, route: OfficialProviderAliasRoute, patch: Partial<Pick<OfficialProviderAliasRoute, 'enabled' | 'pool' | 'weight' | 'displayOrder'>>) {
async function updateRoute(alias: CapabilityAlias, route: CapabilityAliasRoute, patch: Partial<Pick<CapabilityAliasRoute, 'enabled' | 'pool' | 'weight' | 'displayOrder'>>) {
try {
const updated = await adminApi.updateOfficialAliasRoute(route.id, patch)
const updated = await adminApi.updateCapabilityAliasRoute(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)
+14 -14
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { OfficialTtsModel, OfficialTtsVoice } from '../modules/api'
import type { ProviderCatalogTtsModel, ProviderCatalogTtsVoice } from '../modules/api'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
import { Button } from '@proj-airi/ui'
@@ -8,8 +8,8 @@ import { toast } from 'vue-sonner'
import { adminApi } from '../modules/api'
const models = shallowRef<OfficialTtsModel[]>([])
const voices = shallowRef<OfficialTtsVoice[]>([])
const models = shallowRef<ProviderCatalogTtsModel[]>([])
const voices = shallowRef<ProviderCatalogTtsVoice[]>([])
const selectedModel = ref('')
const loadingModels = shallowRef(false)
const loadingVoices = shallowRef(false)
@@ -34,7 +34,7 @@ onMounted(() => {
async function loadModels() {
loadingModels.value = true
try {
models.value = await adminApi.officialTtsModels()
models.value = await adminApi.providerCatalogTtsModels()
if (!selectedModel.value && models.value[0])
selectedModel.value = models.value[0].routerModelId
}
@@ -52,7 +52,7 @@ async function loadVoices() {
loadingVoices.value = true
try {
voices.value = await adminApi.officialTtsVoices(selectedModel.value)
voices.value = await adminApi.providerCatalogTtsVoices(selectedModel.value)
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to load TTS voices'))
@@ -65,7 +65,7 @@ async function loadVoices() {
async function syncModels() {
syncingModels.value = true
try {
await adminApi.syncOfficialTtsModels()
await adminApi.syncProviderCatalogTtsModels()
toast.success('TTS models synced')
await loadModels()
}
@@ -83,7 +83,7 @@ async function syncVoices() {
syncingVoices.value = true
try {
const result = await adminApi.syncOfficialTtsVoices(selectedModel.value)
const result = await adminApi.syncProviderCatalogTtsVoices(selectedModel.value)
toast.success(`${result.syncedCount} voices synced`)
await loadVoices()
}
@@ -95,9 +95,9 @@ async function syncVoices() {
}
}
async function updateModel(model: OfficialTtsModel, patch: Partial<Pick<OfficialTtsModel, 'displayName' | 'enabled' | 'displayOrder'>>) {
async function updateModel(model: ProviderCatalogTtsModel, patch: Partial<Pick<ProviderCatalogTtsModel, 'displayName' | 'enabled' | 'displayOrder'>>) {
try {
const updated = await adminApi.updateOfficialTtsModel(model.id, patch)
const updated = await adminApi.updateProviderCatalogTtsModel(model.id, patch)
models.value = models.value.map(item => item.id === updated.id ? updated : item)
toast.success('TTS model updated')
}
@@ -106,9 +106,9 @@ async function updateModel(model: OfficialTtsModel, patch: Partial<Pick<Official
}
}
async function updateVoice(voice: OfficialTtsVoice, patch: Partial<Pick<OfficialTtsVoice, 'displayName' | 'enabled' | 'displayOrder' | 'previewAudioUrl'>>) {
async function updateVoice(voice: ProviderCatalogTtsVoice, patch: Partial<Pick<ProviderCatalogTtsVoice, 'displayName' | 'enabled' | 'displayOrder' | 'previewAudioUrl'>>) {
try {
const updated = await adminApi.updateOfficialTtsVoice(voice.id, patch)
const updated = await adminApi.updateProviderCatalogTtsVoice(voice.id, patch)
voices.value = voices.value.map(item => item.id === updated.id ? updated : item)
toast.success('TTS voice updated')
}
@@ -117,10 +117,10 @@ async function updateVoice(voice: OfficialTtsVoice, patch: Partial<Pick<Official
}
}
async function generatePreview(voice: OfficialTtsVoice) {
async function generatePreview(voice: ProviderCatalogTtsVoice) {
generatingPreviewVoiceId.value = voice.id
try {
const result = await adminApi.generateOfficialTtsVoicePreview(voice.id)
const result = await adminApi.generateProviderCatalogTtsVoicePreview(voice.id)
voices.value = voices.value.map(item => item.id === result.voice.id ? result.voice : item)
toast.success('Preview generated')
}
@@ -138,7 +138,7 @@ function formatDate(value: string | null): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
}
function languageSummary(voice: OfficialTtsVoice): string {
function languageSummary(voice: ProviderCatalogTtsVoice): string {
if (!voice.languages.length)
return 'Not set'
return voice.languages.map(language => language.title ?? language.code).join(', ')