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
|
|
@ -13,8 +13,11 @@ import {
|
|||
type NormalizedSttResult,
|
||||
SttInputError,
|
||||
STT_PROVIDER_TIMEOUT_MS,
|
||||
gatewayDeadlineMs,
|
||||
providerLanguageCode,
|
||||
validateSttAudio,
|
||||
} from '../_shared/stt-contract.ts'
|
||||
import { readProviderKey } from '../_shared/provider-key.ts'
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
|
|
@ -117,14 +120,22 @@ Deno.serve(async (req: Request) => {
|
|||
const apiServerUrl = Deno.env.get('D3RO_API_URL') ?? Deno.env.get('BACKEND_ORIGIN') ?? ''
|
||||
// Supabase user JWTs are not valid D3RO API JWTs. Configure a dedicated backend token.
|
||||
const apiServerToken = Deno.env.get('D3RO_API_TOKEN') ?? ''
|
||||
const groqKey = Deno.env.get('GROQ_API_KEY') ?? ''
|
||||
const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? ''
|
||||
const deepgramKey = Deno.env.get('DEEPGRAM_API_KEY') ?? ''
|
||||
const groqKey = readProviderKey('GROQ_API_KEY')
|
||||
const openaiKey = readProviderKey('OPENAI_API_KEY')
|
||||
const deepgramKey = readProviderKey('DEEPGRAM_API_KEY')
|
||||
|
||||
let result: NormalizedSttResult | null = null
|
||||
let attemptedProvider = false
|
||||
let sawBadGatewayFailure = false
|
||||
let sawServiceUnavailable = false
|
||||
// Which provider failed and how (status code or error class) — no bodies, no secrets.
|
||||
// Returned with a fail-closed response so an outage is diagnosable without log access.
|
||||
const attempts: Array<{ provider: string; failure: string }> = []
|
||||
const failureOf = (err: unknown): string => {
|
||||
if (!(err instanceof Error)) return 'error'
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'timeout'
|
||||
return `${err.name}: ${err.message}`.slice(0, 120)
|
||||
}
|
||||
|
||||
// 4) Forward to D3RO API Gateway Orchestrator if available
|
||||
if (apiServerUrl && apiServerToken) {
|
||||
|
|
@ -139,7 +150,9 @@ Deno.serve(async (req: Request) => {
|
|||
method: 'POST',
|
||||
headers: { 'X-D3RO-STT-Gateway-Token': apiServerToken },
|
||||
body: forwardForm,
|
||||
signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS),
|
||||
signal: AbortSignal.timeout(
|
||||
gatewayDeadlineMs(audio.size, Boolean(groqKey || openaiKey || deepgramKey)),
|
||||
),
|
||||
})
|
||||
|
||||
if (apiResp.ok) {
|
||||
|
|
@ -150,18 +163,20 @@ Deno.serve(async (req: Request) => {
|
|||
result = normalizeSttResult({
|
||||
transcript: apiData.text,
|
||||
confidence: apiData.confidence ?? 0.98,
|
||||
language_code: apiData.language
|
||||
?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode),
|
||||
language_code: providerLanguageCode(apiData.language, audioInput.languageCode),
|
||||
duration_seconds: apiData.durationSeconds ?? (audio.size / 4000),
|
||||
provider: apiData.provider ?? 'd3ro-gateway',
|
||||
})
|
||||
} else if (apiResp.status === 503) {
|
||||
sawServiceUnavailable = true
|
||||
attempts.push({ provider: 'gateway', failure: 'http_503' })
|
||||
} else {
|
||||
sawBadGatewayFailure = true
|
||||
attempts.push({ provider: 'gateway', failure: `http_${apiResp.status}` })
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
sawBadGatewayFailure = true
|
||||
attempts.push({ provider: 'gateway', failure: failureOf(err) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,16 +210,17 @@ Deno.serve(async (req: Request) => {
|
|||
result = normalizeSttResult({
|
||||
transcript: groqData.text,
|
||||
confidence: 0.98,
|
||||
language_code: groqData.language
|
||||
?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode),
|
||||
language_code: providerLanguageCode(groqData.language, audioInput.languageCode),
|
||||
duration_seconds: groqData.duration ?? (audio.size / 4000),
|
||||
provider: 'groq',
|
||||
})
|
||||
} else {
|
||||
sawBadGatewayFailure = true
|
||||
attempts.push({ provider: 'groq', failure: `http_${groqResp.status}` })
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
sawBadGatewayFailure = true
|
||||
attempts.push({ provider: 'groq', failure: failureOf(err) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,16 +254,17 @@ Deno.serve(async (req: Request) => {
|
|||
result = normalizeSttResult({
|
||||
transcript: openAiData.text,
|
||||
confidence: 0.98,
|
||||
language_code: openAiData.language
|
||||
?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode),
|
||||
language_code: providerLanguageCode(openAiData.language, audioInput.languageCode),
|
||||
duration_seconds: openAiData.duration ?? (audio.size / 4000),
|
||||
provider: 'openai',
|
||||
})
|
||||
} else {
|
||||
sawBadGatewayFailure = true
|
||||
attempts.push({ provider: 'openai', failure: `http_${openAiResp.status}` })
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
sawBadGatewayFailure = true
|
||||
attempts.push({ provider: 'openai', failure: failureOf(err) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,9 +303,11 @@ Deno.serve(async (req: Request) => {
|
|||
})
|
||||
} else {
|
||||
sawBadGatewayFailure = true
|
||||
attempts.push({ provider: 'deepgram', failure: `http_${dgResp.status}` })
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
sawBadGatewayFailure = true
|
||||
attempts.push({ provider: 'deepgram', failure: failureOf(err) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +317,7 @@ Deno.serve(async (req: Request) => {
|
|||
quotaReservation = null
|
||||
const status = !attemptedProvider || (!sawBadGatewayFailure && sawServiceUnavailable) ? 503 : 502
|
||||
const error = status === 503 ? 'stt_provider_unavailable' : 'stt_upstream_failed'
|
||||
return new Response(JSON.stringify({ error }), {
|
||||
return new Response(JSON.stringify({ error, attempts }), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue