feat(server): voice pack and tts routing (#1905)

Track per-app TTS concurrency in Redis, route capped upstreams by
available pool capacity, and surface pool saturation metrics. Document
the Voice Pack plan so the remaining backend and card-binding work has
an explicit implementation map.
This commit is contained in:
RainbowBird
2026-06-05 22:39:30 +08:00
committed by GitHub
parent df141e1429
commit e6fa39ed4b
53 changed files with 7107 additions and 81 deletions
@@ -0,0 +1,14 @@
CREATE TABLE "voice_packs" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"provider" text NOT NULL,
"model" text NOT NULL,
"voice_id" text NOT NULL,
"tts_model_id" text NOT NULL,
"params" jsonb DEFAULT '{}'::jsonb NOT NULL,
"cost_multiplier" real DEFAULT 1 NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -106,6 +106,13 @@
"when": 1780498188307,
"tag": "0014_vengeful_blonde_phantom",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1780498188308,
"tag": "0015_concerned_piledriver",
"breakpoints": true
}
]
}
}
+1
View File
@@ -51,6 +51,7 @@ function createTestDeps() {
adminUsersService: {} as any,
ttsMeter: {} as any,
requestLogService: {} as any,
voicePackService: {} as any,
productEventService: {
track: vi.fn(async () => undefined),
countDistinctUsersByFeature: vi.fn(async () => []),
+39 -2
View File
@@ -20,6 +20,7 @@ import type { ProviderService } from './services/domain/providers'
import type { RequestLogService } from './services/domain/request-log'
import type { StripeService } from './services/domain/stripe'
import type { UserDeletionService } from './services/domain/user-deletion'
import type { VoicePackService } from './services/domain/voice-packs'
import type { HonoEnv } from './types/hono'
import type { EnvelopeCrypto } from './utils/envelope-crypto'
@@ -50,11 +51,13 @@ import { registerActiveSessionsGauge } from './otel/gauges/active-sessions'
import { registerDistinctActiveUsersGauge } from './otel/gauges/distinct-active-users'
import { registerRollingActiveUsersGauge } from './otel/gauges/rolling-active-users'
import { registerTotalUsersGauge } from './otel/gauges/total-users'
import { registerTtsPoolGauge } from './otel/gauges/tts-pool'
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 { createAdminUsersRoutes } from './routes/admin/users'
import { createAdminVoicePackRoutes } from './routes/admin/voice-packs'
import { createAudioSpeechWsHandlers } from './routes/audio-speech-ws'
import { createAuthRoutes } from './routes/auth'
import { createCharacterRoutes } from './routes/characters'
@@ -64,6 +67,7 @@ import { createFluxRoutes } from './routes/flux'
import { createV1Routes } from './routes/openai/v1'
import { createProviderRoutes } from './routes/providers'
import { createStripeRoutes } from './routes/stripe'
import { createVoicePackRoutes } from './routes/voice-packs'
import { createConfigKVService } from './services/adapters/config-kv'
import { createEmailService } from './services/adapters/email'
import { createAdminFluxGrantsService } from './services/domain/admin/flux-grants'
@@ -75,12 +79,13 @@ import { createCharacterService } from './services/domain/characters'
import { createChatService } from './services/domain/chats'
import { createFluxService } from './services/domain/flux'
import { createFluxTransactionService } from './services/domain/flux-transaction'
import { createConfigSyncSubscriber, createLlmRouterService } from './services/domain/llm-router'
import { createConcurrencyLedger, createConfigSyncSubscriber, createLlmRouterService } from './services/domain/llm-router'
import { createProductEventService } from './services/domain/product-events'
import { createProviderService } from './services/domain/providers'
import { createRequestLogService } from './services/domain/request-log'
import { createStripeService } from './services/domain/stripe'
import { createUserDeletionService } from './services/domain/user-deletion'
import { createVoicePackService } from './services/domain/voice-packs'
import { createEnvelopeCrypto } from './utils/envelope-crypto'
import { ApiError, createInternalError } from './utils/error'
import { nanoid } from './utils/id'
@@ -101,6 +106,7 @@ interface AppDeps {
adminUsersService: AdminUsersService
ttsMeter: FluxMeter
requestLogService: RequestLogService
voicePackService: VoicePackService
productEventService: ProductEventService
configKV: ConfigKVService
envelopeCrypto: EnvelopeCrypto
@@ -227,6 +233,7 @@ export async function buildApp(deps: AppDeps) {
productEventService: deps.productEventService,
ttsMeter: deps.ttsMeter,
llmRouter: deps.llmRouter,
voicePackService: deps.voicePackService,
genAi: deps.otel?.genAi,
revenue: deps.otel?.revenue,
rateLimitMetrics: deps.otel?.rateLimit,
@@ -339,6 +346,11 @@ export async function buildApp(deps: AppDeps) {
*/
.route('/api/v1/providers', createProviderRoutes(deps.providerService))
/**
* Voice Pack routes expose the enabled curated library for binding.
*/
.route('/api/v1/voice-packs', createVoicePackRoutes(deps.voicePackService))
/**
* Chat routes are handled by the chat service.
*/
@@ -377,6 +389,14 @@ export async function buildApp(deps: AppDeps) {
*/
.route('/api/admin/users', createAdminUsersRoutes(deps.adminUsersService))
/**
* Admin Voice Pack curation routes.
*/
.route('/api/admin/voice-packs', createAdminVoicePackRoutes({
productEventService: deps.productEventService,
service: deps.voicePackService,
}))
/**
* Admin LLM router config seeding/patching. Single entry point for
* writing `LLM_ROUTER_CONFIG`, `UNSPEECH_UPSTREAM`, and the
@@ -588,6 +608,11 @@ export async function createApp() {
build: ({ dependsOn }) => createRequestLogService(dependsOn.db),
})
const voicePackService = injeca.provide('services:voicePack', {
dependsOn: { db },
build: ({ dependsOn }) => createVoicePackService(dependsOn.db),
})
const billingService = injeca.provide('services:billing', {
dependsOn: { db, redis, configKV, otel },
build: ({ dependsOn }) => createBillingService(dependsOn.db, dependsOn.redis, dependsOn.configKV, dependsOn.otel?.revenue),
@@ -655,13 +680,21 @@ export async function createApp() {
// LLM router (KTD-5 in-process replacement for the knoway sidecar).
// LLM_ROUTER_MASTER_KEY is required at env-parse time, so this provider
// always builds a real router — the legacy `null` fallback path is gone.
// Shared by the TTS router (acquires slots) and the pool watermark gauge
// (reads the snapshot). Cluster-wide Redis state — the server is multi-instance.
const ttsConcurrencyLedger = injeca.provide('services:ttsConcurrencyLedger', {
dependsOn: { redis },
build: ({ dependsOn }) => createConcurrencyLedger(dependsOn.redis),
})
const llmRouter = injeca.provide('services:llmRouter', {
dependsOn: { configKV, envelopeCrypto, otel, redis },
dependsOn: { configKV, envelopeCrypto, otel, redis, ttsConcurrencyLedger },
build: ({ dependsOn }) => createLlmRouterService({
configKV: dependsOn.configKV,
envelopeCrypto: dependsOn.envelopeCrypto,
gatewayMetrics: dependsOn.otel?.gateway ?? null,
redis: dependsOn.redis,
concurrencyLedger: dependsOn.ttsConcurrencyLedger,
}),
})
@@ -675,6 +708,7 @@ export async function createApp() {
fluxService,
fluxTransactionService,
requestLogService,
voicePackService,
productEventService,
stripeService,
billingService,
@@ -689,6 +723,7 @@ export async function createApp() {
otel,
userDeletionService,
llmRouter,
ttsConcurrencyLedger,
})
// Register the cluster-wide ObservableGauges for sessions / users. Each
// replica polls the same DB (cached inside each gauge, in-flight coalesced);
@@ -705,6 +740,7 @@ export async function createApp() {
registerActiveSessionsGauge(resolved.otel.auth.activeSessions, resolved.db, resolved.otel.observability.metricReadErrors)
registerDistinctActiveUsersGauge(resolved.otel.auth.distinctActiveUsers, resolved.db, resolved.otel.observability.metricReadErrors)
registerRollingActiveUsersGauge(resolved.otel.auth.rollingActiveUsers, resolved.db, resolved.otel.observability.metricReadErrors)
registerTtsPoolGauge(resolved.otel.gateway.poolInflight, resolved.ttsConcurrencyLedger, resolved.otel.observability.metricReadErrors)
}
const { app, injectWebSocket } = await buildApp({
@@ -716,6 +752,7 @@ export async function createApp() {
fluxService: resolved.fluxService,
fluxTransactionService: resolved.fluxTransactionService,
stripeService: resolved.stripeService,
voicePackService: resolved.voicePackService,
billingService: resolved.billingService,
adminFluxGrantsService: resolved.adminFluxGrantsService,
adminRouterConfigService: resolved.adminRouterConfigService,
@@ -0,0 +1,118 @@
import type { GatewayMetrics, ObservabilityMetrics } from '..'
import type { ConcurrencyLedger } from '../../services/domain/llm-router/concurrency-ledger'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registerTtsPoolGauge } from './tts-pool'
/**
* Capture the callback registered via `gauge.addCallback` plus a spyable
* `observe` so tests can drive OTel collection cycles by hand.
*
* @example
* const { gauge, observe, run } = makeGauge()
* registerTtsPoolGauge(gauge, ledger, errs)
* await run()
* expect(observe).toHaveBeenCalledWith(3, { app_id: 'app-1' })
*/
function makeGauge() {
let cb: ((result: { observe: (v: number, attrs: Record<string, string>) => void }) => void | Promise<void>) | null = null
const observe = vi.fn()
const gauge = {
addCallback: vi.fn((fn: typeof cb) => { cb = fn }),
} as unknown as GatewayMetrics['poolInflight']
return {
gauge,
observe,
run: async () => {
if (!cb)
throw new Error('no callback registered')
await cb({ observe })
},
}
}
function makeLedger(snapshot: () => Promise<Array<{ poolId: string, inflight: number }>>): ConcurrencyLedger {
return {
tryAcquire: vi.fn(),
release: vi.fn(),
markSaturated: vi.fn(),
isSaturated: vi.fn(),
currentInflight: vi.fn(),
snapshot: vi.fn(snapshot),
} as unknown as ConcurrencyLedger
}
function makeReadErrors() {
const add = vi.fn()
return { metricReadErrors: { add } as unknown as ObservabilityMetrics['metricReadErrors'], add }
}
describe('registerTtsPoolGauge', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('observes one point per pool with the app_id attribute', async () => {
const ledger = makeLedger(async () => [
{ poolId: 'app-1', inflight: 3 },
{ poolId: 'app-2', inflight: 7 },
])
const { metricReadErrors } = makeReadErrors()
const { gauge, observe, run } = makeGauge()
registerTtsPoolGauge(gauge, ledger, metricReadErrors)
await run()
expect(observe).toHaveBeenCalledTimes(2)
expect(observe).toHaveBeenCalledWith(3, { app_id: 'app-1' })
expect(observe).toHaveBeenCalledWith(7, { app_id: 'app-2' })
})
it('does not observe and records a read error when the snapshot fails', async () => {
// Letting the gauge skip an export cycle lets Prometheus staleness expose the
// outage instead of masking it with a stale value.
const ledger = makeLedger(async () => {
throw new Error('redis down')
})
const { metricReadErrors, add } = makeReadErrors()
const { gauge, observe, run } = makeGauge()
registerTtsPoolGauge(gauge, ledger, metricReadErrors)
await run()
expect(observe).not.toHaveBeenCalled()
expect(add).toHaveBeenCalledWith(1, { metric: 'airi.gen_ai.gateway.pool.inflight' })
})
it('serves the cached snapshot within the 10s TTL without re-reading Redis', async () => {
const ledger = makeLedger(async () => [{ poolId: 'app-1', inflight: 1 }])
const { metricReadErrors } = makeReadErrors()
const { gauge, observe, run } = makeGauge()
registerTtsPoolGauge(gauge, ledger, metricReadErrors)
await run()
vi.advanceTimersByTime(5_000)
await run()
expect(ledger.snapshot).toHaveBeenCalledTimes(1)
expect(observe).toHaveBeenCalledTimes(2)
})
it('re-reads Redis after the cache TTL expires', async () => {
const ledger = makeLedger(async () => [{ poolId: 'app-1', inflight: 1 }])
const { metricReadErrors } = makeReadErrors()
const { gauge, run } = makeGauge()
registerTtsPoolGauge(gauge, ledger, metricReadErrors)
await run()
vi.advanceTimersByTime(10_001)
await run()
expect(ledger.snapshot).toHaveBeenCalledTimes(2)
})
})
+82
View File
@@ -0,0 +1,82 @@
import type { GatewayMetrics, ObservabilityMetrics } from '..'
import type { ConcurrencyLedger } from '../../services/domain/llm-router/concurrency-ledger'
import { useLogger } from '@guiiai/logg'
/**
* Wire the `airi.gen_ai.gateway.pool.inflight` ObservableGauge to the Redis-backed
*pool concurrency ledger, emitting one series per app_id.
*
* Use when:
* - Assembling DI in `createApp()`, exactly once per process, only when OTel is
* enabled.
*
* Expects:
* - `gauge` is the ObservableGauge handle from `initOtel`.
* - `ledger` is the same concurrency ledger the TTS router acquires slots on.
* - `metricReadErrors` is the shared self-monitoring counter, labelled by the
* originating metric name.
*
* Multi-replica note:
* - Cluster-wide gauge — every replica reads the same Redis counters and reports
* the same per-pool value. Dashboards MUST aggregate with `avg()`, NOT `sum()`.
* See observability-conventions.md.
*
* Concurrency:
* - Multiple OTel collection cycles can race. The in-flight promise lock keeps at
* most one Redis snapshot in flight per process; concurrent callbacks await the
* same result rather than stampeding Redis.
*
* Failure mode:
* - On Redis error we increment `airi.observability.read_errors{metric}` and
* intentionally DO NOT observe — letting the gauge skip an export cycle lets
* Prometheus staleness expose the outage instead of masking it with a stale value.
*/
export function registerTtsPoolGauge(
gauge: GatewayMetrics['poolInflight'],
ledger: ConcurrencyLedger,
metricReadErrors: ObservabilityMetrics['metricReadErrors'],
) {
const log = useLogger('tts-pool-gauge').useGlobalConfig()
const CACHE_TTL_MS = 10_000
let cachedAt = 0
let cachedSnapshot: Array<{ poolId: string, inflight: number }> = []
let refreshInFlight: Promise<boolean> | null = null
async function refresh(): Promise<boolean> {
try {
cachedSnapshot = await ledger.snapshot()
cachedAt = Date.now()
return true
}
catch (err) {
log.withError(err).warn('Failed to read tts pool snapshot for gauge')
metricReadErrors.add(1, { metric: 'airi.gen_ai.gateway.pool.inflight' })
return false
}
}
gauge.addCallback(async (result) => {
const now = Date.now()
if (cachedAt !== 0 && now - cachedAt < CACHE_TTL_MS) {
for (const { poolId, inflight } of cachedSnapshot)
result.observe(inflight, { app_id: poolId })
return
}
if (!refreshInFlight) {
refreshInFlight = refresh().finally(() => {
refreshInFlight = null
})
}
const ok = await refreshInFlight
if (ok) {
for (const { poolId, inflight } of cachedSnapshot)
result.observe(inflight, { app_id: poolId })
}
// else: deliberately do nothing — let Prometheus staleness expose the outage.
})
}
+33
View File
@@ -25,6 +25,9 @@ import {
METRIC_AIRI_GEN_AI_GATEWAY_DECRYPT_FAILURES,
METRIC_AIRI_GEN_AI_GATEWAY_FALLBACK_COUNT,
METRIC_AIRI_GEN_AI_GATEWAY_KEY_EXHAUSTED_COUNT,
METRIC_AIRI_GEN_AI_GATEWAY_POOL_INFLIGHT,
METRIC_AIRI_GEN_AI_GATEWAY_POOL_SATURATION_MARKED,
METRIC_AIRI_GEN_AI_GATEWAY_POOL_SLOT_REJECTED,
METRIC_AIRI_GEN_AI_GATEWAY_SAME_STATUS_EXHAUSTION,
METRIC_AIRI_GEN_AI_GATEWAY_SUBSCRIBER_STATE,
METRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORS,
@@ -276,6 +279,27 @@ export interface GatewayMetrics {
* >0 = forged or replayed message — investigate Redis access boundary.
*/
configInvalidHmac: Counter
/**
* Capacity-aware TTS routing skipped a pool because its app_id was already at
* the concurrency cap (the pre-read said free but the atomic acquire lost the
* race, or every pool was full). Labels: `provider`, `app_id`.
*
* Recommended alert: sustained rate relative to TTS request volume means the
*pool is undersized — add app_ids or raise the cap.
*/
poolSlotRejected: Counter
/**
* Apool was circuit-broken after exhausting with a 429 (app_id concurrency
* exceeded upstream-side). Labels: `provider`, `app_id`. A pool with a high
* mark rate is being driven past its real upstream limit.
*/
poolSaturationMarked: Counter
/**
* Cluster-wide gauge of current in-flight requests per pool, sourced from
* Redis. Label: `app_id`. Every replica reports the same value — dashboards
* MUST aggregate with `avg()`, NOT `sum()` (see observability-conventions.md).
*/
poolInflight: ObservableGauge
}
export interface EmailMetrics {
@@ -501,6 +525,15 @@ export function initOtel(env: Env): OtelInstance | null {
configInvalidHmac: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_INVALID_HMAC, {
description: 'Pub/Sub invalidation messages dropped due to HMAC mismatch (forged or replayed)',
}),
poolSlotRejected: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_POOL_SLOT_REJECTED, {
description: 'Capacity-aware TTS routing skipped a pool already at its app_id concurrency cap',
}),
poolSaturationMarked: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_POOL_SATURATION_MARKED, {
description: 'TTSpool circuit-broken after exhausting with a 429 (app_id concurrency exceeded)',
}),
poolInflight: meter.createObservableGauge(METRIC_AIRI_GEN_AI_GATEWAY_POOL_INFLIGHT, {
description: 'In-flight TTS requests per pool sourced from Redis (cluster-wide; dashboard must use avg(), not sum())',
}),
}
const email: EmailMetrics = {
@@ -0,0 +1,116 @@
import type { ProductEventService } from '../../../services/domain/product-events'
import type { VoicePackService } from '../../../services/domain/voice-packs'
import type { HonoEnv } from '../../../types/hono'
import { Hono } from 'hono'
import { safeParse } from 'valibot'
import { adminGuard } from '../../../middlewares/admin-guard'
import { authGuard } from '../../../middlewares/auth'
import { CreateVoicePackInputSchema, UpdateVoicePackInputSchema } from '../../../services/domain/voice-packs'
import { createBadRequestError, createNotFoundError } from '../../../utils/error'
function parseIssues(issues: Array<{ path?: Array<{ key: unknown }>, message: string }>) {
return issues.map(i => ({
path: i.path?.map(p => p.key).join('.'),
message: i.message,
}))
}
/**
* Admin CRUD routes for curated Voice Packs.
*
* Mounted at `/api/admin/voice-packs`. Disabling is soft (`enabled=false`) so
* existing character-card snapshots never lose their historical definition.
*/
export function createAdminVoicePackRoutes(deps: {
productEventService: ProductEventService
service: VoicePackService
}) {
return new Hono<HonoEnv>()
.use('*', authGuard)
.use('*', adminGuard)
.get('/', async (c) => {
const packs = await deps.service.list()
return c.json(packs)
})
.post('/', async (c) => {
const user = c.get('user')!
const raw = await c.req.json().catch(() => null)
if (raw == null)
throw createBadRequestError('Request body must be JSON', 'INVALID_BODY')
const parsed = safeParse(CreateVoicePackInputSchema, raw)
if (!parsed.success)
throw createBadRequestError('Invalid request body', 'INVALID_BODY', parseIssues(parsed.issues))
const created = await deps.service.create(parsed.output)
void deps.productEventService.track({
userId: user.id,
feature: 'voice_pack',
action: 'voice_pack_created',
status: 'succeeded',
source: 'admin.voice_packs',
metadata: {
voice_pack_id: created.id,
provider: created.provider,
model: created.model,
tts_model_id: created.ttsModelId,
cost_multiplier: created.costMultiplier,
},
})
return c.json(created, 201)
})
.patch('/:id', async (c) => {
const user = c.get('user')!
const raw = await c.req.json().catch(() => null)
if (raw == null)
throw createBadRequestError('Request body must be JSON', 'INVALID_BODY')
const parsed = safeParse(UpdateVoicePackInputSchema, raw)
if (!parsed.success)
throw createBadRequestError('Invalid request body', 'INVALID_BODY', parseIssues(parsed.issues))
const updated = await deps.service.update(c.req.param('id'), parsed.output)
if (!updated)
throw createNotFoundError('Voice Pack not found')
void deps.productEventService.track({
userId: user.id,
feature: 'voice_pack',
action: 'voice_pack_updated',
status: 'succeeded',
source: 'admin.voice_packs',
metadata: {
voice_pack_id: updated.id,
provider: updated.provider,
model: updated.model,
tts_model_id: updated.ttsModelId,
cost_multiplier: updated.costMultiplier,
enabled: updated.enabled,
},
})
return c.json(updated)
})
.post('/:id/disable', async (c) => {
const user = c.get('user')!
const disabled = await deps.service.disable(c.req.param('id'))
if (!disabled)
throw createNotFoundError('Voice Pack not found or already disabled')
void deps.productEventService.track({
userId: user.id,
feature: 'voice_pack',
action: 'voice_pack_disabled',
status: 'succeeded',
source: 'admin.voice_packs',
metadata: {
voice_pack_id: disabled.id,
provider: disabled.provider,
model: disabled.model,
tts_model_id: disabled.ttsModelId,
},
})
return c.json(disabled)
})
}
@@ -0,0 +1,196 @@
import type { VoicePack } from '../../../schemas/voice-packs'
import type { ProductEventService } from '../../../services/domain/product-events'
import type { CreateVoicePackInput, UpdateVoicePackInput, VoicePackService } from '../../../services/domain/voice-packs'
import type { HonoEnv } from '../../../types/hono'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { createAdminVoicePackRoutes } from '.'
import { ApiError } from '../../../utils/error'
interface MockUser {
id: string
email: string
role?: string | null
}
const ADMIN: MockUser = { id: 'admin-1', email: 'admin@example.com', role: 'admin' }
function createService() {
const makePack = (overrides: Partial<VoicePack> = {}): VoicePack => ({
id: 'vp-1',
name: 'Neuro Sama',
description: null,
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-neuro',
ttsModelId: 'volcengine/neuro-pool',
params: {},
costMultiplier: 1.5,
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
return {
list: vi.fn(async () => []),
create: vi.fn(async (input: CreateVoicePackInput) => makePack(input)),
update: vi.fn(async (_id: string, input: UpdateVoicePackInput): Promise<VoicePack | null> => makePack(input)),
disable: vi.fn(async (id: string): Promise<VoicePack | null> => makePack({ id, enabled: false })),
listEnabled: vi.fn(),
findById: vi.fn(),
} satisfies VoicePackService
}
function createProductEventService(): ProductEventService {
return {
track: vi.fn(async () => undefined),
countDistinctUsersByFeature: vi.fn(async () => []),
}
}
function createTestApp(service: VoicePackService, user: MockUser | null, productEventService = createProductEventService()) {
return new Hono<HonoEnv>()
.use('*', async (c, next) => {
c.set('user', user as HonoEnv['Variables']['user'])
await next()
})
.route('/api/admin/voice-packs', createAdminVoicePackRoutes({
productEventService,
service,
}))
.onError((err, c) => {
if (err instanceof ApiError)
return c.json({ error: err.errorCode, details: err.details }, err.statusCode)
return c.json({ error: 'internal', message: (err as Error).message }, 500)
})
}
function jsonRequest(app: Hono<HonoEnv>, method: string, path: string, body?: unknown) {
return app.request(path, {
method,
headers: { 'content-type': 'application/json' },
body: body == null ? undefined : JSON.stringify(body),
})
}
describe('admin voice packs — auth guards', () => {
it('returns 401 when unauthenticated', async () => {
// @example no session -> admin curation is not reachable.
const service = createService()
const app = createTestApp(service, null)
const res = await jsonRequest(app, 'GET', '/api/admin/voice-packs')
expect(res.status).toBe(401)
expect(service.list).not.toHaveBeenCalled()
})
it('returns 403 for a non-admin user', async () => {
// @example ordinary authenticated user -> forbidden.
const service = createService()
const app = createTestApp(service, { id: 'u', email: 'u@example.com', role: 'user' })
const res = await jsonRequest(app, 'GET', '/api/admin/voice-packs')
expect(res.status).toBe(403)
expect(service.list).not.toHaveBeenCalled()
})
})
describe('admin voice packs — CRUD', () => {
it('lists all packs for admins', async () => {
// @example admin list includes disabled rows; service owns filtering behavior.
const service = createService()
const app = createTestApp(service, ADMIN)
const res = await jsonRequest(app, 'GET', '/api/admin/voice-packs')
expect(res.status).toBe(200)
expect(service.list).toHaveBeenCalled()
})
it('creates a pack with validated fields', async () => {
// @example valid body -> route forwards normalized params and enabled default.
const service = createService()
const productEventService = createProductEventService()
const app = createTestApp(service, ADMIN, productEventService)
const body = {
name: 'Neuro Sama',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-neuro',
ttsModelId: 'volcengine/neuro-pool',
params: { pitch: '+20%' },
costMultiplier: 1.5,
}
const res = await jsonRequest(app, 'POST', '/api/admin/voice-packs', body)
expect(res.status).toBe(201)
expect(service.create).toHaveBeenCalledWith({ ...body, enabled: true })
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
userId: 'admin-1',
feature: 'voice_pack',
action: 'voice_pack_created',
status: 'succeeded',
source: 'admin.voice_packs',
metadata: expect.objectContaining({
voice_pack_id: 'vp-1',
cost_multiplier: 1.5,
}),
}))
})
it('rejects invalid cost multiplier on create', async () => {
// @example negative cost multiplier -> 400 before service call.
const service = createService()
const app = createTestApp(service, ADMIN)
const res = await jsonRequest(app, 'POST', '/api/admin/voice-packs', {
name: 'Bad',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-neuro',
ttsModelId: 'volcengine/neuro-pool',
params: {},
costMultiplier: -1,
})
expect(res.status).toBe(400)
expect(service.create).not.toHaveBeenCalled()
})
it('updates a pack and maps missing ids to 404', async () => {
// @example known id -> update; missing id -> not found.
const service = createService()
const app = createTestApp(service, ADMIN)
const ok = await jsonRequest(app, 'PATCH', '/api/admin/voice-packs/vp-1', { name: 'Updated' })
expect(ok.status).toBe(200)
expect(service.update).toHaveBeenCalledWith('vp-1', { name: 'Updated' })
service.update.mockResolvedValueOnce(null)
const missing = await jsonRequest(app, 'PATCH', '/api/admin/voice-packs/missing', { name: 'Updated' })
expect(missing.status).toBe(404)
})
it('soft-disables a pack', async () => {
// @example disable endpoint does not delete; it returns the disabled row.
const service = createService()
const productEventService = createProductEventService()
const app = createTestApp(service, ADMIN, productEventService)
const res = await jsonRequest(app, 'POST', '/api/admin/voice-packs/vp-1/disable')
expect(res.status).toBe(200)
expect(service.disable).toHaveBeenCalledWith('vp-1')
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
userId: 'admin-1',
feature: 'voice_pack',
action: 'voice_pack_disabled',
status: 'succeeded',
source: 'admin.voice_packs',
metadata: expect.objectContaining({
voice_pack_id: 'vp-1',
}),
}))
expect(await res.json()).toMatchObject({ id: 'vp-1', enabled: false })
})
})
@@ -20,6 +20,7 @@ export function speechGeneration(deps: V1RouteDeps): GatewayCallback<'speech.gen
productEventService: deps.productEventService,
requestLogService: deps.requestLogService,
ttsMeter: deps.ttsMeter,
voicePackService: deps.voicePackService,
})
return context => speechService.handleSpeechRequest(context.input)
+211 -4
View File
@@ -3,7 +3,9 @@ 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 { ProductEventService } from '../../../services/domain/product-events'
import type { RequestLogService } from '../../../services/domain/request-log'
import type { VoicePackService } from '../../../services/domain/voice-packs'
import type { HonoEnv } from '../../../types/hono'
import { Hono } from 'hono'
@@ -128,6 +130,25 @@ function createMockLlmRouter(impl?: Partial<LlmRouterService>): LlmRouterService
} as LlmRouterService
}
function createMockProductEventService(): ProductEventService {
return {
track: vi.fn(async () => undefined),
countDistinctUsersByFeature: vi.fn(async () => []),
}
}
function createMockVoicePackService(impl?: Partial<VoicePackService>): VoicePackService {
return {
listEnabled: vi.fn(async () => []),
list: vi.fn(async () => []),
create: vi.fn(),
update: vi.fn(),
disable: vi.fn(),
findById: vi.fn(async () => null),
...impl,
} as unknown as VoicePackService
}
function createTestApp(
fluxService: FluxService,
configKV: ConfigKVService,
@@ -136,18 +157,18 @@ function createTestApp(
ttsMeter?: ReturnType<typeof createMockTtsMeter>,
llmRouter?: LlmRouterService,
llmTracing = createMockLlmTracing(),
productEventService = createMockProductEventService(),
voicePackService = createMockVoicePackService(),
) {
const { openaiRoutes, audioRoutes } = createV1Routes({
fluxService,
billingService: billingService ?? createMockBillingService(),
configKV,
requestLogService: requestLogService ?? createMockRequestLogService(),
productEventService: {
track: vi.fn(async () => undefined),
countDistinctUsersByFeature: vi.fn(async () => []),
},
productEventService,
ttsMeter: ttsMeter ?? createMockTtsMeter(),
llmRouter: llmRouter ?? createMockLlmRouter(),
voicePackService,
genAi: null,
revenue: null,
rateLimitMetrics: null,
@@ -658,6 +679,82 @@ describe('v1CompletionsRoutes', () => {
)
})
/**
* @example
* POST /api/v1/audio/speech { "speed": 1.2, "extra_body": { "voice_pack": { "pitch": 20 } } }
*/
it('forwards TTS speed and Voice Pack prosody options to the router input', async () => {
const routeTts = vi.fn(async () => new Response(new Uint8Array([1]), {
status: 200,
headers: { 'Content-Type': 'audio/mpeg' },
}))
const app = createTestApp(
createMockFluxService(),
createMockConfigKV({ DEFAULT_TTS_MODEL: 'microsoft/v1' }),
undefined,
undefined,
undefined,
createMockLlmRouter({ routeTts }),
createMockLlmTracing(),
createMockProductEventService(),
createMockVoicePackService({
findById: vi.fn(async () => ({
id: 'vp-azure',
name: 'Azure',
description: null,
provider: 'azure',
model: 'microsoft/v1',
voiceId: 'en-US-AvaMultilingualNeural',
ttsModelId: 'microsoft/v1',
params: {},
costMultiplier: 1.5,
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
})),
}),
)
await app.fetch(
new Request('http://localhost/api/v1/audio/speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'auto',
input: 'test',
voice: 'en-US-AvaMultilingualNeural',
speed: 1.2,
extra_body: {
voice_pack: {
pack_id: 'vp-azure',
cost_multiplier: 1.5,
pitch: 20,
volume: 5,
},
},
}),
}),
{ user: testUser } as any,
)
expect(routeTts).toHaveBeenCalledWith(
expect.objectContaining({
modelName: 'microsoft/v1',
input: expect.objectContaining({
text: 'test',
voice: 'en-US-AvaMultilingualNeural',
speed: 1.2,
extraOptions: {
pitch: 20,
volume: 5,
},
}),
}),
expect.any(Object),
)
})
it('should bill per character with minimum charge', async () => {
globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), {
status: 200,
@@ -680,6 +777,73 @@ describe('v1CompletionsRoutes', () => {
expect(billingService.consumeFluxForLLM).not.toHaveBeenCalled()
})
/**
* @example
* POST /api/v1/audio/speech { "input": "hello", "extra_body": { "voice_pack": { "cost_multiplier": 2 } } }
*/
it('uses Voice Pack cost multiplier for affordability and billing units', async () => {
globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), {
status: 200,
headers: { 'Content-Type': 'audio/mpeg' },
}))
const ttsMeter = createMockTtsMeter()
const voicePackService = createMockVoicePackService({
findById: vi.fn(async () => ({
id: 'vp-premium',
name: 'Premium',
description: null,
provider: 'azure',
model: 'microsoft/v1',
voiceId: 'alloy',
ttsModelId: 'tts-1',
params: {},
costMultiplier: 2,
enabled: false,
createdAt: new Date(),
updatedAt: new Date(),
})),
})
const app = createTestApp(
createMockFluxService(),
createMockConfigKV(),
undefined,
undefined,
ttsMeter,
undefined,
createMockLlmTracing(),
createMockProductEventService(),
voicePackService,
)
await app.fetch(
new Request('http://localhost/api/v1/audio/speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'auto',
input: 'hello',
voice: 'alloy',
extra_body: {
voice_pack: {
pack_id: 'vp-premium',
cost_multiplier: 2,
},
},
}),
}),
{ user: testUser } as any,
)
expect(ttsMeter.assertCanAfford).toHaveBeenCalledWith('user-1', 10, 100)
expect(ttsMeter.accumulate).toHaveBeenCalledWith(expect.objectContaining({
units: 10,
metadata: expect.objectContaining({
costMultiplier: 2,
}),
}))
})
it('should not charge when routeTts upstream returns error', async () => {
const llmRouter = createMockLlmRouter({
routeTts: vi.fn(async () => new Response('{"error":"service down"}', {
@@ -703,6 +867,49 @@ describe('v1CompletionsRoutes', () => {
expect(billingService.consumeFluxForLLM).not.toHaveBeenCalled()
})
/**
* @example
* routeTts throws ApiError(429, 'TOO_MANY_REQUESTS', 'Too many requests')
*/
it('records routeTts ApiError status and reason in product events', async () => {
const productEventService = createMockProductEventService()
const llmRouter = createMockLlmRouter({
routeTts: vi.fn(async () => {
throw new ApiError(429, 'TOO_MANY_REQUESTS', 'Too many requests')
}) as any,
})
const app = createTestApp(
createMockFluxService(),
createMockConfigKV(),
undefined,
undefined,
undefined,
llmRouter,
createMockLlmTracing(),
productEventService,
)
const res = await app.fetch(
new Request('http://localhost/api/v1/audio/speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'auto', input: 'hello', voice: 'alloy' }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(429)
expect(productEventService.track).toHaveBeenCalledWith(
expect.objectContaining({
action: 'speech_failed',
reason: 'TOO_MANY_REQUESTS',
metadata: expect.objectContaining({
http_status: 429,
}),
}),
)
})
it('should return 402 when flux is insufficient', async () => {
const app = createTestApp(
createMockFluxService(0),
@@ -7,6 +7,7 @@ import type { LlmRouterService } from '../../../services/domain/llm-router'
import type { ChatGenerationTrace, TtsGenerationTrace } from '../../../services/domain/llm-tracing'
import type { ProductEventService } from '../../../services/domain/product-events'
import type { RequestLogService } from '../../../services/domain/request-log'
import type { VoicePackService } from '../../../services/domain/voice-packs'
import { startChatGeneration, startTtsGeneration } from '../../../services/domain/llm-tracing'
@@ -23,6 +24,7 @@ export interface V1RouteDeps {
productEventService: ProductEventService
ttsMeter: FluxMeter
llmRouter: LlmRouterService
voicePackService: VoicePackService
genAi?: GenAiMetrics | null
revenue?: RevenueMetrics | null
rateLimitMetrics?: RateLimitMetrics | null
@@ -0,0 +1,22 @@
import type { VoicePackService } from '../../services/domain/voice-packs'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { authGuard } from '../../middlewares/auth'
/**
* User-facing Voice Pack routes.
*
* Mounted at `/api/v1/voice-packs`. Only enabled packs are exposed so disabled
* curated entries remain available to historical character snapshots but cannot
* be newly selected.
*/
export function createVoicePackRoutes(service: VoicePackService) {
return new Hono<HonoEnv>()
.use('*', authGuard)
.get('/', async (c) => {
const packs = await service.listEnabled()
return c.json(packs)
})
}
@@ -0,0 +1,56 @@
import type { VoicePackService } from '../../services/domain/voice-packs'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { createVoicePackRoutes } from '.'
import { ApiError } from '../../utils/error'
function createTestApp(service: VoicePackService, user: { id: string } | null) {
return new Hono<HonoEnv>()
.use('*', async (c, next) => {
c.set('user', user as HonoEnv['Variables']['user'])
await next()
})
.route('/api/v1/voice-packs', createVoicePackRoutes(service))
.onError((err, c) => {
if (err instanceof ApiError)
return c.json({ error: err.errorCode }, err.statusCode)
return c.json({ error: 'internal', message: (err as Error).message }, 500)
})
}
function createService() {
return {
listEnabled: vi.fn(async () => [{ id: 'vp-1', name: 'Enabled', enabled: true }]),
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
disable: vi.fn(),
findById: vi.fn(),
} as unknown as VoicePackService
}
describe('voice packs routes', () => {
it('requires auth before listing enabled packs', async () => {
// @example anonymous users cannot enumerate curated packs.
const service = createService()
const app = createTestApp(service, null)
const res = await app.request('/api/v1/voice-packs')
expect(res.status).toBe(401)
expect(service.listEnabled).not.toHaveBeenCalled()
})
it('lists only enabled packs through the service', async () => {
// @example client binding surface delegates to enabled-only service method.
const service = createService()
const app = createTestApp(service, { id: 'u-1' })
const res = await app.request('/api/v1/voice-packs')
expect(res.status).toBe(200)
expect(await res.json()).toEqual([{ id: 'vp-1', name: 'Enabled', enabled: true }])
expect(service.listEnabled).toHaveBeenCalled()
})
})
+1
View File
@@ -8,3 +8,4 @@ export * from './product-events'
export * from './providers'
export * from './stripe'
export * from './user-character'
export * from './voice-packs'
+30
View File
@@ -0,0 +1,30 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import { boolean, jsonb, pgTable, real, text, timestamp } from 'drizzle-orm/pg-core'
import { nanoid } from '../utils/id'
export type VoicePackParams = Record<string, string | number | boolean | null>
export const voicePacks = pgTable(
'voice_packs',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
name: text('name').notNull(),
description: text('description'),
provider: text('provider').notNull(),
model: text('model').notNull(),
voiceId: text('voice_id').notNull(),
ttsModelId: text('tts_model_id').notNull(),
params: jsonb('params').notNull().$type<VoicePackParams>().default({}),
costMultiplier: real('cost_multiplier').notNull().default(1),
enabled: boolean('enabled').notNull().default(true),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
)
export type VoicePack = InferSelectModel<typeof voicePacks>
export type NewVoicePack = InferInsertModel<typeof voicePacks>
@@ -58,6 +58,13 @@ export const ttsUpstreamSchema = object({
baseURL: pipe(string(), nonEmpty('tts.upstreams[].baseURL must not be empty')),
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'tts.upstreams[].keys must contain at least 1 entry')),
adapterParams: optional(record(string(), any()), {}),
// Per-app_id concurrency cap for the pool load balancer. One upstream maps to
// one app_id (Volcengine `adapterParams.appid`), capped by the provider at a
// small number (e.g. 10). When set on any upstream of a model, the router
// switches from fixed-order fallback to capacity-aware routing across pools.
// Absent = unlimited: that model keeps the original fixed-order behavior and
// makes zero Redis calls (no regression for existing single-app configs).
maxConcurrency: optional(pipe(number(), check(v => v >= 1, 'tts.upstreams[].maxConcurrency must be >= 1 when set'))),
})
export const streamingTtsUpstreamSchema = object({
+71 -2
View File
@@ -2,7 +2,7 @@ import type { Voice } from 'unspeech'
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
import { buildMicrosoftSsml, inferMicrosoftContentType, isMicrosoftVoiceId, resolveMicrosoftOutputFormat } from 'unspeech'
import { inferMicrosoftContentType, isMicrosoftVoiceId, resolveMicrosoftOutputFormat } from 'unspeech'
import { createBadRequestError, createInternalError, createServiceUnavailableError } from '../../../utils/error'
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
@@ -41,7 +41,10 @@ export const azureAdapter: TtsAdapter = {
const ssml = disableSsml
? input.text
: buildMicrosoftSsml(input.text, voice, input.speed)
: buildAzureSsml(input.text, voice, input.speed, {
pitch: typeof input.extraOptions?.pitch === 'number' ? input.extraOptions.pitch : undefined,
volume: typeof input.extraOptions?.volume === 'number' ? input.extraOptions.volume : undefined,
})
const region = ctx.adapterParams?.region
if (typeof region !== 'string' || !region)
@@ -78,3 +81,69 @@ export const azureAdapter: TtsAdapter = {
})
},
}
/**
* Builds Azure-compatible SSML, preserving Voice Pack prosody settings.
*
* NOTICE:
* `unspeech` owns the canonical Microsoft helpers, but the currently consumed
* helper surface only lets AIRI pass speed. Voice Pack pitch and volume must be
* encoded before the request reaches unspeech because AIRI sends pre-built SSML
* with `disable_ssml: true`.
* Source/context: this adapter's `extraOptions.pitch` and `extraOptions.volume`
* contract, covered by `azureAdapter.send` tests.
* Removal condition: delete this helper once `unspeech` exposes a
* `buildMicrosoftSsml` overload that accepts pitch and volume.
*/
function buildAzureSsml(
text: string,
voice: string,
speed: number | undefined,
options: {
pitch?: number
volume?: number
},
): string {
const safe = escapeForSsml(text)
const rate = speedToProsodyRate(speed)
const pitch = percentToProsodyValue(options.pitch)
const volume = percentToProsodyValue(options.volume)
const prosodyAttrs = [
rate ? `rate='${rate}'` : undefined,
pitch ? `pitch='${pitch}'` : undefined,
volume ? `volume='${volume}'` : undefined,
].filter(Boolean).join(' ')
const inner = prosodyAttrs
? `<prosody ${prosodyAttrs}>${safe}</prosody>`
: safe
return `<speak version='1.0' xml:lang='en-US'><voice name='${voice}'>${inner}</voice></speak>`
}
function speedToProsodyRate(speed: number | undefined): string {
if (speed == null || speed === 1)
return ''
const delta = Math.round((speed - 1) * 100)
if (delta === 0)
return ''
return delta > 0 ? `+${delta}%` : `${delta}%`
}
function percentToProsodyValue(value: number | undefined): string {
if (value == null)
return ''
if (value > 0)
return `+${value}%`
if (value < 0)
return `${value}%`
return '0%'
}
function escapeForSsml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&apos;')
}
@@ -88,6 +88,33 @@ describe('dashscopeCosyvoiceAdapter', () => {
expect(fetchImpl).not.toHaveBeenCalled()
})
/**
* @example
* dashscopeCosyvoiceAdapter.send({ text: 'hi', extraOptions: { volume: 5 } }, ctx)
*/
it('fails fast when Voice Pack pitch or volume params reach DashScope cosyvoice', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(new Uint8Array([0])))
await expect(dashscopeCosyvoiceAdapter.send(
{
text: 'hi',
voice: 'longxiaochun_v2',
extraOptions: {
volume: 5,
},
},
{
keyPlaintext: Buffer.from('sk-test', 'utf8'),
baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
unspeechBaseURL: UNSPEECH,
adapterParams: {},
fetchImpl: fetchImpl as unknown as typeof fetch,
},
)).rejects.toMatchObject({ statusCode: 400 })
expect(fetchImpl).not.toHaveBeenCalled()
})
it('voice catalog is proxied through unspeech with the selected cosyvoice model', async () => {
// The catalog itself is unspeech-owned now (embedded JSON in
// unspeech/pkg/backend/alibaba/voices.go). This test only verifies the
@@ -54,6 +54,12 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = {
: DEFAULT_COSYVOICE_MODEL
if (!input.voice)
throw createBadRequestError('dashscope-cosyvoice voice is required', 'BAD_REQUEST')
if (typeof input.extraOptions?.pitch === 'number' || typeof input.extraOptions?.volume === 'number') {
throw createBadRequestError(
'dashscope-cosyvoice does not support Voice Pack pitch or volume parameters',
'BAD_REQUEST',
)
}
const voice = input.voice
const format = input.responseFormat ?? DEFAULT_COSYVOICE_FORMAT
@@ -201,7 +201,15 @@ describe('azureAdapter.send', () => {
})) as unknown as typeof fetch
await adapter.send(
{ text: 'hi there', voice: 'en-US-AvaMultilingualNeural', speed: 1.2 },
{
text: 'hi there',
voice: 'en-US-AvaMultilingualNeural',
speed: 1.2,
extraOptions: {
pitch: 20,
volume: 5,
},
},
{
keyPlaintext: Buffer.from('azure-sub-key', 'utf8'),
baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1',
@@ -220,7 +228,7 @@ describe('azureAdapter.send', () => {
expect((body.extra_body as { disable_ssml?: boolean }).disable_ssml).toBe(true)
// SSML is built on our side so speed survives — verify the prosody tag is in
// the input field unspeech receives.
expect(body.input).toContain('<prosody rate=\'+20%\'>')
expect(body.input).toContain('<prosody rate=\'+20%\' pitch=\'+20%\' volume=\'+5%\'>')
expect(body.input).toContain('hi there')
const headers = init.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer azure-sub-key')
@@ -322,6 +330,34 @@ describe('volcengineAdapter.send', () => {
expect(result.body).toBeInstanceOf(ArrayBuffer)
})
/**
* @example
* volcengineAdapter.send({ text: 'hi', extraOptions: { pitch: 20 } }, ctx)
*/
it('fails fast when Voice Pack pitch or volume params reach Volcengine', async () => {
const adapter = getAdapter('volcengine')
const fetchImpl = vi.fn(async () => new Response(new Uint8Array([0x49]))) as unknown as typeof fetch
await expect(adapter.send(
{
text: 'hi',
voice: 'BV001_streaming',
extraOptions: {
pitch: 20,
},
},
{
keyPlaintext: Buffer.from('volc-token', 'utf8'),
baseURL: 'https://openspeech.bytedance.com/api/v1/tts',
unspeechBaseURL: 'http://unspeech.local:5933',
adapterParams: { appid: 'APP-123' },
fetchImpl,
},
)).rejects.toMatchObject({ statusCode: 400 })
expect(fetchImpl).not.toHaveBeenCalled()
})
it('rejects when adapterParams.appid is missing', async () => {
const adapter = getAdapter('volcengine')
const fetchImpl = vi.fn() as unknown as typeof fetch
@@ -2,7 +2,7 @@ import type { Voice } from 'unspeech'
import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types'
import { createInternalError } from '../../../utils/error'
import { createBadRequestError, createInternalError } from '../../../utils/error'
import { nanoid } from '../../../utils/id'
import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech'
@@ -58,6 +58,12 @@ export const volcengineAdapter: TtsAdapter = {
: undefined
const voice = input.voice ?? DEFAULT_VOLCENGINE_VOICE
if (typeof input.extraOptions?.pitch === 'number' || typeof input.extraOptions?.volume === 'number') {
throw createBadRequestError(
'volcengine does not support Voice Pack pitch or volume parameters',
'BAD_REQUEST',
)
}
const encoding = input.responseFormat ?? DEFAULT_VOLCENGINE_FORMAT
const speed = input.speed ?? 1
@@ -0,0 +1,149 @@
import type Redis from 'ioredis'
import {
ttsPoolInflightRedisKey,
ttsPoolKnownRedisKey,
ttsPoolSaturatedRedisKey,
} from '../../../utils/redis-keys'
// NOTICE: Atomic capacity-gated acquire. The TTSpool routes requests across
// multiple app_ids, each capped at a small concurrency limit (e.g. 10). To use
// the pooled capacity without overshooting any single app_id, we track in-flight
// requests per pool in Redis (shared across replicas — the server is multi-instance
// on Railway). A check-then-INCR done in two round-trips would race between
// replicas and overshoot the cap, so the check + increment happen inside one Lua
// script. The EXPIRE bounds leakage: if a replica crashes between acquire and
// release, the counter self-heals after `inflightTtlSeconds` instead of pinning
// the pool as permanently full. Source: flux-meter.ts ACCUMULATE_SCRIPT (same
// "INCR + EXPIRE, TTL survives crash" shape).
const ACQUIRE_SCRIPT = `
local inflightKey = KEYS[1]
local knownKey = KEYS[2]
local max = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local poolId = ARGV[3]
local current = tonumber(redis.call('GET', inflightKey) or '0')
if current < max then
local next = redis.call('INCR', inflightKey)
redis.call('EXPIRE', inflightKey, ttl)
redis.call('SADD', knownKey, poolId)
return next
end
return -1
`
// NOTICE: Floor-guarded release. A bare DECR on a missing/expired key would
// drive the counter negative (Redis DECR on a nonexistent key yields -1), which
// would then let the pool accept more than `max` concurrent requests. Guarding
// with GET>0 inside Lua keeps release idempotent against the TTL self-heal: if
// the inflight key already expired, release is a no-op rather than a corruption.
const RELEASE_SCRIPT = `
local inflightKey = KEYS[1]
local current = tonumber(redis.call('GET', inflightKey) or '0')
if current > 0 then
return redis.call('DECR', inflightKey)
end
return 0
`
/**
* Tracks per-pool in-flight concurrency in Redis so the TTS router can spread
* load across multiple app_ids without overshooting any one app_id's cap.
*
* Use when:
* - Building the LLM/TTS router service (`createLlmRouterService`), which
* acquires a slot before dispatching to a capacity-capped upstream and
* releases it once the attempt finishes.
*
* Expects:
* - `redis` is the shared cluster Redis (the same instance the flux meter and
* config cache use). Counts are cluster-wide, not per-process.
*
* Returns:
* - An acquire/release/saturation API. `tryAcquire` is the only capacity
* decision; everything else is bookkeeping the router and the watermark
* gauge read.
*/
export function createConcurrencyLedger(redis: Redis, options?: {
/**
* TTL (seconds) on the in-flight counter. Bounds leakage when a replica
* crashes between acquire and release. Should comfortably exceed the longest
* single TTS attempt so a live request is never evicted mid-flight.
* @default 60
*/
inflightTtlSeconds?: number
}) {
const inflightTtlSeconds = options?.inflightTtlSeconds ?? 60
const knownKey = ttsPoolKnownRedisKey()
/**
* Atomically acquire one slot on `poolId` if it is below `maxConcurrency`.
* Returns true when the slot was taken (caller MUST later call `release`),
* false when the pool is already at capacity (caller should try another pool).
*/
async function tryAcquire(poolId: string, maxConcurrency: number): Promise<boolean> {
const result = await redis.eval(
ACQUIRE_SCRIPT,
2,
ttsPoolInflightRedisKey(poolId),
knownKey,
maxConcurrency,
inflightTtlSeconds,
poolId,
) as number | string
return Number(result) >= 0
}
/**
* Release one slot previously taken via {@link tryAcquire}. Idempotent and
* floor-guarded releasing an already-zero/expired counter is a no-op.
*/
async function release(poolId: string): Promise<void> {
await redis.eval(RELEASE_SCRIPT, 1, ttsPoolInflightRedisKey(poolId))
}
/**
* Flag `poolId` as saturated for `ttlSeconds`. Called when an upstream
* exhausts with a 429 (app_id concurrency exceeded upstream-side) so the
* router skips this pool during the cool-down instead of re-probing a pool it
* already knows is full.
*/
async function markSaturated(poolId: string, ttlSeconds: number): Promise<void> {
await redis.set(ttsPoolSaturatedRedisKey(poolId), '1', 'EX', ttlSeconds)
}
/** Whether `poolId` is within a saturation cool-down window. */
async function isSaturated(poolId: string): Promise<boolean> {
const exists = await redis.exists(ttsPoolSaturatedRedisKey(poolId))
return exists === 1
}
/** Current in-flight count for `poolId` (0 when the counter is absent). */
async function currentInflight(poolId: string): Promise<number> {
const raw = await redis.get(ttsPoolInflightRedisKey(poolId))
return raw == null ? 0 : Number(raw)
}
/**
* Snapshot every known pool's in-flight count. Backs the watermark gauge
* reads the known-pools set, then MGETs each counter in one round-trip.
* Returns an empty array when no pool has ever been acquired.
*/
async function snapshot(): Promise<Array<{ poolId: string, inflight: number }>> {
const poolIds = await redis.smembers(knownKey)
if (poolIds.length === 0)
return []
const values = await redis.mget(poolIds.map(ttsPoolInflightRedisKey))
return poolIds.map((poolId, i) => ({
poolId,
inflight: values[i] == null ? 0 : Number(values[i]),
}))
}
return { tryAcquire, release, markSaturated, isSaturated, currentInflight, snapshot }
}
export type ConcurrencyLedger = ReturnType<typeof createConcurrencyLedger>
@@ -1,3 +1,6 @@
export { createConcurrencyLedger } from './concurrency-ledger'
export type { ConcurrencyLedger } from './concurrency-ledger'
export { createConfigSyncSubscriber } from './config-sync-subscriber'
export { createLlmRouterService } from './router'
@@ -7,6 +7,7 @@ import type { GatewayMetrics } from '../../../otel'
import type { EnvelopeCrypto } from '../../../utils/envelope-crypto'
import type { ConfigKVService } from '../../adapters/config-kv'
import type { TtsAdapterId, TtsInput } from '../../adapters/tts/types'
import type { ConcurrencyLedger } from './concurrency-ledger'
import type { LlmRouteContext, LlmRouteRequest, LlmUpstream, TtsUpstream } from './types'
import { Buffer as NodeBuffer } from 'node:buffer'
@@ -14,7 +15,7 @@ import { Buffer as NodeBuffer } from 'node:buffer'
import { useLogger } from '@guiiai/logg'
import { trace } from '@opentelemetry/api'
import { ApiError } from '../../../utils/error'
import { ApiError, createServiceUnavailableError } from '../../../utils/error'
import { errorMessageFromUnknown } from '../../../utils/error-message'
import {
AIRI_ATTR_GEN_AI_GATEWAY_FALLBACK_DEPTH,
@@ -91,6 +92,18 @@ function deriveProviderTag(baseURL: string): string {
}
}
/**
* Identity of the pool (concurrency pool) one TTS upstream belongs to. One
* upstream == one app_id, so the Volcengine `adapterParams.appid` is the pool
* key when present; the baseURL is a stable fallback for providers without an
* app_id concept. Two upstreams sharing an app_id would (correctly) share one
* concurrency budget, though thetypical config gives each app_id its own upstream.
*/
function ttsPoolId(upstream: TtsUpstream): string {
const appid = upstream.adapterParams?.appid
return typeof appid === 'string' && appid.length > 0 ? appid : upstream.baseURL
}
export interface CreateLlmRouterServiceOptions {
/** ConfigKV used to read `LLM_ROUTER_CONFIG`. */
configKV: ConfigKVService
@@ -104,6 +117,20 @@ export interface CreateLlmRouterServiceOptions {
* picker open while keeping freshness within {@link TTS_VOICES_CACHE_TTL_S}.
*/
redis: Redis
/**
* Per-pool concurrency ledger backing capacity-aware TTS routing. When a TTS
* model has any upstream with `maxConcurrency` set, the router acquires a slot
* here before dispatching and releases it after, spreading load across app_ids
* instead of hammering the first upstream.
*/
concurrencyLedger: ConcurrencyLedger
/**
* Cool-down (seconds) a pool is skipped after exhausting with a 429 (app_id
* concurrency exceeded upstream-side). Separate from the ledger's in-flight
* TTL: this is a reactive circuit-breaker window, not a leak bound.
* @default 15
*/
ttsPoolSaturationTtlSeconds?: number
/**
* Fetch implementation. Defaults to `globalThis.fetch`. Tests inject a
* `vi.fn` so we never touch the real network.
@@ -177,6 +204,8 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
const logger = useLogger('llm-router').useGlobalConfig()
const fetchImpl = options.fetchImpl ?? globalThis.fetch
const configLoader = createConfigLoader({ configKV: options.configKV, ttlMs: options.configCacheTtlMs })
const ledger = options.concurrencyLedger
const ttsPoolSaturationTtlSeconds = options.ttsPoolSaturationTtlSeconds ?? 15
const ttsVoiceCatalogLoads = new Map<string, Promise<Voice[]>>()
/**
@@ -542,6 +571,110 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
return { kind: 'exhausted', failures }
}
/**
* Capacity-aware layer over {@link dispatchOneTtsUpstream}: spreads one TTS
* request across the model's pool (one app_id per upstream) by least-loaded
* ordering, gating each dispatch on an atomic concurrency-slot acquire.
*
* Returns:
* - the 2xx `Response` on success,
* - `null` when every dispatched upstream exhausted (caller maps the recorded
* failures to an upstream error via the shared exhaustion path),
* - throws 503 `TTS_POOL_SATURATED` when every pool was at capacity or in a
* 429 cool-down so nothing was dispatched - fail-fast with context, never a
* silent stall (origin R3).
*/
async function routeTtsAcrossPools(
upstreams: readonly TtsUpstream[],
modelName: string,
attemptUpstream: (upstream: TtsUpstream, index: number) => Promise<
| { kind: 'ok', response: Response }
| { kind: 'exhausted', sawTooManyRequests: boolean }
>,
): Promise<Response | null> {
async function markSaturated(upstream: TtsUpstream, poolId: string): Promise<void> {
await ledger.markSaturated(poolId, ttsPoolSaturationTtlSeconds)
options.gatewayMetrics?.poolSaturationMarked.add(1, {
provider: deriveProviderTag(upstream.baseURL),
app_id: poolId,
})
}
// Best-effort pre-read: order pools least-loaded-first (spreads load) and
// drop pools already full or in a saturation cool-down. tryAcquire below is
// the authoritative gate against the cross-replica race — ordering only
// decides *preference*, not correctness.
const ranked = (await Promise.all(upstreams.map(async (upstream, index) => {
const poolId = ttsPoolId(upstream)
const maxConcurrency = typeof upstream.maxConcurrency === 'number' ? upstream.maxConcurrency : null
const saturated = await ledger.isSaturated(poolId)
if (saturated) {
return {
upstream,
index,
poolId,
maxConcurrency,
remaining: maxConcurrency == null ? Number.POSITIVE_INFINITY : 0,
eligible: false,
}
}
if (maxConcurrency == null)
return { upstream, index, poolId, maxConcurrency, remaining: Number.POSITIVE_INFINITY, eligible: true }
const inflight = await ledger.currentInflight(poolId)
const remaining = maxConcurrency - inflight
return { upstream, index, poolId, maxConcurrency, remaining, eligible: remaining > 0 }
})))
.filter(c => c.eligible)
.sort((a, b) => b.remaining - a.remaining)
let dispatchedAny = false
for (const { upstream, index, poolId, maxConcurrency } of ranked) {
if (maxConcurrency == null) {
// Unlimited pool — dispatch without occupying a slot.
dispatchedAny = true
const result = await attemptUpstream(upstream, index)
if (result.kind === 'ok')
return result.response
if (result.sawTooManyRequests)
await markSaturated(upstream, poolId)
continue
}
const acquired = await ledger.tryAcquire(poolId, maxConcurrency)
if (!acquired) {
// Pool filled between the snapshot and now — skip without dispatching.
options.gatewayMetrics?.poolSlotRejected.add(1, {
provider: deriveProviderTag(upstream.baseURL),
app_id: poolId,
})
continue
}
dispatchedAny = true
try {
const result = await attemptUpstream(upstream, index)
if (result.kind === 'ok')
return result.response
if (result.sawTooManyRequests)
await markSaturated(upstream, poolId)
}
finally {
await ledger.release(poolId)
}
}
if (!dispatchedAny) {
throw createServiceUnavailableError(
`ttspool capacity exhausted for model ${modelName}: all pools at concurrency limit or in saturation cool-down`,
'TTS_POOL_SATURATED',
{ modelName, pools: upstreams.length },
)
}
return null
}
async function routeTts(req: { modelName: string, input: TtsInput, abortSignal?: AbortSignal }, ctx?: LlmRouteContext): Promise<Response> {
if (req.abortSignal?.aborted)
throw req.abortSignal.reason ?? new Error('aborted')
@@ -553,8 +686,13 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
throw new Error(`Expected tts model slice for ${req.modelName}, got ${slice.kind}`)
}
// Capture the narrowed TTS model: the `slice.kind` narrowing above does not
// flow into the nested `attemptUpstream` closure below, so reference this
// local instead of `slice.model` to keep `provider`/`upstreams` typed.
const ttsModel = slice.model
const defaults = slice.defaults ?? { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] }
const fallbackHttpCodes = slice.model.fallbackTriggers?.httpCodes ?? defaults.fallbackHttpCodes ?? [401, 402, 403, 429, 500, 502, 503, 504]
const fallbackHttpCodes = ttsModel.fallbackTriggers?.httpCodes ?? defaults.fallbackHttpCodes ?? [401, 402, 403, 429, 500, 502, 503, 504]
// Adapters POST to unspeech `/v1/audio/speech`; resolve the base URL once
// per request rather than per upstream attempt so a single configKV miss
@@ -564,23 +702,28 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
const allFailures: Array<{ provider: string, keyId: string, status: number | 'timeout', errorMessage?: string }> = []
let triedUpstreams = 0
for (let i = 0; i < slice.model.upstreams.length; i += 1) {
const upstream = slice.model.upstreams[i]
// tts upstream schema has no per-upstream timeoutMs (see ttsUpstreamSchema);
// the defaults bucket alone governs per-attempt timeout.
const perAttemptTimeoutMs = defaults.perAttemptTimeoutMs ?? 30000
// Dispatch one upstream and fold its outcome into the shared failure log.
// Returns the 2xx Response on success, or an exhaustion marker carrying
// whether the upstream saw a 429 (app_id concurrency exceeded upstream-side)
// so the caller can circuit-break thatpool.
async function attemptUpstream(upstream: TtsUpstream, index: number): Promise<
| { kind: 'ok', response: Response }
| { kind: 'exhausted', sawTooManyRequests: boolean }
> {
const providerTag = deriveProviderTag(upstream.baseURL)
triedUpstreams += 1
// Surface the current upstream so the caller can label success metrics
// by provider (winning provider on `ok`, last-tried on exhaustion).
if (ctx)
ctx.provider = providerTag
// tts upstream schema has no per-upstream timeoutMs (see ttsUpstreamSchema);
// we use the defaults bucket alone here.
const perAttemptTimeoutMs = defaults.perAttemptTimeoutMs ?? 30000
const result = await dispatchOneTtsUpstream(
upstream,
i,
slice.model.provider,
index,
ttsModel.provider,
req.input,
req.modelName,
req.abortSignal,
@@ -591,13 +734,32 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
)
if (result.kind === 'ok') {
return new Response(result.body, {
status: 200,
headers: { 'content-type': result.contentType },
})
return {
kind: 'ok',
response: new Response(result.body, { status: 200, headers: { 'content-type': result.contentType } }),
}
}
options.gatewayMetrics?.keyExhaustedCount.add(1, { provider: providerTag })
return { kind: 'exhausted', sawTooManyRequests: result.failures.some(f => f.status === 429) }
}
// A model "uses the pool" when any upstream declares a concurrency cap. Models
// without one keep the original fixed-order fallback and make zero Redis
// calls — no behavior change for existing single-app configs.
const poolingEnabled = ttsModel.upstreams.some(u => typeof u.maxConcurrency === 'number')
if (!poolingEnabled) {
for (let i = 0; i < ttsModel.upstreams.length; i += 1) {
const result = await attemptUpstream(ttsModel.upstreams[i], i)
if (result.kind === 'ok')
return result.response
}
}
else {
const served = await routeTtsAcrossPools(ttsModel.upstreams, req.modelName, attemptUpstream)
if (served != null)
return served
}
const lastFailure = allFailures.at(-1)
@@ -0,0 +1,135 @@
import type Redis from 'ioredis'
import { beforeEach, describe, expect, it } from 'vitest'
import { createConcurrencyLedger } from '../concurrency-ledger'
// NOTICE: Mimic the subset of Redis semantics the ledger uses (EVAL for the
// ACQUIRE/RELEASE Lua, plus SET/EXISTS/GET/SADD/SMEMBERS/MGET). The two Lua
// scripts are told apart by numKeys (acquire passes 2 keys, release passes 1) —
// same approach flux-meter.test.ts uses for its single script. Real Lua
// atomicity is exercised by ioredis hitting Redis in integration; here we verify
// the capacity decision, floor-guarded release, saturation flags, and snapshot.
function createMockRedis() {
const inflight = new Map<string, number>()
const saturated = new Set<string>()
const known = new Set<string>()
const evalImpl = async (_script: string, numKeys: number, ...args: Array<string | number>) => {
if (numKeys === 2) {
// ACQUIRE_SCRIPT: inflightKey, knownKey, max, ttl, poolId
const inflightKey = String(args[0])
const knownKey = String(args[1])
const max = Number(args[2])
const poolId = String(args[4])
const current = inflight.get(inflightKey) ?? 0
if (current < max) {
const next = current + 1
inflight.set(inflightKey, next)
known.add(`${knownKey}::${poolId}`)
return next
}
return -1
}
// RELEASE_SCRIPT: inflightKey
const inflightKey = String(args[0])
const current = inflight.get(inflightKey) ?? 0
if (current > 0) {
const next = current - 1
inflight.set(inflightKey, next)
return next
}
return 0
}
const redis = {
eval: evalImpl,
set: async (key: string, _val: string, _mode: string, _ttl: number) => {
saturated.add(key)
return 'OK'
},
exists: async (key: string) => (saturated.has(key) ? 1 : 0),
get: async (key: string) => {
const v = inflight.get(key)
return v == null ? null : String(v)
},
smembers: async (key: string) => {
const prefix = `${key}::`
return [...known].filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length))
},
mget: async (keys: string[]) => keys.map(k => (inflight.has(k) ? String(inflight.get(k)) : null)),
} as unknown as Redis
return { redis, inflight, saturated }
}
describe('concurrencyLedger', () => {
let mock: ReturnType<typeof createMockRedis>
let ledger: ReturnType<typeof createConcurrencyLedger>
beforeEach(() => {
mock = createMockRedis()
ledger = createConcurrencyLedger(mock.redis)
})
it('tryAcquire grants a slot while the pool is below max and increments inflight', async () => {
// @example acquire on an empty pool (cap 10) -> granted, inflight becomes 1
const granted = await ledger.tryAcquire('app-1', 10)
expect(granted).toBe(true)
expect(await ledger.currentInflight('app-1')).toBe(1)
})
it('tryAcquire rejects once the pool is at max without incrementing past the cap', async () => {
// @example cap 2 -> first two granted, third rejected, inflight stays 2
expect(await ledger.tryAcquire('app-1', 2)).toBe(true)
expect(await ledger.tryAcquire('app-1', 2)).toBe(true)
expect(await ledger.tryAcquire('app-1', 2)).toBe(false)
expect(await ledger.currentInflight('app-1')).toBe(2)
})
it('grants no more than max across many acquires on one pool (capacity invariant)', async () => {
// @example cap 10, attempt 15 acquires -> exactly 10 granted
const results = await Promise.all(
Array.from({ length: 15 }, () => ledger.tryAcquire('app-1', 10)),
)
expect(results.filter(Boolean)).toHaveLength(10)
expect(await ledger.currentInflight('app-1')).toBe(10)
})
it('release returns a slot so a previously-full pool can grant again', async () => {
// @example cap 1: acquire, reject second, release, then acquire succeeds
expect(await ledger.tryAcquire('app-1', 1)).toBe(true)
expect(await ledger.tryAcquire('app-1', 1)).toBe(false)
await ledger.release('app-1')
expect(await ledger.currentInflight('app-1')).toBe(0)
expect(await ledger.tryAcquire('app-1', 1)).toBe(true)
})
it('release floors at zero and never drives the counter negative', async () => {
// @example releasing an idle pool keeps inflight at 0 (no negative overshoot)
await ledger.release('app-1')
expect(await ledger.currentInflight('app-1')).toBe(0)
})
it('isSaturated reflects markSaturated', async () => {
// @example before mark -> false; after mark -> true
expect(await ledger.isSaturated('app-1')).toBe(false)
await ledger.markSaturated('app-1', 5)
expect(await ledger.isSaturated('app-1')).toBe(true)
})
it('snapshot lists every acquired pool with its current inflight count', async () => {
// @example acquire on two pools -> snapshot reports both with counts
await ledger.tryAcquire('app-1', 10)
await ledger.tryAcquire('app-1', 10)
await ledger.tryAcquire('app-2', 10)
const snap = await ledger.snapshot()
expect(snap).toContainEqual({ poolId: 'app-1', inflight: 2 })
expect(snap).toContainEqual({ poolId: 'app-2', inflight: 1 })
})
it('snapshot is empty before any pool is acquired', async () => {
// @example fresh ledger -> snapshot returns []
expect(await ledger.snapshot()).toEqual([])
})
})
@@ -5,6 +5,7 @@ import type Redis from 'ioredis'
import type { GatewayMetrics } from '../../../../otel'
import type { ConfigKVService } from '../../../adapters/config-kv'
import type { ConcurrencyLedger } from '../concurrency-ledger'
import type { LlmRouteContext, RouterConfig } from '../types'
import { randomBytes } from 'node:crypto'
@@ -50,7 +51,27 @@ function makeMetrics(): GatewayMetrics {
subscriberState: makeCounter(),
configWrite: makeCounter(),
configInvalidHmac: makeCounter(),
} as GatewayMetrics
poolSlotRejected: makeCounter(),
poolSaturationMarked: makeCounter(),
poolInflight: { addCallback: vi.fn(), removeCallback: vi.fn() },
} as unknown as GatewayMetrics
}
/**
* Stub concurrency ledger. Defaults model an always-free pool (tryAcquire grants,
* nothing saturated) so the existing fixed-order LLM/TTS tests never engage the
* pooling branch. Pooling tests pass `overrides` to drive capacity decisions.
*/
function makeLedger(overrides: Partial<ConcurrencyLedger> = {}): ConcurrencyLedger {
return {
tryAcquire: vi.fn(async () => true),
release: vi.fn(async () => {}),
markSaturated: vi.fn(async () => {}),
isSaturated: vi.fn(async () => false),
currentInflight: vi.fn(async () => 0),
snapshot: vi.fn(async () => []),
...overrides,
}
}
function makeConfigKV(config: RouterConfig | null): ConfigKVService {
@@ -147,6 +168,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: metrics,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
const res = await router.route({ modelName: 'openai/gpt-5-mini', body: { messages: [] } })
@@ -172,6 +194,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
const ctx: LlmRouteContext = { provider: 'unknown', triedUpstreams: 0, triedKeys: 0, lastStatus: null }
@@ -203,6 +226,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: makeMetrics(),
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
const ctx: LlmRouteContext = { provider: 'unknown', triedUpstreams: 0, triedKeys: 0, lastStatus: null }
@@ -221,6 +245,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
await router.route({ modelName: 'openai/gpt-5-mini', body: { messages: [{ role: 'user', content: 'hi' }] } })
@@ -246,6 +271,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
const ctx: LlmRouteContext = { provider: 'unknown', triedUpstreams: 0, triedKeys: 0, lastStatus: null }
@@ -269,6 +295,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: metrics,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
const res = await router.route({ modelName: 'openai/gpt-5-mini', body: {} })
@@ -303,6 +330,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: metrics,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
const res = await router.route({ modelName: 'openai/gpt-5-mini', body: {} })
@@ -329,6 +357,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: metrics,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
try {
@@ -374,6 +403,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
try {
@@ -419,6 +449,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: metrics,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
await expect(router.route({ modelName: 'openai/gpt-5-mini', body: {} })).rejects.toMatchObject({ statusCode: 503, errorCode: 'SERVICE_UNAVAILABLE' })
@@ -448,6 +479,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
try {
@@ -494,6 +526,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
const res = await router.route({ modelName: 'openai/gpt-5-mini', body: {} })
@@ -524,6 +557,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
try {
@@ -549,6 +583,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: metrics,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
try {
@@ -574,6 +609,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
await expect(router.route({ modelName: 'whatever', body: {} })).rejects.toMatchObject({ statusCode: 503, errorCode: 'CONFIG_NOT_SET' })
@@ -592,6 +628,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
await expect(router.route({ modelName: 'openai/gpt-5-mini', body: {}, abortSignal: ctrl.signal })).rejects.toThrow(/client-disconnected/)
@@ -623,6 +660,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
await expect(router.route({ modelName: 'openai/gpt-5-mini', body: {}, abortSignal: ctrl.signal })).rejects.toThrow(/client-disconnected/)
@@ -641,6 +679,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
await router.route({ modelName: 'openai/gpt-5-mini', body: {} })
@@ -717,6 +756,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: metrics,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
let caught: unknown
@@ -760,6 +800,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: metrics,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
const res = await router.routeTts({
@@ -795,6 +836,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: metrics,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
const res = await router.routeTts({
@@ -840,6 +882,7 @@ describe('createLlmRouterService', () => {
gatewayMetrics: null,
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: makeLedger(),
})
const first = router.listTtsVoices('tts-test')
@@ -855,4 +898,242 @@ describe('createLlmRouterService', () => {
expect(secondVoices.map(voice => voice.id)).toEqual(['en-US-AvaMultilingualNeural'])
})
})
describe('routeTtspool capacity-aware routing', () => {
// One app_id == one upstream (Volcengine `adapterParams.appid`), each capped
// at `maxConcurrency`. The router spreads load least-loaded-first across pools
// and circuit-breaks a pool on 429 (app_id concurrency exceeded upstream-side).
function makePoolConfig(
upstreams: Array<{ baseURL: string, appid: string, maxConcurrency?: number }>,
): { config: RouterConfig, crypto: ReturnType<typeof createEnvelopeCrypto> } {
const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() })
const modelName = 'tts-pool'
const upstreamConfigs = upstreams.map((u, i) => {
const id = `k${i}`
const ct = crypto.encryptKey(`sk-${id}`, { modelName, keyEntryId: id })
return {
baseURL: u.baseURL,
keys: [{ id, ciphertext: ct }],
adapterParams: { appid: u.appid },
...(u.maxConcurrency != null ? { maxConcurrency: u.maxConcurrency } : {}),
}
})
const config = {
llm: { models: {} },
tts: {
models: {
[modelName]: {
provider: 'volcengine',
upstreams: upstreamConfigs,
fallbackTriggers: { httpCodes: [401, 429, 500, 502, 503, 504], onTimeout: true },
},
},
},
defaults: { perAttemptTimeoutMs: 5000, fullChainTimeoutMs: 10000, fallbackHttpCodes: [401, 429, 500, 502, 503, 504] },
} as RouterConfig
return { config, crypto }
}
// Stateful in-memory ledger so least-loaded ordering and capacity gating are
// observable. `seed` pre-loads inflight counts to drive deterministic ranking.
function makeStatefulLedger(seed: Record<string, number> = {}, saturatedSeed: string[] = []) {
const inflight = new Map<string, number>(Object.entries(seed))
const saturated = new Set<string>(saturatedSeed)
const tryAcquire = vi.fn(async (poolId: string, max: number) => {
const cur = inflight.get(poolId) ?? 0
if (saturated.has(poolId) || cur >= max)
return false
inflight.set(poolId, cur + 1)
return true
})
const release = vi.fn(async (poolId: string) => {
inflight.set(poolId, Math.max(0, (inflight.get(poolId) ?? 0) - 1))
})
const markSaturated = vi.fn(async (poolId: string) => {
saturated.add(poolId)
})
const ledger: ConcurrencyLedger = {
tryAcquire,
release,
markSaturated,
isSaturated: vi.fn(async (poolId: string) => saturated.has(poolId)),
currentInflight: vi.fn(async (poolId: string) => inflight.get(poolId) ?? 0),
snapshot: vi.fn(async () => [...inflight].map(([poolId, n]) => ({ poolId, inflight: n }))),
}
return { ledger, inflight, saturated, tryAcquire, release, markSaturated }
}
function makePoolRouter(config: RouterConfig, crypto: ReturnType<typeof createEnvelopeCrypto>, ledger: ConcurrencyLedger, fetchImpl: typeof fetch) {
return createLlmRouterService({
configKV: makeConfigKV(config),
envelopeCrypto: crypto,
gatewayMetrics: makeMetrics(),
fetchImpl,
redis: makeRedisStub(),
concurrencyLedger: ledger,
})
}
it('routes to the least-loadedpool (covers AE1 — load spread, not first-fill)', async () => {
// @example two app_ids cap 10, seeded 8 vs 2 in-flight -> the new request
// goes to the freer pool (app-2), not the config-first pool (app-1).
const { config, crypto } = makePoolConfig([
{ baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 },
{ baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 },
])
const { ledger, tryAcquire } = makeStatefulLedger({ 'app-1': 8, 'app-2': 2 })
const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch
const router = makePoolRouter(config, crypto, ledger, fetchImpl)
const res = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })
expect(res.status).toBe(200)
expect(tryAcquire).toHaveBeenCalledTimes(1)
expect(tryAcquire.mock.calls[0][0]).toBe('app-2')
})
it('skips a fullpool and dispatches to one with capacity', async () => {
// @example app-1 at cap (10/10) -> filtered out; app-2 (0/10) serves.
const { config, crypto } = makePoolConfig([
{ baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 },
{ baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 },
])
const { ledger, tryAcquire } = makeStatefulLedger({ 'app-1': 10, 'app-2': 0 })
const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch
const router = makePoolRouter(config, crypto, ledger, fetchImpl)
const res = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })
expect(res.status).toBe(200)
expect(tryAcquire.mock.calls.every(([poolId]) => poolId !== 'app-1')).toBe(true)
expect(tryAcquire.mock.calls.some(([poolId]) => poolId === 'app-2')).toBe(true)
})
it('fails fast with 503 TTS_POOL_SATURATED when everypool is full (covers AE2 — no silent stall)', async () => {
// @example both app_ids at cap -> 503, upstream is never dispatched.
const { config, crypto } = makePoolConfig([
{ baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 },
{ baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 },
])
const { ledger } = makeStatefulLedger({ 'app-1': 10, 'app-2': 10 })
const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch
const router = makePoolRouter(config, crypto, ledger, fetchImpl)
let caught: unknown
try {
await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })
}
catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(ApiError)
expect((caught as ApiError).statusCode).toBe(503)
expect((caught as ApiError).errorCode).toBe('TTS_POOL_SATURATED')
expect(fetchImpl).not.toHaveBeenCalled()
})
it('releases the slot after a successful dispatch', async () => {
// @example acquire then release leaves the pool's inflight back at baseline.
const { config, crypto } = makePoolConfig([
{ baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 },
])
const { ledger, release, inflight } = makeStatefulLedger({ 'app-1': 3 })
const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch
const router = makePoolRouter(config, crypto, ledger, fetchImpl)
await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })
expect(release).toHaveBeenCalledWith('app-1')
expect(inflight.get('app-1')).toBe(3)
})
it('makes zero ledger calls when no upstream declares maxConcurrency (no regression)', async () => {
// @example a model without any concurrency cap keeps the original
// fixed-order path and never touches Redis.
const { config, crypto } = makePoolConfig([
{ baseURL: 'https://up-a.example', appid: 'app-1' },
])
const { ledger, tryAcquire } = makeStatefulLedger()
const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch
const router = makePoolRouter(config, crypto, ledger, fetchImpl)
const res = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })
expect(res.status).toBe(200)
expect(tryAcquire).not.toHaveBeenCalled()
})
it('marks a pool saturated when it exhausts with a 429 (covers AE3 — bad-pool circuit break)', async () => {
// @example single pool returns 429 (app_id concurrency exceeded) -> the
// pool is circuit-broken so later requests skip it during the cool-down.
const { config, crypto } = makePoolConfig([
{ baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 },
])
const { ledger, markSaturated } = makeStatefulLedger()
const fetchImpl = vi.fn(async () => failResponse(429)) as unknown as typeof fetch
const router = makePoolRouter(config, crypto, ledger, fetchImpl)
await expect(router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })).rejects.toBeInstanceOf(ApiError)
expect(markSaturated).toHaveBeenCalledWith('app-1', expect.any(Number))
})
it('does NOT mark saturated when a pool exhausts with a non-429 status', async () => {
// @example a 500 is a server error, not a concurrency signal — the pool
// must stay eligible rather than being circuit-broken.
const { config, crypto } = makePoolConfig([
{ baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 },
])
const { ledger, markSaturated } = makeStatefulLedger()
const fetchImpl = vi.fn(async () => failResponse(500)) as unknown as typeof fetch
const router = makePoolRouter(config, crypto, ledger, fetchImpl)
await expect(router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })).rejects.toBeInstanceOf(ApiError)
expect(markSaturated).not.toHaveBeenCalled()
})
it('skips a pool already in a saturation cool-down', async () => {
// @example app-1 flagged saturated -> filtered out; app-2 serves.
const { config, crypto } = makePoolConfig([
{ baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 },
{ baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 },
])
const { ledger, tryAcquire } = makeStatefulLedger({}, ['app-1'])
const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch
const router = makePoolRouter(config, crypto, ledger, fetchImpl)
const res = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })
expect(res.status).toBe(200)
expect(tryAcquire.mock.calls.every(([poolId]) => poolId !== 'app-1')).toBe(true)
expect(tryAcquire.mock.calls.some(([poolId]) => poolId === 'app-2')).toBe(true)
})
it('skips an uncapped pool already in a saturation cool-down when another pool is capped', async () => {
// ROOT CAUSE:
//
// Before the fix, the capacity-aware branch returned uncapped pools as
// always eligible without reading the saturation flag. In mixed configs
// (`app-1` uncapped, `app-2` capped), a 429-saturated uncapped app stayed
// first because it had infinite remaining capacity.
//
// We fixed this by checking cooldown state before the capped/uncapped
// branch so both pool shapes honor the same circuit breaker.
const { config, crypto } = makePoolConfig([
{ baseURL: 'https://up-a.example', appid: 'app-1' },
{ baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 },
])
const { ledger, tryAcquire } = makeStatefulLedger({}, ['app-1'])
const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch
const router = makePoolRouter(config, crypto, ledger, fetchImpl)
const res = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })
expect(res.status).toBe(200)
expect(tryAcquire).toHaveBeenCalledTimes(1)
expect(tryAcquire).toHaveBeenCalledWith('app-2', 10)
})
})
})
@@ -6,11 +6,12 @@ import type { LlmRouterService } from '../llm-router'
import type { startTtsGeneration, TtsGenerationTrace } from '../llm-tracing'
import type { ProductEventService } from '../product-events'
import type { RequestLogService } from '../request-log'
import type { VoicePackService } from '../voice-packs'
import { useLogger } from '@guiiai/logg'
import { context, SpanStatusCode, trace } from '@opentelemetry/api'
import { createPaymentRequiredError } from '../../../utils/error'
import { ApiError, createBadRequestError, createPaymentRequiredError } from '../../../utils/error'
import { nanoid } from '../../../utils/id'
import {
AIRI_ATTR_BILLING_FLUX_CONSUMED,
@@ -27,12 +28,26 @@ const SAFE_RESPONSE_HEADERS = new Set([
'cache-control',
])
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'object' || value == null || Array.isArray(value))
return undefined
return value as Record<string, unknown>
}
function readOptionalNumber(record: Record<string, unknown> | undefined, key: string): number | undefined {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined
}
export interface OpenAiSpeechServiceDeps {
fluxService: FluxService
configKV: ConfigKVService
requestLogService: RequestLogService
ttsMeter: FluxMeter
llmRouter: LlmRouterService
voicePackService: VoicePackService
productEventService: ProductEventService
genAi?: GenAiMetrics | null
llmTracing: {
@@ -72,6 +87,13 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
if (requestModel === 'auto')
requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL')
const voicePackRequest = await voicePackRequestOptions(input.body, {
model: requestModel,
voice: typeof input.body.voice === 'string' ? input.body.voice : undefined,
voicePackService: deps.voicePackService,
})
const billingUnits = Math.ceil(inputText.length * voicePackRequest.costMultiplier)
logger.withFields({
requestId,
userId: input.userId,
@@ -95,13 +117,14 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
const flux = await deps.fluxService.getFlux(input.userId)
if (flux.flux <= 0)
throw createPaymentRequiredError('Insufficient flux')
await deps.ttsMeter.assertCanAfford(input.userId, inputText.length, flux.flux)
await deps.ttsMeter.assertCanAfford(input.userId, billingUnits, flux.flux)
const ttsInput = {
text: inputText,
voice: typeof input.body.voice === 'string' ? input.body.voice : undefined,
speed: typeof input.body.speed === 'number' ? input.body.speed : undefined,
responseFormat: typeof input.body.response_format === 'string' ? input.body.response_format : undefined,
extraOptions: voicePackRequest.extraOptions,
}
const generationTrace = deps.llmTracing.startTtsGeneration({
@@ -131,15 +154,16 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
}, routeCtx))
}
catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: 'TTS router exhausted or unknown model' })
const failure = routerFailure(err)
span.setStatus({ code: SpanStatusCode.ERROR, message: failure.message })
span.end()
generationTrace.fail('TTS router exhausted or unknown model')
generationTrace.fail(failure.message)
recordMetrics({
durationMs: Date.now() - startedAt,
fluxConsumed: 0,
model: requestModel,
provider: routeCtx.provider,
status: 502,
status: failure.status,
})
void deps.productEventService.track({
userId: input.userId,
@@ -149,8 +173,9 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
source: 'audio.speech',
model: requestModel,
provider: routeCtx.provider,
reason: 'router_exhausted',
reason: failure.reason,
metadata: {
http_status: failure.status,
duration_ms: Date.now() - startedAt,
},
})
@@ -191,10 +216,10 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
try {
const result = await deps.ttsMeter.accumulate({
userId: input.userId,
units: inputText.length,
units: billingUnits,
currentBalance: flux.flux,
requestId,
metadata: { model: requestModel },
metadata: { model: requestModel, costMultiplier: voicePackRequest.costMultiplier },
})
fluxConsumed = result.fluxDebited
span.setAttribute(AIRI_ATTR_BILLING_FLUX_CONSUMED, fluxConsumed)
@@ -224,6 +249,8 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
metadata: {
http_status: response.status,
input_chars: inputText.length,
billing_units: billingUnits,
cost_multiplier: voicePackRequest.costMultiplier,
duration_ms: durationMs,
flux_consumed: fluxConsumed,
},
@@ -273,6 +300,78 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
return { handleSpeechRequest }
}
async function voicePackRequestOptions(
body: Record<string, unknown>,
context: {
model: string
voice?: string
voicePackService: VoicePackService
},
): Promise<{ extraOptions: Record<string, unknown> | undefined, costMultiplier: number }> {
const extraBody = asRecord(body.extra_body)
const voicePackOptions = asRecord(extraBody?.voice_pack)
const pitch = readOptionalNumber(voicePackOptions, 'pitch')
const volume = readOptionalNumber(voicePackOptions, 'volume')
const costMultiplier = await resolveVoicePackCostMultiplier(voicePackOptions, context)
const extraOptions: Record<string, unknown> = {}
if (pitch != null)
extraOptions.pitch = pitch
if (volume != null)
extraOptions.volume = volume
return {
extraOptions: Object.keys(extraOptions).length > 0 ? extraOptions : undefined,
costMultiplier,
}
}
async function resolveVoicePackCostMultiplier(
voicePackOptions: Record<string, unknown> | undefined,
context: {
model: string
voice?: string
voicePackService: VoicePackService
},
): Promise<number> {
const packId = voicePackOptions?.pack_id
const value = voicePackOptions?.cost_multiplier
if (packId == null && value == null)
return 1
if (typeof packId !== 'string' || !packId.trim())
throw createBadRequestError('voice_pack.pack_id is required when Voice Pack billing metadata is provided', 'INVALID_VOICE_PACK')
const pack = await context.voicePackService.findById(packId)
if (!pack)
throw createBadRequestError('Voice Pack not found', 'INVALID_VOICE_PACK', { packId })
if (pack.ttsModelId !== context.model || pack.voiceId !== context.voice) {
throw createBadRequestError('Voice Pack does not match requested model and voice', 'INVALID_VOICE_PACK', {
packId,
expectedModel: pack.ttsModelId,
actualModel: context.model,
expectedVoice: pack.voiceId,
actualVoice: context.voice,
})
}
return pack.costMultiplier
}
function routerFailure(error: unknown): { status: number, reason: string, message: string } {
if (error instanceof ApiError) {
return {
status: error.statusCode,
reason: error.errorCode,
message: error.message,
}
}
return {
status: 502,
reason: 'router_exhausted',
message: 'TTS router exhausted or unknown model',
}
}
function buildSafeResponseHeaders(response: Response): Headers {
const headers = new Headers()
response.headers.forEach((value, key) => {
@@ -9,7 +9,7 @@ import * as schema from '../../schemas/product-events'
const logger = useLogger('product-events')
export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing'
export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing' | 'voice_pack'
export type ProductEventStatus = 'started' | 'succeeded' | 'failed'
@@ -23,6 +23,9 @@ export type ProductAction
| 'speech_requested'
| 'speech_succeeded'
| 'speech_failed'
| 'voice_pack_created'
| 'voice_pack_updated'
| 'voice_pack_disabled'
| 'checkout_started'
| 'payment_completed'
@@ -0,0 +1,126 @@
import type { Database } from '../../../libs/db'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { createVoicePackService } from '.'
import { mockDB } from '../../../libs/mock-db'
import * as schema from '../../../schemas'
describe('voicePackService', () => {
let db: Database
let service: ReturnType<typeof createVoicePackService>
beforeAll(async () => {
db = await mockDB(schema)
service = createVoicePackService(db)
})
beforeEach(async () => {
await db.delete(schema.voicePacks)
})
it('creates a Voice Pack with provider, model, voice, params, cost multiplier, and tts model pin', async () => {
// @example create one curated cloud voice -> row stores the resolved routing pin.
const pack = await service.create({
name: 'Neuro Sama',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-neuro',
ttsModelId: 'volcengine/neuro-pool',
params: { pitch: '+20%', volume: '+5%' },
costMultiplier: 1.5,
enabled: true,
})
expect(pack.name).toBe('Neuro Sama')
expect(pack.provider).toBe('volcengine')
expect(pack.model).toBe('seed-tts-2.0')
expect(pack.voiceId).toBe('voice-neuro')
expect(pack.ttsModelId).toBe('volcengine/neuro-pool')
expect(pack.params).toEqual({ pitch: '+20%', volume: '+5%' })
expect(pack.costMultiplier).toBe(1.5)
expect(pack.enabled).toBe(true)
})
it('keeps parameter variants as separate packs', async () => {
// @example same provider/model/voice with different params -> two library entries.
await service.create({
name: 'Base',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-a',
ttsModelId: 'volcengine/pool',
params: {},
costMultiplier: 1,
enabled: true,
})
await service.create({
name: 'Pitched',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-a',
ttsModelId: 'volcengine/pool',
params: { pitch: '+20%' },
costMultiplier: 1,
enabled: true,
})
const packs = await service.list()
expect(packs).toHaveLength(2)
expect(packs.map(p => p.name).sort()).toEqual(['Base', 'Pitched'])
})
it('updates mutable fields without replacing the row', async () => {
// @example edit curation metadata/params -> same id, updated values.
const pack = await service.create({
name: 'Old',
provider: 'azure',
model: 'v1',
voiceId: 'en-US-AvaMultilingualNeural',
ttsModelId: 'microsoft/v1',
params: {},
costMultiplier: 1,
enabled: true,
})
const updated = await service.update(pack.id, {
name: 'New',
params: { rate: '+10%' },
costMultiplier: 2,
})
expect(updated?.id).toBe(pack.id)
expect(updated?.name).toBe('New')
expect(updated?.params).toEqual({ rate: '+10%' })
expect(updated?.costMultiplier).toBe(2)
})
it('soft-disables a pack and excludes it from listEnabled', async () => {
// @example disabled packs remain in admin list but disappear from user list.
const pack = await service.create({
name: 'Disable me',
provider: 'dashscope-cosyvoice',
model: 'cosyvoice-v2',
voiceId: 'longxiaochun_v2',
ttsModelId: 'alibaba/cosyvoice-v2',
params: {},
costMultiplier: 1,
enabled: true,
})
const disabled = await service.disable(pack.id)
const all = await service.list()
const enabled = await service.listEnabled()
expect(disabled?.enabled).toBe(false)
expect(all).toHaveLength(1)
expect(enabled).toEqual([])
})
it('returns null when updating or disabling a missing pack', async () => {
// @example unknown id -> null so routes can map to 404.
expect(await service.update('missing', { name: 'Nope' })).toBeNull()
expect(await service.disable('missing')).toBeNull()
})
})
@@ -0,0 +1,128 @@
import type { InferOutput } from 'valibot'
import type { Database } from '../../../libs/db'
import type { VoicePack } from '../../../schemas/voice-packs'
import { and, eq } from 'drizzle-orm'
import { boolean, maxLength, minValue, nonEmpty, null_, number, object, optional, pipe, record, string, union } from 'valibot'
import * as schema from '../../../schemas/voice-packs'
export const VoicePackParamsSchema = record(
pipe(string(), nonEmpty('params keys must not be empty'), maxLength(100)),
union([string(), number(), boolean(), null_()]),
)
export const VoicePackCostMultiplierSchema = pipe(
number(),
minValue(0, 'costMultiplier must not be negative'),
)
export const CreateVoicePackInputSchema = object({
name: pipe(string(), nonEmpty('name is required'), maxLength(120)),
description: optional(pipe(string(), maxLength(500))),
provider: pipe(string(), nonEmpty('provider is required'), maxLength(100)),
model: pipe(string(), nonEmpty('model is required'), maxLength(200)),
voiceId: pipe(string(), nonEmpty('voiceId is required'), maxLength(200)),
ttsModelId: pipe(string(), nonEmpty('ttsModelId is required'), maxLength(200)),
params: optional(VoicePackParamsSchema, {}),
costMultiplier: VoicePackCostMultiplierSchema,
enabled: optional(boolean(), true),
})
export const UpdateVoicePackInputSchema = object({
name: optional(pipe(string(), nonEmpty('name must not be empty'), maxLength(120))),
description: optional(pipe(string(), maxLength(500))),
provider: optional(pipe(string(), nonEmpty('provider must not be empty'), maxLength(100))),
model: optional(pipe(string(), nonEmpty('model must not be empty'), maxLength(200))),
voiceId: optional(pipe(string(), nonEmpty('voiceId must not be empty'), maxLength(200))),
ttsModelId: optional(pipe(string(), nonEmpty('ttsModelId must not be empty'), maxLength(200))),
params: optional(VoicePackParamsSchema),
costMultiplier: optional(VoicePackCostMultiplierSchema),
enabled: optional(boolean()),
})
/**
* Voice Pack creation input accepted by the admin service.
*/
export type CreateVoicePackInput = InferOutput<typeof CreateVoicePackInputSchema>
/**
* Voice Pack update input accepted by the admin service.
*/
export type UpdateVoicePackInput = InferOutput<typeof UpdateVoicePackInputSchema>
/**
* Handles the curated server-side Voice Pack library.
*
* Use when:
* - Admin routes create, update, disable, or list curated cloud-provider voices.
* - Client routes need the enabled-only market list for binding.
*
* Expects:
* - HTTP routes validate input with the exported Valibot schemas before calling.
*
* Returns:
* - CRUD methods that preserve rows and use `enabled=false` as soft disable.
*/
export function createVoicePackService(db: Database) {
return {
async create(input: CreateVoicePackInput) {
const [inserted] = await db.insert(schema.voicePacks).values({
name: input.name,
description: input.description,
provider: input.provider,
model: input.model,
voiceId: input.voiceId,
ttsModelId: input.ttsModelId,
params: input.params,
costMultiplier: input.costMultiplier,
enabled: input.enabled,
}).returning()
return inserted
},
async list() {
return await db.query.voicePacks.findMany({
orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)],
})
},
async listEnabled() {
return await db.query.voicePacks.findMany({
where: eq(schema.voicePacks.enabled, true),
orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)],
})
},
async findById(id: string) {
return await db.query.voicePacks.findFirst({
where: eq(schema.voicePacks.id, id),
})
},
async update(id: string, input: UpdateVoicePackInput): Promise<VoicePack | null> {
const [updated] = await db.update(schema.voicePacks)
.set({ ...input, updatedAt: new Date() })
.where(eq(schema.voicePacks.id, id))
.returning()
return updated ?? null
},
async disable(id: string): Promise<VoicePack | null> {
const [updated] = await db.update(schema.voicePacks)
.set({ enabled: false, updatedAt: new Date() })
.where(and(
eq(schema.voicePacks.id, id),
eq(schema.voicePacks.enabled, true),
))
.returning()
return updated ?? null
},
}
}
export type VoicePackService = ReturnType<typeof createVoicePackService>
+12
View File
@@ -150,6 +150,18 @@ export const METRIC_AIRI_GEN_AI_GATEWAY_DECRYPT_FAILURES = 'airi.gen_ai.gateway.
export const METRIC_AIRI_GEN_AI_GATEWAY_SUBSCRIBER_STATE = 'airi.gen_ai.gateway.subscriber_state'
export const METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_WRITE = 'airi.gen_ai.gateway.config.write'
export const METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_INVALID_HMAC = 'airi.gen_ai.gateway.config.invalid_hmac'
// TTSpool (per app_id concurrency pool) load-balancer signals.
// pool_slot_rejected — capacity-aware routing skipped a pool because its app_id
// was already at the concurrency cap (labels: provider, app_id).
// pool_saturation_marked
// — a pool was circuit-broken after exhausting with a 429
// (labels: provider, app_id).
// pool_inflight — cluster-wide gauge of current in-flight requests per pool,
// sourced from Redis (label: app_id). Dashboard must avg(),
// not sum() — every replica reports the same value.
export const METRIC_AIRI_GEN_AI_GATEWAY_POOL_SLOT_REJECTED = 'airi.gen_ai.gateway.pool.slot_rejected'
export const METRIC_AIRI_GEN_AI_GATEWAY_POOL_SATURATION_MARKED = 'airi.gen_ai.gateway.pool.saturation_marked'
export const METRIC_AIRI_GEN_AI_GATEWAY_POOL_INFLIGHT = 'airi.gen_ai.gateway.pool.inflight'
// ---------------------------------------------------------------------------
// Canonical gen_ai.system values
+28
View File
@@ -32,3 +32,31 @@ export function userChatBroadcastRedisKey(userId: string): string {
export function lockRedisKey(domain: string, ...identifiers: RedisKeyPart[]): string {
return redisKeyFrom('lock', domain, ...identifiers)
}
/**
* In-flight request counter for one TTSpool (per app_id concurrency pool).
* `poolId` is the upstream's `adapterParams.appid` (or baseURL fallback). The
* counter is INCR'd on slot acquire and DECR'd on release; a short TTL bounds
* leakage if a replica crashes between acquire and release.
*/
export function ttsPoolInflightRedisKey(poolId: string): string {
return redisKeyFrom('tts', 'pool', 'inflight', poolId)
}
/**
* Short-TTL saturation flag for one TTSpool. Set when an upstream exhausts with
* a 429 (app_id concurrency exceeded) so capacity-aware routing skips that pool
* for a cool-down window instead of repeatedly hammering a known-full pool.
*/
export function ttsPoolSaturatedRedisKey(poolId: string): string {
return redisKeyFrom('tts', 'pool', 'saturated', poolId)
}
/**
* Set of everypool id the router has acquired a slot on. The pool watermark
* gauge reads this set's members, then MGETs each inflight counter avoids
* parsing LLM_ROUTER_CONFIG inside the metric callback.
*/
export function ttsPoolKnownRedisKey(): string {
return redisKeyFrom('tts', 'pool', 'known')
}
+25
View File
@@ -0,0 +1,25 @@
# AIRI Admin Dashboard
Admin dashboard for operating the hosted AIRI server. It is a Vue/Vite app built into `apps/server/public/ui-admin` and served by the server at `/admin`.
## Use When
- Reviewing server metrics, users, flux balances, LLM router config, and curated Voice Packs.
- Building operator-only workflows that depend on the server admin API under `/api/admin`.
## Do Not Use When
- Building end-user settings or character-card flows. Those belong in the stage apps and shared stage packages.
- Adding unauthenticated server UI. This app expects the server admin guard and Better Auth session cookies.
## Commands
```sh
pnpm -F @proj-airi/ui-admin dev
pnpm -F @proj-airi/ui-admin typecheck
pnpm -F @proj-airi/ui-admin build
```
## Build Output
`pnpm -F @proj-airi/ui-admin build` writes to `apps/server/public/ui-admin`. Build this app before running a server image or local server flow that needs `/admin` to serve real HTML instead of reporting a missing admin UI artifact.
+1
View File
@@ -19,6 +19,7 @@ const navItems = [
{ to: '/users', icon: 'i-lucide-users', label: 'Users' },
{ to: '/flux', icon: 'i-lucide-coins', label: 'Flux' },
{ to: '/llm-router', icon: 'i-lucide-route', label: 'LLM Router' },
{ to: '/voice-packs', icon: 'i-lucide-volume-2', label: 'Voice Packs' },
]
const currentTitle = computed(() => navItems.find(item => item.to === route.path)?.label ?? 'Overview')
+2
View File
@@ -10,6 +10,7 @@ import FluxPage from './pages/FluxPage.vue'
import LlmRouterPage from './pages/LlmRouterPage.vue'
import OverviewPage from './pages/OverviewPage.vue'
import UsersPage from './pages/UsersPage.vue'
import VoicePacksPage from './pages/VoicePacksPage.vue'
import '@proj-airi/font-chillroundm/index.css'
import '@unocss/reset/tailwind.css'
@@ -24,6 +25,7 @@ const router = createRouter({
{ path: '/users', component: UsersPage },
{ path: '/flux', component: FluxPage },
{ path: '/llm-router', component: LlmRouterPage },
{ path: '/voice-packs', component: VoicePacksPage },
],
})
+46
View File
@@ -64,6 +64,37 @@ export interface AdminRouterConfigResult {
preview: Record<string, unknown>
}
export interface VoicePackParams {
[key: string]: string | number | boolean | null
}
export interface VoicePack {
id: string
name: string
description: string | null
provider: string
model: string
voiceId: string
ttsModelId: string
params: VoicePackParams
costMultiplier: number
enabled: boolean
createdAt: string
updatedAt: string
}
export interface VoicePackPayload {
name: string
description?: string
provider: string
model: string
voiceId: string
ttsModelId: string
params?: VoicePackParams
costMultiplier: number
enabled?: boolean
}
export class AdminApiError extends Error {
constructor(
message: string,
@@ -171,4 +202,19 @@ export const adminApi = {
method: 'POST',
body: JSON.stringify({ ...body, dryRun }),
}),
voicePacks: () => adminFetch<VoicePack[]>('/voice-packs'),
createVoicePack: (body: VoicePackPayload) =>
adminFetch<VoicePack>('/voice-packs', {
method: 'POST',
body: JSON.stringify(body),
}),
updateVoicePack: (id: string, body: Partial<VoicePackPayload>) =>
adminFetch<VoicePack>(`/voice-packs/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
disableVoicePack: (id: string) =>
adminFetch<VoicePack>(`/voice-packs/${encodeURIComponent(id)}/disable`, {
method: 'POST',
}),
}
+371
View File
@@ -0,0 +1,371 @@
<script setup lang="ts">
import type { VoicePack, VoicePackParams, VoicePackPayload } from '../modules/api'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
import { computed, onMounted, reactive, shallowRef } from 'vue'
import { toast } from 'vue-sonner'
import { adminApi } from '../modules/api'
const DEFAULT_PARAMS = '{}'
const packs = shallowRef<VoicePack[]>([])
const selected = shallowRef<VoicePack | null>(null)
const loading = shallowRef(false)
const saving = shallowRef(false)
const form = reactive({
name: '',
description: '',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: '',
ttsModelId: '',
paramsJson: DEFAULT_PARAMS,
costMultiplier: 1,
enabled: true,
})
const enabledCount = computed(() => packs.value.filter(pack => pack.enabled).length)
const disabledCount = computed(() => packs.value.length - enabledCount.value)
const selectedId = computed(() => selected.value?.id ?? null)
const paramsError = computed(() => {
try {
parseParams()
return null
}
catch (error) {
return errorMessageFromUnknown(error, 'Invalid params JSON')
}
})
const formError = computed(() => {
if (!form.name.trim())
return 'Name is required'
if (!form.provider.trim())
return 'Provider is required'
if (!form.model.trim())
return 'Model is required'
if (!form.ttsModelId.trim())
return 'TTS model ID is required'
if (!form.voiceId.trim())
return 'Voice ID is required'
if (!Number.isFinite(Number(form.costMultiplier)) || Number(form.costMultiplier) < 0)
return 'Cost multiplier must be a non-negative number'
return null
})
onMounted(() => {
void loadPacks()
})
async function loadPacks() {
loading.value = true
try {
packs.value = await adminApi.voicePacks()
if (selectedId.value) {
selected.value = packs.value.find(pack => pack.id === selectedId.value) ?? null
if (selected.value)
fillForm(selected.value)
}
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to load Voice Packs'))
}
finally {
loading.value = false
}
}
function fillForm(pack: VoicePack) {
selected.value = pack
form.name = pack.name
form.description = pack.description ?? ''
form.provider = pack.provider
form.model = pack.model
form.voiceId = pack.voiceId
form.ttsModelId = pack.ttsModelId
form.paramsJson = JSON.stringify(pack.params ?? {}, null, 2)
form.costMultiplier = pack.costMultiplier
form.enabled = pack.enabled
}
function resetForm() {
selected.value = null
form.name = ''
form.description = ''
form.provider = 'volcengine'
form.model = 'seed-tts-2.0'
form.voiceId = ''
form.ttsModelId = ''
form.paramsJson = DEFAULT_PARAMS
form.costMultiplier = 1
form.enabled = true
}
function parseParams(): VoicePackParams {
const parsed = JSON.parse(form.paramsJson || '{}') as unknown
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('Params must be a JSON object')
for (const [key, value] of Object.entries(parsed)) {
if (!key.trim())
throw new Error('Params keys must not be empty')
const valid = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value == null
if (!valid)
throw new Error(`Unsupported params value for "${key}"`)
}
return parsed as VoicePackParams
}
function payload(): VoicePackPayload {
return {
name: form.name.trim(),
description: form.description.trim() || undefined,
provider: form.provider.trim(),
model: form.model.trim(),
voiceId: form.voiceId.trim(),
ttsModelId: form.ttsModelId.trim(),
params: parseParams(),
costMultiplier: Number(form.costMultiplier),
enabled: form.enabled,
}
}
async function savePack() {
saving.value = true
try {
if (selected.value) {
const updated = await adminApi.updateVoicePack(selected.value.id, payload())
toast.success('Voice Pack updated')
selected.value = updated
}
else {
const created = await adminApi.createVoicePack(payload())
toast.success('Voice Pack created')
selected.value = created
}
await loadPacks()
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to save Voice Pack'))
}
finally {
saving.value = false
}
}
async function disableSelected() {
if (!selected.value)
return
saving.value = true
try {
const disabled = await adminApi.disableVoicePack(selected.value.id)
toast.success('Voice Pack disabled')
selected.value = disabled
await loadPacks()
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to disable Voice Pack'))
}
finally {
saving.value = false
}
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(value))
}
function formatMultiplier(value: number): string {
return `${Number(value.toFixed(2))}x`
}
</script>
<template>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
<section class="panel overflow-hidden">
<div class="flex flex-col gap-3 border-b border-neutral-200 px-5 py-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-sm font-semibold">
Voice Packs
</h2>
<p class="mt-1 text-sm text-neutral-500">
Curated speech presets exposed to users for character-card binding.
</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="badge badge-green">
<span class="i-lucide-volume-2" />
{{ enabledCount }} enabled
</span>
<span class="badge" :class="disabledCount > 0 ? 'badge-amber' : 'badge-green'">
<span class="i-lucide-circle-slash" />
{{ disabledCount }} disabled
</span>
</div>
</div>
<div v-if="loading && packs.length === 0" class="empty-state">
<span class="i-lucide-loader-2 animate-spin text-2xl" />
Loading Voice Packs
</div>
<table v-else-if="packs.length > 0" class="table">
<thead>
<tr>
<th>Name</th>
<th>Routing</th>
<th>Cost</th>
<th>Status</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<tr
v-for="pack in packs"
:key="pack.id"
class="cursor-pointer transition-colors hover:bg-neutral-50"
:class="{ 'bg-emerald-50/50': selectedId === pack.id }"
tabindex="0"
@click="fillForm(pack)"
@keydown.enter.prevent="fillForm(pack)"
@keydown.space.prevent="fillForm(pack)"
>
<td>
<div class="font-medium">
{{ pack.name }}
</div>
<div class="mt-1 max-w-[280px] truncate text-xs text-neutral-500">
{{ pack.description || pack.voiceId }}
</div>
</td>
<td>
<div class="text-xs font-mono">
{{ pack.ttsModelId }}
</div>
<div class="mt-1 text-xs text-neutral-500">
{{ pack.provider }} / {{ pack.model }}
</div>
</td>
<td>{{ formatMultiplier(pack.costMultiplier) }}</td>
<td>
<span class="badge" :class="pack.enabled ? 'badge-green' : 'badge-amber'">
<span :class="pack.enabled ? 'i-lucide-check-circle-2' : 'i-lucide-pause-circle'" />
{{ pack.enabled ? 'Enabled' : 'Disabled' }}
</span>
</td>
<td>{{ formatDate(pack.updatedAt) }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty-state">
<span class="i-lucide-volume-x text-2xl" />
No Voice Packs configured
</div>
</section>
<aside class="panel p-5">
<div class="mb-5 flex items-start justify-between gap-3">
<div>
<h2 class="text-sm font-semibold">
{{ selected ? 'Edit Voice Pack' : 'New Voice Pack' }}
</h2>
<p class="mt-1 text-sm text-neutral-500">
Frozen copies stay on character cards after binding.
</p>
</div>
<button class="btn btn-secondary" type="button" @click="resetForm">
<span class="i-lucide-plus" />
New
</button>
</div>
<form class="space-y-4" @submit.prevent="savePack">
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Name</span>
<input v-model="form.name" class="field" required type="text">
</label>
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Description</span>
<input v-model="form.description" class="field" type="text">
</label>
<div class="grid gap-4 md:grid-cols-2">
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Provider</span>
<input v-model="form.provider" class="field" required type="text">
</label>
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Model</span>
<input v-model="form.model" class="field" required type="text">
</label>
</div>
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">TTS model ID</span>
<input v-model="form.ttsModelId" class="field text-xs font-mono" required type="text">
</label>
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Voice ID</span>
<input v-model="form.voiceId" class="field text-xs font-mono" required type="text">
</label>
<div class="grid gap-4 md:grid-cols-[1fr_120px]">
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Cost multiplier</span>
<input v-model.number="form.costMultiplier" class="field" min="0" step="0.1" type="number">
</label>
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Enabled</span>
<select v-model="form.enabled" class="field">
<option :value="true">
Yes
</option>
<option :value="false">
No
</option>
</select>
</label>
</div>
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Params JSON</span>
<textarea
v-model="form.paramsJson"
class="textarea min-h-[180px] text-xs leading-5 font-mono"
placeholder="{&#10; &quot;rate&quot;: &quot;+5%&quot;&#10;}"
spellcheck="false"
/>
</label>
<div v-if="paramsError" class="border border-amber-200 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
{{ paramsError }}
</div>
<div v-else-if="formError" class="border border-amber-200 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
{{ formError }}
</div>
<div class="flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4">
<button
v-if="selected?.enabled"
class="btn btn-danger"
:disabled="saving"
type="button"
@click="disableSelected"
>
<span class="i-lucide-ban" />
Disable
</button>
<button class="btn btn-primary" :disabled="saving || paramsError != null || formError != null" type="submit">
<span :class="saving ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-save'" />
Save
</button>
</div>
</form>
</aside>
</div>
</template>
@@ -0,0 +1,113 @@
---
date: 2026-05-30
topic: voice-pack
---
# Voice Pack 音色系统需求
## Summary
两件事一起做,号池负载均衡优先:
1. **TTS 号池负载均衡(最高优先)。** 一个上游 `app_id` 只有 10 并发,买并发贵,所以一个账号开 10 个 app10 个 app_id)= 100 并发。需要一个容量感知的号池:实时追踪每个 app_id 的在途请求数,把流量路由到还有并发余量的号,并对池子整体水位做监控。
2. **Voice Pack 音色系统。** 引入服务端 `voice_packs` 表,把 `provider + model + voice + 参数覆盖` 收敛成用户只选一个「声线」。绑定到角色卡时把**解析后的值快照冻结**进卡,之后改表永不影响已绑定的卡。一个 Voice Pack pin 一个 tts model id,该 model 的 upstreams/keys 就是上面那个号池。
## Problem Frame
**号池并发约束。** 上游 TTS 服务按 `app_id` 限制并发(典型 10),扩并发额度很贵。绕开的办法是同一账号注册多个 app 拿到多个 `app_id` 凑并发。但当前服务端 `routeTts``createKeyRotator``apps/server/src/services/.../router.ts:429`)是**盲轮转**:不追踪每个号的在途请求数,会把某个号打爆到并发上限、别的号还闲着;跨 upstream 更是固定顺序、不分摊。结果是 100 并发的理论容量用不满,还会因为单号超限触发 429。
**音色被动漂移。** 当前音色是全局 UI 状态:`active-provider` + `active-model` + `voice` 三个独立 localStorage key`packages/stage-ui/src/stores/modules/speech.ts:32-35`),不绑定角色卡、不是快照。voice catalog 是 per-model 的,上游 model 下线、默认音色被改、目录调整时,用户选好的音色会悄悄变成另一个甚至失效。`DEFAULT_TTS_VOICES`commit `95915923e`)已把 per-model 默认音色配置化、并要求 caller 必须显式传 voice,但「绑定后永不变」这层语义还不存在。
**用户被迫理解 provider 拓扑。** 选音色要先懂 Microsoft / 阿里云 等各自的 model 和 voice id 格式,对用户是无关负担。
## Key Decisions
- **号池负载均衡排在最前,且与 Voice Pack 解耦。** 它是 TTS 基建,惠及所有 TTS 合成,不依赖 voice_packs。Voice Pack 只是 pin 一个 tts model id,那个 model 的 upstreams/keys 即号池。一个计划覆盖两块,unit 顺序把号池 LB 放最前。
- **容量感知而非盲轮转。** 调度按每个 app_id 的在途并发余量挑号,不是 round-robin 盲转。并发计数若服务端多副本则必须放共享存储(Redis,与现有 flux meter 同源),否则各副本各算、号池超卖。(部署拓扑规划时实测确认。)
- **Voice Pack library 是服务端 `voice_packs` 表(复数表名),管理员策展。** 不是前端 localStorage。本轮只装「云提供商音色」= `provider + model + voice + 参数覆盖`,同时覆盖标准 voice 与阿里云克隆 model id 两类(结构相同)。软禁用用 `enabled` 列,不删行。
- **参数覆盖是 pack 身份的一部分。** 同一 `provider+model+voice` 配不同 pitch / 响度 = 不同 Voice Pack,用户分别可选(Neuro-sama 那个 Pitch +20%、响度 +5% 的例子)。
- **绑定冻结的是解析后的值,不是表外键。** 角色卡冻结 `provider/model/voice/params/tier + pin 的 tts model id``extensions.airi.modules.speech.voicePack`(扩 `airi-card.ts:173-176` 现有 speech 快照点)。存外键会导致改表连带改卡,回到漂移。`resolveAiriExtension``airi-card.ts:161`)处理字段缺失,不加 backward-compat guard。
- **failover 复用现有 routeTts。** 等价后端容灾(耗尽 fail-fast、带上下文、绝不静默换音色)复用 `routeTts` 现有跨 upstream/key 重试。R7「等价判定」(同音色、可复现参数)是服务端不校验的新语义,只能在 `voice_packs` 定义层把关。
- **tier 复用 `tts-billing-tiers.md` 的 lite/standard/pro/premium 命名。** `voice_packs` 一列,冻进快照。本轮只有一个 meter(`FLUX_PER_1K_CHARS_TTS` 单值),四档 meter 拆分属 billing 独立线,所以本轮 tier 是**展示 + 数据**,「按最高档取价」暂无真实差价效果。
## Key Flows
- F1. **号池容量感知路由**
- **Trigger:** 一次 TTS 合成请求进入服务端路由。
- **Steps:** 解析目标 tts model 的号池(upstreams/keys,每个 key 对应一个 app_id)→ 读各 app_id 当前在途并发数 → 挑还有并发余量的号 → 占用一个并发槽 → 发起合成 → 完成/失败释放槽。全部号满 → 排队或返回容量错误(不静默吞)。
- **Covered by:** R1, R2, R3
- F2. **绑定流程**
- **Trigger:** 用户选定一个 Voice Pack 绑定到某角色卡。
- **Steps:**`voice_packs` 读 enabled 的 pack → 把解析后的值快照冻结写入角色卡 extensions → 角色卡此后只读自己的冻结快照。
- **Covered by:** R8, R9
- F3. **合成读快照 + 容灾**
- **Trigger:** 角色卡触发 TTS 合成。
- **Steps:** 读角色卡冻结快照 → 映射 tts model id → 参数走 SSML prosody / adapter options → 经号池 LB(F1)挑号合成 → 后端不可用在等价后端间 failover,耗尽 fail-fast。
- **Covered by:** R10, R11
## Requirements
**TTS 号池负载均衡(最高优先)**
- R1. 服务端追踪号池内每个 `app_id`(key)的实时在途请求数,路由时挑还有并发余量的号,不用盲轮转。
- R2. 并发计数在服务端多副本部署下跨副本共享一致(避免超卖);单副本则进程内即可。最终方案以实测部署拓扑为准。
- R3. 号池全满时不静默降级:要么排队等空位,要么返回带上下文的容量错误(可 grep),让调用方看见。
- R4. 跨 upstream 的多个号都参与负载均衡,不是固定优先第一个 upstream。
- R5. 监控号池水位:每个 app_id 的并发利用率、饱和、429、池子整体使用率,出到现有可观测栈(指标走 Prometheus/OTel metricstrace 已有 Langfuse)。
- R6. 容量感知调度跳过最近失败/限流的号一段时间(轻量 reactive 健康判定),避免反复打到坏号。
- R7. 一个号(app_id)打满或失败时,failover 到池内其它号;全池耗尽 fail-fast,带 `triedKeys/triedUpstreams` 类上下文,复用现有 `mapUpstreamError` 模式。
**Voice Pack 表与管理**
- R8. 服务端 `voice_packs` 表存「云提供商音色」:`provider + model + voice_id + 参数覆盖(pitch/rate/volume 等)+ tier + enabled`。同时覆盖标准 voice 与云端克隆 model id 两类。
- R9. 参数覆盖是 pack 身份的一部分:同 `provider+model+voice` 不同参数 = 不同 pack。
- R10. admin CRUD HTTP API:新增 / 编辑 / 禁用(软禁用)/ 列出 pack,复用现有 admin + injeca 机制。本轮不做管理 UI。
- R11. 市场侧只列 `enabled` 的 pack。
**角色卡绑定与合成**
- R12. 角色卡绑定 Voice Pack 时,冻结**解析后的值**provider/model/voice/params/tier + pin 的 tts model id)进 `extensions.airi.modules.speech.voicePack`;改表不影响已绑定卡。
- R13. 合成读冻结快照,参数走 SSML prosodySSML-capable provider)或 adapter speed/extraOptions;某参数无法在目标后端应用时 fail-fast 报错,不静默丢弃。
- R14. 提供最小绑定入口(复用现有 speech 设置页选 pack → 触发冻结),保证端到端可绑可合成可验证。
- R15. tier`voice_packs` 一列,复用 lite/standard/pro/premium,冻进快照(本轮展示 + 数据,不碰实际扣费)。
## Acceptance Examples
- AE1. **号池容量感知(覆盖 R1、R4)。** 池内 10 个 app_id 各上限 10 并发。并发打到 50 路时,请求被摊到多个号(如每号约 5 路),不是把前几个号打满到 10 再溢出。
- AE2. **不超卖 + 不静默(覆盖 R2、R3)。** 多副本下并发计数共享:100 路全满时第 101 路排队或收到容量错误,不会因为副本各算各的把某号打到 11 并发。
- AE3. **坏号退避(覆盖 R6、R7)。** 某 app_id 连续 429/失败 → 一段时间内不再被选中,流量转到健康号;全池耗尽才 fail-fast 带上下文。
- AE4. **绑定后不漂移(覆盖 R12)。** 绑定 Voice Pack A 到角色卡 → 之后在 `voice_packs` 编辑 A(换 voice、改参数)或禁用 A → 角色卡音色不变,仍用绑定时快照。
- AE5. **参数不可应用 fail-fast(覆盖 R13)。** 冻结快照带某 provider 不支持的参数 → 合成报错指出该参数无法应用,而非静默出声丢参数。
## Scope Boundaries
**Deferred for later(第二轮或独立线)**
- 参考音频整块(含 materialize 字节存储、随机 roll、情绪标签)。未来落 `voice_pack_reference` 子表(FK → `voice_packs`,一个 pack 多条参考音频);`voice_packs` 永远是唯一身份/市场/计费实体,市场/绑定/合成只读它、不做多态双表读。本轮只把这个形状记进文档,不建表。
- emotion embedding 内容类型(百分比向量)。
- 声音克隆 `upload → 调云端 clone API → 轮询 model id` 流程;本轮只消费已克隆好的 model id。
- 四档计量器拆分(lite/standard/pro/premium 各一个 `ttsMeter`),属 `tts-billing-tiers.md` 线。
- 用户侧精选市场浏览页(声线卡片列表 + tier badge filter)。本轮只做最小绑定入口。
- Voice Pack 管理 UI 页面(本轮 admin 只出 HTTP API)。
- 可分发市场(发布、下载、分享他人的 Voice Pack)。
## Dependencies / Assumptions
- 号池并发计数的存储方案依赖 server 部署拓扑(多副本 → Redis 共享,复用 flux meter 的 Redis pattern;单副本 → 进程内)。规划/实现时实测确认。
- 现有 `routeTts` 跨 upstream/key 重试、`mapUpstreamError``fallbackHttpCodes`(含 429)是号池 failover 的复用基础。
- `app_id` / access token 在 `ttsUpstreamSchema``config-kv.ts:57-61`)的落位(keys[] 还是 adapterParams)需按 Volcengine adapter 实测确认,决定「一个号」对应 schema 哪个粒度。
- 监控指标出口:trace 已接 LangfuseOTel SpanProcessor),并发 gauge/counter 类指标需确认现有 Prometheus/OTel metrics 注册点。
- `packages/ccc``Extensions` 开放可扩展(`extensions.ts:1`),冻结快照扩 `extensions.airi.modules.speech`。角色卡正在上整卡 LWW 云同步(`docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md`),快照 schema 改动会被同步带走,需对齐。
- `tts-billing-tiers.md` 的四档命名是 tier 取值来源;该文档当前在 main worktree 未提交,本分支引用时注意同步。
## Sources / Research
- `apps/server/src/services/.../router.ts:413-617``routeTts` 主循环、`dispatchOneTtsUpstream``createKeyRotator`(盲轮转,号池 LB 的改造点)。
- `apps/server/src/app.ts:616-632``ttsMeter` = `createFluxMeter`(Redis 用法,号池并发计数可复用的 Redis pattern)。
- `apps/server/src/services/adapters/config-kv.ts:57-61, 83-87``ttsUpstreamSchema` / `ttsModelSchema`(多 upstreams/keys 结构,号池建模点)。
- `apps/server/src/routes/openai/v1/index.ts:489-642, 738``handleTTS``/audio/voices` catalog、`ttsGuard`
- `packages/stage-ui/src/stores/modules/airi-card.ts:161-215` — 角色卡 speech 快照写入/读取(冻结快照落点)。
- `packages/stage-ui/src/stores/modules/speech.ts:32-35, 298-338` — 当前全局 voice 状态、`generateSSML`pitch/rate/volume)。
- `packages/ccc/src/export/types/extensions.ts:1` — 开放 extensions。
- `docs/ai/context/tts-billing-tiers.md` — 四档 tier 命名来源。
- `docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md` — 整卡 LWW 云同步,快照 schema 需对齐。
@@ -0,0 +1,308 @@
---
title: "feat: TTS 号池负载均衡 + Voice Pack 音色系统"
status: active
date: 2026-05-30
type: feat
origin: docs/brainstorms/2026-05-30-voice-pack-requirements.md
---
# feat: TTS 号池负载均衡 + Voice Pack 音色系统
## Summary
一个计划两块,号池负载均衡排最前。先做 TTS 号池容量感知负载均衡(Redis 在途计数 + 容量感知路由 + 429 反哺 + 监控),让「一账号 10 app × 10 并发 = 100 并发」的理论容量真正用满;再做 Voice Pack 音色系统(服务端 `voice_packs` 表 + admin CRUD API + 角色卡冻结快照 + 合成读快照)。两块解耦:Voice Pack 只 pin 一个 tts model id,那个 model 的 upstreams 即号池。
## Problem Frame
号池侧:上游按 `app_id` 限并发(典型 10),扩额贵,绕法是一账号开多 app 凑并发。但现有 `routeTts``createKeyRotator``apps/server/src/services/.../router.ts:429`)是无状态盲轮转,每请求新建、纯按 config 顺序遍历;upstream 主循环(`router.ts:559`)也是固定顺序,永远先打满第一个再降级。结果是 100 并发理论容量用不满,且单号越界触发 429。这层不是负载均衡,是 failover 顺序表。
音色侧:当前音色是全局 localStorage 状态(`packages/stage-ui/src/stores/modules/speech.ts:32-35`),不绑角色卡、不是快照。voice catalog per-model,上游变化时已选音色会被动漂移。用户还被迫理解 provider 拓扑。
详见 origin 需求文档(`docs/brainstorms/2026-05-30-voice-pack-requirements.md`)的 Problem Frame 与 Key Decisions。
## Key Technical Decisions
- **并发计数放 Redis,按多副本设计。** 代码已有多副本假设(`apps/server/src/.../gauges/active-sessions.ts:23-26` 明确 cluster-wide gauge、必须 `avg()``sum()``otel/index.ts:76-90` 选 ObservableGauge 避免多副本重复计数)。进程内内存会让各副本各算、号池超卖。复用 flux meter 的 Lua 原子模式(`flux-meter.ts:23-31` INCRBY+EXPIRE+条件 DECRBY),TTL 兜底防崩溃副本永久占槽。(see origin: docs/brainstorms/2026-05-30-voice-pack-requirements.md R1/R2)
- **建模:一个 app_id = 一个 upstream。** volcengine 的 `appid` 取自 `ctx.adapterParams.appid``adapters/tts/volcengine.ts:49-51`upstream 级),token 走 `keys[].ciphertext`。要表达 10 个 app_id 就配 10 个 `upstreams[]`(每个一个 appid + 它的 token)。这样 appid 已在路由层可见(`upstream.adapterParams.appid`),并发计数 key 自然是 `pool:inflight:<appid>`,且直接落在 routeTts 已有的 upstream 遍历上。否决「appid 下沉到 key 级」方案:要改 adapter 取值位置且注入面更大。
- **容量感知做在 routeTts 路由层,不加 route gate。** 「哪个 app_id 还有余量」是路由决策不是请求准入。现有 `ttsMeter.assertCanAfford`(计费余额)和 `ttsGuard`(配置存在性,`config-guard.ts:12`)都不是并发 gate,不复用它们做并发。全池满 → 走现有 exhaustion fail-fast`router.ts:591-616` `mapUpstreamError`),不静默降级。
- **429 是安全网不是主信号。** `fallbackHttpCodes` 默认含 429`router.ts:549`),现状是上游回 429 才被动切号。主动层(Redis 计数)在派发前就跳过满号;429 仍作兜底,且收到 429 时把该 appid 标记短 TTL「已满」反哺主动层,避免继续往坏号派(对应 origin R6 坏号退避)。
- **监控复用现有 OTel metrics,零新基建。** counter 加进 `GatewayMetrics``otel/index.ts:202-244`,已有 fallbackCount/upstreamErrors/keyExhaustedCountrouter 里已打点);池水位 gauge 仿 `gauges/active-sessions.ts:42-102` 把数据源从 Postgres 换 Redis。Langfuse 是 trace 不碰。
- **Voice Pack library 是服务端 `voice_packs` 表(复数),管理员策展。** 本轮只装云提供商音色 = `provider + model + voice + 参数覆盖`,覆盖标准 voice 与云端克隆 model id 两类。参数覆盖是 pack 身份的一部分(同 voice 不同参数 = 不同 pack)。未来参考音频落 `voice_pack_reference` 子表(FK → voice_packs),`voice_packs` 永远是唯一身份/市场/计费实体,本轮不建子表只记形状。
- **绑定冻结解析后的值,不存表外键。** 角色卡冻结 `provider/model/voice/params/tier + pin 的 tts model id``extensions.airi.modules.speech.voicePack`(扩 `airi-card.ts:173-215` 现有 speech 快照点)。存外键会让改表连带改卡,回到漂移。`resolveAiriExtension``airi-card.ts:161`)处理字段缺失,不加 backward-compat guard。
- **tier 本轮是展示 + 数据。** `voice_packs` 一列,复用 `tts-billing-tiers.md` 的 lite/standard/pro/premium 命名,冻进快照。本轮只有单 meter(`FLUX_PER_1K_CHARS_TTS`),四档拆分属 billing 独立线,「按最高档取价」暂无真实差价效果。
## High-Level Technical Design
### 号池容量感知路由(Phase A 核心)
```mermaid
flowchart TB
REQ[TTS 合成请求进入 routeTts] --> RESOLVE[解析目标 tts model 的 upstreams]
RESOLVE --> QUERY["读 Redis 各 upstream.adapterParams.appid 在途计数<br/>pool:inflight:&lt;appid&gt;"]
QUERY --> RANK[按剩余并发余量排序 upstreams]
RANK --> PICK{有 upstream 还有余量?}
PICK -->|否| EXHAUST["fail-fast: mapUpstreamError<br/>带 triedKeys/triedUpstreams 上下文"]
PICK -->|是| ACQUIRE["Lua 原子: 检查容量 + INCR 占槽"]
ACQUIRE --> SEND[dispatchOneTtsUpstream → adapter.send]
SEND --> RESULT{结果}
RESULT -->|成功| RELEASE_OK["finally: DECR 释放槽"]
RESULT -->|429/失败| MARK["标记该 appid 短 TTL 已满 (熔断)<br/>+ DECR 释放槽"]
MARK --> PICK
RELEASE_OK --> DONE[返回音频]
```
### Voice Pack 身份 → 冻结快照 → 合成(source-of-truth
```mermaid
flowchart LR
TABLE["voice_packs 表 (货架)<br/>provider/model/voice/params/tier/enabled"] -->|admin CRUD| TABLE
TABLE -->|"列 enabled"| MARKET[最小绑定入口]
MARKET -->|绑定: 冻结解析后的值| CARD["角色卡 extensions.airi.modules.speech.voicePack<br/>(买走的快照, 不存表外键)"]
CARD -->|合成读快照| SYNTH[映射 tts model id]
SYNTH --> POOL[经号池 LB 挑号合成]
TABLE -.改表/禁用.-> TABLE
CARD -.不受改表影响.-> CARD
```
## Implementation Units
### Phase A — TTS 号池负载均衡(最高优先)
### U1. Redis 在途并发计数账本
- **Goal:** 提供「按 appid 原子占用/释放一个并发槽」的 Redis 账本,作为容量感知路由的底层。
- **Requirements:** R1, R2, R3origin
- **Dependencies:**
- **Files:**
- `apps/server/src/services/tts/concurrency-ledger.ts`(新建,命名待 review,避免 `manager`/`pool` 泛词;候选 `concurrency-ledger` / `inflight-slots`
- `apps/server/src/services/tts/concurrency-ledger.test.ts`(新建)
- **Approach:** 仿 `flux-meter.ts:23-31` 的 Lua 原子脚本:`tryAcquire(appId, maxConcurrency)` = Lua 内 `GET pool:inflight:<appId>`,未超上限则 `INCR` + `EXPIRE`(短 TTL 防泄漏) 返回成功,超上限返回失败;`release(appId)` = `DECR`(下限 0)。另出 `markSaturated(appId, ttl)`429 熔断用,set 一个 `pool:saturated:<appId>` 短 TTL flag)与 `currentInflight(appId)` 读数。复用现有 Redis client injeca(与 `createFluxMeter` 同源,`app.ts:616` 附近)。
- **Patterns to follow:** `apps/server/src/services/.../flux-meter.ts:23-31`Lua INCRBY+EXPIRE+条件 DECRBY)、:106`redis.eval` 用法)、:18TTL survives 注释思路)。
- **Test scenarios:**
- tryAcquire 在未达上限时 INCR 并返回成功;达上限返回失败且不 INCR。
- tryAcquire + release 配对后计数回到原值。
- 并发 N 个 tryAcquire 对同一 appid(用 Lua 原子性):成功数不超过 maxConcurrencycheck-then-incr 无竞态)。
- release 在计数为 0 时不变成负数。
- markSaturated 后 saturated flag 存在且在 TTL 后消失(用短 TTL + 等待或 fakeable clock;若不可控用最小真实 TTL 断言存在性)。
- 槽未释放时 TTL 到期后计数自动清零(防崩溃副本占槽)。
- **Verification:** 单测覆盖 acquire/release/saturate 的原子性与边界;Redis 用测试实例或 ioredis-mock 等价物(按 server 现有测试惯例)。
### U2. 容量感知 upstream 路由
- **Goal:**`routeTts` 的固定顺序 upstream 遍历换成按 appid 剩余并发余量挑选,并在派发前后占/放槽。
- **Requirements:** R1, R4, R7origin
- **Dependencies:** U1
- **Files:**
- `apps/server/src/services/.../router.ts`(改 `routeTts` upstream 选择 :559、`dispatchOneTtsUpstream` 槽位获取/释放 :413-534
- `apps/server/src/services/.../router.test.ts`(新增/扩展号池路由用例)
- **Approach:** 路由前查 U1 账本各 `upstream.adapterParams.appid` 的在途数与 saturated flag,过滤掉满号/熔断号,按剩余余量排序后遍历(替代 :559 的 `for i in 0..length` 固定顺序)。进入 `adapter.send`:450)前 `tryAcquire`;失败(该号刚好满)则跳到下一个候选。槽释放放 `dispatchOneTtsUpstream` 已有的 finally:527-529,与 `key.plaintext.fill(0)` 同块)。所有候选都满/耗尽 → 现有 exhaustion 路径 `mapUpstreamError`:591-616fail-fast。key 级 `createKeyRotator` 不动(appid 在 upstream 级)。
- **Patterns to follow:** `router.ts:559`upstream 遍历)、:527-529finally 释放点)、:591-616exhaustion fail-fast)、`mapUpstreamError``error-mapping.ts:62`)。
- **Test scenarios:**
- Covers AE1. 池内多 upstream(多 appid)各上限 10,并发 50 路被摊到多个号(每号约均匀),不是打满前几个再溢出。
- 单个 upstream(appid)满时路由跳过它选下一个有余量的。
- 全池满时 fail-fast 抛带上下文错误(triedUpstreams 等),不静默挂起、不静默降级。
- 成功路径在 finally 释放槽;异常路径也释放槽(不泄漏)。
- 只有一个 upstream 且未满时行为与改造前一致(不回归)。
- **Verification:** router 单测用 mock adapter + mock/test Redis 断言「派发分布跨 appid」「满号被跳过」「耗尽 fail-fast」「槽必释放」。
### U3. 429 反哺主动层(reactive 熔断)
- **Goal:** 上游回 429app_id 越界)时把该 appid 标记短 TTL「已满」,让主动路由一段时间内不再选它。
- **Requirements:** R6origin
- **Dependencies:** U1, U2
- **Files:**
- `apps/server/src/services/.../router.ts`429 fallback 分支 :506-524 处调用 `markSaturated`
- `apps/server/src/services/.../router.test.ts`(扩展)
- **Approach:** 现有 fallback 判断(:518 `fallbackHttpCodes.includes(rawStatus)`)命中 429 时,除继续切号外,调用 U1 的 `markSaturated(appid, shortTtl)`。U2 的候选过滤已读 saturated flag,自然跳过。区分 429(并发/限流,熔断该号)与其它 fallback 码(如 500/502,按现有逻辑切号但不必熔断),避免把临时网络错误误判成号满。
- **Patterns to follow:** `router.ts:506-524`fallback 分支与状态判断)、`fallbackHttpCodes`:549)。
- **Test scenarios:**
- Covers AE3. 某 appid 连续 429 → 被 markSaturated → 后续路由窗口期内不再选它,流量转健康号。
- 熔断 TTL 过后该 appid 重新可被选中。
- 非 429 的 fallback 码(如 502)触发切号但不 markSaturated。
- 全池都被熔断时 fail-fast,不静默挂起。
- **Verification:** 单测断言「429 后该号进入 saturated 窗口被跳过」「TTL 后恢复」「非 429 不熔断」。
### U4. 号池监控指标
- **Goal:** 暴露号池水位与饱和指标到现有 OTel metrics pipeline。
- **Requirements:** R5origin
- **Dependencies:** U1
- **Files:**
- `apps/server/src/.../otel/index.ts``GatewayMetrics` 接口 :202-244 加字段 + 实例化 :442-458
- `apps/server/src/.../gauges/tts-pool.ts`(新建,仿 active-sessions gauge
- `apps/server/src/app.ts`(注册 gauge,仿 :695-712 `registerActiveSessionsGauge`
- `apps/server/src/.../gauges/tts-pool.test.ts`(新建)
- **Approach:** counter:在 `GatewayMetrics``poolSaturationCount`429-as-full 次数)、`slotAcquireFailCount`(主动层判满拒派次数),打点位置复用 router 现有 counter 打点处(U2/U3 内)。gauge:池水位 ObservableGauge,回调读 Redis 各 `pool:inflight:<appid>`,仿 `gauges/active-sessions.ts:42-102`10s 缓存 + in-flight 去重 + 失败不 observe 让 staleness 报警)。给 gauge 加 `app_id` label。
- **Patterns to follow:** `otel/index.ts:202-244`GatewayMetrics 定义与打点)、`gauges/active-sessions.ts:42-102`cluster-wide ObservableGauge 模板)、`app.ts:695-712`(注册)。
- **Test scenarios:**
- gauge 回调读 Redis 多个 appid 在途数并 observe 对应值 + 正确 label。
- Redis 读失败时回调不 observe(让 Prometheus staleness 生效),不抛崩回调。
- 10s 缓存命中时不重复打 Redis;in-flight 去重不并发重复读。
- counter 在 markSaturated / 判满拒派时各 +1。
- **Verification:** 单测覆盖 gauge 回调读数/失败/缓存与 counter 自增;多副本语义在注释与 dashboard 说明(avg 不 sum)。
### Phase B — Voice Pack 表与管理
### U5. `voice_packs` 表 + 迁移
- **Goal:**`voice_packs` 表存云提供商音色定义。
- **Requirements:** R8, R9, R15origin
- **Dependencies:** 无(可与 Phase A 并行)
- **Files:**
- server 端 DB schema / migration(路径按现有迁移工具,见 Approach 待确认项)
- 对应 schema/migration 测试或快照
- **Approach:** 表列:`id``name``provider``model``voice_id``params`(jsonbpitch/rate/volume 等)、`tier`(picklist lite/standard/pro/premium)、`enabled`(bool 软禁用)、`created_at`/`updated_at`。参数覆盖入 jsonb(同 voice 不同参数 = 不同行 = 不同 pack)。**待确认(实现期):** apps/server 的迁移工具与既有表定义位置(cloud-sync 设计提到 `characters` 表,沿用同一 ORM/迁移机制);确认后按现有 migration 约定落表。
- **Patterns to follow:** 现有 server 表/迁移定义(与 `characters` 表同机制);列命名贴近域、复数表名(`voice_packs`)。
- **Test scenarios:**
- 迁移可正向应用建表,列与约束符合预期(enabled 默认值、tier 枚举约束、jsonb 默认)。
- Test expectation: 以迁移/schema 校验为主;无业务逻辑分支。
- **Verification:** 迁移在测试库正向应用成功,schema 与计划列一致。
### U6. voice_packs valibot schema + 域服务
- **Goal:** 提供 voice_packs 的校验 schema 与 CRUD 域服务(含 list-enabled)。
- **Requirements:** R8, R9, R10, R11origin
- **Dependencies:** U5
- **Files:**
- `apps/server/src/services/domain/voice-packs/`(新建域服务 + valibot schema
- 对应 `*.test.ts`
- **Approach:** valibot schema 定义 pack 形状(provider/model/voice/params/tier/enabled),在外部边界(API 入参、DB 行)各做一次校验,内部不重复防御。域服务出 `create/update/disable/list/listEnabled`injeca 注入 DB(仅 DB 边界用 DI,不建 pass-through service)。复用现有 admin router-config 服务的组织方式(`services/domain/admin/router-config`)。
- **Patterns to follow:** `apps/server/src/services/adapters/config-kv.ts:25-153`valibot 用法)、`services/domain/admin/router-config`(域服务 + injeca)。
- **Test scenarios:**
- create 持久化一行并通过 schema 校验;非法 tier / 缺字段被 schema 拒绝。
- update 改 params 产生新形状;不影响其它行。
- disable 置 enabled=false,行仍在。
- listEnabled 只返回 enabled=true 的行;list 返回全部。
- 参数覆盖:同 provider/model/voice 不同 params 是两条独立记录。
- **Verification:** 域服务单测覆盖 CRUD + listEnabled + schema 边界;DB 用测试库或等价。
### U7. admin CRUD HTTP API
- **Goal:** 暴露 admin 路由:新增 / 编辑 / 禁用 / 列出 voice pack。
- **Requirements:** R10, R11origin
- **Dependencies:** U6
- **Files:**
- `apps/server/src/routes/admin/voice-packs/`(新建路由)
- 路由挂载处(仿现有 admin config 路由注册)
- 对应 `*.test.ts`
- **Approach:** 复用现有 admin 鉴权/路由 pattern`apps/server/src/routes/admin/config/`),路由调 U6 域服务。入参 valibot 校验(外部边界)。列表接口供最小绑定入口读 enabled pack。
- **Patterns to follow:** `apps/server/src/routes/admin/config/router/index.ts`admin 路由 + body schema)、injeca 服务注入(`app.ts:648` adminRouterConfig)。
- **Test scenarios:**
- POST 新增返回创建的 pack;非法 body 返回 400schema 拒绝)。
- PATCH 编辑、POST/PATCH 禁用置 enabled=false。
- GET 列出(admin 看全部;enabled 过滤接口供客户端)。
- 未授权请求被 admin 鉴权拒绝(复用现有 admin guard)。
- **Verification:** 路由集成测试(Hono test client)断言状态码、鉴权、与域服务交互。
### Phase C — 角色卡绑定与合成
### U8. 角色卡 Voice Pack 快照契约
- **Goal:** 在角色卡 speech 扩展里定义冻结快照字段,写入即冻结、读取兼容缺失。
- **Requirements:** R12, R15origin
- **Dependencies:** U6(快照形状需与 pack 解析值对齐)
- **Files:**
- `packages/stage-ui/src/stores/modules/airi-card.ts`(扩 `AiriExtension.modules.speech``voicePack` 子对象 :19-74;写入 :173-215;读取 `resolveAiriExtension` :161
- `packages/stage-ui/src/stores/modules/airi-card.test.ts`(新增/扩展)
- **Approach:** `voicePack` 快照 = `{ packId, name, provider, model, voiceId, params, tier, ttsModelId }`(解析后的值,非表外键)。绑定写入这个对象;`resolveAiriExtension` 对缺失返回 undefined 分支(不加 backward-compat guard,缺失即「未绑定 Voice Pack」走旧 speech 字段)。注意与整卡 LWW 云同步(`docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md`)对齐:快照随整卡同步,schema 改动不破坏 LWW。
- **Patterns to follow:** `airi-card.ts:161-215`speech 快照读写)、`resolveAiriExtension`:161 缺失兼容)、`packages/ccc/src/export/types/extensions.ts:1`(开放 extensions)。
- **Test scenarios:**
- 写入 voicePack 快照后读回值一致。
- Covers AE4. 写入后再「改 library 值」(模拟另一份 pack 定义)不改变已写入快照(快照是值拷贝,无表引用)。
- 卡内无 voicePack 字段时 resolveAiriExtension 返回未绑定分支,不抛错(旧卡兼容靠默认路径非 guard)。
- 快照含 ttsModelId,供合成映射。
- **Verification:** store 单测断言快照值拷贝语义、缺失兼容、字段完整。
### U9. 最小绑定入口
- **Goal:** 在现有 speech 设置页让用户选一个 enabled Voice Pack,触发冻结快照写入当前角色卡。
- **Requirements:** R11, R14origin
- **Dependencies:** U7, U8
- **Files:**
- `packages/stage-pages/src/pages/settings/modules/speech.vue`(加 Voice Pack 选择 → 调绑定)
- `packages/stage-ui/src/stores/modules/`(绑定动作:读 listEnabled API → 冻结快照入卡,复用 speech store / airi-card store
- i18n key 加到 `packages/i18n`(注意 `@` 转义 `{'@'}`
- 对应 `*.test.ts`
- **Approach:** 复用 `VoiceCardManySelect` / speech.vue 现有结构(`stage-pages` 共享页,双端生效),列 enabled pack(带 tier badge 展示,复用属性 chips 落点 `voice-card.vue:167-180`)。选中即调 U8 的绑定动作冻结快照。本轮不做独立市场浏览页。tier 仅展示。
- **Patterns to follow:** `packages/stage-pages/src/pages/settings/modules/speech.vue`(共享页 + `<route lang="yaml"> layout: settings`)、`components/menu/voice-card.vue`badge/chips)、`use-modules-list.ts:59-64`(模块入口)。
- **Test scenarios:**
- 选中一个 enabled pack → 角色卡写入对应冻结快照(断言 store 调用与快照值)。
- 列表只展示 enabled pack。
- tier badge 正确渲染(展示层)。
- 双端共享页:组件在 stage-web/tamagotchi 同一实现(不双写)。
- **Verification:** 组件/store 单测 + 真实浏览器实测绑定流程(screenshot/console,前端改动按 CLAUDE.md 需浏览器证据)。
### U10. 合成读快照 + 参数应用
- **Goal:** 合成时读角色卡冻结快照,映射 tts model 并应用参数;不支持的参数 fail-fast。
- **Requirements:** R12, R13origin
- **Dependencies:** U8;(路由经 U2 号池 LB
- **Files:**
- `packages/stage-ui/src/stores/modules/speech.ts`(读快照构造合成请求;参数 → `generateSSML` prosody :298-338 或 adapter options
- server 合成入参处理(`apps/server/src/routes/openai/v1/index.ts:489-614` handleTTS,参数透传/校验)
- 对应 `*.test.ts`
- **Approach:** 绑定卡合成时读 `voicePack` 快照,用 `ttsModelId` 作为 model(经服务端 routeTts → 号池 LB)。参数覆盖:SSML-capable provider 走 `generateSSML`pitch/rate/volume);其它走 adapter `speed`/`extraOptions`。目标后端无法应用某参数时 fail-fast 报带上下文错误(可 grep),不静默丢参数(符合禁止静默降级)。
- **Patterns to follow:** `speech.ts:280-296`(合成入口)、:298-338generateSSML prosody)、`adapters/tts/types.ts:12-30`TtsInput speed/extraOptions)、`errorMessageFrom``speech.ts:113`)。
- **Test scenarios:**
- 绑定卡合成用快照的 ttsModelId + voice + 参数构造请求。
- SSML-capable provider 的 pitch/volume 进 SSML prosody。
- Covers AE5. 目标后端不支持的参数 → fail-fast 抛带上下文错误,不静默出声丢参数。
- 未绑定卡走旧 speech 字段路径(不回归)。
- **Verification:** 单测覆盖快照→请求构造、参数映射、不支持参数 fail-fast;触外部边界(合成)需一次真实合成命令/日志证据(按 CLAUDE.md Iron Law)。
## System-Wide Impact
- **TTS 路由层行为变更(U2/U3)影响所有走 routeTts 的 TTS 合成**,不止 Voice Pack。改造需保「单 upstream/未满」场景不回归。
- **多副本一致性**:并发计数与 gauge 是 cluster-widedashboard 必须 `avg()``sum()`;新增 gauge 沿用此约束并在注释写明。
- **角色卡 schema 变更(U8)进整卡 LWW 云同步**,需与 cloud-sync 设计对齐,避免破坏同步。
- **计费**:本轮 tier 不碰实际扣费;四档 meter 拆分是独立 billing 线,勿在本计划顺手改 meter。
## Scope Boundaries
### 本计划范围内
- Phase A 号池负载均衡(U1-U4)、Phase B Voice Pack 表与 admin APIU5-U7)、Phase C 绑定与合成(U8-U10)。
### Deferred for laterorigin 已列)
- 参考音频整块(materialize、随机 roll、情绪标签);未来 `voice_pack_reference` 子表,本轮只记形状不建表。
- emotion embedding 内容类型。
- 声音克隆 upload→clone API 流程(本轮只消费已克隆 model id)。
- 四档 meter 拆分(billing 独立线)。
- 用户侧精选市场浏览页(本轮只最小绑定入口)。
- Voice Pack 管理 UI 页面(本轮只 admin HTTP API)。
- 可分发市场(发布/下载/分享)。
### Deferred to Follow-Up Work(计划期发现,本轮不顺手做)
- 「排队等空位」语义(现框架无等待逻辑):本轮全池满直接 fail-fast,不实现排队。
- key 级 appid 建模(appid 下沉到 key):本轮用 app_id = upstream,不重构 schema 粒度。
## Risks & Dependencies
- **Redis 计数与上游实际并发不同步**:主动层估算可能偏差,靠 429 兜底 + 熔断反哺(U3)收敛;TTL 防泄漏。风险可控但需监控(U4)验证实际命中率。
- **迁移工具未确认(U5**:实现期需先定位 apps/server 迁移机制(与 `characters` 表同源),再落表。
- **app_id 落位假设(建模)**:基于 volcengine `adapterParams.appid``volcengine.ts:49`);若实际有 provider 把 app 凭据放别处,需在 U2 路由前确认 appid 提取统一。
- **云同步对齐(U8**:快照 schema 改动需与 cloud-sync 设计联动,避免 LWW 整卡同步破坏。
- **`tts-billing-tiers.md` 不在本分支**:tier 命名来源文档当前在 main worktree 未提交,引用时同步。
## Sources & Research
- `apps/server/src/services/.../router.ts:413-617` — routeTts、dispatchOneTtsUpstream、createKeyRotator、upstream 遍历(号池 LB 改造点)、exhaustion fail-fast。
- `apps/server/src/services/.../flux-meter.ts:18, 23-31, 106` — Lua INCRBY+EXPIRE+条件 DECRBY + redis.eval(并发账本复用模式)。
- `apps/server/src/.../gauges/active-sessions.ts:23-26, 42-102` — cluster-wide ObservableGauge 模板 + 多副本 avg 约束。
- `apps/server/src/.../otel/index.ts:76-90, 202-244, 442-458` — GatewayMetrics、ObservableGauge 选型。
- `apps/server/src/services/adapters/tts/volcengine.ts:49-55, 90` — appid 取自 adapterParams、token 走 keyPlaintext。
- `apps/server/src/services/adapters/config-kv.ts:33-40, 57-61, 83-87, 118, 134` — keyEntry/ttsUpstream/ttsModel schema、FLUX_PER_1K_CHARS_TTS、DEFAULT_TTS_VOICES。
- `apps/server/src/routes/openai/v1/index.ts:489-642, 738, 759` — handleTTS、/audio/voices、ttsGuard、/speech 路由挂载。
- `apps/server/src/app.ts:616-632, 695-712` — ttsMeterRedis)、registerActiveSessionsGauge。
- `apps/server/railway.toml:1-11` — Railway 部署(无 replicas 字段,副本数控制台侧)。
- `packages/stage-ui/src/stores/modules/airi-card.ts:19-74, 161-215` — speech 快照读写(冻结快照落点)。
- `packages/stage-ui/src/stores/modules/speech.ts:32-35, 280-296, 298-338` — 全局 voice 状态、合成入口、generateSSML。
- `packages/ccc/src/export/types/extensions.ts:1` — 开放 extensions。
- `docs/ai/context/tts-billing-tiers.md` — tier 命名来源。
- `docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md` — 整卡 LWW 云同步对齐。
@@ -712,6 +712,12 @@ pages:
select-voice:
loading: Loading model...
required: Please select a voice
voice-pack:
description: Select a curated voice for the active character
empty: No Voice Packs available
error: Error loading Voice Packs
loading: Loading Voice Packs...
title: Voice Pack
provider-voice-selection:
custom_model_placeholder: Enter custom model name...
custom_voice_placeholder: Enter custom voice ID...
@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { VoicePackSnapshot } from '@proj-airi/stage-ui/stores/modules/airi-card'
import type { VoiceInfo } from '@proj-airi/stage-ui/stores/providers'
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import { errorMessageFrom } from '@moeru/std'
@@ -11,6 +13,8 @@ import {
VoiceCardManySelect,
} from '@proj-airi/stage-ui/components'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { OFFICIAL_SPEECH_PROVIDER_ID } from '@proj-airi/stage-ui/libs/providers/providers/official'
import { useAiriCardStore, useVoicePacksStore } from '@proj-airi/stage-ui/stores'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import {
@@ -29,7 +33,11 @@ import { RouterLink } from 'vue-router'
const { t } = useI18n()
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
const airiCardStore = useAiriCardStore()
const voicePacksStore = useVoicePacksStore()
const { configuredSpeechProvidersMetadata } = storeToRefs(providersStore)
const { activeCard } = storeToRefs(airiCardStore)
const { packs: voicePacks, loading: isLoadingVoicePacks, error: voicePacksError } = storeToRefs(voicePacksStore)
const {
activeSpeechProvider,
activeSpeechModel,
@@ -58,6 +66,22 @@ const audioUrl = ref('')
const audioPlayer = ref<HTMLAudioElement | null>(null)
const errorMessage = ref('')
function createVoicePackVoice(voicePack: VoicePackSnapshot): VoiceInfo {
return {
id: voicePack.voiceId,
name: voicePack.name,
description: voicePack.name,
previewURL: '',
languages: [{ code: 'en', title: 'English' }],
provider: activeSpeechProvider.value,
gender: 'neutral',
}
}
function formatCostMultiplier(multiplier: number) {
return `${Number.isInteger(multiplier) ? multiplier : multiplier.toFixed(2).replace(/\.?0+$/, '')}x`
}
// Sync OpenAI Compatible model and voice from provider config
function syncOpenAICompatibleSettings() {
if (activeSpeechProvider.value !== 'openai-compatible-audio-speech')
@@ -87,11 +111,19 @@ function syncOpenAICompatibleSettings() {
onMounted(async () => {
await providersStore.loadModelsForConfiguredProviders()
await voicePacksStore.load()
speechStore.ensureActiveSpeechModel()
await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined)
syncOpenAICompatibleSettings()
})
async function bindVoicePack(pack: (typeof voicePacks.value)[number]) {
const bound = airiCardStore.bindVoicePackToActiveCard(pack)
if (!bound)
return
await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined)
}
watch(activeSpeechProvider, async (newProvider, oldProvider) => {
await providersStore.loadModelsForConfiguredProviders()
@@ -154,6 +186,13 @@ async function generateTestSpeech() {
}
}
const voicePack = activeCard.value?.extensions.airi.modules.speech.voicePack
if (voicePack) {
model = voicePack.ttsModelId
if (!voice || voice.id !== voicePack.voiceId)
voice = createVoicePackVoice(voicePack)
}
if (!model) {
console.error('No model selected')
return
@@ -173,15 +212,28 @@ async function generateTestSpeech() {
stopTestAudio()
}
const input = useSSML.value
? ssmlText.value
: ssmlEnabled.value && speechStore.supportsSSML
? speechStore.generateSSML(testText.value, voice, { ...providerConfig, pitch: pitch.value })
: testText.value
const speechRequest = useSSML.value
? {
input: ssmlText.value,
providerConfig,
}
: speechStore.resolveVoicePackSpeechInput({
text: testText.value,
voice,
providerConfig: {
...providerConfig,
pitch: ssmlEnabled.value ? pitch.value : undefined,
},
params: voicePack?.params,
voicePack,
forceSSML: ssmlEnabled.value,
supportsSSML: speechStore.supportsSSML,
supportsAdapterProsody: activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID,
})
const response = await generateSpeech({
...provider.speech(model, providerConfig),
input,
...provider.speech(model, speechRequest.providerConfig),
input: speechRequest.input,
voice: voice.id,
})
@@ -266,6 +318,62 @@ function handleDeleteProvider(providerId: string) {
<div flex="~ col md:row gap-6">
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="h-fit w-full md:w-[40%]">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.speech.sections.section.voice-pack.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.speech.sections.section.voice-pack.description') }}</span>
</div>
</div>
<div v-if="isLoadingVoicePacks" :class="['flex items-center gap-2', 'text-sm text-neutral-400 dark:text-neutral-500']">
<div i-solar:spinner-line-duotone class="animate-spin text-base" />
<span>{{ t('settings.pages.modules.speech.sections.section.voice-pack.loading') }}</span>
</div>
<ErrorContainer
v-else-if="voicePacksError"
:title="t('settings.pages.modules.speech.sections.section.voice-pack.error')"
:error="voicePacksError"
/>
<div v-else-if="voicePacks.length > 0" :class="['grid grid-cols-1 gap-2']">
<button
v-for="pack in voicePacks"
:key="pack.id"
type="button"
:class="[
'w-full border rounded-lg px-3 py-2 text-left transition-colors',
'border-neutral-200 bg-white hover:border-primary-400 dark:border-neutral-800 dark:bg-neutral-900/60 dark:hover:border-primary-500',
airiCardStore.activeCard?.extensions.airi.modules.speech.voicePack?.packId === pack.id
? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-950/30'
: '',
]"
@click="bindVoicePack(pack)"
>
<div :class="['flex items-center justify-between gap-3']">
<div :class="['min-w-0']">
<div :class="['truncate text-sm font-medium text-neutral-700 dark:text-neutral-200']">
{{ pack.name }}
</div>
<div :class="['truncate text-xs text-neutral-400 dark:text-neutral-500']">
{{ pack.ttsModelId }} / {{ pack.voiceId }}
</div>
</div>
<span :class="['shrink-0 rounded bg-neutral-100 px-2 py-1 text-xs text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400']">
{{ formatCostMultiplier(pack.costMultiplier) }}
</span>
</div>
</button>
</div>
<Alert v-else type="info" icon="i-solar:info-circle-line-duotone">
<template #title>
{{ t('settings.pages.modules.speech.sections.section.voice-pack.empty') }}
</template>
</Alert>
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.title') }}
@@ -6,6 +6,8 @@ import type { UnElevenLabsOptions } from 'unspeech'
import type { EmotionPayload } from '../../constants/emotions'
import type { SpeechTransport, StageTtsSession, StreamingSessionSnapshot } from '../../libs/speech/tts-session'
import type { VoicePackSnapshot } from '../../stores/modules/airi-card'
import type { VoiceInfo } from '../../stores/providers'
import { sleep } from '@moeru/std'
import { createLive2DLipSync } from '@proj-airi/model-driver-lipsync'
@@ -33,6 +35,7 @@ import { initIOTracer } from '../../composables/use-io-tracer'
import { useSpeechPipelineAnalytics } from '../../composables/use-speech-pipeline-analytics'
import { Emotion, EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
import { getDefaultStreamingModel, getDefinedProvider } from '../../libs/providers/providers'
import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official'
import { createStageTtsSession } from '../../libs/speech/tts-session'
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
import { useBackgroundStore } from '../../stores/background'
@@ -306,6 +309,18 @@ const playbackManager = createPlaybackManager<AudioBuffer>({
ownerOverflowPolicy: 'steal-oldest',
})
function createVoicePackVoice(voicePack: VoicePackSnapshot): VoiceInfo {
return {
id: voicePack.voiceId,
name: voicePack.name,
description: voicePack.name,
previewURL: '',
languages: [{ code: 'en', title: 'English' }],
provider: activeSpeechProvider.value,
gender: 'neutral',
}
}
const speechPipeline = createSpeechPipeline<AudioBuffer>({
tts: async (request, signal) => {
if (signal.aborted)
@@ -387,20 +402,37 @@ const speechPipeline = createSpeechPipeline<AudioBuffer>({
}
}
const voicePack = activeCard.value?.extensions.airi.modules.speech.voicePack
if (voicePack) {
model = voicePack.ttsModelId
if (!voice || voice.id !== voicePack.voiceId)
voice = createVoicePackVoice(voicePack)
}
if (!model || !voice)
return null
const input = ssmlEnabled.value
? speechStore.generateSSML(request.text, voice, { ...providerConfig, pitch: pitch.value })
: request.text
try {
const speechRequest = speechStore.resolveVoicePackSpeechInput({
text: request.text,
voice,
providerConfig: {
...providerConfig,
pitch: ssmlEnabled.value ? pitch.value : undefined,
},
params: voicePack?.params,
voicePack,
forceSSML: ssmlEnabled.value,
supportsSSML: speechStore.supportsSSML,
supportsAdapterProsody: activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID,
})
// Non-streaming providers only: synth via REST. Streaming provider
// was already early-returned above; it owns its own ws path opened
// in `onBeforeMessageComposed`.
const res = await generateSpeech({
...provider.speech(model, providerConfig),
input,
...provider.speech(model, speechRequest.providerConfig),
input: speechRequest.input,
voice: voice.id,
})
@@ -0,0 +1,42 @@
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import { describe, expect, it } from 'vitest'
import { providerOfficialSpeech } from './index'
interface OfficialSpeechOptions {
speed?: number
extraBody?: {
voice_pack?: {
pitch?: number
}
}
}
describe('official speech provider', () => {
/**
* @example
* provider.speech('microsoft/v1', { speed: 1.2 })
*/
it('keeps speech extra options on the generated request config', () => {
const provider = providerOfficialSpeech.createProvider({}) as SpeechProviderWithExtraOptions<string, OfficialSpeechOptions>
const request = provider.speech('microsoft/v1', {
speed: 1.2,
extraBody: {
voice_pack: {
pitch: 20,
},
},
})
expect(request.model).toBe('microsoft/v1')
expect(request.speed).toBe(1.2)
expect(request.extraBody).toEqual({
voice_pack: {
pitch: 20,
},
})
expect(request.fetch).toBeTypeOf('function')
})
})
@@ -103,8 +103,11 @@ export const providerOfficialSpeech = defineProvider({
createProvider(_config) {
const provider = createOfficialAudioProvider()
const originalSpeech = provider.speech.bind(provider)
provider.speech = (model: string) => {
const result = originalSpeech(model)
provider.speech = (model: string, extraOptions?: Record<string, unknown>) => {
const result = {
...originalSpeech(model),
...extraOptions,
}
result.fetch = withCredentials()
return result
}
+1
View File
@@ -10,3 +10,4 @@ export * from './modules/consciousness'
export * from './modules/speech'
export * from './providers'
export * from './settings'
export * from './voice-packs'
@@ -1,6 +1,7 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official'
import { useSettingsStageModel } from '../settings/stage-model'
import { useAiriCardStore } from './airi-card'
@@ -85,4 +86,69 @@ describe('airi-card store', () => {
expect(cardStore.activeCard?.extensions.airi.modules.displayModelId).toBe('display-model-iru-v2')
expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1')
})
/**
* @example
* it('freezes a Voice Pack snapshot on the active card', () => {})
*/
it('freezes a Voice Pack snapshot on the active card', () => {
const cardStore = useAiriCardStore()
cardStore.initialize()
const pack = {
id: 'vp-1',
name: 'Neuro Sama',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-neuro',
ttsModelId: 'volcengine/neuro-pool',
params: { pitch: '+20%', volume: '+5%' },
costMultiplier: 1.5,
}
const bound = cardStore.bindVoicePackToActiveCard(pack)
expect(bound).toBe(true)
expect(cardStore.activeCard?.extensions.airi.modules.speech).toMatchObject({
provider: OFFICIAL_SPEECH_PROVIDER_ID,
model: 'volcengine/neuro-pool',
voice_id: 'voice-neuro',
voicePack: {
packId: 'vp-1',
name: 'Neuro Sama',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-neuro',
ttsModelId: 'volcengine/neuro-pool',
params: { pitch: '+20%', volume: '+5%' },
costMultiplier: 1.5,
},
})
})
/**
* @example
* it('keeps the frozen Voice Pack independent from later library edits', () => {})
*/
it('keeps the frozen Voice Pack independent from later library edits', () => {
const cardStore = useAiriCardStore()
cardStore.initialize()
const params = { pitch: '+20%' }
cardStore.bindVoicePackToActiveCard({
id: 'vp-1',
name: 'Frozen',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-a',
ttsModelId: 'volcengine/pool-a',
params,
costMultiplier: 1,
})
params.pitch = '-10%'
expect(cardStore.activeCard?.extensions.airi.modules.speech.voicePack?.params).toEqual({ pitch: '+20%' })
expect(cardStore.activeCard?.extensions.airi.modules.speech.voicePack?.voiceId).toBe('voice-a')
})
})
@@ -10,12 +10,37 @@ import { useI18n } from 'vue-i18n'
import SystemPromptV2 from '../../constants/prompts/system-v2'
import { DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT } from '../../constants/prompts/character-defaults'
import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official'
import { capturePosthogEvent } from '../analytics/posthog'
import { useSettingsStageModel } from '../settings/stage-model'
import { useArtistryStore } from './artistry'
import { useConsciousnessStore } from './consciousness'
import { useSpeechStore } from './speech'
export type VoicePackParams = Record<string, string | number | boolean | null>
export interface VoicePackBindingInput {
id: string
name: string
provider: string
model: string
voiceId: string
ttsModelId: string
params: VoicePackParams
costMultiplier: number
}
export interface VoicePackSnapshot {
packId: string
name: string
provider: string
model: string
voiceId: string
ttsModelId: string
params: VoicePackParams
costMultiplier: number
}
export interface AiriExtension {
modules: {
consciousness: {
@@ -32,6 +57,7 @@ export interface AiriExtension {
rate?: number
ssml?: boolean
language?: string
voicePack?: VoicePackSnapshot
}
vrm?: {
@@ -213,6 +239,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
rate: existingExtension.modules?.speech?.rate,
ssml: existingExtension.modules?.speech?.ssml,
language: existingExtension.modules?.speech?.language,
voicePack: existingExtension.modules?.speech?.voicePack,
},
vrm: existingExtension.modules?.vrm,
live2d: existingExtension.modules?.live2d,
@@ -284,6 +311,53 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
}
function bindVoicePackToActiveCard(pack: VoicePackBindingInput) {
const cardId = activeCardId.value
const card = cards.value.get(cardId)
if (!card)
return false
const extension = resolveAiriExtension(card)
const voicePack: VoicePackSnapshot = {
packId: pack.id,
name: pack.name,
provider: pack.provider,
model: pack.model,
voiceId: pack.voiceId,
ttsModelId: pack.ttsModelId,
params: { ...pack.params },
costMultiplier: pack.costMultiplier,
}
const speech: AiriExtension['modules']['speech'] = {
...extension.modules.speech,
provider: OFFICIAL_SPEECH_PROVIDER_ID,
model: pack.ttsModelId,
voice_id: pack.voiceId,
voicePack,
}
cards.value.set(cardId, {
...card,
extensions: {
...card.extensions,
airi: {
...extension,
modules: {
...extension.modules,
speech,
},
},
},
})
activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID
activeSpeechModel.value = pack.ttsModelId
activeSpeechVoiceId.value = pack.voiceId
return true
}
function initialize() {
if (cards.value.has('default'))
return
@@ -348,6 +422,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
addCard,
removeCard,
updateCard,
bindVoicePackToActiveCard,
updateActiveCardDisplayModel,
getCard,
resetState,
@@ -363,6 +438,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
provider: activeSpeechProvider.value,
model: activeSpeechModel.value,
voice_id: activeSpeechVoiceId.value,
voicePack: activeCard.value?.extensions?.airi?.modules?.speech?.voicePack,
},
displayModelId: stageModelStore.stageModelSelected,
activeBackgroundId: activeCard.value?.extensions?.airi?.modules?.activeBackgroundId,
@@ -30,6 +30,163 @@ describe('speech store helpers', () => {
expect(toSignedPercent(0)).toBe('0%')
})
/**
* @example
* speechStore.resolveVoicePackSpeechInput({ text, voice, params: { rate: '+20%' } })
*/
it('maps Voice Pack rate params to provider speed', () => {
const speechStore = useSpeechStore()
const voice = {
id: 'voice-1',
name: 'Voice 1',
provider: OFFICIAL_SPEECH_PROVIDER_ID,
languages: [{ code: 'en-US', title: 'English' }],
}
const request = speechStore.resolveVoicePackSpeechInput({
text: 'hello',
voice,
params: { rate: '+20%' },
})
expect(request.input).toBe('hello')
expect(request.providerConfig.speed).toBe(1.2)
})
/**
* @example
* speechStore.resolveVoicePackSpeechInput({ text, voice, params: { pitch: '+20%' }, supportsSSML: true })
*/
it('applies Voice Pack prosody params through SSML when supported', () => {
const speechStore = useSpeechStore()
const voice = {
id: 'voice-1',
name: 'Voice 1',
provider: OFFICIAL_SPEECH_PROVIDER_ID,
languages: [{ code: 'en-US', title: 'English' }],
gender: 'neutral',
}
const request = speechStore.resolveVoicePackSpeechInput({
text: 'hello',
voice,
params: {
pitch: '+20%',
volume: '-5%',
},
supportsSSML: true,
})
expect(request.input).toContain('<prosody')
expect(request.input).toContain('pitch="+20%"')
expect(request.input).toContain('volume="-5%"')
})
/**
* @example
* speechStore.resolveVoicePackSpeechInput({ text, voice, params: { pitch: '+20%' }, supportsAdapterProsody: true })
*/
it('passes Voice Pack prosody params through adapter options when supported', () => {
const speechStore = useSpeechStore()
const voice = {
id: 'voice-1',
name: 'Voice 1',
provider: OFFICIAL_SPEECH_PROVIDER_ID,
languages: [{ code: 'en-US', title: 'English' }],
}
const request = speechStore.resolveVoicePackSpeechInput({
text: 'hello',
voice,
params: {
pitch: '+20%',
volume: '+5%',
},
supportsAdapterProsody: true,
})
expect(request.input).toBe('hello')
expect(request.providerConfig.extraBody).toEqual({
voice_pack: {
pitch: 20,
volume: 5,
},
})
})
/**
* @example
* speechStore.resolveVoicePackSpeechInput({ text, voice, voicePack: { packId: 'vp-1', costMultiplier: 1.5 } })
*/
it('passes Voice Pack snapshot billing metadata through adapter options', () => {
const speechStore = useSpeechStore()
const voice = {
id: 'voice-1',
name: 'Voice 1',
provider: OFFICIAL_SPEECH_PROVIDER_ID,
languages: [{ code: 'en-US', title: 'English' }],
}
const request = speechStore.resolveVoicePackSpeechInput({
text: 'hello',
voice,
params: {},
voicePack: {
packId: 'vp-1',
costMultiplier: 1.5,
},
supportsAdapterProsody: true,
})
expect(request.providerConfig.extraBody).toEqual({
voice_pack: {
pack_id: 'vp-1',
cost_multiplier: 1.5,
},
})
})
/**
* @example
* speechStore.resolveVoicePackSpeechInput({ text, voice, params: { pitch: '+20%' }, supportsSSML: false })
*/
it('fails fast when Voice Pack prosody params cannot be applied', () => {
const speechStore = useSpeechStore()
const voice = {
id: 'voice-1',
name: 'Voice 1',
provider: OFFICIAL_SPEECH_PROVIDER_ID,
languages: [{ code: 'en-US', title: 'English' }],
}
expect(() => speechStore.resolveVoicePackSpeechInput({
text: 'hello',
voice,
params: { pitch: '+20%' },
supportsSSML: false,
})).toThrow('SSML-capable speech provider')
})
/**
* @example
* speechStore.resolveVoicePackSpeechInput({ text, voice, params: { emotion: 'happy' } })
*/
it('fails fast on unsupported Voice Pack params', () => {
const speechStore = useSpeechStore()
const voice = {
id: 'voice-1',
name: 'Voice 1',
provider: OFFICIAL_SPEECH_PROVIDER_ID,
languages: [{ code: 'en-US', title: 'English' }],
}
expect(() => speechStore.resolveVoicePackSpeechInput({
text: 'hello',
voice,
params: { emotion: 'happy' },
})).toThrow('Unsupported Voice Pack parameter "emotion"')
})
/**
* @example
* await speechStore.loadVoicesForProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, 'volcengine/seed-tts-2.0')
+177 -4
View File
@@ -1,6 +1,7 @@
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import type { VoiceInfo } from '../providers'
import type { VoicePackParams, VoicePackSnapshot } from './airi-card'
import { errorMessageFrom } from '@moeru/std'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
@@ -23,6 +24,114 @@ export function toSignedPercent(value: number): string {
return '0%'
}
interface VoicePackSpeechInputOptions {
text: string
voice: VoiceInfo
providerConfig?: Record<string, unknown>
params?: VoicePackParams
voicePack?: Pick<VoicePackSnapshot, 'packId' | 'costMultiplier'>
forceSSML?: boolean
supportsSSML?: boolean
supportsAdapterProsody?: boolean
}
interface VoicePackSpeechInput {
input: string
providerConfig: Record<string, unknown>
}
const voicePackSupportedParams = new Set(['pitch', 'rate', 'volume'])
/**
* Normalizes a Voice Pack percent-style option.
*
* Before:
* - "+20%"
* - "-10%"
* - 15
*
* After:
* - 20
* - -10
* - 15
*/
function normalizePercentOption(value: string | number | boolean | null | undefined, name: string): number | undefined {
if (value == null)
return undefined
if (typeof value === 'number') {
if (Number.isFinite(value))
return value
throw new Error(`Voice Pack parameter "${name}" must be a finite number.`)
}
if (typeof value !== 'string')
throw new Error(`Voice Pack parameter "${name}" must be a number or percent string.`)
const trimmed = value.trim()
const normalized = trimmed.endsWith('%') ? trimmed.slice(0, -1) : trimmed
const parsed = Number(normalized)
if (!Number.isFinite(parsed))
throw new Error(`Voice Pack parameter "${name}" must be a number or percent string.`)
return parsed
}
/**
* Normalizes a Voice Pack rate option into provider speed.
*
* Before:
* - "+20%"
* - "-10%"
* - 1.2
*
* After:
* - 1.2
* - 0.9
* - 1.2
*/
function normalizeRateOption(value: string | number | boolean | null | undefined): number | undefined {
if (value == null)
return undefined
if (typeof value === 'number') {
if (Number.isFinite(value) && value > 0)
return value
throw new Error('Voice Pack parameter "rate" must be a positive finite number or percent string.')
}
if (typeof value !== 'string')
throw new Error('Voice Pack parameter "rate" must be a positive finite number or percent string.')
const trimmed = value.trim()
if (trimmed.endsWith('%')) {
const percent = normalizePercentOption(trimmed, 'rate')
const speed = 1 + (percent ?? 0) / 100
if (speed > 0)
return speed
throw new Error('Voice Pack parameter "rate" percent must resolve to a positive speed.')
}
const parsed = Number(trimmed)
if (Number.isFinite(parsed) && parsed > 0)
return parsed
throw new Error('Voice Pack parameter "rate" must be a positive finite number or percent string.')
}
function assertSupportedVoicePackParams(params: VoicePackParams | undefined) {
if (!params)
return
for (const [key, value] of Object.entries(params)) {
if (value == null)
continue
if (!voicePackSupportedParams.has(key))
throw new Error(`Unsupported Voice Pack parameter "${key}".`)
}
}
export const useSpeechStore = defineStore('speech', () => {
const providersStore = useProvidersStore()
const { allAudioSpeechProvidersMetadata } = storeToRefs(providersStore)
@@ -298,22 +407,22 @@ export const useSpeechStore = defineStore('speech', () => {
function generateSSML(
text: string,
voice: VoiceInfo,
providerConfig?: Record<string, any>,
providerConfig?: Record<string, unknown>,
): string {
const pitch = providerConfig?.pitch
const speed = providerConfig?.speed
const volume = providerConfig?.volume
const prosody = {
pitch: pitch != null
pitch: typeof pitch === 'number'
? toSignedPercent(pitch)
: undefined,
rate: speed != null
rate: typeof speed === 'number'
? speed !== 1.0
? `${speed}`
: '1'
: undefined,
volume: volume != null
volume: typeof volume === 'number'
? toSignedPercent(volume)
: undefined,
}
@@ -337,6 +446,69 @@ export const useSpeechStore = defineStore('speech', () => {
return toXml(ssmlXast)
}
function resolveVoicePackSpeechInput(options: VoicePackSpeechInputOptions): VoicePackSpeechInput {
const providerConfig = { ...options.providerConfig }
if (!options.params) {
return {
input: options.forceSSML
? generateSSML(options.text, options.voice, providerConfig)
: options.text,
providerConfig,
}
}
assertSupportedVoicePackParams(options.params)
const pitch = normalizePercentOption(options.params.pitch, 'pitch')
const volume = normalizePercentOption(options.params.volume, 'volume')
const speed = normalizeRateOption(options.params.rate)
const needsProsody = pitch != null || volume != null
if (speed != null)
providerConfig.speed = speed
if (options.voicePack) {
providerConfig.extraBody = {
...(providerConfig.extraBody as Record<string, unknown> | undefined),
voice_pack: {
pack_id: options.voicePack.packId,
cost_multiplier: options.voicePack.costMultiplier,
...(needsProsody && options.supportsAdapterProsody
? { pitch, volume }
: {}),
},
}
}
else if (needsProsody && options.supportsAdapterProsody) {
providerConfig.extraBody = {
...(providerConfig.extraBody as Record<string, unknown> | undefined),
voice_pack: { pitch, volume },
}
}
else if (needsProsody && !options.forceSSML && !options.supportsSSML) {
throw new Error('Voice Pack pitch and volume parameters require an SSML-capable speech provider.')
}
if (!options.forceSSML && (!needsProsody || options.supportsAdapterProsody)) {
return {
input: options.text,
providerConfig,
}
}
const ssmlConfig = { ...providerConfig }
if (pitch != null)
ssmlConfig.pitch = pitch
if (volume != null)
ssmlConfig.volume = volume
return {
input: generateSSML(options.text, options.voice, ssmlConfig),
providerConfig,
}
}
const configured = computed(() => {
if (activeSpeechProvider.value === 'speech-noop')
return false
@@ -402,6 +574,7 @@ export const useSpeechStore = defineStore('speech', () => {
ensureStreamingDefaultModel,
ensureActiveSpeechModel,
generateSSML,
resolveVoicePackSpeechInput,
resetState,
}
})
@@ -0,0 +1,60 @@
import type { VoicePackBindingInput } from './modules/airi-card'
import { errorMessageFrom } from '@moeru/std'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { authedFetch } from '../libs/auth-fetch'
import { SERVER_URL } from '../libs/server'
export type VoicePackListItem = VoicePackBindingInput & {
description: string | null
enabled: boolean
createdAt: string
updatedAt: string
}
/**
* Loads the enabled Voice Pack library from the AIRI server.
*
* Use when:
* - Settings pages need the curated Voice Pack list before binding one to the
* active character card.
*
* Expects:
* - The user is authenticated; {@link authedFetch} refreshes an expired access
* token once before surfacing the response.
*
* Returns:
* - Reactive list/error/loading state plus a `load()` action.
*/
export const useVoicePacksStore = defineStore('voice-packs', () => {
const packs = ref<VoicePackListItem[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function load() {
loading.value = true
error.value = null
try {
const res = await authedFetch(new URL('/api/v1/voice-packs', SERVER_URL))
if (!res.ok)
throw new Error(`voice packs upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256))
const data = await res.json() as VoicePackListItem[]
packs.value = data
return data
}
catch (err) {
error.value = errorMessageFrom(err) ?? 'Unknown error'
packs.value = []
return []
}
finally {
loading.value = false
}
}
return { packs, loading, error, load }
})
+235 -25
View File
@@ -2339,7 +2339,7 @@ importers:
version: 3.0.2(electron@41.2.1)
'@electron-toolkit/tsconfig':
specifier: 'catalog:'
version: 2.0.0(@types/node@25.6.0)
version: 2.0.0(@types/node@24.12.2)
'@electron-toolkit/utils':
specifier: 'catalog:'
version: 4.0.0(electron@41.2.1)
@@ -2378,7 +2378,7 @@ importers:
version: 3.1.0
'@intlify/unplugin-vue-i18n':
specifier: 'catalog:'
version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@modelcontextprotocol/sdk':
specifier: 'catalog:'
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
@@ -2414,10 +2414,10 @@ importers:
version: link:../../packages/ui-transitions
'@proj-airi/unplugin-fetch':
specifier: 'catalog:'
version: 0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@proj-airi/unplugin-live2d-sdk':
specifier: 'catalog:'
version: 0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@types/audioworklet':
specifier: 'catalog:'
version: 0.0.97
@@ -2444,7 +2444,7 @@ importers:
version: 2.10.3
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
version: 6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar':
specifier: 'catalog:'
version: 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
@@ -2477,7 +2477,7 @@ importers:
version: 6.8.3
electron-vite:
specifier: 'catalog:'
version: 5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
get-port-please:
specifier: 'catalog:'
version: 3.2.0
@@ -2498,31 +2498,31 @@ importers:
version: 2.2.6
unocss-preset-scrollbar:
specifier: 'catalog:'
version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
unplugin-info:
specifier: 'catalog:'
version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unplugin-yaml:
specifier: 'catalog:'
version: 4.1.0(@nuxt/kit@3.20.2(magicast@0.5.2))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite:
specifier: 'catalog:'
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-bundle-visualizer:
specifier: 'catalog:'
version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@4.60.1)
vite-plugin-mkcert:
specifier: 'catalog:'
version: 2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
version: 8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
vite-plugin-vue-layouts:
specifier: 'catalog:'
version: 0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue-macros:
specifier: 'catalog:'
version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
vue-tsc:
specifier: 'catalog:'
version: 3.2.6(typescript@5.9.3)
@@ -20888,9 +20888,9 @@ snapshots:
dependencies:
electron: 41.2.1
'@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)':
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
dependencies:
'@types/node': 25.6.0
'@types/node': 24.12.2
'@electron-toolkit/utils@4.0.0(electron@41.2.1)':
dependencies:
@@ -21802,6 +21802,31 @@ snapshots:
- supports-color
- typescript
'@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
'@intlify/bundle-utils': 11.0.7(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))
'@intlify/shared': 11.3.2
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.32)(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
'@typescript-eslint/scope-manager': 8.58.1
'@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
pathe: 2.0.3
picocolors: 1.1.1
unplugin: 2.3.11
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
optionalDependencies:
vue-i18n: 11.3.2(vue@3.5.32(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/compiler-dom'
- eslint
- rollup
- supports-color
- typescript
'@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
@@ -23804,11 +23829,35 @@ snapshots:
transitivePeerDependencies:
- magicast
'@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
yauzl: 3.3.0
transitivePeerDependencies:
- '@types/node'
- '@vitejs/devtools'
- esbuild
- jiti
- less
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
'@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)':
dependencies:
ofetch: 1.5.1
@@ -25374,6 +25423,12 @@ snapshots:
vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
@@ -25454,9 +25509,9 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
optionalDependencies:
'@vitest/browser': 4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)
'@vitest/browser': 4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)
'@vitest/eslint-plugin@1.6.15(@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.4)':
dependencies:
@@ -25676,6 +25731,15 @@ snapshots:
transitivePeerDependencies:
- vue
'@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
sirv: 3.0.2
vue: 3.5.32(typescript@5.9.3)
optionalDependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- typescript
'@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
sirv: 3.0.2
@@ -27817,7 +27881,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-vite@5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
electron-vite@5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0)
@@ -27825,7 +27889,7 @@ snapshots:
esbuild: 0.25.12
magic-string: 0.30.21
picocolors: 1.1.1
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -33847,11 +33911,6 @@ snapshots:
'@unocss/preset-mini': 66.6.8
unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unocss-preset-scrollbar@4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))):
dependencies:
'@unocss/preset-mini': 66.6.8
unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@unocss/cli': 66.6.8
@@ -33948,6 +34007,14 @@ snapshots:
unplugin: 2.3.11
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
esbuild: 0.27.2
rolldown: 1.0.0-rc.16
rollup: 4.60.1
unplugin: 2.3.11
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
esbuild: 0.27.2
@@ -33982,6 +34049,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ci-info: 4.4.0
git-url-parse: 16.1.0
simple-git: 3.36.0
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.27.2
rollup: 4.60.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ci-info: 4.4.0
@@ -34091,6 +34171,17 @@ snapshots:
rollup: 4.60.1
vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-yaml@4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
unplugin: 3.0.0
yaml: 2.8.3
optionalDependencies:
esbuild: 0.27.2
rolldown: 1.0.0-rc.16
rollup: 4.60.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin@2.3.11:
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -34322,12 +34413,22 @@ snapshots:
- rollup
- supports-color
vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
birpc: 2.9.0
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client: 2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
birpc: 2.9.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client: 2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-hot-client@2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client@2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@@ -34373,6 +34474,21 @@ snapshots:
- tsx
- yaml
vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ansis: 4.2.0
debug: 4.4.3(supports-color@10.2.2)
error-stack-parser-es: 1.0.5
ohash: 2.0.11
open: 10.2.0
perfect-debounce: 2.1.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-dev-rpc: 1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- supports-color
vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ansis: 4.2.0
@@ -34404,6 +34520,13 @@ snapshots:
- typescript
- ws
vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
supports-color: 10.2.2
undici: 8.1.0
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@@ -34422,6 +34545,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
'@vue/devtools-kit': 8.1.1
'@vue/devtools-shared': 8.1.1
sirv: 3.0.2
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-inspect: 11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
@@ -34436,6 +34573,21 @@ snapshots:
- supports-color
- vue
vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0)
'@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
'@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.29.0)
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0)
'@vue/compiler-dom': 3.5.32
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
@@ -34451,6 +34603,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
vue-router: 5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
transitivePeerDependencies:
- supports-color
vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@@ -34717,6 +34879,54 @@ snapshots:
- vue-tsc
- webpack
vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))
'@vue-macros/boolean-prop': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/chain-call': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/config': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-emit': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-models': 3.1.2(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-prop': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-props': 3.1.2(@vue-macros/reactivity-transform@3.1.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-props-refs': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-render': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-slots': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-stylex': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/devtools': 3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vue-macros/export-expose': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/export-props': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/export-render': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/hoist-static': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/jsx-directive': 3.1.2(typescript@5.9.3)
'@vue-macros/named-template': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/reactivity-transform': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/script-lang': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-block': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-component': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-sfc': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-bind': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-emits': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-vmodel': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar': 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
unplugin: 2.3.11
unplugin-combine: 2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unplugin-vue-define-options: 3.1.2(vue@3.5.32(typescript@5.9.3))
vue: 3.5.32(typescript@5.9.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
- '@rspack/core'
- '@vueuse/core'
- esbuild
- rolldown
- rollup
- typescript
- vite
- vue-tsc
- webpack
vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))