fix(edge): stop short clips waiting a minute on the NAS and make the OpenAI STT fallback usable
All checks were successful
ci / 정본·보안·린트·타입·테스트 (push) Successful in 52s
ci / 모바일 린트·타입·Jest (push) Successful in 41s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 22s
ci / .NET API 서버 테스트 (push) Successful in 14s
deploy-site / deploy (push) Successful in 41s
ci / 워크스페이스 빌드 검증 (push) Successful in 36s
All checks were successful
ci / 정본·보안·린트·타입·테스트 (push) Successful in 52s
ci / 모바일 린트·타입·Jest (push) Successful in 41s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 22s
ci / .NET API 서버 테스트 (push) Successful in 14s
deploy-site / deploy (push) Successful in 41s
ci / 워크스페이스 빌드 검증 (push) Successful in 36s
A five-second phone recording took over a minute: stt-proxy waited up to 60 s for the self-hosted gateway, whose GPU endpoint was off and whose NAS CPU Whisper needs 30-90 s per clip. With a direct provider configured the gateway now gets 5 s plus the clip length (30 s cap). The direct OpenAI fallback never produced a result. The production key held characters that are not valid in an HTTP header, so every request threw while being built; provider keys are now stripped of BOM/zero-width characters and a still-invalid key counts as not configured. whisper-1 verbose_json reports the language by name, which the result contract rejected; names now map to codes. Fail-closed responses list each provider's failure (status or error class, no secrets) so an outage can be diagnosed without log access.
This commit is contained in:
parent
c90946ce16
commit
0273c6abaa
11 changed files with 139 additions and 20 deletions
16
server/supabase/functions/_shared/provider-key.test.ts
Normal file
16
server/supabase/functions/_shared/provider-key.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { sanitizeProviderKey } from './provider-key.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
Deno.test('provider keys lose BOMs, zero-width characters and whitespace', () => {
|
||||
assert(sanitizeProviderKey('sk-abc123\r\n') === 'sk-abc123', 'BOM and line ending were kept')
|
||||
assert(sanitizeProviderKey(' sk-abc123 ') === 'sk-abc123', 'zero-width space was kept')
|
||||
assert(new Headers({ Authorization: `Bearer ${sanitizeProviderKey('sk-x')}` }).has('Authorization'), 'header was not constructible')
|
||||
})
|
||||
|
||||
Deno.test('a key that is still not printable ASCII counts as not configured', () => {
|
||||
assert(sanitizeProviderKey('키를 여기에') === '', 'non-ASCII placeholder was accepted')
|
||||
assert(sanitizeProviderKey(undefined) === '', 'missing key was not empty')
|
||||
})
|
||||
22
server/supabase/functions/_shared/provider-key.ts
Normal file
22
server/supabase/functions/_shared/provider-key.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Provider API keys read from function secrets.
|
||||
//
|
||||
// A secret pasted from a Windows file or a rich-text source can carry a BOM,
|
||||
// zero-width characters or surrounding whitespace. Such a value is not a valid
|
||||
// HTTP header ByteString, so every request built with it throws before leaving
|
||||
// the function — the OpenAI STT fallback failed this way on every call until
|
||||
// 2026-09-27 ("headers of RequestInit is not a valid ByteString"). Strip the
|
||||
// invisible characters; anything still outside printable ASCII is treated as
|
||||
// not configured, so the provider is skipped instead of failing each request.
|
||||
|
||||
const INVISIBLE = /[- \s]/g
|
||||
const PRINTABLE_ASCII = /^[\x21-\x7E]+$/
|
||||
|
||||
export function sanitizeProviderKey(raw: string | undefined | null): string {
|
||||
if (!raw) return ''
|
||||
const value = raw.replace(INVISIBLE, '')
|
||||
return PRINTABLE_ASCII.test(value) ? value : ''
|
||||
}
|
||||
|
||||
export function readProviderKey(name: string): string {
|
||||
return sanitizeProviderKey(Deno.env.get(name))
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import {
|
|||
buildDictionaryHints,
|
||||
createDeepgramSttUrl,
|
||||
createInternalSttGatewayUrl,
|
||||
gatewayDeadlineMs,
|
||||
providerLanguageCode,
|
||||
MAX_STT_AUDIO_BYTES,
|
||||
normalizeSttResult,
|
||||
SttInputError,
|
||||
|
|
@ -12,6 +14,19 @@ function assert(condition: boolean, message: string): asserts condition {
|
|||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
Deno.test('provider language names from Whisper verbose_json become codes', () => {
|
||||
assert(providerLanguageCode('korean', 'ko') === 'ko', 'OpenAI/Groq report the language by name')
|
||||
assert(providerLanguageCode('KO', 'en') === 'ko', 'codes pass through lower-cased')
|
||||
assert(providerLanguageCode('klingon', 'ko') === 'ko', 'unknown names fall back to the request')
|
||||
assert(providerLanguageCode(undefined, 'auto') === 'und', 'auto without a report is undetermined')
|
||||
})
|
||||
|
||||
Deno.test('gateway deadline follows clip length only when a direct provider can take over', () => {
|
||||
assert(gatewayDeadlineMs(96_000, true) === 8_000, '3 s clip should wait 5 s + 3 s')
|
||||
assert(gatewayDeadlineMs(32_000 * 600, true) === 30_000, 'long clips are capped at 30 s')
|
||||
assert(gatewayDeadlineMs(96_000, false) === 60_000, 'without a fallback the gateway keeps the full timeout')
|
||||
})
|
||||
|
||||
Deno.test('STT input contract accepts real supported audio and preserves its type', () => {
|
||||
const audio = new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mp4' })
|
||||
const result = validateSttAudio(audio, 'ko')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,47 @@
|
|||
export const MAX_STT_AUDIO_BYTES = 25 * 1024 * 1024
|
||||
export const STT_PROVIDER_TIMEOUT_MS = 60_000
|
||||
|
||||
/** 16 kHz 16-bit mono PCM — the most common upload; compressed audio only shortens the estimate. */
|
||||
const PCM_BYTES_PER_SECOND = 32_000
|
||||
const GATEWAY_BASE_MS = 5_000
|
||||
const GATEWAY_MAX_WITH_FALLBACK_MS = 30_000
|
||||
|
||||
/**
|
||||
* How long to wait for the self-hosted gateway before a direct provider takes over.
|
||||
*
|
||||
* The gateway tries a GPU box that may be off and a CPU-only NAS Whisper that
|
||||
* needed 50–90 s for a 3 s clip (2 cores under load, 2026-09-27). Waiting the
|
||||
* full provider timeout turned a short recording into a minute-long wait, and
|
||||
* the phone gave up to its tiny local model. With a direct provider configured,
|
||||
* give the gateway 5 s plus the clip length (enough for a GPU, far too little
|
||||
* for the NAS) and fall through; without one, keep the full timeout.
|
||||
*/
|
||||
const LANGUAGE_NAME_TO_CODE: Readonly<Record<string, string>> = {
|
||||
korean: 'ko', english: 'en', japanese: 'ja', chinese: 'zh', spanish: 'es', french: 'fr',
|
||||
german: 'de', portuguese: 'pt', russian: 'ru', vietnamese: 'vi', thai: 'th', italian: 'it',
|
||||
dutch: 'nl', indonesian: 'id', turkish: 'tr', arabic: 'ar', hindi: 'hi', polish: 'pl',
|
||||
}
|
||||
|
||||
/**
|
||||
* Whisper verbose_json (OpenAI, Groq) reports the language by name ("korean"), while the
|
||||
* STT contract carries a code. A name used to fail normalizeSttResult, so every direct
|
||||
* OpenAI/Groq fallback was thrown away as an invalid result. Accept a code, map a name,
|
||||
* otherwise use what the client asked for.
|
||||
*/
|
||||
export function providerLanguageCode(reported: unknown, requested: string): string {
|
||||
const fallback = requested === 'auto' || requested === 'multi' ? 'und' : requested
|
||||
if (typeof reported !== 'string') return fallback
|
||||
const value = reported.trim().toLowerCase()
|
||||
if (/^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/.test(value)) return value
|
||||
return LANGUAGE_NAME_TO_CODE[value] ?? fallback
|
||||
}
|
||||
|
||||
export function gatewayDeadlineMs(audioBytes: number, hasDirectFallback: boolean): number {
|
||||
if (!hasDirectFallback) return STT_PROVIDER_TIMEOUT_MS
|
||||
const estimatedSeconds = Math.max(0, audioBytes) / PCM_BYTES_PER_SECOND
|
||||
return Math.min(GATEWAY_MAX_WITH_FALLBACK_MS, Math.round(GATEWAY_BASE_MS + estimatedSeconds * 1_000))
|
||||
}
|
||||
|
||||
export interface NormalizedSttResult {
|
||||
transcript: string
|
||||
confidence: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue