diff --git a/apps/server/drizzle/0017_nappy_dagger.sql b/apps/server/drizzle/0017_nappy_dagger.sql index 12f100c09..2ace6c04f 100644 --- a/apps/server/drizzle/0017_nappy_dagger.sql +++ b/apps/server/drizzle/0017_nappy_dagger.sql @@ -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"); \ No newline at end of file +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"); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0017_snapshot.json b/apps/server/drizzle/meta/0017_snapshot.json index fbe6cad34..807919225 100644 --- a/apps/server/drizzle/meta/0017_snapshot.json +++ b/apps/server/drizzle/meta/0017_snapshot.json @@ -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" ], diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index 2aa864e45..3ea053a25 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -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 () => []), diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 492c1c4b3..b6d4574b6 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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') diff --git a/apps/server/src/routes/admin/official-catalog/index.ts b/apps/server/src/routes/admin/provider-catalog/index.ts similarity index 87% rename from apps/server/src/routes/admin/official-catalog/index.ts rename to apps/server/src/routes/admin/provider-catalog/index.ts index bce744ae3..5c6b4104d 100644 --- a/apps/server/src/routes/admin/official-catalog/index.ts +++ b/apps/server/src/routes/admin/provider-catalog/index.ts @@ -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(c: Context, 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() .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) }) } diff --git a/apps/server/src/routes/admin/official-catalog/route.test.ts b/apps/server/src/routes/admin/provider-catalog/route.test.ts similarity index 87% rename from apps/server/src/routes/admin/official-catalog/route.test.ts rename to apps/server/src/routes/admin/provider-catalog/route.test.ts index b67821764..eddbf00ad 100644 --- a/apps/server/src/routes/admin/official-catalog/route.test.ts +++ b/apps/server/src/routes/admin/provider-catalog/route.test.ts @@ -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[0]) => input.voices.map((voice, index) => ({ + syncTtsVoices: vi.fn(async (input: Parameters[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() .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, 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.', }) diff --git a/apps/server/src/routes/audio-speech-ws/session.ts b/apps/server/src/routes/audio-speech-ws/session.ts index 52542c51e..790b9987e 100644 --- a/apps/server/src/routes/audio-speech-ws/session.ts +++ b/apps/server/src/routes/audio-speech-ws/session.ts @@ -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() } diff --git a/apps/server/src/routes/audio-transcription-stream/route.test.ts b/apps/server/src/routes/audio-transcription-stream/route.test.ts index b6621d926..00ccb9ee2 100644 --- a/apps/server/src/routes/audio-transcription-stream/route.test.ts +++ b/apps/server/src/routes/audio-transcription-stream/route.test.ts @@ -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 { } } -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', }) }) }) diff --git a/apps/server/src/routes/audio-transcription-stream/route.ts b/apps/server/src/routes/audio-transcription-stream/route.ts index 46154128e..d4ea55953 100644 --- a/apps/server/src/routes/audio-transcription-stream/route.ts +++ b/apps/server/src/routes/audio-transcription-stream/route.ts @@ -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) diff --git a/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts b/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts index 7285bf726..a30642bd2 100644 --- a/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts +++ b/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts @@ -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 { - 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))) } diff --git a/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts b/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts index 9e07fec60..e67dd8599 100644 --- a/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts +++ b/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts @@ -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, }) } diff --git a/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts b/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts index e12f8d154..dddf244cc 100644 --- a/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts +++ b/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts @@ -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, diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index 359b6741f..33b34876a 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -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): VoicePack } as unknown as VoicePackService } -function createMockOfficialCatalogService(impl?: Partial): OfficialCatalogService { +function createMockProviderCatalogService(impl?: Partial): ProviderCatalogService { let syncedAliasRoutes: Array<{ id: string aliasId: string @@ -172,11 +172,11 @@ function createMockOfficialCatalogService(impl?: Partial createdAt: Date updatedAt: Date }> = [] - let syncedModels: Awaited> = [] - const syncedVoicesByModel = new Map>>() + let syncedModels: Awaited> = [] + const syncedVoicesByModel = new Map>>() return { - syncAliasesFromRouterConfig: vi.fn(async (input: Parameters[0]) => { + syncAliasesFromRouterConfig: vi.fn(async (input: Parameters[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 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 updatedAt: new Date(), }], })), - syncTtsModelsFromRouterConfig: vi.fn(async (input: Parameters[0]) => { + syncTtsModelsFromRouterConfig: vi.fn(async (input: Parameters[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 createdAt: new Date(), updatedAt: new Date(), })), - syncTtsVoices: vi.fn(async (input: Parameters[0]) => { + syncTtsVoices: vi.fn(async (input: Parameters[0]) => { const { routerModelId, voices } = input const syncedVoices = voices.map((voice, index) => ({ id: `tts-voice-${index}`, @@ -295,7 +307,7 @@ function createMockOfficialCatalogService(impl?: Partial 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>, recommended: Record } - 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> } 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 () => { diff --git a/apps/server/src/routes/openai/v1/types.ts b/apps/server/src/routes/openai/v1/types.ts index cbf261595..41b216239 100644 --- a/apps/server/src/routes/openai/v1/types.ts +++ b/apps/server/src/routes/openai/v1/types.ts @@ -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 diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts index 4af95df02..ad4136b6e 100644 --- a/apps/server/src/schemas/index.ts +++ b/apps/server/src/schemas/index.ts @@ -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' diff --git a/apps/server/src/schemas/official-catalog.ts b/apps/server/src/schemas/provider-catalog.ts similarity index 54% rename from apps/server/src/schemas/official-catalog.ts rename to apps/server/src/schemas/provider-catalog.ts index 911cea89c..28dc43dad 100644 --- a/apps/server/src/schemas/official-catalog.ts +++ b/apps/server/src/schemas/provider-catalog.ts @@ -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 +export type ProviderCatalogTtsVoiceLabels = Record -export const officialProviderAliases = pgTable( - 'official_provider_aliases', +export const capabilityAliases = pgTable( + 'capability_aliases', { id: text('id').primaryKey().$defaultFn(() => nanoid()), - surface: text('surface').notNull().$type(), + surface: text('surface').notNull().$type(), 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().default('primary'), + pool: text('pool').notNull().$type().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().default([]), - labels: jsonb('labels').notNull().$type().default({}), + languages: jsonb('languages').notNull().$type().default([]), + labels: jsonb('labels').notNull().$type().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 -export type NewOfficialProviderAlias = InferInsertModel -export type OfficialProviderAliasRoute = InferSelectModel -export type NewOfficialProviderAliasRoute = InferInsertModel -export type OfficialTtsModel = InferSelectModel -export type NewOfficialTtsModel = InferInsertModel -export type OfficialTtsVoice = InferSelectModel -export type NewOfficialTtsVoice = InferInsertModel +export type CapabilityAlias = InferSelectModel +export type NewCapabilityAlias = InferInsertModel +export type CapabilityAliasRoute = InferSelectModel +export type NewCapabilityAliasRoute = InferInsertModel +export type ProviderCatalogTtsModel = InferSelectModel +export type NewProviderCatalogTtsModel = InferInsertModel +export type ProviderCatalogTtsVoice = InferSelectModel +export type NewProviderCatalogTtsVoice = InferInsertModel diff --git a/apps/server/src/services/domain/official-catalog/index.ts b/apps/server/src/services/domain/official-catalog/index.ts deleted file mode 100644 index 023e5abf9..000000000 --- a/apps/server/src/services/domain/official-catalog/index.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 - }) { - 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 { - return await db.query.officialTtsModels.findMany({ - orderBy: [asc(officialTtsModels.displayOrder), asc(officialTtsModels.routerModelId)], - }) - }, - - async updateTtsModel(id: string, input: OfficialTtsModelUpdateInput): Promise { - const [updated] = await db.update(officialTtsModels) - .set({ ...input, updatedAt: new Date() }) - .where(eq(officialTtsModels.id, id)) - .returning() - return updated ?? null - }, - - async listEnabledTtsModels(): Promise { - return await db.query.officialTtsModels.findMany({ - where: eq(officialTtsModels.enabled, true), - orderBy: [asc(officialTtsModels.displayOrder), asc(officialTtsModels.routerModelId)], - }) - }, - - async assertTtsModelEnabled(routerModelId: string): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 diff --git a/apps/server/src/services/domain/openai-speech/index.ts b/apps/server/src/services/domain/openai-speech/index.ts index 5322c9e4f..2c386008e 100644 --- a/apps/server/src/services/domain/openai-speech/index.ts +++ b/apps/server/src/services/domain/openai-speech/index.ts @@ -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, diff --git a/apps/server/src/services/domain/official-catalog/index.test.ts b/apps/server/src/services/domain/provider-catalog/index.test.ts similarity index 79% rename from apps/server/src/services/domain/official-catalog/index.test.ts rename to apps/server/src/services/domain/provider-catalog/index.test.ts index 6b87bf02e..465425126 100644 --- a/apps/server/src/services/domain/official-catalog/index.test.ts +++ b/apps/server/src/services/domain/provider-catalog/index.test.ts @@ -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 + let service: ReturnType 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', }) }) }) diff --git a/apps/server/src/services/domain/provider-catalog/index.ts b/apps/server/src/services/domain/provider-catalog/index.ts new file mode 100644 index 000000000..e9279698a --- /dev/null +++ b/apps/server/src/services/domain/provider-catalog/index.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + }) { + 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 { + return await db.query.providerCatalogTtsModels.findMany({ + orderBy: [asc(providerCatalogTtsModels.displayOrder), asc(providerCatalogTtsModels.routerModelId)], + }) + }, + + async updateTtsModel(id: string, input: ProviderCatalogTtsModelUpdateInput): Promise { + const [updated] = await db.update(providerCatalogTtsModels) + .set({ ...input, updatedAt: new Date() }) + .where(eq(providerCatalogTtsModels.id, id)) + .returning() + return updated ?? null + }, + + async listEnabledTtsModels(): Promise { + return await db.query.providerCatalogTtsModels.findMany({ + where: eq(providerCatalogTtsModels.enabled, true), + orderBy: [asc(providerCatalogTtsModels.displayOrder), asc(providerCatalogTtsModels.routerModelId)], + }) + }, + + async assertTtsModelEnabled(routerModelId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 diff --git a/apps/server/src/services/domain/official-catalog/provider-voices.ts b/apps/server/src/services/domain/provider-catalog/provider-voices.ts similarity index 66% rename from apps/server/src/services/domain/official-catalog/provider-voices.ts rename to apps/server/src/services/domain/provider-catalog/provider-voices.ts index 02c1a4f3b..76e637cf0 100644 --- a/apps/server/src/services/domain/official-catalog/provider-voices.ts +++ b/apps/server/src/services/domain/provider-catalog/provider-voices.ts @@ -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 | 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, } } diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts index 92f33f9c3..4ab6085ce 100644 --- a/apps/ui-admin/src/modules/api.ts +++ b/apps/ui-admin/src/modules/api.ts @@ -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(`/voice-packs/${encodeURIComponent(id)}/disable`, { method: 'POST', }), - officialAliases: (surface?: OfficialCatalogSurface) => { + capabilityAliases: (surface?: CapabilityAliasSurface) => { const suffix = surface ? `?surface=${encodeURIComponent(surface)}` : '' - return adminFetch(`/official-catalog/aliases${suffix}`) + return adminFetch(`/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>) => - adminFetch(`/official-catalog/aliases/${encodeURIComponent(id)}`, { + updateCapabilityAlias: (id: string, body: Partial>) => + adminFetch(`/provider-catalog/aliases/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(body), }), - updateOfficialAliasRoute: (id: string, body: Partial>) => - adminFetch(`/official-catalog/alias-routes/${encodeURIComponent(id)}`, { + updateCapabilityAliasRoute: (id: string, body: Partial>) => + adminFetch(`/provider-catalog/alias-routes/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(body), }), - officialTtsModels: () => adminFetch('/official-catalog/tts/models'), - syncOfficialTtsModels: () => - adminFetch<{ models: OfficialTtsModel[] }>('/official-catalog/tts/models/sync', { + providerCatalogTtsModels: () => adminFetch('/provider-catalog/tts/models'), + syncProviderCatalogTtsModels: () => + adminFetch<{ models: ProviderCatalogTtsModel[] }>('/provider-catalog/tts/models/sync', { method: 'POST', }), - updateOfficialTtsModel: (id: string, body: Partial>) => - adminFetch(`/official-catalog/tts/models/${encodeURIComponent(id)}`, { + updateProviderCatalogTtsModel: (id: string, body: Partial>) => + adminFetch(`/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(`/official-catalog/tts/voices?${query.toString()}`) + return adminFetch(`/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>) => - adminFetch(`/official-catalog/tts/voices/${encodeURIComponent(id)}`, { + updateProviderCatalogTtsVoice: (id: string, body: Partial>) => + adminFetch(`/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), }), diff --git a/apps/ui-admin/src/pages/ProviderCatalogPage.vue b/apps/ui-admin/src/pages/ProviderCatalogPage.vue index 548a2ff51..fa6077f69 100644 --- a/apps/ui-admin/src/pages/ProviderCatalogPage.vue +++ b/apps/ui-admin/src/pages/ProviderCatalogPage.vue @@ -1,5 +1,5 @@