438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
import type { Database } from '../../../libs/db'
|
|
import type {
|
|
OfficialCatalogRoutePool,
|
|
OfficialCatalogSurface,
|
|
OfficialProviderAlias,
|
|
OfficialProviderAliasRoute,
|
|
OfficialTtsModel,
|
|
OfficialTtsVoice,
|
|
OfficialTtsVoiceLabels,
|
|
OfficialTtsVoiceLanguage,
|
|
} from '../../../schemas/official-catalog'
|
|
|
|
import { and, asc, eq, inArray } from 'drizzle-orm'
|
|
|
|
import {
|
|
officialProviderAliases,
|
|
officialProviderAliasRoutes,
|
|
officialTtsModels,
|
|
officialTtsVoices,
|
|
} from '../../../schemas/official-catalog'
|
|
import { createBadRequestError } from '../../../utils/error'
|
|
|
|
const DEFAULT_ALIAS_ID = 'auto'
|
|
|
|
export interface OfficialTtsModelSyncInput {
|
|
provider: string
|
|
}
|
|
|
|
export interface OfficialTtsVoiceSyncInput {
|
|
id: string
|
|
name?: string
|
|
languages?: OfficialTtsVoiceLanguage[]
|
|
labels?: OfficialTtsVoiceLabels
|
|
previewAudioUrl?: string | null
|
|
}
|
|
|
|
export interface OfficialProviderAliasWithRoutes extends OfficialProviderAlias {
|
|
routes: OfficialProviderAliasRoute[]
|
|
}
|
|
|
|
export interface OfficialTtsVoiceWithModel {
|
|
model: OfficialTtsModel
|
|
voice: OfficialTtsVoice
|
|
}
|
|
|
|
export interface OfficialProviderAliasUpdateInput {
|
|
displayName?: string
|
|
enabled?: boolean
|
|
displayOrder?: number
|
|
fallbackEnabled?: boolean
|
|
loadBalancingEnabled?: boolean
|
|
}
|
|
|
|
export interface OfficialProviderAliasRouteUpdateInput {
|
|
enabled?: boolean
|
|
pool?: OfficialCatalogRoutePool
|
|
weight?: number
|
|
displayOrder?: number
|
|
}
|
|
|
|
export interface OfficialTtsModelUpdateInput {
|
|
displayName?: string
|
|
enabled?: boolean
|
|
displayOrder?: number
|
|
}
|
|
|
|
export interface OfficialTtsVoiceUpdateInput {
|
|
displayName?: string
|
|
enabled?: boolean
|
|
displayOrder?: number
|
|
languages?: OfficialTtsVoiceLanguage[]
|
|
labels?: OfficialTtsVoiceLabels
|
|
previewAudioUrl?: string | null
|
|
}
|
|
|
|
function defaultAliasDisplayName(surface: OfficialCatalogSurface, aliasId: string): string {
|
|
if (aliasId !== DEFAULT_ALIAS_ID)
|
|
return aliasId
|
|
return surface === 'llm' ? 'Auto' : 'Auto Transcription'
|
|
}
|
|
|
|
function nextOrder(rows: Array<{ displayOrder: number }>): number {
|
|
if (rows.length === 0)
|
|
return 0
|
|
return Math.max(...rows.map(row => row.displayOrder)) + 1
|
|
}
|
|
|
|
function catalogError(message: string, errorCode: string, details?: unknown) {
|
|
return createBadRequestError(message, errorCode, details)
|
|
}
|
|
|
|
/**
|
|
* Owns AIRI's official product catalog.
|
|
*
|
|
* The router config still owns real provider URLs, keys, and fallback
|
|
* mechanics. This service owns what users can see and what requests may use.
|
|
* Public list endpoints and gateway request gates should both call this
|
|
* service so UI hiding and handwritten request validation cannot drift.
|
|
*/
|
|
export function createOfficialCatalogService(db: Database) {
|
|
async function findAlias(surface: OfficialCatalogSurface, aliasId: string) {
|
|
return await db.query.officialProviderAliases.findFirst({
|
|
where: and(
|
|
eq(officialProviderAliases.surface, surface),
|
|
eq(officialProviderAliases.aliasId, aliasId),
|
|
),
|
|
})
|
|
}
|
|
|
|
async function ensureAlias(surface: OfficialCatalogSurface, aliasId: string) {
|
|
const existing = await findAlias(surface, aliasId)
|
|
if (existing)
|
|
return existing
|
|
|
|
const existingAliases = await db.query.officialProviderAliases.findMany({
|
|
where: eq(officialProviderAliases.surface, surface),
|
|
})
|
|
const [created] = await db.insert(officialProviderAliases).values({
|
|
surface,
|
|
aliasId,
|
|
displayName: defaultAliasDisplayName(surface, aliasId),
|
|
enabled: true,
|
|
displayOrder: nextOrder(existingAliases),
|
|
fallbackEnabled: true,
|
|
loadBalancingEnabled: false,
|
|
}).returning()
|
|
return created
|
|
}
|
|
|
|
async function syncAliasRoute(input: {
|
|
aliasRowId: string
|
|
routerModelId: string
|
|
pool: OfficialCatalogRoutePool
|
|
order: number
|
|
}) {
|
|
const existing = await db.query.officialProviderAliasRoutes.findFirst({
|
|
where: and(
|
|
eq(officialProviderAliasRoutes.aliasId, input.aliasRowId),
|
|
eq(officialProviderAliasRoutes.routerModelId, input.routerModelId),
|
|
eq(officialProviderAliasRoutes.pool, input.pool),
|
|
),
|
|
})
|
|
|
|
if (existing) {
|
|
const [updated] = await db.update(officialProviderAliasRoutes)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(officialProviderAliasRoutes.id, existing.id))
|
|
.returning()
|
|
return updated
|
|
}
|
|
|
|
const [created] = await db.insert(officialProviderAliasRoutes).values({
|
|
aliasId: input.aliasRowId,
|
|
routerModelId: input.routerModelId,
|
|
pool: input.pool,
|
|
enabled: true,
|
|
weight: 1,
|
|
displayOrder: input.order,
|
|
}).returning()
|
|
return created
|
|
}
|
|
|
|
return {
|
|
async syncAliasesFromRouterConfig(input: {
|
|
surface: OfficialCatalogSurface
|
|
modelIds: string[]
|
|
}) {
|
|
const alias = await ensureAlias(input.surface, DEFAULT_ALIAS_ID)
|
|
const uniqueModelIds = Array.from(new Set(input.modelIds))
|
|
for (const [index, routerModelId] of uniqueModelIds.entries()) {
|
|
await syncAliasRoute({
|
|
aliasRowId: alias.id,
|
|
routerModelId,
|
|
pool: 'primary',
|
|
order: index,
|
|
})
|
|
}
|
|
|
|
return await db.query.officialProviderAliases.findMany({
|
|
where: eq(officialProviderAliases.surface, input.surface),
|
|
orderBy: [asc(officialProviderAliases.displayOrder), asc(officialProviderAliases.aliasId)],
|
|
})
|
|
},
|
|
|
|
async listAliases(surface?: OfficialCatalogSurface): Promise<OfficialProviderAliasWithRoutes[]> {
|
|
const aliases = await db.query.officialProviderAliases.findMany({
|
|
where: surface ? eq(officialProviderAliases.surface, surface) : undefined,
|
|
orderBy: [asc(officialProviderAliases.displayOrder), asc(officialProviderAliases.aliasId)],
|
|
})
|
|
if (aliases.length === 0)
|
|
return []
|
|
|
|
const routes = await db.query.officialProviderAliasRoutes.findMany({
|
|
where: inArray(officialProviderAliasRoutes.aliasId, aliases.map(alias => alias.id)),
|
|
orderBy: [asc(officialProviderAliasRoutes.displayOrder), asc(officialProviderAliasRoutes.routerModelId)],
|
|
})
|
|
return aliases.map(alias => ({
|
|
...alias,
|
|
routes: routes.filter(route => route.aliasId === alias.id),
|
|
}))
|
|
},
|
|
|
|
async updateAlias(id: string, input: OfficialProviderAliasUpdateInput): Promise<OfficialProviderAlias | null> {
|
|
const [updated] = await db.update(officialProviderAliases)
|
|
.set({ ...input, updatedAt: new Date() })
|
|
.where(eq(officialProviderAliases.id, id))
|
|
.returning()
|
|
return updated ?? null
|
|
},
|
|
|
|
async updateAliasRoute(id: string, input: OfficialProviderAliasRouteUpdateInput): Promise<OfficialProviderAliasRoute | null> {
|
|
const [updated] = await db.update(officialProviderAliasRoutes)
|
|
.set({ ...input, updatedAt: new Date() })
|
|
.where(eq(officialProviderAliasRoutes.id, id))
|
|
.returning()
|
|
return updated ?? null
|
|
},
|
|
|
|
async resolveEnabledAlias(surface: OfficialCatalogSurface, aliasId: string): Promise<OfficialProviderAliasWithRoutes> {
|
|
const alias = await findAlias(surface, aliasId)
|
|
if (!alias) {
|
|
throw catalogError('Official provider alias is not configured', 'OFFICIAL_ALIAS_NOT_FOUND', { surface, aliasId })
|
|
}
|
|
if (!alias.enabled) {
|
|
throw catalogError('Official provider alias is disabled', 'OFFICIAL_ALIAS_DISABLED', { surface, aliasId })
|
|
}
|
|
|
|
const routes = await db.query.officialProviderAliasRoutes.findMany({
|
|
where: and(
|
|
eq(officialProviderAliasRoutes.aliasId, alias.id),
|
|
eq(officialProviderAliasRoutes.enabled, true),
|
|
),
|
|
orderBy: [asc(officialProviderAliasRoutes.displayOrder), asc(officialProviderAliasRoutes.routerModelId)],
|
|
})
|
|
if (routes.length === 0) {
|
|
throw catalogError('Official provider alias has no enabled route', 'OFFICIAL_ALIAS_ROUTE_NOT_FOUND', { surface, aliasId })
|
|
}
|
|
|
|
return { ...alias, routes }
|
|
},
|
|
|
|
async syncTtsModelsFromRouterConfig(input: {
|
|
models: Record<string, OfficialTtsModelSyncInput>
|
|
}) {
|
|
const existingModels = await db.query.officialTtsModels.findMany()
|
|
const existingByRouterModel = new Map(existingModels.map(model => [model.routerModelId, model]))
|
|
const synced: OfficialTtsModel[] = []
|
|
const now = new Date()
|
|
|
|
for (const [routerModelId, model] of Object.entries(input.models).sort(([a], [b]) => a.localeCompare(b))) {
|
|
const existing = existingByRouterModel.get(routerModelId)
|
|
if (existing) {
|
|
const [updated] = await db.update(officialTtsModels)
|
|
.set({
|
|
provider: model.provider,
|
|
lastSyncedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(officialTtsModels.id, existing.id))
|
|
.returning()
|
|
synced.push(updated)
|
|
continue
|
|
}
|
|
|
|
const [created] = await db.insert(officialTtsModels).values({
|
|
routerModelId,
|
|
provider: model.provider,
|
|
displayName: routerModelId,
|
|
enabled: true,
|
|
displayOrder: nextOrder([...existingModels, ...synced]),
|
|
lastSyncedAt: now,
|
|
}).returning()
|
|
synced.push(created)
|
|
}
|
|
|
|
return synced
|
|
},
|
|
|
|
async listTtsModels(): Promise<OfficialTtsModel[]> {
|
|
return await db.query.officialTtsModels.findMany({
|
|
orderBy: [asc(officialTtsModels.displayOrder), asc(officialTtsModels.routerModelId)],
|
|
})
|
|
},
|
|
|
|
async updateTtsModel(id: string, input: OfficialTtsModelUpdateInput): Promise<OfficialTtsModel | null> {
|
|
const [updated] = await db.update(officialTtsModels)
|
|
.set({ ...input, updatedAt: new Date() })
|
|
.where(eq(officialTtsModels.id, id))
|
|
.returning()
|
|
return updated ?? null
|
|
},
|
|
|
|
async listEnabledTtsModels(): Promise<OfficialTtsModel[]> {
|
|
return await db.query.officialTtsModels.findMany({
|
|
where: eq(officialTtsModels.enabled, true),
|
|
orderBy: [asc(officialTtsModels.displayOrder), asc(officialTtsModels.routerModelId)],
|
|
})
|
|
},
|
|
|
|
async assertTtsModelEnabled(routerModelId: string): Promise<OfficialTtsModel> {
|
|
const model = await db.query.officialTtsModels.findFirst({
|
|
where: eq(officialTtsModels.routerModelId, routerModelId),
|
|
})
|
|
if (!model) {
|
|
throw catalogError('Official TTS model is not configured', 'OFFICIAL_MODEL_NOT_FOUND', { model: routerModelId })
|
|
}
|
|
if (!model.enabled) {
|
|
throw catalogError('Official TTS model is disabled', 'OFFICIAL_MODEL_DISABLED', { model: routerModelId })
|
|
}
|
|
return model
|
|
},
|
|
|
|
async syncTtsVoices(input: {
|
|
routerModelId: string
|
|
voices: OfficialTtsVoiceSyncInput[]
|
|
}) {
|
|
const model = await db.query.officialTtsModels.findFirst({
|
|
where: eq(officialTtsModels.routerModelId, input.routerModelId),
|
|
})
|
|
if (!model) {
|
|
throw catalogError('Official TTS model is not configured', 'OFFICIAL_MODEL_NOT_FOUND', { model: input.routerModelId })
|
|
}
|
|
const existingVoices = await db.query.officialTtsVoices.findMany({
|
|
where: eq(officialTtsVoices.ttsModelId, model.id),
|
|
})
|
|
const existingByVoiceId = new Map(existingVoices.map(voice => [voice.providerVoiceId, voice]))
|
|
const synced: OfficialTtsVoice[] = []
|
|
const now = new Date()
|
|
|
|
for (const voice of input.voices) {
|
|
const existing = existingByVoiceId.get(voice.id)
|
|
if (existing) {
|
|
const [updated] = await db.update(officialTtsVoices)
|
|
.set({
|
|
languages: voice.languages ?? existing.languages,
|
|
labels: voice.labels ?? existing.labels,
|
|
lastSyncedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(officialTtsVoices.id, existing.id))
|
|
.returning()
|
|
synced.push(updated)
|
|
continue
|
|
}
|
|
|
|
const [created] = await db.insert(officialTtsVoices).values({
|
|
ttsModelId: model.id,
|
|
providerVoiceId: voice.id,
|
|
displayName: voice.name ?? voice.id,
|
|
enabled: false,
|
|
displayOrder: nextOrder([...existingVoices, ...synced]),
|
|
languages: voice.languages ?? [],
|
|
labels: voice.labels ?? {},
|
|
previewAudioUrl: voice.previewAudioUrl ?? null,
|
|
source: 'provider-sync',
|
|
lastSyncedAt: now,
|
|
}).returning()
|
|
synced.push(created)
|
|
}
|
|
|
|
return synced
|
|
},
|
|
|
|
async listTtsVoices(routerModelId: string): Promise<OfficialTtsVoice[]> {
|
|
const model = await db.query.officialTtsModels.findFirst({
|
|
where: eq(officialTtsModels.routerModelId, routerModelId),
|
|
})
|
|
if (!model)
|
|
return []
|
|
|
|
return await db.query.officialTtsVoices.findMany({
|
|
where: eq(officialTtsVoices.ttsModelId, model.id),
|
|
orderBy: [asc(officialTtsVoices.displayOrder), asc(officialTtsVoices.providerVoiceId)],
|
|
})
|
|
},
|
|
|
|
async getTtsVoiceWithModel(id: string): Promise<OfficialTtsVoiceWithModel | null> {
|
|
const voice = await db.query.officialTtsVoices.findFirst({
|
|
where: eq(officialTtsVoices.id, id),
|
|
})
|
|
if (!voice)
|
|
return null
|
|
|
|
const model = await db.query.officialTtsModels.findFirst({
|
|
where: eq(officialTtsModels.id, voice.ttsModelId),
|
|
})
|
|
if (!model)
|
|
return null
|
|
|
|
return { model, voice }
|
|
},
|
|
|
|
async updateTtsVoice(id: string, input: OfficialTtsVoiceUpdateInput): Promise<OfficialTtsVoice | null> {
|
|
const [updated] = await db.update(officialTtsVoices)
|
|
.set({ ...input, updatedAt: new Date() })
|
|
.where(eq(officialTtsVoices.id, id))
|
|
.returning()
|
|
return updated ?? null
|
|
},
|
|
|
|
async listEnabledTtsVoices(routerModelId: string): Promise<OfficialTtsVoice[]> {
|
|
const model = await this.assertTtsModelEnabled(routerModelId)
|
|
return await db.query.officialTtsVoices.findMany({
|
|
where: and(
|
|
eq(officialTtsVoices.ttsModelId, model.id),
|
|
eq(officialTtsVoices.enabled, true),
|
|
),
|
|
orderBy: [asc(officialTtsVoices.displayOrder), asc(officialTtsVoices.providerVoiceId)],
|
|
})
|
|
},
|
|
|
|
async assertTtsVoiceEnabled(routerModelId: string, providerVoiceId: string): Promise<OfficialTtsVoice> {
|
|
const model = await this.assertTtsModelEnabled(routerModelId)
|
|
const voice = await db.query.officialTtsVoices.findFirst({
|
|
where: and(
|
|
eq(officialTtsVoices.ttsModelId, model.id),
|
|
eq(officialTtsVoices.providerVoiceId, providerVoiceId),
|
|
),
|
|
})
|
|
if (!voice) {
|
|
throw catalogError('Official TTS voice is not configured for this model', 'OFFICIAL_VOICE_NOT_FOUND', {
|
|
model: routerModelId,
|
|
voice: providerVoiceId,
|
|
})
|
|
}
|
|
if (!voice.enabled) {
|
|
throw catalogError('Official TTS voice is disabled', 'OFFICIAL_VOICE_DISABLED', {
|
|
model: routerModelId,
|
|
voice: providerVoiceId,
|
|
})
|
|
}
|
|
return voice
|
|
},
|
|
}
|
|
}
|
|
|
|
export type OfficialCatalogService = ReturnType<typeof createOfficialCatalogService>
|