164 lines
7.2 KiB
TypeScript
164 lines
7.2 KiB
TypeScript
import {
|
|
createDeepgramProvider,
|
|
createGatewayProvider,
|
|
createGroqProvider,
|
|
createOpenAiProvider,
|
|
createSttProviders,
|
|
type SttProviderInput,
|
|
type SttProviderOptions,
|
|
} from './providers.ts'
|
|
|
|
function assert(condition: boolean, message: string): asserts condition {
|
|
if (!condition) throw new Error(message)
|
|
}
|
|
|
|
function assertEquals(actual: unknown, expected: unknown, message: string): void {
|
|
const a = JSON.stringify(actual)
|
|
const e = JSON.stringify(expected)
|
|
if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
|
|
}
|
|
|
|
const INPUT: SttProviderInput = {
|
|
audio: new Blob([new Uint8Array(8_000)], { type: 'audio/ogg' }),
|
|
fileName: 'audio.ogg',
|
|
contentType: 'audio/ogg',
|
|
languageCode: 'ko',
|
|
hints: { keyterms: ['D3RO'], prompt: 'D3RO' },
|
|
}
|
|
|
|
const INTERNAL_URL = 'https://nas.internal.example/api/stt/internal/transcribe'
|
|
|
|
function options(fetchImpl: typeof fetch, logs: Array<Record<string, unknown>> = []): SttProviderOptions {
|
|
return { fetchImpl, logger: { warn: (_message, meta) => logs.push(meta) } }
|
|
}
|
|
|
|
function respond(status: number, body: unknown): typeof fetch {
|
|
return () =>
|
|
Promise.resolve(
|
|
new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }),
|
|
)
|
|
}
|
|
|
|
Deno.test('a 2xx empty transcript is no_speech for every provider, never a failure', async () => {
|
|
const cases = [
|
|
createGatewayProvider({ url: 'https://gw.example', token: 't', hasDirectFallback: true }, options(respond(200, { text: '' }))),
|
|
createGroqProvider('k', options(respond(200, { text: ' ' }))),
|
|
createOpenAiProvider('k', options(respond(200, { text: '' }))),
|
|
createDeepgramProvider('k', options(respond(200, {
|
|
results: { channels: [{ alternatives: [{ transcript: '', confidence: 0 }] }] },
|
|
}))),
|
|
]
|
|
for (const provider of cases) {
|
|
assertEquals(await provider.transcribe(INPUT), { kind: 'no_speech' }, `${provider.id} empty transcript`)
|
|
}
|
|
})
|
|
|
|
Deno.test('a 2xx transcript is normalized into an ok result', async () => {
|
|
const groq = createGroqProvider('k', options(respond(200, { text: ' 안녕 ', language: 'korean', duration: 2 })))
|
|
assertEquals(await groq.transcribe(INPUT), {
|
|
kind: 'ok',
|
|
result: { transcript: '안녕', confidence: 0.98, language_code: 'ko', duration_seconds: 2, provider: 'groq' },
|
|
}, 'groq result')
|
|
|
|
const deepgram = createDeepgramProvider('k', options(respond(200, {
|
|
metadata: { duration: 3 },
|
|
results: { channels: [{ detected_language: 'en', alternatives: [{ transcript: 'hi', confidence: 0.9 }] }] },
|
|
})))
|
|
assertEquals(await deepgram.transcribe(INPUT), {
|
|
kind: 'ok',
|
|
result: { transcript: 'hi', confidence: 0.9, language_code: 'en', duration_seconds: 3, provider: 'deepgram' },
|
|
}, 'deepgram result')
|
|
})
|
|
|
|
Deno.test('a network error leaks neither the message nor the internal gateway URL', async () => {
|
|
const logs: Array<Record<string, unknown>> = []
|
|
const throwing: typeof fetch = () =>
|
|
Promise.reject(new TypeError(`error sending request for url (${INTERNAL_URL}): connection refused`))
|
|
const gateway = createGatewayProvider(
|
|
{ url: 'https://nas.internal.example', token: 't', hasDirectFallback: true },
|
|
options(throwing, logs),
|
|
)
|
|
const outcome = await gateway.transcribe(INPUT)
|
|
assertEquals(outcome, { kind: 'retryable', failure: 'network_error', billed: false, unavailable: false }, 'fixed code')
|
|
assert(!JSON.stringify(outcome).includes('nas.internal'), 'internal host must not reach the outcome')
|
|
assert(String(logs[0]?.message).includes('nas.internal'), 'raw message stays in the server log')
|
|
})
|
|
|
|
Deno.test('timeouts, http failures and gateway 503 map to fixed codes', async () => {
|
|
const timeout: typeof fetch = () => Promise.reject(new DOMException('signal timed out', 'TimeoutError'))
|
|
assertEquals(
|
|
await createOpenAiProvider('k', options(timeout)).transcribe(INPUT),
|
|
{ kind: 'retryable', failure: 'timeout', billed: false, unavailable: false },
|
|
'timeout',
|
|
)
|
|
assertEquals(
|
|
await createGroqProvider('k', options(respond(429, { error: { message: 'org-SECRET' } }))).transcribe(INPUT),
|
|
{ kind: 'retryable', failure: 'http_429', billed: false, unavailable: false },
|
|
'429',
|
|
)
|
|
const gateway = createGatewayProvider({ url: 'https://gw.example', token: 't', hasDirectFallback: false }, options(respond(503, 'down')))
|
|
assertEquals(
|
|
await gateway.transcribe(INPUT),
|
|
{ kind: 'retryable', failure: 'http_503', billed: false, unavailable: true },
|
|
'gateway 503 is unavailable',
|
|
)
|
|
assertEquals(
|
|
await createGroqProvider('k', options(respond(503, 'down'))).transcribe(INPUT),
|
|
{ kind: 'retryable', failure: 'http_503', billed: false, unavailable: false },
|
|
'direct provider 503 is a failure',
|
|
)
|
|
})
|
|
|
|
Deno.test('a malformed 2xx body is a billed invalid_response', async () => {
|
|
for (const body of ['not json {', { nope: true }, { text: 'hi', language: 'korean', duration: 'long' }]) {
|
|
assertEquals(
|
|
await createOpenAiProvider('k', options(respond(200, body))).transcribe(INPUT),
|
|
{ kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false },
|
|
`malformed body ${JSON.stringify(body)}`,
|
|
)
|
|
}
|
|
})
|
|
|
|
Deno.test('a misconfigured gateway URL is reported as misconfigured without calling fetch', async () => {
|
|
let called = false
|
|
const gateway = createGatewayProvider(
|
|
{ url: 'http://public.example', token: 't', hasDirectFallback: true },
|
|
options(() => {
|
|
called = true
|
|
return Promise.resolve(new Response('{}'))
|
|
}),
|
|
)
|
|
assertEquals(
|
|
await gateway.transcribe(INPUT),
|
|
{ kind: 'retryable', failure: 'misconfigured', billed: false, unavailable: false },
|
|
'public http gateway rejected',
|
|
)
|
|
assert(!called, 'gateway token never sent over public http')
|
|
})
|
|
|
|
Deno.test('gateway uses the dedicated token header on the canonical internal URL', async () => {
|
|
const seen: Array<{ url: string; headers: HeadersInit | undefined }> = []
|
|
const capture = ((url: string | URL | Request, init?: { headers?: HeadersInit }) => {
|
|
seen.push({ url: String(url), headers: init?.headers })
|
|
return Promise.resolve(new Response(JSON.stringify({ text: 'hi', language: 'ko' })))
|
|
}) as typeof fetch
|
|
const gateway = createGatewayProvider({ url: 'https://gw.example/ignored', token: 'gw-token', hasDirectFallback: true }, options(capture))
|
|
const outcome = await gateway.transcribe(INPUT)
|
|
assertEquals(outcome.kind, 'ok', 'gateway success')
|
|
assertEquals(seen, [{
|
|
url: 'https://gw.example/api/stt/internal/transcribe',
|
|
headers: { 'X-D3RO-STT-Gateway-Token': 'gw-token' },
|
|
}], 'canonical URL and header, no user JWT')
|
|
})
|
|
|
|
Deno.test('provider chain keeps the gateway → groq → openai → deepgram order and skips unconfigured ones', () => {
|
|
const fetchImpl = respond(200, { text: 'x' })
|
|
const all = createSttProviders({
|
|
gatewayUrl: 'https://gw.example', gatewayToken: 't', groqKey: 'g', openaiKey: 'o', deepgramKey: 'd',
|
|
}, options(fetchImpl))
|
|
assertEquals(all.map((p) => p.id), ['gateway', 'groq', 'openai', 'deepgram'], 'full order')
|
|
const noToken = createSttProviders({
|
|
gatewayUrl: 'https://gw.example', gatewayToken: '', groqKey: '', openaiKey: 'o', deepgramKey: '',
|
|
}, options(fetchImpl))
|
|
assertEquals(noToken.map((p) => p.id), ['openai'], 'gateway is skipped without its own token')
|
|
})
|