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
|
|
@ -101,6 +101,7 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
|
||||||
| GAP-SYNC-07 | Sync | `sync_tombstones` grows without a schedule. `prune_sync_tombstones_v1(interval)` (service_role) exists; nothing calls it. | migration §1 | `[x]` 2026-09-27: migration `0035` enables `pg_cron` and schedules `prune-sync-tombstones` daily at 03:17 UTC (production `cron.job` active=true). A client whose cursor is older than 180 days must fall back to a full resync. |
|
| GAP-SYNC-07 | Sync | `sync_tombstones` grows without a schedule. `prune_sync_tombstones_v1(interval)` (service_role) exists; nothing calls it. | migration §1 | `[x]` 2026-09-27: migration `0035` enables `pg_cron` and schedules `prune-sync-tombstones` daily at 03:17 UTC (production `cron.job` active=true). A client whose cursor is older than 180 days must fall back to a full resync. |
|
||||||
|
|
||||||
| GAP-OPS-01 | Ops | **Production Supabase was five migrations behind** (`0029` content reporting, `0030` profile column ACL — users could raise their own `role`/`tier` through PostgREST, `0031` audit-log actor unlink, `0032` meeting-document reporting, `0033` team activity feed) and `content-report` was never deployed, while the map listed those features GREEN from local runs. Most functions still ran the 2026-08-21 build. | `server/supabase/migrations/`, Management API `schema_migrations`, `functions` list | `[x]` 2026-09-27: schema+data dump taken, `db push` applied `0029`–`0035`, all functions redeployed (unauthenticated calls return 401, i.e. live and gated); `authenticated` can no longer UPDATE `profiles.tier`; real-account export returns exactly the v1 keys. Next: add a CI check that compares `supabase migration list --linked` with the repo so production cannot silently lag again. |
|
| GAP-OPS-01 | Ops | **Production Supabase was five migrations behind** (`0029` content reporting, `0030` profile column ACL — users could raise their own `role`/`tier` through PostgREST, `0031` audit-log actor unlink, `0032` meeting-document reporting, `0033` team activity feed) and `content-report` was never deployed, while the map listed those features GREEN from local runs. Most functions still ran the 2026-08-21 build. | `server/supabase/migrations/`, Management API `schema_migrations`, `functions` list | `[x]` 2026-09-27: schema+data dump taken, `db push` applied `0029`–`0035`, all functions redeployed (unauthenticated calls return 401, i.e. live and gated); `authenticated` can no longer UPDATE `profiles.tier`; real-account export returns exactly the v1 keys. Next: add a CI check that compares `supabase migration list --linked` with the repo so production cannot silently lag again. |
|
||||||
|
| GAP-STT-01 | Cloud STT | A 5 s phone recording took ~66 s and ended on the phone's tiny local model (2026-09-27). Chain: the gateway's first endpoint (laptop GPU 192.168.0.227) is off (3 s), the NAS CPU Whisper (2 cores, load ~7) needs 30–90 s for 3 s of audio, and `stt-proxy` waited up to 60 s for the gateway. The direct OpenAI fallback **never worked**: the production `OPENAI_API_KEY` secret is not a valid header value (invisible/non-ASCII characters — `TypeError: headers … not a valid ByteString`), and whisper-1 `verbose_json` reports the language by name, which the result contract rejected. The same key backs `embed-chunks`, `search-knowledge` and `realtime-token`. | `server/supabase/functions/{stt-proxy,_shared/stt-contract.ts,_shared/provider-key.ts}` | `[!]` 2026-09-27 code fixed and deployed: gateway wait = 5 s + clip length (max 30 s) when a direct provider exists, provider language names mapped to codes, keys stripped of BOM/zero-width characters (still-invalid keys count as not configured), fail-closed responses list per-provider failures (`attempts`). **External:** re-enter a valid `OPENAI_API_KEY` (or add `GROQ_API_KEY`) in Supabase function secrets, or keep the GPU endpoint online; until then cloud STT runs on the NAS CPU (~30 s for a short clip). |
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Mobile checklist roll-up (from `MOBILE_APP_COMPLETION_SSOT.md` §4)
|
## 2. Mobile checklist roll-up (from `MOBILE_APP_COMPLETION_SSOT.md` §4)
|
||||||
|
|
|
||||||
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,
|
buildDictionaryHints,
|
||||||
createDeepgramSttUrl,
|
createDeepgramSttUrl,
|
||||||
createInternalSttGatewayUrl,
|
createInternalSttGatewayUrl,
|
||||||
|
gatewayDeadlineMs,
|
||||||
|
providerLanguageCode,
|
||||||
MAX_STT_AUDIO_BYTES,
|
MAX_STT_AUDIO_BYTES,
|
||||||
normalizeSttResult,
|
normalizeSttResult,
|
||||||
SttInputError,
|
SttInputError,
|
||||||
|
|
@ -12,6 +14,19 @@ function assert(condition: boolean, message: string): asserts condition {
|
||||||
if (!condition) throw new Error(message)
|
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', () => {
|
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 audio = new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mp4' })
|
||||||
const result = validateSttAudio(audio, 'ko')
|
const result = validateSttAudio(audio, 'ko')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,47 @@
|
||||||
export const MAX_STT_AUDIO_BYTES = 25 * 1024 * 1024
|
export const MAX_STT_AUDIO_BYTES = 25 * 1024 * 1024
|
||||||
export const STT_PROVIDER_TIMEOUT_MS = 60_000
|
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 {
|
export interface NormalizedSttResult {
|
||||||
transcript: string
|
transcript: string
|
||||||
confidence: number
|
confidence: number
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||||
|
import { readProviderKey } from '../_shared/provider-key.ts'
|
||||||
|
|
||||||
const EMBEDDING_DIMENSIONS = 1536
|
const EMBEDDING_DIMENSIONS = 1536
|
||||||
const PROVIDER_TIMEOUT_MS = 45_000
|
const PROVIDER_TIMEOUT_MS = 45_000
|
||||||
|
|
@ -35,7 +36,7 @@ Deno.serve(async (req: Request) => {
|
||||||
return json(400, { error: 'invalid_document_id' })
|
return json(400, { error: 'invalid_document_id' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? ''
|
const openaiKey = readProviderKey('OPENAI_API_KEY')
|
||||||
if (!openaiKey) return json(503, { error: 'embedding_provider_unavailable' })
|
if (!openaiKey) return json(503, { error: 'embedding_provider_unavailable' })
|
||||||
|
|
||||||
const serviceClient = createServiceRoleClient()
|
const serviceClient = createServiceRoleClient()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
parseProviderDocumentResult,
|
parseProviderDocumentResult,
|
||||||
} from '../_shared/meeting-document-contract.ts'
|
} from '../_shared/meeting-document-contract.ts'
|
||||||
import { buildMeetingDocumentSystemPrompt } from '../_shared/generative-ai-safety.ts'
|
import { buildMeetingDocumentSystemPrompt } from '../_shared/generative-ai-safety.ts'
|
||||||
|
import { readProviderKey } from '../_shared/provider-key.ts'
|
||||||
|
|
||||||
interface ClaimResult {
|
interface ClaimResult {
|
||||||
claimed: boolean
|
claimed: boolean
|
||||||
|
|
@ -105,7 +106,7 @@ Deno.serve(async (request: Request) => {
|
||||||
const body = parseGenerateMeetingDocumentRequest(rawBody)
|
const body = parseGenerateMeetingDocumentRequest(rawBody)
|
||||||
idempotencyKey = body.idempotencyKey
|
idempotencyKey = body.idempotencyKey
|
||||||
|
|
||||||
const providerKey = Deno.env.get('ANTHROPIC_API_KEY')?.trim() ?? ''
|
const providerKey = readProviderKey('ANTHROPIC_API_KEY')
|
||||||
if (providerKey.length === 0) {
|
if (providerKey.length === 0) {
|
||||||
return json(503, { error: 'provider_unavailable' })
|
return json(503, { error: 'provider_unavailable' })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
type GenerationPurpose,
|
type GenerationPurpose,
|
||||||
} from '../_shared/generation-receipt.ts'
|
} from '../_shared/generation-receipt.ts'
|
||||||
import { buildAnthropicSystemBlocks } from '../_shared/generative-ai-safety.ts'
|
import { buildAnthropicSystemBlocks } from '../_shared/generative-ai-safety.ts'
|
||||||
|
import { readProviderKey } from '../_shared/provider-key.ts'
|
||||||
|
|
||||||
/** 티어별 허용 모델 — free는 Haiku만, pro는 +Sonnet, pro_plus는 +Opus, team/enterprise는 전 모델 */
|
/** 티어별 허용 모델 — free는 Haiku만, pro는 +Sonnet, pro_plus는 +Opus, team/enterprise는 전 모델 */
|
||||||
const TIER_MODELS: Record<Tier, string[]> = {
|
const TIER_MODELS: Record<Tier, string[]> = {
|
||||||
|
|
@ -96,7 +97,7 @@ Deno.serve(async (req: Request) => {
|
||||||
)
|
)
|
||||||
|
|
||||||
// A deployment without a provider must not consume quota or fabricate an answer.
|
// A deployment without a provider must not consume quota or fabricate an answer.
|
||||||
const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY')?.trim() ?? ''
|
const anthropicKey = readProviderKey('ANTHROPIC_API_KEY')
|
||||||
if (!anthropicKey) {
|
if (!anthropicKey) {
|
||||||
return new Response(JSON.stringify({ error: 'provider_unavailable' }), {
|
return new Response(JSON.stringify({ error: 'provider_unavailable' }), {
|
||||||
status: 503,
|
status: 503,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
getQuotaPolicy,
|
getQuotaPolicy,
|
||||||
type Tier,
|
type Tier,
|
||||||
} from '../_shared/quota.ts'
|
} from '../_shared/quota.ts'
|
||||||
|
import { readProviderKey } from '../_shared/provider-key.ts'
|
||||||
|
|
||||||
interface RealtimeTokenRequest {
|
interface RealtimeTokenRequest {
|
||||||
model?: string
|
model?: string
|
||||||
|
|
@ -113,7 +114,7 @@ Deno.serve(async (req: Request) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? ''
|
const openaiKey = readProviderKey('OPENAI_API_KEY')
|
||||||
if (!openaiKey) {
|
if (!openaiKey) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'not_configured', message: 'OPENAI_API_KEY 미설정' }),
|
JSON.stringify({ error: 'not_configured', message: 'OPENAI_API_KEY 미설정' }),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||||
|
import { readProviderKey } from '../_shared/provider-key.ts'
|
||||||
|
|
||||||
const EMBEDDING_DIMENSIONS = 1536
|
const EMBEDDING_DIMENSIONS = 1536
|
||||||
const PROVIDER_TIMEOUT_MS = 45_000
|
const PROVIDER_TIMEOUT_MS = 45_000
|
||||||
|
|
@ -37,7 +38,7 @@ Deno.serve(async (req: Request) => {
|
||||||
return json(400, { error: 'invalid_count' })
|
return json(400, { error: 'invalid_count' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? ''
|
const openaiKey = readProviderKey('OPENAI_API_KEY')
|
||||||
if (!openaiKey) return json(503, { error: 'embedding_provider_unavailable' })
|
if (!openaiKey) return json(503, { error: 'embedding_provider_unavailable' })
|
||||||
|
|
||||||
let embeddingResponse: Response
|
let embeddingResponse: Response
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,11 @@ import {
|
||||||
type NormalizedSttResult,
|
type NormalizedSttResult,
|
||||||
SttInputError,
|
SttInputError,
|
||||||
STT_PROVIDER_TIMEOUT_MS,
|
STT_PROVIDER_TIMEOUT_MS,
|
||||||
|
gatewayDeadlineMs,
|
||||||
|
providerLanguageCode,
|
||||||
validateSttAudio,
|
validateSttAudio,
|
||||||
} from '../_shared/stt-contract.ts'
|
} from '../_shared/stt-contract.ts'
|
||||||
|
import { readProviderKey } from '../_shared/provider-key.ts'
|
||||||
|
|
||||||
Deno.serve(async (req: Request) => {
|
Deno.serve(async (req: Request) => {
|
||||||
const preflight = handleCorsPreflightRequest(req)
|
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') ?? ''
|
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.
|
// Supabase user JWTs are not valid D3RO API JWTs. Configure a dedicated backend token.
|
||||||
const apiServerToken = Deno.env.get('D3RO_API_TOKEN') ?? ''
|
const apiServerToken = Deno.env.get('D3RO_API_TOKEN') ?? ''
|
||||||
const groqKey = Deno.env.get('GROQ_API_KEY') ?? ''
|
const groqKey = readProviderKey('GROQ_API_KEY')
|
||||||
const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? ''
|
const openaiKey = readProviderKey('OPENAI_API_KEY')
|
||||||
const deepgramKey = Deno.env.get('DEEPGRAM_API_KEY') ?? ''
|
const deepgramKey = readProviderKey('DEEPGRAM_API_KEY')
|
||||||
|
|
||||||
let result: NormalizedSttResult | null = null
|
let result: NormalizedSttResult | null = null
|
||||||
let attemptedProvider = false
|
let attemptedProvider = false
|
||||||
let sawBadGatewayFailure = false
|
let sawBadGatewayFailure = false
|
||||||
let sawServiceUnavailable = 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
|
// 4) Forward to D3RO API Gateway Orchestrator if available
|
||||||
if (apiServerUrl && apiServerToken) {
|
if (apiServerUrl && apiServerToken) {
|
||||||
|
|
@ -139,7 +150,9 @@ Deno.serve(async (req: Request) => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-D3RO-STT-Gateway-Token': apiServerToken },
|
headers: { 'X-D3RO-STT-Gateway-Token': apiServerToken },
|
||||||
body: forwardForm,
|
body: forwardForm,
|
||||||
signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS),
|
signal: AbortSignal.timeout(
|
||||||
|
gatewayDeadlineMs(audio.size, Boolean(groqKey || openaiKey || deepgramKey)),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (apiResp.ok) {
|
if (apiResp.ok) {
|
||||||
|
|
@ -150,18 +163,20 @@ Deno.serve(async (req: Request) => {
|
||||||
result = normalizeSttResult({
|
result = normalizeSttResult({
|
||||||
transcript: apiData.text,
|
transcript: apiData.text,
|
||||||
confidence: apiData.confidence ?? 0.98,
|
confidence: apiData.confidence ?? 0.98,
|
||||||
language_code: apiData.language
|
language_code: providerLanguageCode(apiData.language, audioInput.languageCode),
|
||||||
?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode),
|
|
||||||
duration_seconds: apiData.durationSeconds ?? (audio.size / 4000),
|
duration_seconds: apiData.durationSeconds ?? (audio.size / 4000),
|
||||||
provider: apiData.provider ?? 'd3ro-gateway',
|
provider: apiData.provider ?? 'd3ro-gateway',
|
||||||
})
|
})
|
||||||
} else if (apiResp.status === 503) {
|
} else if (apiResp.status === 503) {
|
||||||
sawServiceUnavailable = true
|
sawServiceUnavailable = true
|
||||||
|
attempts.push({ provider: 'gateway', failure: 'http_503' })
|
||||||
} else {
|
} else {
|
||||||
sawBadGatewayFailure = true
|
sawBadGatewayFailure = true
|
||||||
|
attempts.push({ provider: 'gateway', failure: `http_${apiResp.status}` })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
sawBadGatewayFailure = true
|
sawBadGatewayFailure = true
|
||||||
|
attempts.push({ provider: 'gateway', failure: failureOf(err) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,16 +210,17 @@ Deno.serve(async (req: Request) => {
|
||||||
result = normalizeSttResult({
|
result = normalizeSttResult({
|
||||||
transcript: groqData.text,
|
transcript: groqData.text,
|
||||||
confidence: 0.98,
|
confidence: 0.98,
|
||||||
language_code: groqData.language
|
language_code: providerLanguageCode(groqData.language, audioInput.languageCode),
|
||||||
?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode),
|
|
||||||
duration_seconds: groqData.duration ?? (audio.size / 4000),
|
duration_seconds: groqData.duration ?? (audio.size / 4000),
|
||||||
provider: 'groq',
|
provider: 'groq',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
sawBadGatewayFailure = true
|
sawBadGatewayFailure = true
|
||||||
|
attempts.push({ provider: 'groq', failure: `http_${groqResp.status}` })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
sawBadGatewayFailure = true
|
sawBadGatewayFailure = true
|
||||||
|
attempts.push({ provider: 'groq', failure: failureOf(err) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,16 +254,17 @@ Deno.serve(async (req: Request) => {
|
||||||
result = normalizeSttResult({
|
result = normalizeSttResult({
|
||||||
transcript: openAiData.text,
|
transcript: openAiData.text,
|
||||||
confidence: 0.98,
|
confidence: 0.98,
|
||||||
language_code: openAiData.language
|
language_code: providerLanguageCode(openAiData.language, audioInput.languageCode),
|
||||||
?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode),
|
|
||||||
duration_seconds: openAiData.duration ?? (audio.size / 4000),
|
duration_seconds: openAiData.duration ?? (audio.size / 4000),
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
sawBadGatewayFailure = true
|
sawBadGatewayFailure = true
|
||||||
|
attempts.push({ provider: 'openai', failure: `http_${openAiResp.status}` })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
sawBadGatewayFailure = true
|
sawBadGatewayFailure = true
|
||||||
|
attempts.push({ provider: 'openai', failure: failureOf(err) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,9 +303,11 @@ Deno.serve(async (req: Request) => {
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
sawBadGatewayFailure = true
|
sawBadGatewayFailure = true
|
||||||
|
attempts.push({ provider: 'deepgram', failure: `http_${dgResp.status}` })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
sawBadGatewayFailure = true
|
sawBadGatewayFailure = true
|
||||||
|
attempts.push({ provider: 'deepgram', failure: failureOf(err) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,7 +317,7 @@ Deno.serve(async (req: Request) => {
|
||||||
quotaReservation = null
|
quotaReservation = null
|
||||||
const status = !attemptedProvider || (!sawBadGatewayFailure && sawServiceUnavailable) ? 503 : 502
|
const status = !attemptedProvider || (!sawBadGatewayFailure && sawServiceUnavailable) ? 503 : 502
|
||||||
const error = status === 503 ? 'stt_provider_unavailable' : 'stt_upstream_failed'
|
const error = status === 503 ? 'stt_provider_unavailable' : 'stt_upstream_failed'
|
||||||
return new Response(JSON.stringify({ error }), {
|
return new Response(JSON.stringify({ error, attempts }), {
|
||||||
status,
|
status,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue