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:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -8,3 +8,4 @@ export * from './product-events'
|
||||
export * from './providers'
|
||||
export * from './stripe'
|
||||
export * from './user-character'
|
||||
export * from './voice-packs'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('\'', ''')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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')
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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="{ "rate": "+5%" }"
|
||||
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 个 app(10 个 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 metrics,trace 已有 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 prosody(SSML-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 已接 Langfuse(OTel 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/keyExhaustedCount,router 里已打点);池水位 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:<appid>"]
|
||||
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, R3(origin)
|
||||
- **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` 用法)、:18(TTL survives 注释思路)。
|
||||
- **Test scenarios:**
|
||||
- tryAcquire 在未达上限时 INCR 并返回成功;达上限返回失败且不 INCR。
|
||||
- tryAcquire + release 配对后计数回到原值。
|
||||
- 并发 N 个 tryAcquire 对同一 appid(用 Lua 原子性):成功数不超过 maxConcurrency(check-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, R7(origin)
|
||||
- **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-616)fail-fast。key 级 `createKeyRotator` 不动(appid 在 upstream 级)。
|
||||
- **Patterns to follow:** `router.ts:559`(upstream 遍历)、:527-529(finally 释放点)、:591-616(exhaustion 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:** 上游回 429(app_id 越界)时把该 appid 标记短 TTL「已满」,让主动路由一段时间内不再选它。
|
||||
- **Requirements:** R6(origin)
|
||||
- **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:** R5(origin)
|
||||
- **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, R15(origin)
|
||||
- **Dependencies:** 无(可与 Phase A 并行)
|
||||
- **Files:**
|
||||
- server 端 DB schema / migration(路径按现有迁移工具,见 Approach 待确认项)
|
||||
- 对应 schema/migration 测试或快照
|
||||
- **Approach:** 表列:`id`、`name`、`provider`、`model`、`voice_id`、`params`(jsonb:pitch/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, R11(origin)
|
||||
- **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, R11(origin)
|
||||
- **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 返回 400(schema 拒绝)。
|
||||
- PATCH 编辑、POST/PATCH 禁用置 enabled=false。
|
||||
- GET 列出(admin 看全部;enabled 过滤接口供客户端)。
|
||||
- 未授权请求被 admin 鉴权拒绝(复用现有 admin guard)。
|
||||
- **Verification:** 路由集成测试(Hono test client)断言状态码、鉴权、与域服务交互。
|
||||
|
||||
### Phase C — 角色卡绑定与合成
|
||||
|
||||
### U8. 角色卡 Voice Pack 快照契约
|
||||
|
||||
- **Goal:** 在角色卡 speech 扩展里定义冻结快照字段,写入即冻结、读取兼容缺失。
|
||||
- **Requirements:** R12, R15(origin)
|
||||
- **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, R14(origin)
|
||||
- **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, R13(origin)
|
||||
- **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-338(generateSSML 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-wide,dashboard 必须 `avg()` 不 `sum()`;新增 gauge 沿用此约束并在注释写明。
|
||||
- **角色卡 schema 变更(U8)进整卡 LWW 云同步**,需与 cloud-sync 设计对齐,避免破坏同步。
|
||||
- **计费**:本轮 tier 不碰实际扣费;四档 meter 拆分是独立 billing 线,勿在本计划顺手改 meter。
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### 本计划范围内
|
||||
- Phase A 号池负载均衡(U1-U4)、Phase B Voice Pack 表与 admin API(U5-U7)、Phase C 绑定与合成(U8-U10)。
|
||||
|
||||
### Deferred for later(origin 已列)
|
||||
- 参考音频整块(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` — ttsMeter(Redis)、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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
Generated
+235
-25
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user