feat(server): track blocked TTS preflight

This commit is contained in:
RainbowBird
2026-06-06 00:22:11 +08:00
parent 37d12fd2dd
commit abb6d16b67
12 changed files with 370 additions and 32 deletions
+18 -1
View File
@@ -209,7 +209,10 @@ export async function buildApp(deps: AppDeps) {
if (!session?.user)
return createUnauthorizedWsEvents()
return audioSpeechWsSetup(session.user.id)
return audioSpeechWsSetup(session.user.id, {
trigger: c.req.query('tts_trigger') === 'auto' ? 'auto' : 'manual',
source: parseTtsSource(c.req.query('tts_source'), 'audio.speech.ws'),
})
}))
// Cross-instance config invalidation. The subscriber owns its own
@@ -429,6 +432,20 @@ export async function buildApp(deps: AppDeps) {
return { app: builtApp, injectWebSocket }
}
function parseTtsSource(
value: string | undefined,
fallback: 'audio.speech.ws',
): 'audio.speech.ws' | 'chat_auto_tts' | 'manual_preview' | 'settings_test' {
switch (value) {
case 'chat_auto_tts':
case 'manual_preview':
case 'settings_test':
return value
default:
return fallback
}
}
export type AppType = Awaited<ReturnType<typeof buildApp>>['app']
export async function createApp() {
@@ -1,5 +1,6 @@
import type { WSEvents } from 'hono/ws'
import type { AudioSpeechSessionAnalytics } from './session'
import type { AudioSpeechWsHandlersOptions } from './types'
import { useLogger } from '@guiiai/logg'
@@ -31,8 +32,8 @@ export type { AudioSpeechWsHandlersOptions } from './types'
* peer registry because streaming TTS is single-session per connection.
*/
export function createAudioSpeechWsHandlers(opts: AudioSpeechWsHandlersOptions) {
return function setupPeer(userId: string): WSEvents {
const sessionState = createSessionState(userId, opts)
return function setupPeer(userId: string, analytics?: AudioSpeechSessionAnalytics): WSEvents {
const sessionState = createSessionState(userId, opts, analytics)
return {
onOpen(_event, ws) {
@@ -152,7 +152,10 @@ function makeFakeDeps(overrides: {
decryptedKey?: string
}) {
const ttsMeter = {
assertCanAfford: vi.fn(async () => undefined),
assertCanAfford: vi.fn(async (_userId: string, _newUnits: number, currentBalance: number) => {
if (currentBalance <= 0)
throw Object.assign(new Error('Insufficient flux'), { statusCode: 402 })
}),
accumulate: vi.fn(async () => ({
fluxDebited: 1,
debtAfter: 0,
@@ -282,7 +285,7 @@ describe('audio-speech-ws route', () => {
upstream = await startMockUpstream([])
const deps = makeFakeDeps({ upstreamURL: upstream.url, fluxBalance: 0 })
const handlers = createAudioSpeechWsHandlers(deps as any)
const events = handlers('user-broke')
const events = handlers('user-broke', { trigger: 'auto', source: 'chat_auto_tts' })
const client = makeMockClientWs()
await driveClientSession(events, client, [])
@@ -299,6 +302,18 @@ describe('audio-speech-ws route', () => {
})
expect(client.closed).toBe(true)
expect(client.closeCode).toBe(1008)
expect(deps.productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-broke',
feature: 'tts',
action: 'speech_blocked',
status: 'blocked',
source: 'chat_auto_tts',
reason: 'insufficient_balance',
metadata: expect.objectContaining({
trigger: 'auto',
balance_state: 'insufficient',
}),
}))
})
it('refuses with streaming_tts_not_configured when UNSPEECH_UPSTREAM.streaming is empty', async () => {
@@ -11,6 +11,7 @@ import WebSocket from 'ws'
import { useLogger } from '@guiiai/logg'
import { context as otelContext, SpanStatusCode, trace } from '@opentelemetry/api'
import { ApiError } from '../../utils/error'
import { nanoid } from '../../utils/id'
import {
AIRI_ATTR_BILLING_FLUX_CONSUMED,
@@ -49,6 +50,14 @@ export interface AudioSpeechSessionState {
handleClientClose: () => void
}
export type StreamingTtsTrigger = 'auto' | 'manual'
export type StreamingTtsSource = 'audio.speech.ws' | 'chat_auto_tts' | 'manual_preview' | 'settings_test'
export interface AudioSpeechSessionAnalytics {
trigger?: StreamingTtsTrigger
source?: StreamingTtsSource
}
/**
* Creates the per-connection streaming speech state machine.
*
@@ -63,9 +72,14 @@ export interface AudioSpeechSessionState {
* Returns:
* - A connection-scoped state object with no global peer registry.
*/
export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOptions): AudioSpeechSessionState {
export function createSessionState(
userId: string,
opts: AudioSpeechWsHandlersOptions,
analyticsInput: AudioSpeechSessionAnalytics = {},
): AudioSpeechSessionState {
const requestId = nanoid()
const startedAt = Date.now()
const analytics = normalizeAnalytics(analyticsInput)
const span = tracer.startSpan('llm.gateway.tts.stream', {
attributes: {
[AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'text_to_speech_stream',
@@ -96,8 +110,11 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
feature: 'tts',
action: 'speech_requested',
status: 'started',
source: 'audio.speech.ws',
source: analytics.source,
model: modelLabel,
metadata: {
trigger: analytics.trigger,
},
})
let unspeech: Awaited<ReturnType<AudioSpeechWsHandlersOptions['configKV']['getOptional']>>
@@ -120,10 +137,6 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
// afford the worst-case session.
try {
const flux = await opts.fluxService.getFlux(userId)
if (flux.flux <= 0) {
closeWithError(1008, 'insufficient_flux')
return
}
await opts.ttsMeter.assertCanAfford(userId, STREAMING_PREFLIGHT_CHARS_ESTIMATE, flux.flux)
}
catch (err) {
@@ -131,7 +144,10 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
// assertCanAfford throws PaymentRequiredError (402) — translate to ws
// policy-violation close. The client can read the close code/reason to
// surface a 'top up' prompt.
closeWithError(1008, 'insufficient_flux')
if (isPaymentRequiredError(err))
closeWithBlockedPreflight(1008, 'insufficient_flux')
else
closeWithError(1011, 'flux_preflight_failed')
return
}
@@ -205,11 +221,12 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
feature: 'tts',
action: 'speech_failed',
status: 'failed',
source: 'audio.speech.ws',
source: analytics.source,
model: modelLabel,
reason: 'upstream_error',
metadata: {
duration_ms: Date.now() - startedAt,
trigger: analytics.trigger,
},
})
try {
@@ -409,12 +426,13 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
source: 'audio.speech.ws',
source: analytics.source,
model: modelLabel,
metadata: {
input_chars: units,
duration_ms: durationMs,
flux_consumed: fluxConsumed,
trigger: analytics.trigger,
},
})
@@ -445,12 +463,46 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
feature: 'tts',
action: 'speech_failed',
status: 'failed',
source: 'audio.speech.ws',
source: analytics.source,
model: modelLabel,
reason,
metadata: {
close_code: code,
duration_ms: Date.now() - startedAt,
trigger: analytics.trigger,
},
})
if (clientWs) {
try {
clientWs.send(JSON.stringify({ event: 'error', code: reason, message: reason }))
}
catch {}
try {
clientWs.close(code, reason)
}
catch {}
}
closed = true
span.end()
}
function closeWithBlockedPreflight(code: number, reason: string) {
if (closed)
return
void opts.productEventService.track({
userId,
feature: 'tts',
action: 'speech_blocked',
status: 'blocked',
source: analytics.source,
model: modelLabel,
reason: 'insufficient_balance',
metadata: {
balance_state: 'insufficient',
billing_units: STREAMING_PREFLIGHT_CHARS_ESTIMATE,
close_code: code,
duration_ms: Date.now() - startedAt,
trigger: analytics.trigger,
},
})
if (clientWs) {
@@ -474,3 +526,35 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
handleClientClose,
}
}
function normalizeAnalytics(input: AudioSpeechSessionAnalytics): Required<AudioSpeechSessionAnalytics> {
return {
trigger: normalizeTrigger(input.trigger),
source: normalizeSource(input.source),
}
}
function normalizeTrigger(trigger: AudioSpeechSessionAnalytics['trigger']): StreamingTtsTrigger {
return trigger === 'auto' ? 'auto' : 'manual'
}
function normalizeSource(source: AudioSpeechSessionAnalytics['source']): StreamingTtsSource {
switch (source) {
case 'audio.speech.ws':
case 'chat_auto_tts':
case 'manual_preview':
case 'settings_test':
return source
default:
return 'audio.speech.ws'
}
}
function isPaymentRequiredError(err: unknown): boolean {
if (err instanceof ApiError)
return err.statusCode === 402
return typeof err === 'object'
&& err != null
&& 'statusCode' in err
&& (err as { statusCode?: unknown }).statusCode === 402
}
+72 -2
View File
@@ -75,7 +75,12 @@ function createMockRequestLogService(): RequestLogService {
function createMockTtsMeter(unitsPerFlux = 1000) {
let debt = 0
return {
assertCanAfford: vi.fn(async () => undefined),
assertCanAfford: vi.fn(async (_userId: string, newUnits: number, currentBalance: number) => {
const projectedFlux = Math.floor((debt + newUnits) / unitsPerFlux)
const required = Math.max(projectedFlux, currentBalance <= 0 ? 1 : 0)
if (currentBalance < required)
throw new ApiError(402, 'PAYMENT_REQUIRED', 'Insufficient flux')
}),
accumulate: vi.fn(async ({ units, currentBalance }: { units: number, currentBalance: number }) => {
debt += units
const fluxDebited = Math.floor(debt / unitsPerFlux)
@@ -910,10 +915,18 @@ describe('v1CompletionsRoutes', () => {
)
})
it('should return 402 when flux is insufficient', async () => {
it('returns 402 and records blocked event for manual TTS when flux is insufficient', async () => {
const productEventService = createMockProductEventService()
const llmRouter = createMockLlmRouter()
const app = createTestApp(
createMockFluxService(0),
createMockConfigKV(),
undefined,
undefined,
undefined,
llmRouter,
createMockLlmTracing(),
productEventService,
)
const res = await app.fetch(
@@ -925,6 +938,63 @@ describe('v1CompletionsRoutes', () => {
{ user: testUser } as any,
)
expect(res.status).toBe(402)
expect(llmRouter.routeTts).not.toHaveBeenCalled()
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
action: 'speech_blocked',
status: 'blocked',
source: 'audio.speech',
reason: 'insufficient_balance',
metadata: expect.objectContaining({
trigger: 'manual',
balance_state: 'insufficient',
}),
}))
})
it('returns 204 and records blocked event for auto TTS when flux is insufficient', async () => {
const productEventService = createMockProductEventService()
const llmRouter = createMockLlmRouter()
const app = createTestApp(
createMockFluxService(0),
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',
extra_body: {
airi_analytics: {
trigger: 'auto',
source: 'chat_auto_tts',
},
},
}),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(204)
expect(llmRouter.routeTts).not.toHaveBeenCalled()
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
action: 'speech_blocked',
status: 'blocked',
source: 'chat_auto_tts',
reason: 'insufficient_balance',
metadata: expect.objectContaining({
trigger: 'auto',
balance_state: 'insufficient',
}),
}))
})
it('should not charge when input is empty', async () => {
@@ -62,6 +62,13 @@ export interface OpenAiSpeechRequest {
abortSignal?: AbortSignal
}
type TtsTrigger = 'auto' | 'manual'
interface TtsAnalyticsContext {
trigger: TtsTrigger
source: 'audio.speech' | 'chat_auto_tts' | 'manual_preview' | 'settings_test'
}
/**
* Runs the OpenAI-shaped text-to-speech gateway flow.
*
@@ -83,6 +90,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
const requestId = nanoid()
let requestModel = typeof input.body.model === 'string' ? input.body.model : 'auto'
const inputText = typeof input.body.input === 'string' ? input.body.input : ''
const analytics = ttsAnalyticsContext(input.body)
if (requestModel === 'auto')
requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL')
@@ -107,17 +115,50 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
feature: 'tts',
action: 'speech_requested',
status: 'started',
source: 'audio.speech',
source: analytics.source,
model: requestModel,
metadata: {
input_chars: inputText.length,
trigger: analytics.trigger,
},
})
const flux = await deps.fluxService.getFlux(input.userId)
if (flux.flux <= 0)
try {
await deps.ttsMeter.assertCanAfford(input.userId, billingUnits, flux.flux)
}
catch (err) {
if (!(err instanceof ApiError) || err.statusCode !== 402)
throw err
void deps.productEventService.track({
userId: input.userId,
feature: 'tts',
action: 'speech_blocked',
status: 'blocked',
source: analytics.source,
model: requestModel,
reason: 'insufficient_balance',
metadata: {
input_chars: inputText.length,
billing_units: billingUnits,
balance_state: 'insufficient',
trigger: analytics.trigger,
},
})
logger.withError(err).withFields({
requestId,
userId: input.userId,
model: requestModel,
trigger: analytics.trigger,
source: analytics.source,
}).warn('tts speech blocked by pre-flight balance check')
if (analytics.trigger === 'auto')
return new Response(null, { status: 204 })
throw createPaymentRequiredError('Insufficient flux')
await deps.ttsMeter.assertCanAfford(input.userId, billingUnits, flux.flux)
}
const ttsInput = {
text: inputText,
@@ -170,13 +211,14 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
feature: 'tts',
action: 'speech_failed',
status: 'failed',
source: 'audio.speech',
source: analytics.source,
model: requestModel,
provider: routeCtx.provider,
reason: failure.reason,
metadata: {
http_status: failure.status,
duration_ms: Date.now() - startedAt,
trigger: analytics.trigger,
},
})
throw err
@@ -195,13 +237,14 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
feature: 'tts',
action: 'speech_failed',
status: 'failed',
source: 'audio.speech',
source: analytics.source,
model: requestModel,
provider: routeCtx.provider,
reason: 'upstream_error',
metadata: {
http_status: response.status,
duration_ms: durationMs,
trigger: analytics.trigger,
},
})
logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs })
@@ -243,7 +286,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
source: 'audio.speech',
source: analytics.source,
model: requestModel,
provider: routeCtx.provider,
metadata: {
@@ -253,6 +296,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
cost_multiplier: voicePackRequest.costMultiplier,
duration_ms: durationMs,
flux_consumed: fluxConsumed,
trigger: analytics.trigger,
},
})
deps.requestLogService.logRequest({
@@ -300,6 +344,20 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
return { handleSpeechRequest }
}
function ttsAnalyticsContext(body: Record<string, unknown>): TtsAnalyticsContext {
const extraBody = asRecord(body.extra_body)
const analytics = asRecord(extraBody?.airi_analytics)
const trigger = analytics?.trigger === 'auto' ? 'auto' : 'manual'
const rawSource = analytics?.source
const source = rawSource === 'chat_auto_tts'
|| rawSource === 'manual_preview'
|| rawSource === 'settings_test'
? rawSource
: 'audio.speech'
return { trigger, source }
}
async function voicePackRequestOptions(
body: Record<string, unknown>,
context: {
@@ -99,4 +99,38 @@ describe('productEventService', () => {
distinctUsers: 2,
}])
})
it('writes blocked TTS events for server-side preflight decisions', async () => {
const events = { add: vi.fn() }
const service = createProductEventService(db, { events } as unknown as ProductMetrics)
await service.track({
userId: 'user-1',
feature: 'tts',
action: 'speech_blocked',
status: 'blocked',
source: 'chat_auto_tts',
reason: 'insufficient_balance',
metadata: {
trigger: 'auto',
balance_state: 'insufficient',
},
})
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
feature: 'tts',
action: 'speech_blocked',
status: 'blocked',
source: 'chat_auto_tts',
reason: 'insufficient_balance',
})
expect(events.add).toHaveBeenCalledWith(1, {
feature: 'tts',
action: 'speech_blocked',
status: 'blocked',
source: 'chat_auto_tts',
})
})
})
@@ -11,7 +11,7 @@ const logger = useLogger('product-events')
export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing' | 'voice_pack'
export type ProductEventStatus = 'started' | 'succeeded' | 'failed'
export type ProductEventStatus = 'started' | 'succeeded' | 'failed' | 'blocked'
export type ProductAction
= | 'user_signed_up'
@@ -23,6 +23,7 @@ export type ProductAction
| 'speech_requested'
| 'speech_succeeded'
| 'speech_failed'
| 'speech_blocked'
| 'voice_pack_created'
| 'voice_pack_updated'
| 'voice_pack_disabled'
@@ -430,8 +430,20 @@ const speechPipeline = createSpeechPipeline<AudioBuffer>({
// Non-streaming providers only: synth via REST. Streaming provider
// was already early-returned above; it owns its own ws path opened
// in `onBeforeMessageComposed`.
const providerConfigWithAnalytics = activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID
? {
...speechRequest.providerConfig,
extraBody: {
...(speechRequest.providerConfig.extraBody as Record<string, unknown> | undefined),
airi_analytics: {
trigger: 'auto',
source: 'chat_auto_tts',
},
},
}
: speechRequest.providerConfig
const res = await generateSpeech({
...provider.speech(model, speechRequest.providerConfig),
...provider.speech(model, providerConfigWithAnalytics),
input: speechRequest.input,
voice: voice.id,
})
@@ -48,6 +48,10 @@ export interface StreamingTtsPipelineOptions extends StreamingTtsPipelineEvents
responseFormat?: 'mp3' | 'opus' | 'aac' | 'flac' | 'pcm'
/** Backend-specific knobs forwarded as the `extra_body` of the `start` frame. */
extraBody?: Record<string, unknown>
/** Business trigger hint sent to server-side product analytics. */
ttsTrigger?: 'auto' | 'manual'
/** Low-cardinality source hint sent to server-side product analytics. */
ttsSource?: 'chat_auto_tts' | 'manual_preview' | 'settings_test'
/**
* Decoder context. The pipeline calls `decodeAudioData` on it for each
* sentence (or once at session end in buffered mode). Reusing the page's
@@ -115,7 +119,10 @@ export function createStreamingTtsPipeline(options: StreamingTtsPipelineOptions)
return noopHandle()
}
const wsUrl = toWebSocketUrl(options.serverUrl ?? SERVER_URL, '/api/v1/audio/speech/ws', token)
const wsUrl = toWebSocketUrl(options.serverUrl ?? SERVER_URL, '/api/v1/audio/speech/ws', token, {
ttsTrigger: options.ttsTrigger ?? 'auto',
ttsSource: options.ttsSource ?? 'chat_auto_tts',
})
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
@@ -400,10 +407,17 @@ function noopHandle(): StreamingTtsPipelineHandle {
return { appendText: () => {}, finish: () => {}, cancel: () => {} }
}
function toWebSocketUrl(httpBase: string, path: string, token: string): string {
function toWebSocketUrl(
httpBase: string,
path: string,
token: string,
analytics: { ttsTrigger: 'auto' | 'manual', ttsSource: 'chat_auto_tts' | 'manual_preview' | 'settings_test' },
): string {
const u = new URL(path, httpBase)
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'
u.searchParams.set('token', token)
u.searchParams.set('tts_trigger', analytics.ttsTrigger)
u.searchParams.set('tts_source', analytics.ttsSource)
return u.toString()
}
@@ -54,6 +54,10 @@ export interface StreamingTtsSessionOptions {
* `section_id`, `context_texts`, etc.
*/
extraBody?: Record<string, unknown>
/** Business trigger hint sent to server-side product analytics. */
ttsTrigger?: 'auto' | 'manual'
/** Low-cardinality source hint sent to server-side product analytics. */
ttsSource?: 'chat_auto_tts' | 'manual_preview' | 'settings_test'
/** Caller-side abort signal. Closes the ws and rejects with `AbortError`. */
signal?: AbortSignal
}
@@ -85,7 +89,10 @@ export async function streamingSynthesize(options: StreamingTtsSessionOptions):
throw new Error('streaming-tts: not authenticated')
const baseUrl = options.serverUrl ?? SERVER_URL
const wsUrl = toWebSocketUrl(baseUrl, '/api/v1/audio/speech/ws', token)
const wsUrl = toWebSocketUrl(baseUrl, '/api/v1/audio/speech/ws', token, {
ttsTrigger: options.ttsTrigger ?? 'manual',
ttsSource: options.ttsSource ?? 'manual_preview',
})
const audioChunks: ArrayBuffer[] = []
const sentences: StreamingTtsSessionResult['sentences'] = []
@@ -225,10 +232,17 @@ export async function streamingSynthesize(options: StreamingTtsSessionOptions):
})
}
function toWebSocketUrl(httpBase: string, path: string, token: string): string {
function toWebSocketUrl(
httpBase: string,
path: string,
token: string,
analytics: { ttsTrigger: 'auto' | 'manual', ttsSource: 'chat_auto_tts' | 'manual_preview' | 'settings_test' },
): string {
const u = new URL(path, httpBase)
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'
u.searchParams.set('token', token)
u.searchParams.set('tts_trigger', analytics.ttsTrigger)
u.searchParams.set('tts_source', analytics.ttsSource)
return u.toString()
}
+21 -3
View File
@@ -393,10 +393,15 @@ export const useSpeechStore = defineStore('speech', () => {
voice: string,
providerConfig: Record<string, any> = {},
): Promise<ArrayBuffer> {
const requestProviderConfig = activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID
|| activeSpeechProvider.value === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID
? withAiriTtsAnalytics(providerConfig, {
trigger: 'manual',
source: 'manual_preview',
})
: providerConfig
const response = await generateSpeech({
...provider.speech(model, {
...providerConfig,
}),
...provider.speech(model, requestProviderConfig),
input,
voice,
})
@@ -404,6 +409,19 @@ export const useSpeechStore = defineStore('speech', () => {
return response
}
function withAiriTtsAnalytics(
providerConfig: Record<string, any>,
analytics: { trigger: 'auto' | 'manual', source: 'chat_auto_tts' | 'manual_preview' | 'settings_test' },
): Record<string, any> {
return {
...providerConfig,
extraBody: {
...(providerConfig.extraBody as Record<string, unknown> | undefined),
airi_analytics: analytics,
},
}
}
function generateSSML(
text: string,
voice: VoiceInfo,