feat(server): add Aliyun NLS ASR gateway and official transcription provider (#1970)

This commit is contained in:
RainbowBird
2026-06-14 04:22:07 +08:00
committed by GitHub
parent 0f975a4f73
commit 3215687e98
22 changed files with 1484 additions and 16 deletions
+11
View File
@@ -59,6 +59,7 @@ 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 { createAudioTranscriptionStreamHandler } from './routes/audio-transcription-stream/route'
import { createAuthRoutes } from './routes/auth'
import { createCharacterRoutes } from './routes/characters'
import { createChatWsHandlers } from './routes/chat-ws'
@@ -215,6 +216,16 @@ export async function buildApp(deps: AppDeps) {
})
}))
// Realtime ASR proxy. Mounted before the global bodyLimit middleware because
// the request body is a live microphone PCM stream rather than a bounded JSON
// payload. Auth is resolved manually here for the same reason.
app.post('/api/v1/audio/transcriptions/stream', createAudioTranscriptionStreamHandler({
auth: deps.auth,
env: deps.env,
configKV: deps.configKV,
envelopeCrypto: deps.envelopeCrypto,
}))
// Cross-instance config invalidation. The subscriber owns its own
// connection + lifecycle metrics; see services/llm-router/config-sync-subscriber.ts.
createConfigSyncSubscriber({
@@ -82,6 +82,24 @@ const StepfunSliceSchema = object({
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
})
const AliyunNlsAsrSliceSchema = object({
kind: literal('aliyun-nls-asr'),
modelName: pipe(string(), nonEmpty('modelName is required'), maxLength(200), NO_PIPE),
accessKeyId: pipe(string(), nonEmpty('accessKeyId is required'), maxLength(200)),
appKey: pipe(string(), nonEmpty('appKey is required'), maxLength(200)),
region: optional(picklist([
'cn-shanghai',
'cn-shanghai-internal',
'cn-beijing',
'cn-beijing-internal',
'cn-shenzhen',
'cn-shenzhen-internal',
], 'region must be a supported Aliyun NLS region')),
plaintextKey: optional(pipe(string(), nonEmpty('plaintextKey must not be empty when provided'), maxLength(MAX_KEY_LENGTH))),
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
existingKeyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
})
/**
* `restBaseURL` is the unspeech REST root (http(s)://host:port, no path).
* `streaming.upstreamURL` must be ws:// or wss:// — http(s):// here is almost
@@ -120,6 +138,7 @@ const SliceSchema = variant('kind', [
AzureSliceSchema,
DashscopeSliceSchema,
StepfunSliceSchema,
AliyunNlsAsrSliceSchema,
UnspeechSliceSchema,
])
@@ -169,6 +188,8 @@ const BodySchema = object({
* { "kind": "stepfun", "modelName": "stepfun/stepaudio-2.5-tts",
* "upstreamModel": "stepaudio-2.5-tts",
* "defaultVoice": "cixingnansheng", "plaintextKey": "..." },
* { "kind": "aliyun-nls-asr", "modelName": "auto",
* "accessKeyId": "...", "appKey": "...", "plaintextKey": "..." },
* { "kind": "unspeech",
* "restBaseURL": "http://airi-unspeech.railway.internal:5933",
* "streaming": {
@@ -0,0 +1,72 @@
import type { RouterConfig } from '../../services/domain/llm-router/types'
import { Buffer } from 'node:buffer'
import { describe, expect, it } from 'vitest'
import { createEnvelopeCrypto } from '../../utils/envelope-crypto'
import { resolveOfficialAliyunNlsCredentials } from './route'
function createRouterConfig(overrides?: Partial<RouterConfig>): RouterConfig {
return {
llm: { models: {} },
tts: { models: {} },
defaults: {
perAttemptTimeoutMs: 30000,
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
},
...overrides,
}
}
describe('resolveOfficialAliyunNlsCredentials', () => {
/**
* @example
* resolveOfficialAliyunNlsCredentials(routerConfig, envelope, 'auto')
*/
it('returns null when official ASR model config is absent', () => {
const envelope = createEnvelopeCrypto({ masterKey: Buffer.alloc(32, 7) })
const credentials = resolveOfficialAliyunNlsCredentials(createRouterConfig(), envelope, 'auto')
expect(credentials).toBeNull()
})
/**
* @example
* resolveOfficialAliyunNlsCredentials(routerConfig, envelope, 'auto')
*/
it('decrypts Aliyun NLS credentials from LLM_ROUTER_CONFIG.asr', () => {
const envelope = createEnvelopeCrypto({ masterKey: Buffer.alloc(32, 7) })
const ciphertext = envelope.encryptKey(' secret ', {
modelName: 'auto',
keyEntryId: 'aliyun-nls-asr-prod-1',
})
const credentials = resolveOfficialAliyunNlsCredentials(createRouterConfig({
asr: {
models: {
auto: {
provider: 'aliyun-nls',
upstreams: [{
keys: [{ id: 'aliyun-nls-asr-prod-1', ciphertext }],
adapterParams: {
accessKeyId: ' ak ',
appKey: ' app ',
region: '',
},
}],
},
},
},
}), envelope, 'auto')
expect(credentials).toEqual({
accessKeyId: 'ak',
accessKeySecret: 'secret',
appKey: 'app',
region: 'cn-shanghai',
})
})
})
@@ -0,0 +1,139 @@
import type { Context } from 'hono'
import type { AuthInstance } from '../../libs/auth'
import type { Env } from '../../libs/env'
import type { ConfigKVService } from '../../services/adapters/config-kv'
import type { RouterConfig } from '../../services/domain/llm-router/types'
import type { EnvelopeCrypto } from '../../utils/envelope-crypto'
import { resolveRequestAuth } from '../../libs/request-auth'
import { createKeyRotator } from '../../services/domain/llm-router/key-rotator'
import { createServiceUnavailableError, createUnauthorizedError } from '../../utils/error'
import { createAliyunNlsStreamResponse } from './session'
type AliyunNlsRegion = 'cn-shanghai' | 'cn-shanghai-internal' | 'cn-beijing' | 'cn-beijing-internal' | 'cn-shenzhen' | 'cn-shenzhen-internal'
const ALIYUN_NLS_REGION_FALLBACK: AliyunNlsRegion = 'cn-shanghai'
const ALIYUN_NLS_REGIONS = new Set<AliyunNlsRegion>([
'cn-shanghai',
'cn-shanghai-internal',
'cn-beijing',
'cn-beijing-internal',
'cn-shenzhen',
'cn-shenzhen-internal',
])
const OFFICIAL_ASR_MODEL_NAME = 'auto'
function stringAdapterParam(params: Record<string, unknown> | undefined, key: string): string {
const value = params?.[key]
return typeof value === 'string' ? value.trim() : ''
}
/**
* Resolves optional official Aliyun NLS credentials from router config.
*
* Use when:
* - The realtime transcription route needs to decide whether official ASR is configured.
*
* Expects:
* - `LLM_ROUTER_CONFIG.asr.models[modelName]` is an `aliyun-nls` model.
* - The first upstream key ciphertext stores the access key secret.
* - `adapterParams.accessKeyId` and `adapterParams.appKey` are present.
*
* Returns:
* - Decrypted credentials, or `null` when any required config is missing.
*/
export function resolveOfficialAliyunNlsCredentials(
routerConfig: RouterConfig | null | undefined,
envelopeCrypto: EnvelopeCrypto,
modelName: string = OFFICIAL_ASR_MODEL_NAME,
) {
const model = routerConfig?.asr?.models[modelName]
const upstream = model?.upstreams[0]
if (model?.provider !== 'aliyun-nls' || !upstream)
return null
const iterator = createKeyRotator(upstream, envelopeCrypto, modelName, null, model.provider)[Symbol.iterator]()
const next = iterator.next()
if (next.done)
return null
const accessKeySecretBytes = next.value.plaintext
try {
const accessKeyId = stringAdapterParam(upstream.adapterParams, 'accessKeyId')
const accessKeySecret = accessKeySecretBytes.toString('utf8').trim()
const appKey = stringAdapterParam(upstream.adapterParams, 'appKey')
const rawRegion = stringAdapterParam(upstream.adapterParams, 'region')
if (!accessKeyId || !accessKeySecret || !appKey)
return null
const region = ALIYUN_NLS_REGIONS.has(rawRegion as AliyunNlsRegion)
? rawRegion as AliyunNlsRegion
: ALIYUN_NLS_REGION_FALLBACK
return {
accessKeyId,
accessKeySecret,
appKey,
region,
}
}
finally {
accessKeySecretBytes.fill(0)
}
}
async function resolveOfficialAliyunNlsCredentialsFromConfig(input: {
configKV: ConfigKVService
envelopeCrypto: EnvelopeCrypto
}) {
const routerConfig = await input.configKV.getOptional('LLM_ROUTER_CONFIG')
const credentials = resolveOfficialAliyunNlsCredentials(routerConfig, input.envelopeCrypto)
if (!credentials)
return null
return credentials
}
/**
* Handles official realtime transcription audio upload streams.
*
* Use when:
* - A browser client POSTs the Hearing PCM stream and expects SSE transcript deltas.
*
* Expects:
* - Authentication has not yet run through normal session middleware because this route is mounted before body limits.
*
* Returns:
* - An SSE response that mirrors `@xsai/stream-transcription` delta events.
*/
export function createAudioTranscriptionStreamHandler(input: {
auth: AuthInstance
env: Env
configKV: ConfigKVService
envelopeCrypto: EnvelopeCrypto
}) {
return async function handleAudioTranscriptionStream(c: Context) {
const session = await resolveRequestAuth(
input.auth,
input.env,
c.req.raw.headers,
)
if (!session?.user)
throw createUnauthorizedError()
const credentials = await resolveOfficialAliyunNlsCredentialsFromConfig(input)
if (!credentials)
throw createServiceUnavailableError('Official ASR transcription is not configured in LLM_ROUTER_CONFIG.asr.models.auto', 'CONFIG_NOT_SET')
const audioStream = c.req.raw.body
if (!audioStream)
throw createServiceUnavailableError('Streaming transcription request is missing audio body', 'REQUEST_BODY_NOT_STREAMABLE')
return createAliyunNlsStreamResponse({
audioStream: audioStream as ReadableStream<Uint8Array>,
credentials,
})
}
}
@@ -0,0 +1,142 @@
import type { AddressInfo } from 'node:net'
import { Buffer } from 'node:buffer'
import { createServer } from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import { WebSocketServer } from 'ws'
import { createAliyunNlsStreamResponse } from './session'
interface MockAliyunUpstream {
url: string
receivedTextFrames: string[]
receivedBinaryFrames: Buffer[]
close: () => Promise<void>
}
async function startMockAliyunUpstream(): Promise<MockAliyunUpstream> {
const receivedTextFrames: string[] = []
const receivedBinaryFrames: Buffer[] = []
const httpServer = createServer()
const wss = new WebSocketServer({ server: httpServer })
wss.on('connection', (ws) => {
ws.on('message', (data, isBinary) => {
if (isBinary) {
receivedBinaryFrames.push(Buffer.from(data as Buffer))
return
}
const text = data.toString()
receivedTextFrames.push(text)
const parsed = JSON.parse(text) as { header?: { name?: string } }
if (parsed.header?.name === 'StartTranscription') {
ws.send(JSON.stringify({
header: { name: 'TranscriptionStarted' },
payload: { session_id: 'mock-session' },
}))
}
if (parsed.header?.name === 'StopTranscription') {
ws.send(JSON.stringify({
header: { name: 'SentenceEnd' },
payload: { result: 'hello airi' },
}))
ws.send(JSON.stringify({
header: { name: 'TranscriptionCompleted' },
}))
}
})
})
await new Promise<void>((resolve) => {
httpServer.listen(0, '127.0.0.1', resolve)
})
const { port } = httpServer.address() as AddressInfo
return {
url: `ws://127.0.0.1:${port}`,
receivedTextFrames,
receivedBinaryFrames,
async close() {
wss.close()
await new Promise<void>(resolve => httpServer.close(() => resolve()))
},
}
}
function streamOf(chunks: Uint8Array[]) {
return new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks)
controller.enqueue(chunk)
controller.close()
},
})
}
async function readText(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let text = ''
while (true) {
const { done, value } = await reader.read()
if (done)
break
text += decoder.decode(value, { stream: true })
}
text += decoder.decode()
return text
}
describe('createAliyunNlsStreamResponse', () => {
let upstream: MockAliyunUpstream | undefined
afterEach(async () => {
await upstream?.close()
upstream = undefined
})
/**
* @example
* createAliyunNlsStreamResponse({ audioStream, credentials })
*/
it('bridges client audio chunks to Aliyun NLS and emits SSE transcript deltas', async () => {
upstream = await startMockAliyunUpstream()
const response = createAliyunNlsStreamResponse({
audioStream: streamOf([Buffer.from([1, 2]), Buffer.from([3, 4])]),
credentials: {
accessKeyId: 'ak',
accessKeySecret: 'secret',
appKey: 'app',
region: 'cn-shanghai',
},
createToken: async () => ({ token: 'mock-token', expiresAt: Date.now() + 3600_000 }),
websocketBaseURL: upstream.url,
})
const body = await readText(response.body!)
expect(body).toContain('data: {"delta":"hello airi\\n","type":"transcript.text.delta"}')
expect(body).toContain('data: {"delta":"","type":"transcript.text.done"}')
expect(upstream.receivedBinaryFrames).toEqual([
Buffer.from([1, 2]),
Buffer.from([3, 4]),
])
const startFrame = JSON.parse(upstream.receivedTextFrames[0]) as {
header: { appkey: string, name: string }
payload: { format: string, sample_rate: number, enable_intermediate_result: boolean }
}
expect(startFrame.header.appkey).toBe('app')
expect(startFrame.header.name).toBe('StartTranscription')
expect(startFrame.payload.format).toBe('pcm')
expect(startFrame.payload.sample_rate).toBe(16000)
expect(startFrame.payload.enable_intermediate_result).toBe(true)
const stopFrame = JSON.parse(upstream.receivedTextFrames.at(-1)!) as { header: { name: string } }
expect(stopFrame.header.name).toBe('StopTranscription')
})
})
@@ -0,0 +1,231 @@
import { createHmac, randomUUID } from 'node:crypto'
import WebSocket from 'ws'
import { merge } from '@moeru/std'
import { ofetch } from 'ofetch'
type AliyunNlsRegion = 'cn-shanghai' | 'cn-shanghai-internal' | 'cn-beijing' | 'cn-beijing-internal' | 'cn-shenzhen' | 'cn-shenzhen-internal'
interface AliyunNlsCredentials {
accessKeyId: string
accessKeySecret: string
appKey: string
region: AliyunNlsRegion
}
interface AliyunNlsToken {
token: string
expiresAt: number
}
interface AliyunNlsStartPayload {
format?: 'pcm' | 'wav' | 'opus' | 'speex' | 'amr' | 'mp3' | 'aac'
sample_rate?: 8000 | 16000
enable_intermediate_result?: boolean
enable_punctuation_prediction?: boolean
enable_inverse_text_normalization?: boolean
enable_words?: boolean
max_sentence_silence?: number
}
interface AliyunNlsServerEvent {
header?: {
name?: string
}
payload?: {
result?: string
}
}
interface CreateAliyunNlsStreamResponseOptions {
audioStream: ReadableStream<Uint8Array>
credentials: AliyunNlsCredentials
createToken?: (credentials: AliyunNlsCredentials) => Promise<AliyunNlsToken>
sessionOptions?: AliyunNlsStartPayload
websocketBaseURL?: string
}
const encoder = new TextEncoder()
const DEFAULT_SESSION_OPTIONS: AliyunNlsStartPayload = {
format: 'pcm',
sample_rate: 16000,
enable_intermediate_result: true,
enable_punctuation_prediction: true,
enable_words: true,
}
function nlsMetaEndpointFromRegion(region: AliyunNlsRegion): URL {
return new URL(`http://nls-meta.${region}.aliyuncs.com`)
}
function nlsWebSocketEndpointFromRegion(region: AliyunNlsRegion): URL {
const websocketURL = new URL('/ws/v1', 'https://example.com')
switch (region) {
case 'cn-shanghai':
case 'cn-beijing':
case 'cn-shenzhen':
websocketURL.protocol = 'wss:'
websocketURL.hostname = `nls-gateway-${region}.aliyuncs.com`
break
case 'cn-shanghai-internal':
case 'cn-beijing-internal':
case 'cn-shenzhen-internal':
websocketURL.protocol = 'wss:'
websocketURL.hostname = `nls-gateway-${region}-internal.aliyuncs.com:80`
break
}
return websocketURL
}
function canonicalizeQuery(params: Record<string, string>): string {
return Object.keys(params)
.sort()
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&')
}
function createStringToSign(method: string, path: string, canonicalQuery: string): string {
return `${method}&${encodeURIComponent(path)}&${encodeURIComponent(canonicalQuery)}`
}
function signStringToBase64(stringToSign: string, accessKeySecret: string): string {
return createHmac('sha1', `${accessKeySecret}&`).update(stringToSign).digest('base64')
}
function aliyunTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
}
async function createAliyunNlsToken(credentials: AliyunNlsCredentials): Promise<AliyunNlsToken> {
const params: Record<string, string> = {
AccessKeyId: credentials.accessKeyId,
Action: 'CreateToken',
Format: 'JSON',
RegionId: credentials.region,
SignatureMethod: 'HMAC-SHA1',
SignatureNonce: randomUUID(),
SignatureVersion: '1.0',
Timestamp: aliyunTimestamp(new Date()),
Version: '2019-02-28',
}
const canonicalQuery = canonicalizeQuery(params)
const signature = encodeURIComponent(signStringToBase64(createStringToSign('POST', '/', canonicalQuery), credentials.accessKeySecret))
const endpoint = nlsMetaEndpointFromRegion(credentials.region).toString().replace(/\/$/, '')
const response = await ofetch<{
Token?: { ExpireTime?: number, Id?: string }
Message?: string
}>(`${endpoint}/?Signature=${signature}&${canonicalQuery}`, { method: 'POST' })
if (typeof response.Token?.Id === 'string' && typeof response.Token?.ExpireTime === 'number')
return { token: response.Token.Id, expiresAt: response.Token.ExpireTime * 1000 }
throw new Error(`Failed to create Aliyun NLS token: ${response.Message || 'unknown error'}`)
}
function sse(payload: { delta: string, type: 'transcript.text.delta' | 'transcript.text.done' }): Uint8Array {
return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)
}
function createClientEvent(credentials: AliyunNlsCredentials, name: 'StartTranscription' | 'StopTranscription', sessionId: string, payload?: AliyunNlsStartPayload) {
return JSON.stringify({
header: {
appkey: credentials.appKey,
message_id: randomUUID().replaceAll('-', ''),
task_id: sessionId,
namespace: 'SpeechTranscriber',
name,
},
payload,
})
}
async function writeAudioToUpstream(audioStream: ReadableStream<Uint8Array>, ws: WebSocket, credentials: AliyunNlsCredentials, sessionId: string) {
const reader = audioStream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done)
break
if (value)
ws.send(value, { binary: true })
}
}
finally {
ws.send(createClientEvent(credentials, 'StopTranscription', sessionId))
}
}
/**
* Streams client microphone PCM through Aliyun NLS and returns xsai-compatible SSE transcript deltas.
*
* Use when:
* - AIRI owns the Aliyun NLS credentials server-side.
* - The browser uploads a realtime audio `ReadableStream` and expects transcript deltas.
*
* Expects:
* - `audioStream` contains 16 kHz PCM chunks by default, matching the Hearing worklet output.
*
* Returns:
* - A `text/event-stream` response consumable by the existing `streamAliyunTranscription` executor.
*/
export function createAliyunNlsStreamResponse(options: CreateAliyunNlsStreamResponseOptions): Response {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const createToken = options.createToken ?? createAliyunNlsToken
const token = await createToken(options.credentials)
const sessionId = randomUUID().replaceAll('-', '')
const upstreamURL = new URL(options.websocketBaseURL ?? nlsWebSocketEndpointFromRegion(options.credentials.region))
upstreamURL.searchParams.set('token', token.token)
const ws = new WebSocket(upstreamURL)
ws.on('open', () => {
ws.send(createClientEvent(options.credentials, 'StartTranscription', sessionId, merge(DEFAULT_SESSION_OPTIONS, options.sessionOptions)))
})
ws.on('message', (data) => {
const event = JSON.parse(data.toString()) as AliyunNlsServerEvent
switch (event.header?.name) {
case 'TranscriptionStarted':
void writeAudioToUpstream(options.audioStream, ws, options.credentials, sessionId)
break
case 'SentenceEnd': {
const text = event.payload?.result ? `${event.payload.result}\n` : ''
if (text)
controller.enqueue(sse({ delta: text, type: 'transcript.text.delta' }))
controller.enqueue(sse({ delta: '', type: 'transcript.text.done' }))
break
}
case 'TranscriptionCompleted':
controller.close()
ws.close(1000, 'completed')
break
}
})
ws.on('error', (error) => {
controller.error(error)
})
ws.on('close', () => {
try {
controller.close()
}
catch {}
})
},
cancel() {
// The upstream websocket is closed by its own completion/error handlers.
},
})
return new Response(body, {
headers: {
'Cache-Control': 'no-cache',
'Content-Type': 'text/event-stream',
},
})
}
@@ -108,6 +108,49 @@ describe('configKVService', () => {
expect(value).toBe(500)
})
/**
* @example
* service.set('LLM_ROUTER_CONFIG', { asr: { models: { auto: model } } })
*/
it('llm router config should preserve official ASR model config', async () => {
await service.set('LLM_ROUTER_CONFIG', {
llm: { models: {} },
tts: { models: {} },
asr: {
models: {
auto: {
provider: 'aliyun-nls',
upstreams: [{
keys: [{ id: 'aliyun-nls-asr-prod-1', ciphertext: 'ciphertext' }],
adapterParams: {
accessKeyId: 'ak',
appKey: 'app',
region: 'cn-shanghai',
},
}],
},
},
},
defaults: {
perAttemptTimeoutMs: 30000,
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
},
})
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
const asr = value.asr
if (!asr)
throw new Error('Expected ASR config to be preserved')
expect(asr.models.auto.provider).toBe('aliyun-nls')
expect(asr.models.auto.upstreams[0].adapterParams).toEqual({
accessKeyId: 'ak',
appKey: 'app',
region: 'cn-shanghai',
})
})
it('set should store string values as JSON strings', async () => {
await service.set('STRIPE_FLUX_PRODUCT_ID', 'prod_abc123')
@@ -53,6 +53,7 @@ export const llmModelSchema = object({
})
const ttsProviderSchema = picklist(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine'])
const asrProviderSchema = picklist(['aliyun-nls'])
export const ttsUpstreamSchema = object({
baseURL: pipe(string(), nonEmpty('tts.upstreams[].baseURL must not be empty')),
@@ -93,6 +94,16 @@ export const ttsModelSchema = object({
fallbackTriggers: fallbackTriggersSchema,
})
export const asrUpstreamSchema = object({
keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'asr.upstreams[].keys must contain at least 1 entry')),
adapterParams: optional(record(string(), any()), {}),
})
export const asrModelSchema = object({
provider: asrProviderSchema,
upstreams: pipe(array(asrUpstreamSchema), check(v => v.length >= 1, 'asr.models[].upstreams must contain at least 1 entry')),
})
export const llmRouterDefaultsSchema = optional(
object({
perAttemptTimeoutMs: optional(number(), 30000),
@@ -109,6 +120,9 @@ export const llmRouterConfigSchema = object({
tts: object({
models: record(string(), ttsModelSchema),
}),
asr: optional(object({
models: record(string(), asrModelSchema),
})),
defaults: llmRouterDefaultsSchema,
})
@@ -2,7 +2,7 @@ import type Redis from 'ioredis'
import type { InferOutput } from 'valibot'
import type { EnvelopeCrypto } from '../../../../utils/envelope-crypto'
import type { ConfigKVService, llmModelSchema, llmRouterConfigSchema, ttsModelSchema, unspeechUpstreamSchema } from '../../../adapters/config-kv'
import type { asrModelSchema, ConfigKVService, llmModelSchema, llmRouterConfigSchema, ttsModelSchema, unspeechUpstreamSchema } from '../../../adapters/config-kv'
import { useLogger } from '@guiiai/logg'
@@ -24,6 +24,7 @@ const DEFAULT_KEY_ENTRY_IDS = {
'dashscope-cosyvoice': 'dashscope-tts-prod-1',
'stepfun': 'stepfun-tts-prod-1',
'unspeech': 'volcengine-prod-1',
'aliyun-nls-asr': 'aliyun-nls-asr-prod-1',
} as const
const DEFAULT_FALLBACK_TRIGGERS = {
@@ -34,6 +35,7 @@ const DEFAULT_FALLBACK_TRIGGERS = {
type LlmRouterConfig = InferOutput<typeof llmRouterConfigSchema>
type LlmModel = InferOutput<typeof llmModelSchema>
type TtsModel = InferOutput<typeof ttsModelSchema>
type AsrModel = InferOutput<typeof asrModelSchema>
type UnspeechUpstream = InferOutput<typeof unspeechUpstreamSchema>
type KeyEntry = LlmModel['upstreams'][number]['keys'][number]
@@ -50,6 +52,7 @@ export type SliceInput
| AzureSliceInput
| DashscopeSliceInput
| StepfunSliceInput
| AliyunNlsAsrSliceInput
| UnspeechSliceInput
export interface OpenRouterSliceInput {
@@ -138,6 +141,24 @@ export interface UnspeechSliceInput {
}
}
export interface AliyunNlsAsrSliceInput {
kind: 'aliyun-nls-asr'
/** Key under `LLM_ROUTER_CONFIG.asr.models`; the official client currently uses `auto`. */
modelName: string
/** Aliyun AccessKey ID used for token signing. Stored in adapterParams, not encrypted. */
accessKeyId: string
/** Aliyun NLS app key. Stored in adapterParams, not encrypted. */
appKey: string
/** Aliyun NLS region; defaults to cn-shanghai. */
region?: 'cn-shanghai' | 'cn-shanghai-internal' | 'cn-beijing' | 'cn-beijing-internal' | 'cn-shenzhen' | 'cn-shenzhen-internal'
/** Aliyun AccessKey secret. Encrypted in-place; never echoed back. */
plaintextKey?: string
/** @default 'aliyun-nls-asr-prod-1' */
keyEntryId?: string
/** Existing key entry to preserve when `plaintextKey` is omitted. */
existingKeyEntryId?: string
}
interface LlmModelSlice {
target: 'llm-router'
surface: 'llm'
@@ -156,6 +177,15 @@ interface TtsModelSlice {
keyEntryId: string
}
interface AsrModelSlice {
target: 'llm-router'
surface: 'asr'
kind: 'aliyun-nls-asr'
modelName: string
model: AsrModel
keyEntryId: string
}
interface UnspeechSlice {
target: 'unspeech'
kind: 'unspeech'
@@ -164,7 +194,7 @@ interface UnspeechSlice {
keyEntryId: string | null
}
type BuiltSlice = LlmModelSlice | TtsModelSlice | UnspeechSlice
type BuiltSlice = LlmModelSlice | TtsModelSlice | AsrModelSlice | UnspeechSlice
/**
* Encrypts an OpenRouter slice into the LLM_ROUTER_CONFIG.llm shape.
@@ -310,6 +340,43 @@ export function buildStepfunSlice(input: StepfunSliceInput, envelope: EnvelopeCr
}
}
/**
* Encrypts an Aliyun NLS ASR slice into the LLM_ROUTER_CONFIG.asr shape.
*
* Use when:
* - Admin posts an `aliyun-nls-asr` slice for the official realtime
* transcription proxy.
*
* Expects:
* - `plaintextKey` is the Aliyun AccessKey secret. `accessKeyId` and `appKey`
* are non-secret routing params stored in `adapterParams`.
*/
export function buildAliyunNlsAsrSlice(input: AliyunNlsAsrSliceInput, envelope: EnvelopeCrypto): AsrModelSlice {
const keyEntryId = input.keyEntryId ?? DEFAULT_KEY_ENTRY_IDS['aliyun-nls-asr']
const ciphertext = envelope.encryptKey(requiredPlaintextKey(input.plaintextKey, input.kind), {
modelName: input.modelName,
keyEntryId,
})
return {
target: 'llm-router',
surface: 'asr',
kind: 'aliyun-nls-asr',
modelName: input.modelName,
keyEntryId,
model: {
provider: 'aliyun-nls',
upstreams: [{
keys: [{ id: keyEntryId, ciphertext }],
adapterParams: {
accessKeyId: input.accessKeyId,
appKey: input.appKey,
region: input.region ?? 'cn-shanghai',
},
}],
},
}
}
/**
* Encrypts an unspeech slice into the UNSPEECH_UPSTREAM shape.
*
@@ -486,6 +553,32 @@ function buildStepfunSlicePreservingKey(input: StepfunSliceInput, envelope: Enve
}
}
function buildAliyunNlsAsrSlicePreservingKey(input: AliyunNlsAsrSliceInput, envelope: EnvelopeCrypto, existing: AsrModel | undefined): AsrModelSlice {
if (input.plaintextKey?.trim())
return buildAliyunNlsAsrSlice(input, envelope)
const existingUpstream = existing?.upstreams[0]
const key = preservedKeyOrThrow(existingUpstream, input.existingKeyEntryId ?? input.keyEntryId, input.kind)
return {
target: 'llm-router',
surface: 'asr',
kind: 'aliyun-nls-asr',
modelName: input.modelName,
keyEntryId: key.id,
model: {
provider: 'aliyun-nls',
upstreams: [{
keys: [key],
adapterParams: {
accessKeyId: input.accessKeyId,
appKey: input.appKey,
region: input.region ?? stringFromRecord(existingUpstream?.adapterParams, 'region') ?? 'cn-shanghai',
},
}],
},
}
}
function buildUnspeechSlicePreservingKey(input: UnspeechSliceInput, envelope: EnvelopeCrypto, existing: UnspeechUpstream | undefined | null): UnspeechSlice {
if (!input.streaming || input.streaming.plaintextKey?.trim())
return buildUnspeechSlice(input, envelope)
@@ -532,6 +625,8 @@ export function buildSlice(
return buildDashscopeSlicePreservingKey(input, envelope, existing?.routerConfig?.tts.models[input.modelName])
case 'stepfun':
return buildStepfunSlicePreservingKey(input, envelope, existing?.routerConfig?.tts.models[input.modelName])
case 'aliyun-nls-asr':
return buildAliyunNlsAsrSlicePreservingKey(input, envelope, existing?.routerConfig?.asr?.models[input.modelName])
case 'unspeech':
return buildUnspeechSlicePreservingKey(input, envelope, existing?.unspeech)
}
@@ -558,18 +653,22 @@ export function buildSlice(
export function buildNextRouterConfig(
mode: 'merge' | 'reset',
existing: LlmRouterConfig | null | undefined,
slices: (LlmModelSlice | TtsModelSlice)[],
slices: (LlmModelSlice | TtsModelSlice | AsrModelSlice)[],
): LlmRouterConfig {
const llmModels: Record<string, LlmModel>
= mode === 'merge' && existing?.llm?.models ? { ...existing.llm.models } : {}
const ttsModels: Record<string, TtsModel>
= mode === 'merge' && existing?.tts?.models ? { ...existing.tts.models } : {}
const asrModels: Record<string, AsrModel>
= mode === 'merge' && existing?.asr?.models ? { ...existing.asr.models } : {}
for (const slice of slices) {
if (slice.surface === 'llm')
llmModels[slice.modelName] = slice.model
else
else if (slice.surface === 'tts')
ttsModels[slice.modelName] = slice.model
else
asrModels[slice.modelName] = slice.model
}
// Defaults live alongside the models but aren't editable through this
@@ -582,6 +681,7 @@ export function buildNextRouterConfig(
return {
llm: { models: llmModels },
tts: { models: ttsModels },
asr: { models: asrModels },
defaults,
}
}
@@ -628,7 +728,7 @@ export interface ApplyInput {
export interface AppliedSummary {
kind: SliceInput['kind']
target: 'llm-router' | 'unspeech'
surface?: 'llm' | 'tts'
surface?: 'llm' | 'tts' | 'asr'
modelName?: string
keyEntryId: string | null
}
@@ -678,6 +778,11 @@ function slicesFromRouterConfig(config: LlmRouterConfig | null): SliceInput[] {
if (slice)
slices.push(slice)
}
for (const [modelName, model] of Object.entries(config.asr?.models ?? {})) {
const slice = asrSliceFromModel(modelName, model)
if (slice)
slices.push(slice)
}
return slices
}
@@ -743,6 +848,29 @@ function ttsSliceFromModel(modelName: string, model: TtsModel): AzureSliceInput
return null
}
function asrSliceFromModel(modelName: string, model: AsrModel): AliyunNlsAsrSliceInput | null {
const upstream = model.upstreams[0]
const key = upstream?.keys[0]
if (model.provider !== 'aliyun-nls' || !upstream || !key)
return null
const accessKeyId = stringFromRecord(upstream.adapterParams, 'accessKeyId')
const appKey = stringFromRecord(upstream.adapterParams, 'appKey')
if (!accessKeyId || !appKey)
return null
const region = stringFromRecord(upstream.adapterParams, 'region')
return {
kind: 'aliyun-nls-asr',
modelName,
accessKeyId,
appKey,
region: isAliyunNlsRegion(region) ? region : undefined,
keyEntryId: key.id,
existingKeyEntryId: key.id,
}
}
function slicesFromUnspeech(unspeech: UnspeechUpstream | null): UnspeechSliceInput[] {
if (!unspeech)
return []
@@ -779,6 +907,15 @@ function isStepfunInputModel(value: string | undefined): value is NonNullable<St
return value === 'stepaudio-2.5-tts' || value === 'step-tts-2' || value === 'step-tts-mini'
}
function isAliyunNlsRegion(value: string | undefined): value is NonNullable<AliyunNlsAsrSliceInput['region']> {
return value === 'cn-shanghai'
|| value === 'cn-shanghai-internal'
|| value === 'cn-beijing'
|| value === 'cn-beijing-internal'
|| value === 'cn-shenzhen'
|| value === 'cn-shenzhen-internal'
}
interface AdminRouterConfigDeps {
configKV: ConfigKVService
envelope: EnvelopeCrypto
@@ -875,9 +1012,9 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
if (unspeechCount > 1)
throw createBadRequestError('At most one unspeech slice per request', 'INVALID_BODY')
const hasLlmTtsInput = input.slices.some(s => s.kind !== 'unspeech')
const hasRouterInput = input.slices.some(s => s.kind !== 'unspeech')
const hasUnspeechInput = input.slices.some(s => s.kind === 'unspeech')
const shouldReadRouterConfig = hasLlmTtsInput
const shouldReadRouterConfig = hasRouterInput
&& (input.mode === 'merge' || input.slices.some(sliceNeedsExistingKey))
const shouldReadUnspeech = hasUnspeechInput
const [existingRouterConfig, existingUnspeech] = await Promise.all([
@@ -892,14 +1029,14 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
unspeech: existingUnspeech,
}))
const llmTtsSlices = built.filter((s): s is LlmModelSlice | TtsModelSlice => s.target === 'llm-router')
const routerSlices = built.filter((s): s is LlmModelSlice | TtsModelSlice | AsrModelSlice => s.target === 'llm-router')
const unspeechSlice = built.find((s): s is UnspeechSlice => s.target === 'unspeech')
// Step 2: build the next LLM_ROUTER_CONFIG tree if any LLM/TTS slice
// Step 2: build the next LLM_ROUTER_CONFIG tree if any LLM/TTS/ASR slice
// was supplied. `merge` reads existing first; `reset` skips the read.
let nextRouterConfig: LlmRouterConfig | undefined
if (llmTtsSlices.length > 0) {
nextRouterConfig = buildNextRouterConfig(input.mode, existingRouterConfig, llmTtsSlices)
if (routerSlices.length > 0) {
nextRouterConfig = buildNextRouterConfig(input.mode, existingRouterConfig, routerSlices)
}
// Step 3: build the next UNSPEECH_UPSTREAM. Streaming `models` +
@@ -7,6 +7,7 @@ import { randomBytes } from 'node:crypto'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
buildAliyunNlsAsrSlice,
buildAzureSlice,
buildDashscopeSlice,
buildNextRouterConfig,
@@ -178,6 +179,40 @@ describe('buildAzureSlice', () => {
})
})
describe('buildAliyunNlsAsrSlice', () => {
/**
* @example
* buildAliyunNlsAsrSlice({ kind: 'aliyun-nls-asr', modelName: 'auto', accessKeyId: 'ak', appKey: 'app', plaintextKey: 'secret' }, envelope)
*/
it('encrypts the access key secret under the ASR model AAD', () => {
const envelope = freshEnvelope()
const built = buildAliyunNlsAsrSlice({
kind: 'aliyun-nls-asr',
modelName: 'auto',
accessKeyId: 'ak',
appKey: 'app',
plaintextKey: 'secret',
}, envelope)
expect(built.target).toBe('llm-router')
expect(built.surface).toBe('asr')
expect(built.modelName).toBe('auto')
expect(built.keyEntryId).toBe('aliyun-nls-asr-prod-1')
expect(built.model.provider).toBe('aliyun-nls')
expect(built.model.upstreams[0].adapterParams).toEqual({
accessKeyId: 'ak',
appKey: 'app',
region: 'cn-shanghai',
})
const decrypted = envelope.decryptKey(built.model.upstreams[0].keys[0].ciphertext, {
modelName: 'auto',
keyEntryId: 'aliyun-nls-asr-prod-1',
})
expect(decrypted.toString('utf8')).toBe('secret')
})
})
describe('buildDashscopeSlice', () => {
it.each([
['intl', 'dashscope-intl.aliyuncs.com'],
@@ -7,6 +7,8 @@ import type { InferOutput } from 'valibot'
// here.
// Source: apps/server/src/services/config-kv.ts (llmRouterConfigSchema).
import type {
asrModelSchema,
asrUpstreamSchema,
fallbackTriggersSchema,
keyEntrySchema,
llmModelSchema,
@@ -48,6 +50,16 @@ export type TtsUpstream = InferOutput<typeof ttsUpstreamSchema>
*/
export type TtsModel = InferOutput<typeof ttsModelSchema>
/**
* ASR model entry — provider tag + ordered upstreams for realtime transcription.
*/
export type AsrModel = InferOutput<typeof asrModelSchema>
/**
* ASR upstream — one provider credential set plus adapter params.
*/
export type AsrUpstream = InferOutput<typeof asrUpstreamSchema>
/**
* Per-(upstream) fallback trigger config: which upstream HTTP codes should
* cause the router to move on to the next key/upstream.
@@ -5,6 +5,7 @@ import { Button, FieldInput, FieldSelect, FieldTextArea } from '@proj-airi/ui'
import { computed } from 'vue'
import {
ALIYUN_NLS_REGION_OPTIONS,
DASHSCOPE_REGION_OPTIONS,
STEPFUN_MODEL_OPTIONS,
} from '../../modules/router-config-form'
@@ -30,6 +31,8 @@ const title = computed(() => {
return 'DashScope CosyVoice'
case 'stepfun':
return 'StepFun TTS'
case 'aliyun-nls-asr':
return 'Aliyun NLS ASR'
case 'unspeech':
return 'UnSpeech'
default:
@@ -119,7 +122,16 @@ const streamingKeyPlaceholder = computed(() => {
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="stepfun-tts-prod-1" />
</div>
<div v-else :class="['space-y-4']">
<div v-else-if="slice.kind === 'aliyun-nls-asr'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
<FieldInput v-model="slice.modelName" input-class="font-mono text-xs" label="Model alias" placeholder="auto" required />
<FieldInput v-model="slice.accessKeyId" autocomplete="username" input-class="font-mono text-xs" label="Access key ID" placeholder="LTAI..." required />
<FieldInput v-model="slice.appKey" input-class="font-mono text-xs" label="App key" placeholder="nls app key" required />
<FieldSelect v-model="slice.region" label="Region" layout="vertical" :options="ALIYUN_NLS_REGION_OPTIONS" select-class="w-full" />
<FieldInput v-model="slice.plaintextKey" autocomplete="new-password" :description="providerKeyDescription" input-class="font-mono text-xs" label="Access key secret" :placeholder="providerKeyPlaceholder" required type="password" />
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="aliyun-nls-asr-prod-1" />
</div>
<div v-else-if="slice.kind === 'unspeech'" :class="['space-y-4']">
<FieldInput v-model="slice.restBaseURL" input-class="font-mono text-xs" label="REST base URL" placeholder="http://airi-unspeech.railway.internal:5933" required />
<label :class="['flex', 'items-start', 'gap-3', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-950']">
+12
View File
@@ -102,11 +102,23 @@ export interface AdminRouterUnspeechSlice {
}
}
export interface AdminRouterAliyunNlsAsrSlice {
kind: 'aliyun-nls-asr'
modelName: string
accessKeyId: string
appKey: string
region?: 'cn-shanghai' | 'cn-shanghai-internal' | 'cn-beijing' | 'cn-beijing-internal' | 'cn-shenzhen' | 'cn-shenzhen-internal'
plaintextKey?: string
keyEntryId?: string
existingKeyEntryId?: string
}
export type AdminRouterConfigSlice
= | AdminRouterOpenRouterSlice
| AdminRouterAzureSlice
| AdminRouterDashscopeSlice
| AdminRouterStepfunSlice
| AdminRouterAliyunNlsAsrSlice
| AdminRouterUnspeechSlice
export interface AdminRouterConfigRequest {
@@ -1,4 +1,5 @@
import type {
AdminRouterAliyunNlsAsrSlice,
AdminRouterAzureSlice,
AdminRouterConfigRequest,
AdminRouterConfigSlice,
@@ -14,6 +15,7 @@ export type RouterConfigMode = 'merge' | 'reset'
export type RouterSliceKind = AdminRouterConfigSlice['kind']
export type DashscopeRegion = AdminRouterDashscopeSlice['region']
export type StepfunModel = NonNullable<AdminRouterStepfunSlice['upstreamModel']>
export type AliyunNlsRegion = NonNullable<AdminRouterAliyunNlsAsrSlice['region']>
export interface RouterDefaultsDraft {
chatModel: string
@@ -80,11 +82,23 @@ export interface UnspeechSliceDraft extends SliceDraftBase {
streamingDefaultModel: string
}
export interface AliyunNlsAsrSliceDraft extends SliceDraftBase {
kind: 'aliyun-nls-asr'
modelName: string
accessKeyId: string
appKey: string
region: AliyunNlsRegion
plaintextKey: string
keyEntryId: string
existingKeyEntryId: string
}
export type RouterSliceDraft
= | OpenRouterSliceDraft
| AzureSliceDraft
| DashscopeSliceDraft
| StepfunSliceDraft
| AliyunNlsAsrSliceDraft
| UnspeechSliceDraft
export interface RouterConfigFormState {
@@ -108,6 +122,7 @@ export const ROUTER_SLICE_KIND_OPTIONS: Array<{ label: string, value: RouterSlic
{ label: 'Azure Speech', value: 'azure', description: 'Microsoft TTS model alias' },
{ label: 'DashScope CosyVoice', value: 'dashscope-cosyvoice', description: 'Alibaba TTS model alias' },
{ label: 'StepFun TTS', value: 'stepfun', description: 'StepAudio / Step TTS model alias' },
{ label: 'Aliyun NLS ASR', value: 'aliyun-nls-asr', description: 'Alibaba realtime ASR model alias' },
{ label: 'UnSpeech', value: 'unspeech', description: 'REST and optional streaming TTS upstream' },
]
@@ -122,6 +137,15 @@ export const STEPFUN_MODEL_OPTIONS: Array<{ label: string, value: StepfunModel }
{ label: 'Step TTS Mini', value: 'step-tts-mini' },
]
export const ALIYUN_NLS_REGION_OPTIONS: Array<{ label: string, value: AliyunNlsRegion }> = [
{ label: 'Shanghai', value: 'cn-shanghai' },
{ label: 'Shanghai Internal', value: 'cn-shanghai-internal' },
{ label: 'Beijing', value: 'cn-beijing' },
{ label: 'Beijing Internal', value: 'cn-beijing-internal' },
{ label: 'Shenzhen', value: 'cn-shenzhen' },
{ label: 'Shenzhen Internal', value: 'cn-shenzhen-internal' },
]
/**
* Creates the default LLM Router form state.
*
@@ -157,6 +181,7 @@ export function createRouterSliceDraft(kind: 'openrouter', id?: string): OpenRou
export function createRouterSliceDraft(kind: 'azure', id?: string): AzureSliceDraft
export function createRouterSliceDraft(kind: 'dashscope-cosyvoice', id?: string): DashscopeSliceDraft
export function createRouterSliceDraft(kind: 'stepfun', id?: string): StepfunSliceDraft
export function createRouterSliceDraft(kind: 'aliyun-nls-asr', id?: string): AliyunNlsAsrSliceDraft
export function createRouterSliceDraft(kind: 'unspeech', id?: string): UnspeechSliceDraft
export function createRouterSliceDraft(kind: RouterSliceKind, id?: string): RouterSliceDraft
export function createRouterSliceDraft(kind: RouterSliceKind, id?: string): RouterSliceDraft {
@@ -208,6 +233,18 @@ export function createRouterSliceDraft(kind: RouterSliceKind, id?: string): Rout
keyEntryId: '',
existingKeyEntryId: '',
}
case 'aliyun-nls-asr':
return {
id: sliceId,
kind,
modelName: 'auto',
accessKeyId: '',
appKey: '',
region: 'cn-shanghai',
plaintextKey: '',
keyEntryId: '',
existingKeyEntryId: '',
}
case 'unspeech':
return {
id: sliceId,
@@ -349,6 +386,14 @@ function validateSlice(slice: RouterSliceDraft, ordinal: number): string[] {
noPipe(slice.modelName, `${label}: model alias must not contain "|".`),
requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: provider key is required unless an existing key is loaded.`),
].filter(isPresent)
case 'aliyun-nls-asr':
return [
required(slice.modelName, `${label}: model alias is required.`),
noPipe(slice.modelName, `${label}: model alias must not contain "|".`),
required(slice.accessKeyId, `${label}: access key id is required.`),
required(slice.appKey, `${label}: app key is required.`),
requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: access key secret is required unless an existing key is loaded.`),
].filter(isPresent)
case 'unspeech':
return [
required(slice.restBaseURL, `${label}: REST base URL is required.`),
@@ -440,6 +485,19 @@ function sliceToRequest(slice: RouterSliceDraft): AdminRouterConfigSlice {
assignOptional(request, 'existingKeyEntryId', slice.existingKeyEntryId)
return request
}
case 'aliyun-nls-asr': {
const request: AdminRouterAliyunNlsAsrSlice = {
kind: slice.kind,
modelName: trim(slice.modelName),
accessKeyId: trim(slice.accessKeyId),
appKey: trim(slice.appKey),
region: slice.region,
}
assignOptional(request, 'plaintextKey', slice.plaintextKey)
assignOptional(request, 'keyEntryId', slice.keyEntryId)
assignOptional(request, 'existingKeyEntryId', slice.existingKeyEntryId)
return request
}
case 'unspeech': {
const request: AdminRouterUnspeechSlice = {
kind: slice.kind,
@@ -518,6 +576,17 @@ function draftFromRequestSlice(value: unknown, ordinal: number): RouterSliceDraf
draft.existingKeyEntryId = stringValue(value.existingKeyEntryId)
return draft
}
case 'aliyun-nls-asr': {
const draft = createRouterSliceDraft('aliyun-nls-asr', `imported-aliyun-nls-asr-${ordinal}`) as AliyunNlsAsrSliceDraft
draft.modelName = stringValue(value.modelName)
draft.accessKeyId = stringValue(value.accessKeyId)
draft.appKey = stringValue(value.appKey)
draft.region = isAliyunNlsRegion(value.region) ? value.region : draft.region
draft.plaintextKey = stringValue(value.plaintextKey)
draft.keyEntryId = stringValue(value.keyEntryId)
draft.existingKeyEntryId = stringValue(value.existingKeyEntryId)
return draft
}
case 'unspeech': {
const draft = createRouterSliceDraft('unspeech', `imported-unspeech-${ordinal}`) as UnspeechSliceDraft
draft.restBaseURL = stringValue(value.restBaseURL)
@@ -638,3 +707,12 @@ function stringValue(value: unknown): string {
function isStepfunModel(value: unknown): value is StepfunModel {
return value === 'stepaudio-2.5-tts' || value === 'step-tts-2' || value === 'step-tts-mini'
}
function isAliyunNlsRegion(value: unknown): value is AliyunNlsRegion {
return value === 'cn-shanghai'
|| value === 'cn-shanghai-internal'
|| value === 'cn-beijing'
|| value === 'cn-beijing-internal'
|| value === 'cn-shenzhen'
|| value === 'cn-shenzhen-internal'
}
@@ -0,0 +1,355 @@
---
title: "feat: Add Doubao ASR gateway"
type: feat
date: 2026-06-14
---
# feat: Add Doubao ASR gateway
## Summary
**Superseded direction as of 2026-06-14:** do not use Volcengine AUC as AIRI's primary realtime ASR path. AUC requires URL-based recorded-file submission and is unsuitable for low-latency Hearing. The current implementation direction is to ship an official server-side realtime ASR proxy first, starting with Aliyun NLS because AIRI already has a working streaming transcription executor, then revisit Doubao streaming ASR (`/api/v3/sauc/bigmodel_async`) as a follow-up.
Add server-side official recorded-file transcription for AIRI through Doubao/Volcengine ASR. The client uploads a recorded audio file to AIRI, AIRI authenticates the user, stages the audio behind a short-lived public URL because Volcengine AUC accepts audio URLs, submits and polls the standard AUC API, maps the result back to an OpenAI-shaped transcription response, and bills successful requests through a new STT FluxMeter debt ledger.
This plan intentionally does not add realtime streaming ASR, client-side BYOK credentials, or a multi-provider ASR pool. The first user-facing path is recorded audio file transcription through the existing Hearing module and a new Official Transcription provider.
---
## Problem Frame
AIRI already has official hosted chat and TTS providers, plus client-side transcription providers for OpenAI, OpenAI-compatible endpoints, Aliyun NLS streaming, browser Web Speech, CometAPI, MiMo, and local audio paths. It does not yet have an official AIRI-hosted ASR provider that lets normal signed-in users transcribe recordings without bringing their own ASR credentials.
The server also does not expose a mounted transcription route today. The current AIRI audio surface is `/api/v1/audio` with speech, voices, and speech model catalog routes. The OpenAI public route surface under `/api/v1/openai` is kept for actual OpenAI-compatible chat endpoints, so ASR should extend the AIRI audio surface rather than adding another extension under `/api/v1/openai`.
Volcengine's recorded-file ASR APIs are asynchronous and require an online audio URL in the submit body. That means AIRI can present a normal multipart file upload to its clients, but the server needs a transient audio staging boundary before it can call the Doubao ASR upstream.
---
## Requirements
**Product Behavior**
- R1. Signed-in AIRI users can choose an Official Transcription provider in the Hearing module and transcribe a recorded audio file without entering Volcengine credentials.
- R2. The client-facing endpoint accepts an OpenAI-shaped multipart transcription request with `file`, `model`, optional `language`, optional `response_format`, and optional provider options.
- R3. The first supported mode is recorded file transcription. Realtime streaming ASR, idle 24h batch jobs, and client BYOK credentials are out of scope for this version.
- R4. The endpoint returns `json` and best-effort `verbose_json` responses compatible with the existing Hearing confidence filter. When upstream utterances are available, map them to segments with confidence and timing where possible.
**Server Gateway**
- R5. The route lives under the AIRI audio surface as `POST /api/v1/audio/transcriptions`, not under `/api/v1/openai`.
- R6. The route uses the existing v1 gateway lifecycle: auth, session context, request id, operation middleware, config checks, product events, request logs, tracing, and metrics.
- R7. Server-managed Volcengine credentials, resource id, model name, endpoint, timeout, and retry/poll settings are configured through ConfigKV/admin surfaces. Client requests never include upstream provider keys.
- R8. AIRI stages uploaded audio to a temporary, externally reachable object URL before submitting to Volcengine AUC, then deletes or expires the object through a retention policy.
- R9. Raw audio bytes must not be written to logs, traces, product events, request logs, or metrics.
**Billing And Operations**
- R10. Successful transcription usage is billed through a new STT FluxMeter using audio duration seconds, not through minimum whole-request Flux billing.
- R11. The server performs a balance preflight before upstream spend using server-derived or server-verified audio duration metadata.
- R12. If the upstream never reaches a successful result within the synchronous poll budget, AIRI returns a clear gateway timeout/error response and does not bill the user for a successful transcription.
- R13. Admins can configure the Doubao ASR router slice and default ASR model through the existing router config admin workflow.
---
## Key Technical Decisions
- **Use AIRI's audio extension route.** Add `POST /api/v1/audio/transcriptions` beside `/api/v1/audio/speech`. This matches the current route split in `apps/server/src/routes/openai/v1/index.ts` where only actual OpenAI public endpoints stay under `/api/v1/openai`.
- **Use Volcengine standard AUC first.** The standard recorded-file API documented at `https://www.volcengine.com/docs/6561/1354868` has submit and query endpoints intended for normal recorded-file recognition. The idle variant at `https://www.volcengine.com/docs/6561/1840838` may complete within a 24h window, so it is not a good first fit for the synchronous Hearing settings test and recording workflow.
- **Expose a multipart upload to AIRI clients, stage URL internally.** The official Volcengine AUC contract requires an audio URL, so the AIRI route should hide that provider-specific detail from clients and own temporary object storage.
- **Extend `LLM_ROUTER_CONFIG` with ASR.** Add an `asr` slice beside existing `llm` and `tts` models instead of creating a separate router config key. This reuses envelope key encryption, config cache invalidation, admin preview/apply semantics, model defaults, and router ownership.
- **Add `routeAsr` rather than bypassing the router.** ASR should become a first-class gateway operation, e.g. `transcription.generate`, with its own adapter contract and metrics. This keeps chat, TTS, and ASR diagnostics consistent.
- **Use server-side duration for STT billing.** Preflight and final billing should use trusted duration derived by the server from uploaded audio metadata and/or upstream `audio_info.duration`. Do not trust a client-supplied duration for billing.
- **Prefer new-console `X-Api-Key` credentials for v1.** The standard AUC docs support `X-Api-Key`. Start there, with resource id configured per model, and defer old-console `X-Api-App-Key` plus `X-Api-Access-Key` support unless operations needs it.
- **Keep synchronous polling bounded.** The client-facing route should poll standard AUC up to a configurable budget suitable for short recordings. Long-running batch/idle jobs need a later job API or callback workflow.
---
## High-Level Technical Design
```mermaid
flowchart TB
CLIENT[Stage Hearing module] --> PROVIDER[Official Transcription provider]
PROVIDER --> ROUTE[POST /api/v1/audio/transcriptions]
ROUTE --> GW[V1 gateway operation: transcription.generate]
GW --> PARSE[Parse multipart file and options]
PARSE --> STAGE[Stage audio to temporary public URL]
STAGE --> PREFLIGHT[STT Flux preflight by duration]
PREFLIGHT --> ROUTER[llmRouter.routeAsr]
ROUTER --> ADAPTER[Doubao ASR adapter]
ADAPTER --> SUBMIT[Volcengine AUC submit]
SUBMIT --> QUERY[Volcengine AUC query polling]
QUERY --> MAP[Map text, utterances, duration]
MAP --> BILL[sttMeter.accumulate]
BILL --> RESPONSE[OpenAI-shaped transcription response]
STAGE --> CLEANUP[Best-effort delete or TTL expiry]
```
```mermaid
sequenceDiagram
participant Client as Stage UI
participant Route as AIRI audio route
participant Staging as Audio staging
participant Router as LLM router ASR
participant Doubao as Volcengine AUC
participant Billing as STT FluxMeter
Client->>Route: multipart file, model auto, response_format
Route->>Staging: upload temporary object
Staging-->>Route: short-lived audio URL
Route->>Billing: assertCanAfford(duration seconds)
Route->>Router: routeAsr(model, audio URL, options)
Router->>Doubao: submit task
Doubao-->>Router: task id in response headers
loop bounded poll
Router->>Doubao: query task
Doubao-->>Router: processing, queued, success, or error
end
Router-->>Route: text, utterances, duration
Route->>Billing: accumulate(duration seconds)
Route-->>Client: json or verbose_json transcription
Route->>Staging: best-effort cleanup
```
---
## Scope Boundaries
- In scope: server-side official recorded-file transcription, standard Volcengine AUC, authenticated AIRI audio route, temporary audio staging, STT Flux billing, admin configuration, shared Stage UI provider wiring, and docs/tests for those paths.
- Out of scope: realtime/streaming ASR, idle 24h batch mode, user-provided Volcengine credentials, ASR provider fallback pools, diarization UI, long-running job status APIs, webhook/callback processing, and client direct calls to Volcengine.
- Deferred follow-ups: idle batch transcription with background jobs, streaming ASR provider, multi-provider ASR routing, old-console Volcengine credential mode, advanced ASR options UI, and transcript persistence/history.
---
## Implementation Units
### U1. ASR config schema and router contract
- **Goal:** Make ASR a first-class router model kind beside LLM and TTS.
- **Requirements:** R5, R6, R7, R13
- **Dependencies:** None
- **Files:**
- `apps/server/src/services/adapters/config-kv.ts`
- `apps/server/src/services/domain/llm-router/config-loader.ts`
- `apps/server/src/services/domain/llm-router/router.ts`
- `apps/server/src/services/domain/llm-router/types.ts`
- `apps/server/src/services/domain/llm-router/tests/router.test.ts`
- **Approach:** Extend `LLM_ROUTER_CONFIG` with an `asr.models` record. Add `asrProviderSchema` with a first provider value of `volcengine-asr`; use Doubao ASR as the user-facing/admin label. Add `DEFAULT_ASR_MODEL`, `FLUX_PER_MINUTE_STT`, and `STT_DEBT_TTL_SECONDS` ConfigKV entries. Add `routeAsr` and an `AsrRouteContext` that carries provider, model alias, upstream model/resource id, key entry id, timeout, and poll settings.
- **Execution note:** Update config validation tests before wiring the route, because schema drift here would break admin preview/apply and runtime loading.
- **Test scenarios:**
- `LLM_ROUTER_CONFIG` accepts `llm`, `tts`, and `asr` records with independent model ids.
- Missing `DEFAULT_ASR_MODEL` makes `model: "auto"` fail with `CONFIG_NOT_SET`.
- `routeAsr` resolves model aliases, decrypts the configured key, and passes provider-specific adapter params without exposing ciphertext.
- Router cache invalidation clears ASR config together with LLM/TTS config.
- **Verification:** Typecheck and router tests prove ASR config can be loaded, validated, cached, invalidated, and routed without changing chat/TTS behavior.
### U2. Doubao ASR adapter for Volcengine standard AUC
- **Goal:** Implement the upstream submit/query adapter for recorded-file recognition.
- **Requirements:** R3, R4, R7, R12
- **Dependencies:** U1
- **Files:**
- `apps/server/src/services/adapters/asr/types.ts`
- `apps/server/src/services/adapters/asr/volcengine.ts`
- `apps/server/src/services/adapters/asr/index.ts`
- `apps/server/src/services/adapters/asr/volcengine.test.ts`
- `apps/server/src/services/domain/llm-router/router.ts`
- **Approach:** Add an adapter contract that accepts a staged `audioUrl`, file format, optional language, response format, upstream model name, and adapter params. Implement standard AUC submit/query using `https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit` and `/query` by default, with endpoint overrides for tests and operations. Read status from the documented response headers and map success, processing, queued, silent audio, invalid request, empty audio, bad format, oversize, and busy states into AIRI gateway errors.
- **Execution note:** Unit tests should mock `fetch` and exercise both header-level task status and JSON body mapping. Do not include real audio bytes in fixtures.
- **Test scenarios:**
- Submit sends `X-Api-Key`, resource id, request id, sequence header, model name, audio URL, and format.
- Query maps success to `{ text, utterances, durationMs }`.
- Processing/queued statuses continue polling until the poll budget ends.
- Silent audio returns an empty or explicit silent transcription response according to route policy.
- Invalid format, oversize, busy, and malformed upstream responses map to structured gateway errors with safe client messages and detailed server diagnostics.
- **Verification:** Adapter tests cover success, pending, timeout, and documented upstream error statuses without hitting Volcengine.
### U3. Temporary audio staging boundary
- **Goal:** Provide the short-lived public audio URL required by Volcengine without leaking raw upload handling into the route.
- **Requirements:** R8, R9, R11
- **Dependencies:** None, but this unit has an ops choice before implementation.
- **Files:**
- `apps/server/src/services/domain/audio-staging/index.ts`
- `apps/server/src/services/domain/audio-staging/index.test.ts`
- `apps/server/src/app.ts`
- `apps/server/src/services/adapters/config-kv.ts`
- docs under `apps/server/docs/ai-context/`
- **Approach:** Add an `AudioStagingService` interface with `stage({ requestId, userId, file, contentType }) -> { url, objectKey, expiresAt }` and `cleanup(objectKey)`. Implement the first concrete backend only after choosing the deployment storage target. The repository does not currently show a server-side object storage/presigned URL boundary; `unstorage` appears only as a package dependency for `packages/stage-ui`, not as server upload infrastructure.
- **Implementation prerequisite:** Choose the temporary object storage backend and SDK/config shape before coding this unit. Candidate deployment-compatible backends are Volcengine TOS, S3-compatible object storage, or Cloudflare R2. The implementation must not pick a new storage dependency without user/ops confirmation.
- **Execution note:** Keep route code dependent only on the interface, so the selected storage backend is isolated to this unit.
- **Test scenarios:**
- Staging rejects unsupported content types and files over configured size limits before upstream spend.
- Staging returns a URL and expiry without logging raw bytes.
- Cleanup runs on success, upstream error, route error, and timeout, while TTL expiry remains the safety net.
- Object keys include request id or random entropy but do not expose user email, raw filename, or transcript content.
- **Verification:** Unit tests cover interface behavior with a fake backend; integration verification for the real backend is env-guarded and uses a tiny audio fixture.
### U4. Audio transcription route and domain service
- **Goal:** Add the authenticated AIRI route that accepts uploaded recordings and coordinates parsing, staging, routing, billing, tracing, and response mapping.
- **Requirements:** R1, R2, R4, R5, R6, R8, R9, R10, R11, R12
- **Dependencies:** U1, U2, U3
- **Files:**
- `apps/server/src/routes/openai/v1/index.ts`
- `apps/server/src/routes/openai/v1/gateway.ts`
- `apps/server/src/routes/openai/v1/types.ts`
- `apps/server/src/routes/openai/v1/operations/transcription-generation/index.ts`
- `apps/server/src/services/domain/openai-transcription/index.ts`
- `apps/server/src/routes/openai/v1/route.test.ts`
- `apps/server/src/app.ts`
- **Approach:** Mirror the TTS service shape in `apps/server/src/services/domain/openai-speech/index.ts`. Parse multipart form data, resolve `model: "auto"` through `DEFAULT_ASR_MODEL`, stage the audio, derive trusted duration metadata for preflight, call `llmRouter.routeAsr`, map upstream result to OpenAI-style `json` or `verbose_json`, bill successful seconds through `sttMeter`, and emit request logs/product events/metrics. Add a route-specific upload limit so the global 1 MB body limit in `app.ts` does not silently reject normal audio files.
- **Execution note:** If reliable duration extraction requires a new dependency, pause for the storage/duration library decision rather than trusting client-provided duration.
- **Test scenarios:**
- Authenticated multipart request with `model=auto` routes to `DEFAULT_ASR_MODEL` and returns `{ text }`.
- `verbose_json` request maps upstream utterances into segments and sets duration.
- Missing file, unsupported content type, oversized file, missing config, and insufficient balance return safe structured errors.
- Upstream timeout returns a gateway timeout without calling `sttMeter.accumulate`.
- Successful transcription calls `sttMeter.accumulate` with ceil seconds derived from trusted duration.
- Cleanup is attempted for success and failure paths.
- **Verification:** Route tests cover request parsing, config resolution, billing, timeout, and response shape through mocked staging/router services.
### U5. STT billing, tracing, metrics, and request logs
- **Goal:** Add ASR-specific observability and usage accounting consistent with chat and TTS.
- **Requirements:** R6, R9, R10, R11, R12
- **Dependencies:** U4
- **Files:**
- `apps/server/src/app.ts`
- `apps/server/src/services/domain/billing/flux-meter.ts`
- `apps/server/src/services/domain/llm-tracing/index.ts`
- `apps/server/src/services/domain/product-events.ts`
- `apps/server/src/utils/observability.ts`
- `apps/server/docs/ai-context/flux-meter.md`
- `apps/server/docs/ai-context/observability-conventions.md`
- **Approach:** Instantiate `sttMeter` with `service: "stt"`, `FLUX_PER_MINUTE_STT`, and `STT_DEBT_TTL_SECONDS`. Add tracing helpers such as `startTranscriptionGeneration` and OTel operation labels for `transcription.generate`. Record low-cardinality metrics by operation, provider, model, status, and duration bucket. Request/product logs can include request id, provider, model, file metadata, duration seconds, and status, but not raw audio or full transcript unless a deliberate transcript logging policy is added later.
- **Execution note:** Avoid adding transcript text to traces by default. A transcript can contain sensitive user speech and should be treated differently from bounded diagnostic snippets.
- **Test scenarios:**
- STT debt accumulation behaves like TTS dust billing but uses seconds/minutes instead of characters.
- Successful route logs include duration seconds and Flux consumed.
- Failed and timed-out route logs include provider/status/error code without raw audio or transcript text.
- Metrics projections exclude request id, user id, raw file names, and transcript text.
- **Verification:** Existing billing tests plus new STT service tests prove sub-Flux debt accounting and safe observability projections.
### U6. Admin router config support for ASR
- **Goal:** Let operators configure Doubao ASR without editing raw ConfigKV JSON by hand.
- **Requirements:** R7, R13
- **Dependencies:** U1
- **Files:**
- `apps/server/src/routes/admin/config/router/index.ts`
- `apps/server/src/services/domain/admin/router-config/index.ts`
- `apps/server/src/services/domain/admin/router-config/index.test.ts`
- `apps/ui-admin/src/modules/api.ts`
- `apps/ui-admin/src/modules/router-config-form.ts`
- `apps/ui-admin/src/modules/router-config-form.test.ts`
- `apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue`
- **Approach:** Add an ASR slice kind `volcengine-asr` labeled as Doubao ASR in the admin UI. It compiles to `LLM_ROUTER_CONFIG.asr.models[modelName]`. Expose fields for model alias, upstream model name, resource id, endpoint overrides, API key, key entry id, timeout/poll settings, and default ASR model. Preserve the existing preview/apply/redaction flow.
- **Execution note:** Keep credential redaction server-owned. The UI should never render plaintext API keys after submit.
- **Test scenarios:**
- Admin request with one ASR slice creates encrypted key entries and an ASR model config.
- Preview redacts ASR keys and lists `LLM_ROUTER_CONFIG` plus `DEFAULT_ASR_MODEL` as touched keys.
- Reset/merge semantics preserve existing LLM/TTS config according to the current admin route behavior.
- UI builder exports/imports an ASR slice and validates missing API key, missing resource id, and invalid endpoints.
- **Verification:** Server and UI admin tests show ASR config round-trips through preview/apply without regressing existing LLM/TTS slices.
### U7. Stage UI Official Transcription provider
- **Goal:** Surface the server-side ASR route as the official provider for the Hearing module across web, Electron, and mobile shared Stage UI.
- **Requirements:** R1, R2, R3, R4
- **Dependencies:** U4
- **Files:**
- `packages/stage-ui/src/libs/providers/providers/official/index.ts`
- `packages/stage-ui/src/libs/providers/providers/official/shared.ts`
- `packages/stage-ui/src/composables/use-auth-provider-sync.ts`
- `packages/stage-ui/src/stores/providers.ts`
- `packages/stage-pages/src/pages/settings/providers/transcription/official-provider-transcription.vue`
- `packages/i18n/src/locales/*/settings.yaml`
- `packages/stage-ui/src/libs/providers/providers/official/index.test.ts`
- `packages/stage-ui/src/stores/modules/hearing.test.ts`
- **Approach:** Add `OFFICIAL_TRANSCRIPTION_PROVIDER_ID` and `providerOfficialTranscription` using `createOfficialAudioProvider()` plus `withCredentials()`. Ensure the provider's transcription method posts through the existing `@xsai/generate-transcription` flow to `/api/v1/audio/transcriptions`. Add it to auth provider sync for the `hearing` module and provide `auto` model discovery if the server exposes an ASR model catalog, or a static `auto` model if it does not. Add a simple provider settings page with the existing transcription playground and no credential fields.
- **Execution note:** The existing i18n files already contain official transcription title/description strings in at least English; verify all locales touched by provider metadata and fill missing keys centrally.
- **Test scenarios:**
- Signed-in auth sync activates official transcription for Hearing when no hearing provider is set.
- Official transcription provider injects bearer token and `x-airi-session-id`.
- A recording file uses the existing Hearing `transcribeForRecording` path and requests `model: "auto"`.
- `verbose_json` confidence filtering works when the server returns segments and produces the current unsupported warning when it does not.
- **Verification:** Stage UI unit tests cover provider config/auth and Hearing integration. Manual endpoint checks should include stage-web/shared Stage UI, Electron/Tamagotchi, and mobile-responsive settings screens.
### U8. Documentation and verification matrix
- **Goal:** Keep server docs and operational docs aligned with the new ASR surface.
- **Requirements:** R3, R5, R7, R8, R9, R10, R11, R12, R13
- **Dependencies:** U1-U7
- **Files:**
- `apps/server/docs/ai-context/architecture-overview.md`
- `apps/server/docs/ai-context/transport-and-routes.md`
- `apps/server/docs/ai-context/flux-meter.md`
- `apps/server/docs/ai-context/observability-conventions.md`
- `apps/server/docs/ai-context/verifications/doubao-asr.md`
- **Approach:** Update stale audio route documentation, record the new `/api/v1/audio/transcriptions` surface, document the temporary audio staging requirement, and add an env-guarded verification note for real Volcengine AUC tests.
- **Execution note:** Fix existing references that still mention `/api/v1/openai/audio/speech` while editing audio route docs.
- **Test scenarios:** None; this unit is documentation, but it should point to the concrete automated and env-guarded verification commands.
- **Verification:** Docs state the current route topology, config keys, billing units, privacy rules, and live-test prerequisites.
---
## Acceptance Examples
- AE1. Given a signed-in user with no hearing provider selected, when AIRI auth sync runs after login, the Hearing module selects Official Transcription with model `auto`.
- AE2. Given a short WAV recording, when the user runs the Hearing settings transcription test, the client posts multipart audio to `/api/v1/audio/transcriptions`, AIRI stages it, Doubao returns text, and the UI displays the transcript.
- AE3. Given the user enables confidence filtering, when Doubao returns utterances, AIRI maps them into `verbose_json` segments so low-confidence text can be filtered by the existing Hearing store.
- AE4. Given the user's balance cannot cover the server-derived audio duration, when they submit a recording, AIRI rejects the request before calling Volcengine.
- AE5. Given Volcengine stays queued/processing beyond the synchronous poll budget, when AIRI times out, the response is a safe gateway timeout, no successful STT billing is recorded, and the staged object is cleaned up or left to TTL expiry.
- AE6. Given an admin previews a Doubao ASR slice, when the server returns the redacted preview, the API key is not visible and touched keys include `LLM_ROUTER_CONFIG` and `DEFAULT_ASR_MODEL`.
---
## System-Wide Impact
| Surface | Impact |
|---|---|
| `apps/server` routes | Adds `POST /api/v1/audio/transcriptions` and operation `transcription.generate`; route-specific upload limit must avoid the current global 1 MB body limit problem. |
| `apps/server` router/config | Extends `LLM_ROUTER_CONFIG` with `asr`, adds ASR model defaults and adapter params. |
| Billing | Adds `sttMeter` using seconds/minutes and a sub-Flux debt ledger, parallel to TTS chars. |
| Observability | Adds ASR tracing, metrics, request logs, and product events without raw audio or transcript text by default. |
| Admin | Adds ASR slice support to router config preview/apply and UI builder modules. |
| Stage UI | Adds official transcription provider reused by stage-web, stage-tamagotchi/Electron, and stage-pocket/mobile through shared provider wiring. |
| Infrastructure | Requires temporary object storage with externally reachable URLs and a deletion/TTL policy. |
---
## Risks And Dependencies
- **Temporary storage is a real dependency.** The repo does not currently expose a server-side object storage or presigned URL service. Implementation needs an explicit backend choice before U3 can be completed.
- **Volcengine AUC is async.** A synchronous AIRI endpoint can time out even after upstream accepts the task. This is acceptable for short recordings but not for long batch transcription; batch needs a later job API.
- **Preflight billing depends on trusted duration.** Billing should not trust client duration. If existing server code cannot derive audio duration, implementation must choose a duration extraction strategy before upstream spend.
- **Provider cost can occur before success.** On timeout, AIRI may have spent upstream quota without a successful client response. Keep the poll budget and file duration limits conservative for v1.
- **Audio privacy matters.** Temporary objects must expire quickly, object keys must not expose user data, logs must not include raw audio or transcripts, and cleanup must run on all route exits.
- **Route body limits need care.** The current global server body limit is too small for normal audio uploads. The route needs explicit multipart handling and upload limits so failures are intentional and explainable.
- **Credential/resource id mismatch is easy.** Volcengine standard AUC supports different resource ids for model versions. Admin validation and docs should make `volc.seedasr.auc` versus `volc.bigasr.auc` explicit.
---
## Open Questions
- Which temporary object storage backend should AIRI use for ASR audio staging in production: Volcengine TOS, S3-compatible storage, Cloudflare R2, or an existing internal upload service not present in this repo?
- Should v1 support only new-console `X-Api-Key`, or must it also support old-console `X-Api-App-Key` plus `X-Api-Access-Key` credentials?
- What should the initial synchronous poll budget and maximum accepted recording duration be for stage settings tests and normal Hearing usage?
- Which server-side duration extraction strategy is acceptable for preflight billing if no existing duration parser is available?
- Should transcript text be excluded from all server-side logs/traces by default, or should there be an explicit debug-only redacted transcript policy?
---
## Sources And Research
- `apps/server/src/routes/openai/v1/index.ts` defines the current route split: `/api/v1/openai` for OpenAI chat and `/api/v1/audio` for AIRI audio extensions.
- `apps/server/src/routes/openai/v1/gateway.ts` currently lists `chat.completions` and `speech.generate`; ASR needs a new operation.
- `apps/server/src/services/domain/openai-speech/index.ts` is the closest domain-service pattern for routing, tracing, billing, request logs, product events, and metrics.
- `apps/server/src/services/adapters/config-kv.ts` owns `LLM_ROUTER_CONFIG`, `DEFAULT_TTS_MODEL`, and TTS billing config; ASR config belongs near these definitions.
- `apps/server/docs/ai-context/flux-meter.md` and `apps/server/docs/ai-context/billing-architecture.md` already describe STT as a sub-Flux service category.
- `packages/stage-ui/src/stores/modules/hearing.ts` uses `@xsai/generate-transcription` for file-based transcription and already handles `json`/`verbose_json`.
- `packages/stage-ui/src/libs/providers/providers/official/index.ts` and `shared.ts` show how official providers attach AIRI auth and use `/api/v1/audio`.
- `packages/stage-ui/src/composables/use-auth-provider-sync.ts` is the auth-driven provider activation point that needs Hearing support.
- Volcengine standard recorded-file ASR docs: `https://www.volcengine.com/docs/6561/1354868`
- Volcengine idle recorded-file ASR docs: `https://www.volcengine.com/docs/6561/1840838`
@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import {
TranscriptionPlayground,
TranscriptionProviderSettings,
} from '@proj-airi/stage-ui/components'
import { OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '@proj-airi/stage-ui/libs/providers'
import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
const hearingStore = useHearingStore()
const providersStore = useProvidersStore()
const providerId = OFFICIAL_TRANSCRIPTION_PROVIDER_ID
const defaultModel = 'auto'
async function handleGenerateTranscription(file: File) {
const provider = await providersStore.getProviderInstance<TranscriptionProviderWithExtraOptions<string, Record<string, unknown>>>(providerId)
if (!provider)
throw new Error('Failed to initialize official transcription provider')
return await hearingStore.transcription(
providerId,
provider,
defaultModel,
file,
'json',
)
}
</script>
<template>
<TranscriptionProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
>
<template #playground>
<TranscriptionPlayground
:generate-transcription="handleGenerateTranscription"
:api-key-configured="true"
/>
</template>
</TranscriptionProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,7 +1,7 @@
import { nextTick } from 'vue'
import { initializeAuth } from '../libs/auth'
import { getStreamingTtsAvailable } from '../libs/providers'
import { getStreamingTtsAvailable, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../libs/providers'
import { useAuthStore } from '../stores/auth'
import { useConsciousnessStore } from '../stores/modules/consciousness'
import { useHearingStore } from '../stores/modules/hearing'
@@ -15,6 +15,7 @@ import { useProvidersStore } from '../stores/providers'
const AUTH_ACTIVATED_PROVIDERS: Array<{ id: string, module: 'consciousness' | 'speech' | 'hearing' }> = [
{ id: 'official-provider', module: 'consciousness' },
{ id: 'official-provider-speech', module: 'speech' },
{ id: OFFICIAL_TRANSCRIPTION_PROVIDER_ID, module: 'hearing' },
]
// The streaming TTS provider is NOT in the static list above because its
@@ -37,6 +37,7 @@ import './official'
export {
getDefaultStreamingModel,
getStreamingTtsAvailable,
OFFICIAL_TRANSCRIPTION_PROVIDER_ID,
} from './official'
export {
@@ -2,7 +2,7 @@ import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import { describe, expect, it } from 'vitest'
import { providerOfficialSpeech } from './index'
import { OFFICIAL_TRANSCRIPTION_PROVIDER_ID, providerOfficialSpeech, providerOfficialTranscription } from './index'
interface OfficialSpeechOptions {
speed?: number
@@ -40,3 +40,43 @@ describe('official speech provider', () => {
expect(request.fetch).toBeTypeOf('function')
})
})
describe('official transcription provider', () => {
/**
* @example
* provider.transcription('auto')
*/
it('builds an authenticated streaming transcription request for the server audio surface', () => {
const provider = providerOfficialTranscription.createProvider({}) as {
transcription: (model: string) => {
baseURL: URL
fetch?: typeof fetch
model: string
}
}
const request = provider.transcription('auto')
expect(OFFICIAL_TRANSCRIPTION_PROVIDER_ID).toBe('official-provider-transcription')
expect(request.model).toBe('auto')
expect(request.baseURL.pathname).toBe('/api/v1/audio/transcriptions/stream')
expect(request.fetch).toBeTypeOf('function')
})
/**
* @example
* providerOfficialTranscription.extraMethods.listModels()
*/
it('lists the auto realtime model without calling a provider credential flow', async () => {
const models = await providerOfficialTranscription.extraMethods?.listModels?.({}, providerOfficialTranscription.createProvider({}))
expect(models).toEqual([
{
id: 'auto',
name: 'Auto',
provider: OFFICIAL_TRANSCRIPTION_PROVIDER_ID,
description: 'Realtime transcription routed by AIRI',
},
])
})
})
@@ -12,6 +12,7 @@ import { createOfficialAudioProvider, createOfficialOpenAIProvider, OFFICIAL_ICO
export const OFFICIAL_SPEECH_PROVIDER_ID = 'official-provider-speech'
export const OFFICIAL_SPEECH_STREAMING_PROVIDER_ID = 'official-provider-speech-streaming'
export const OFFICIAL_TRANSCRIPTION_PROVIDER_ID = 'official-provider-transcription'
// Locale → voice id map recommended by the server, keyed by provider id.
// Populated by each speech provider's listVoices() from the response's
@@ -324,6 +325,47 @@ export const providerOfficialSpeechStreaming = defineProvider({
},
})
export const providerOfficialTranscription = defineProvider({
id: OFFICIAL_TRANSCRIPTION_PROVIDER_ID,
order: -1,
name: 'Official Transcription Provider',
nameLocalize: ({ t }) => t('settings.pages.providers.provider.official.transcription-title'),
description: 'Official realtime speech-to-text provider by AIRI.',
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.official.transcription-description'),
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt', 'streaming-transcription'],
icon: OFFICIAL_ICON,
requiresCredentials: false,
capabilities: {
transcription: {
protocol: 'http',
generateOutput: false,
streamOutput: true,
streamInput: true,
},
},
createProviderConfig: () => officialConfigSchema,
createProvider(_config) {
return {
transcription: (model: string) => ({
baseURL: new URL(`${SERVER_URL}/api/v1/audio/transcriptions/stream`),
fetch: withCredentials(),
model,
}),
}
},
validationRequiredWhen: () => false,
extraMethods: {
listModels: async (): Promise<ModelInfo[]> => [
{
id: 'auto',
name: 'Auto',
provider: OFFICIAL_TRANSCRIPTION_PROVIDER_ID,
description: 'Realtime transcription routed by AIRI',
},
],
},
})
const LOCALE_SEPARATOR_RE = /[-_]/
function languagePrefix(locale: string): string {
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { filterTranscriptionByConfidence } from './hearing'
import { filterTranscriptionByConfidence, resolveStreamTranscriptionExecutor } from './hearing'
describe('filterTranscriptionByConfidence', () => {
const segments = [
@@ -29,3 +29,15 @@ describe('filterTranscriptionByConfidence', () => {
expect(filterTranscriptionByConfidence([{ text: ' hello ', avg_logprob: -0.5 }], -1)).toBe('hello')
})
})
describe('resolveStreamTranscriptionExecutor', () => {
/**
* @example
* resolveStreamTranscriptionExecutor('official-provider-transcription')
*/
it('routes the official transcription provider through the Aliyun streaming executor', () => {
const executor = resolveStreamTranscriptionExecutor('official-provider-transcription')
expect(executor).toBe(resolveStreamTranscriptionExecutor('aliyun-nls-transcription'))
})
})
@@ -15,6 +15,7 @@ import vadWorkletUrl from '../../workers/vad/process.worklet?worker&url'
import { useAnalytics } from '../../composables/use-analytics'
import { activeTurnSpan, startSpan } from '../../composables/use-io-tracer'
import { OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../../libs/providers'
import { useProvidersStore } from '../providers'
import { streamAliyunTranscription } from '../providers/aliyun/stream-transcription'
import { streamWebSpeechAPITranscription } from '../providers/web-speech-api'
@@ -95,9 +96,14 @@ export function filterTranscriptionByConfidence(
const STREAM_TRANSCRIPTION_EXECUTORS: Record<string, StreamTranscription> = {
'aliyun-nls-transcription': streamAliyunTranscription,
[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]: streamAliyunTranscription,
// Web Speech API is handled specially in transcribeForMediaStream since it works directly with MediaStream
}
export function resolveStreamTranscriptionExecutor(providerId: string): StreamTranscription | undefined {
return STREAM_TRANSCRIPTION_EXECUTORS[providerId]
}
export const useHearingStore = defineStore('hearing-store', () => {
const providersStore = useProvidersStore()
const { allAudioTranscriptionProvidersMetadata } = storeToRefs(providersStore)
@@ -193,7 +199,7 @@ export const useHearingStore = defineStore('hearing-store', () => {
inputAudioStream?: ReadableStream<ArrayBuffer>
}
const features = providersStore.getTranscriptionFeatures(providerId)
const streamExecutor = STREAM_TRANSCRIPTION_EXECUTORS[providerId]
const streamExecutor = resolveStreamTranscriptionExecutor(providerId)
const { trackSttStarted, trackSttSucceeded, trackSttFailed } = useAnalytics()
const sttStartedAt = performance.now()