feat(server): add Aliyun NLS ASR gateway and official transcription provider (#1970)
This commit is contained in:
@@ -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']">
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user