fix(stt-proxy): stop paid fan-out on empty transcripts and redact provider errors
This commit is contained in:
parent
1a4c39cb94
commit
1eb22af1f3
9 changed files with 1107 additions and 380 deletions
|
|
@ -204,3 +204,63 @@ export function createDeepgramSttUrl(languageCode: string, keyterms: readonly st
|
|||
for (const keyterm of keyterms.slice(0, 50)) url.searchParams.append('keyterm', keyterm)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed failure codes reported to callers in `attempts`. Never a raw exception
|
||||
* message: Deno fetch errors carry the request URL (the internal gateway host)
|
||||
* and JSON parse errors quote part of the provider body.
|
||||
*/
|
||||
export type SttFailureCode =
|
||||
| 'timeout'
|
||||
| 'network_error'
|
||||
| 'invalid_response'
|
||||
| 'misconfigured'
|
||||
| 'error'
|
||||
| `http_${number}`
|
||||
|
||||
/**
|
||||
* What one provider call produced.
|
||||
*
|
||||
* - `ok`: a 2xx answer with a real transcript.
|
||||
* - `no_speech`: a 2xx answer with an empty transcript. The provider has billed the
|
||||
* audio, so this is terminal: it is never retried on the next paid provider.
|
||||
* - `retryable`: transport error, non-2xx status or a malformed 2xx body. `billed`
|
||||
* is true when the provider answered 2xx (it charged us even though the body was
|
||||
* unusable), so the quota reservation must be consumed, not refunded.
|
||||
* `unavailable` marks an explicit "not available" answer (gateway 503).
|
||||
*/
|
||||
export type SttOutcome =
|
||||
| { kind: 'ok'; result: NormalizedSttResult }
|
||||
| { kind: 'no_speech' }
|
||||
| { kind: 'retryable'; failure: SttFailureCode; billed: boolean; unavailable: boolean }
|
||||
|
||||
export function sttHttpFailure(status: number): SttFailureCode {
|
||||
return `http_${Math.trunc(status)}`
|
||||
}
|
||||
|
||||
/** Map a thrown transport error to a fixed code. The error text is never kept. */
|
||||
export function sttTransportFailure(err: unknown): SttFailureCode {
|
||||
if (!(err instanceof Error)) return 'error'
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'timeout'
|
||||
// Deno fetch rejects DNS, connection and TLS failures with a TypeError.
|
||||
if (err.name === 'TypeError') return 'network_error'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a 2xx provider answer. The provider has already billed the audio, so
|
||||
* every outcome here is `billed`. An empty transcript is a genuine "no speech"
|
||||
* result; normalizeSttResult stays strict and is only used for real transcripts.
|
||||
*/
|
||||
export function sttOutcomeFromBilledAnswer(candidate: Readonly<Record<string, unknown>>): SttOutcome {
|
||||
const transcript = candidate.transcript
|
||||
if (typeof transcript !== 'string') {
|
||||
return { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false }
|
||||
}
|
||||
if (!transcript.trim()) return { kind: 'no_speech' }
|
||||
try {
|
||||
return { kind: 'ok', result: normalizeSttResult(candidate) }
|
||||
} catch {
|
||||
return { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
server/supabase/functions/_shared/stt-outcome.test.ts
Normal file
37
server/supabase/functions/_shared/stt-outcome.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { sttHttpFailure, sttOutcomeFromBilledAnswer, sttTransportFailure } from './stt-contract.ts'
|
||||
|
||||
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 valid = { confidence: 0.9, language_code: 'ko', duration_seconds: 1, provider: 'groq' }
|
||||
|
||||
Deno.test('a billed empty transcript is no_speech, not a failure', () => {
|
||||
assertEquals(sttOutcomeFromBilledAnswer({ ...valid, transcript: '' }), { kind: 'no_speech' }, 'empty')
|
||||
assertEquals(sttOutcomeFromBilledAnswer({ ...valid, transcript: ' \n ' }), { kind: 'no_speech' }, 'whitespace')
|
||||
})
|
||||
|
||||
Deno.test('a billed answer without a usable transcript is a billed invalid_response', () => {
|
||||
const invalid = { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false }
|
||||
assertEquals(sttOutcomeFromBilledAnswer({ ...valid, transcript: 42 }), invalid, 'non-string transcript')
|
||||
assertEquals(sttOutcomeFromBilledAnswer({ ...valid, transcript: 'hi', confidence: 2 }), invalid, 'bad confidence')
|
||||
})
|
||||
|
||||
Deno.test('a billed real transcript is normalized', () => {
|
||||
assertEquals(
|
||||
sttOutcomeFromBilledAnswer({ ...valid, transcript: ' hi ' }),
|
||||
{ kind: 'ok', result: { transcript: 'hi', ...valid } },
|
||||
'ok',
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('transport failures map to fixed codes only', () => {
|
||||
assertEquals(sttTransportFailure(new TypeError('error sending request for url (https://internal/x)')), 'network_error', 'network')
|
||||
assertEquals(sttTransportFailure(new DOMException('t', 'TimeoutError')), 'timeout', 'timeout')
|
||||
assertEquals(sttTransportFailure(new DOMException('a', 'AbortError')), 'timeout', 'abort')
|
||||
assertEquals(sttTransportFailure(new SyntaxError('Unexpected token < in JSON: <html>secret')), 'error', 'other')
|
||||
assertEquals(sttTransportFailure('boom'), 'error', 'non-error')
|
||||
assertEquals(sttHttpFailure(502), 'http_502', 'http')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue