From c90946ce16433ce62c64aaca51950ff4bb0038a4 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 27 Sep 2026 16:46:14 +0900 Subject: [PATCH 1/2] chore(release): point the Scoop manifest at the 1.9.0 portable build --- bucket/d3ro-voice.json | 8 ++++---- memory/project_status.md | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/bucket/d3ro-voice.json b/bucket/d3ro-voice.json index 5d859f2..23e59c7 100644 --- a/bucket/d3ro-voice.json +++ b/bucket/d3ro-voice.json @@ -1,15 +1,15 @@ { - "version": "1.8.0", + "version": "1.9.0", "description": "로컬 AI 음성 어시스턴트 (faster-whisper + Ollama, 100% 오프라인 지원)", "homepage": "https://d3ro.chanpaca.net", "license": "MIT", "architecture": { "64bit": { "url": [ - "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.8.0/D3RO-Voice-1.8.0-x64-portable.7z.001" + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.9.0/D3RO-Voice-1.9.0-x64-portable.7z.001" ], "hash": [ - "b06ec0505e5d2428422134fa6057381042db27362cac1a83a3788dc9b2c46f96" + "68816cad80105f59d90510b8209e13ff4e5d782c86d97239b0e9c058dd839760" ] } }, @@ -27,7 +27,7 @@ "architecture": { "64bit": { "url": [ - "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.8.0-x64-portable.7z.001" + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.9.0-x64-portable.7z.001" ] } } diff --git a/memory/project_status.md b/memory/project_status.md index 0a47b28..4a5beb7 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -1,5 +1,13 @@ # D3RO-VOICE 프로젝트 현황 +## v1.9.0 — 전사 구간·프리셋 프롬프트 동기화 (2026-09-27) + +- "일부러 동기화 안 함"으로 두었던 두 가지도 구현(사용자: "모두 마무리"). 커밋 08c6504(0036) 2aac10f(데스크톱) 796916b(v1.9.0). +- 발견: 폰은 회의를 `transcripts` 구간이 있으면 구간으로 그리고 `edited_transcript`를 무시 → 데스크톱 전사 수정·자동 다듬기·화자 구분이 폰에 안 보였다. 이제 데스크톱 전사가 바뀔 때마다 `[MM:SS] [화자] 내용` 줄로 구간을 다시 만들어 upsert(meeting_id,segment_index) + 남는 구간 삭제. 파서 정본 `@d3ro/core/meeting-transcript`(렌더러도 사용). +- 프리셋 명령: 데스크톱 translate/summarize/formal/explain-code ↔ 서버 translate_en/summarize/formal/explain_code. 폰은 서버 행 프롬프트로 실행. 0036 RPC `sync_set_builtin_instruction_prompt_v1`(NULL=기본 복원), `sync_list_builtin_instructions_v1`(is_default). `{{targetLanguage}}`→English 치환. 자유 프롬프트는 로컬. +- 운영 DB 0036 적용 완료. 검증: 동기화 단위 33, 로컬 통합 7(Docker 재시작 직후 실시간 1회 워밍업 실패 후 7/7), 데스크톱 1512, core 135. +- 함정: Docker Desktop이 꺼지면 로컬 Supabase도 죽는다 — `Docker Desktop.exe` 실행 후 `supabase start`. + ## v1.7.0·v1.8.0 배포 + 운영 DB 정렬 (2026-09-27, 전부 커밋·push·게시 완료) - 커밋: b5c9ff9(0034) 0a4f5ae(데스크톱 동기화) 53fcdf6(모바일 기기) 3d9faed(v1.7.0) cee4ab9(0035) 9a8f7e6(지식·녹음·설정) 3f1fb8e(v1.8.0). 태그 v1.7.0·v1.8.0, chanpaca+origin push. Forgejo CI 전부 GREEN, release-windows/portable-windows만 실패(서명 게이트, 1.5.0부터 동일 — GAP-REL-06). From 0273c6abaa22ca20e83705006ec76de185938eb4 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 27 Sep 2026 18:02:14 +0900 Subject: [PATCH 2/2] fix(edge): stop short clips waiting a minute on the NAS and make the OpenAI STT fallback usable 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. --- docs/map/11-gap-backlog.md | 1 + .../functions/_shared/provider-key.test.ts | 16 ++++++ .../functions/_shared/provider-key.ts | 22 +++++++++ .../functions/_shared/stt-contract.test.ts | 15 ++++++ .../functions/_shared/stt-contract.ts | 41 ++++++++++++++++ .../supabase/functions/embed-chunks/index.ts | 3 +- .../generate-meeting-document/index.ts | 3 +- server/supabase/functions/llm-proxy/index.ts | 3 +- .../functions/realtime-token/index.ts | 3 +- .../functions/search-knowledge/index.ts | 3 +- server/supabase/functions/stt-proxy/index.ts | 49 +++++++++++++------ 11 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 server/supabase/functions/_shared/provider-key.test.ts create mode 100644 server/supabase/functions/_shared/provider-key.ts diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md index a92e03b..4052fa2 100644 --- a/docs/map/11-gap-backlog.md +++ b/docs/map/11-gap-backlog.md @@ -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-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) diff --git a/server/supabase/functions/_shared/provider-key.test.ts b/server/supabase/functions/_shared/provider-key.test.ts new file mode 100644 index 0000000..a5140aa --- /dev/null +++ b/server/supabase/functions/_shared/provider-key.test.ts @@ -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-abc​123 ') === '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') +}) diff --git a/server/supabase/functions/_shared/provider-key.ts b/server/supabase/functions/_shared/provider-key.ts new file mode 100644 index 0000000..dbed29d --- /dev/null +++ b/server/supabase/functions/_shared/provider-key.ts @@ -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)) +} diff --git a/server/supabase/functions/_shared/stt-contract.test.ts b/server/supabase/functions/_shared/stt-contract.test.ts index ff2fbf5..bc8a143 100644 --- a/server/supabase/functions/_shared/stt-contract.test.ts +++ b/server/supabase/functions/_shared/stt-contract.test.ts @@ -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') diff --git a/server/supabase/functions/_shared/stt-contract.ts b/server/supabase/functions/_shared/stt-contract.ts index 7c1be77..3abebf8 100644 --- a/server/supabase/functions/_shared/stt-contract.ts +++ b/server/supabase/functions/_shared/stt-contract.ts @@ -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> = { + 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 diff --git a/server/supabase/functions/embed-chunks/index.ts b/server/supabase/functions/embed-chunks/index.ts index 267ca9e..1860ad9 100644 --- a/server/supabase/functions/embed-chunks/index.ts +++ b/server/supabase/functions/embed-chunks/index.ts @@ -1,6 +1,7 @@ import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' import { createServiceRoleClient } from '../_shared/quota.ts' +import { readProviderKey } from '../_shared/provider-key.ts' const EMBEDDING_DIMENSIONS = 1536 const PROVIDER_TIMEOUT_MS = 45_000 @@ -35,7 +36,7 @@ Deno.serve(async (req: Request) => { 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' }) const serviceClient = createServiceRoleClient() diff --git a/server/supabase/functions/generate-meeting-document/index.ts b/server/supabase/functions/generate-meeting-document/index.ts index 5d65ce0..07ec2ab 100644 --- a/server/supabase/functions/generate-meeting-document/index.ts +++ b/server/supabase/functions/generate-meeting-document/index.ts @@ -7,6 +7,7 @@ import { parseProviderDocumentResult, } from '../_shared/meeting-document-contract.ts' import { buildMeetingDocumentSystemPrompt } from '../_shared/generative-ai-safety.ts' +import { readProviderKey } from '../_shared/provider-key.ts' interface ClaimResult { claimed: boolean @@ -105,7 +106,7 @@ Deno.serve(async (request: Request) => { const body = parseGenerateMeetingDocumentRequest(rawBody) idempotencyKey = body.idempotencyKey - const providerKey = Deno.env.get('ANTHROPIC_API_KEY')?.trim() ?? '' + const providerKey = readProviderKey('ANTHROPIC_API_KEY') if (providerKey.length === 0) { return json(503, { error: 'provider_unavailable' }) } diff --git a/server/supabase/functions/llm-proxy/index.ts b/server/supabase/functions/llm-proxy/index.ts index 01aa79e..701706c 100644 --- a/server/supabase/functions/llm-proxy/index.ts +++ b/server/supabase/functions/llm-proxy/index.ts @@ -28,6 +28,7 @@ import { type GenerationPurpose, } from '../_shared/generation-receipt.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는 전 모델 */ const TIER_MODELS: Record = { @@ -96,7 +97,7 @@ Deno.serve(async (req: Request) => { ) // 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) { return new Response(JSON.stringify({ error: 'provider_unavailable' }), { status: 503, diff --git a/server/supabase/functions/realtime-token/index.ts b/server/supabase/functions/realtime-token/index.ts index 60e799c..fa82e4e 100644 --- a/server/supabase/functions/realtime-token/index.ts +++ b/server/supabase/functions/realtime-token/index.ts @@ -13,6 +13,7 @@ import { getQuotaPolicy, type Tier, } from '../_shared/quota.ts' +import { readProviderKey } from '../_shared/provider-key.ts' interface RealtimeTokenRequest { 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) { return new Response( JSON.stringify({ error: 'not_configured', message: 'OPENAI_API_KEY 미설정' }), diff --git a/server/supabase/functions/search-knowledge/index.ts b/server/supabase/functions/search-knowledge/index.ts index c84c68e..b8728c2 100644 --- a/server/supabase/functions/search-knowledge/index.ts +++ b/server/supabase/functions/search-knowledge/index.ts @@ -2,6 +2,7 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7' import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' +import { readProviderKey } from '../_shared/provider-key.ts' const EMBEDDING_DIMENSIONS = 1536 const PROVIDER_TIMEOUT_MS = 45_000 @@ -37,7 +38,7 @@ Deno.serve(async (req: Request) => { 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' }) let embeddingResponse: Response diff --git a/server/supabase/functions/stt-proxy/index.ts b/server/supabase/functions/stt-proxy/index.ts index 29f2dae..d9afb86 100644 --- a/server/supabase/functions/stt-proxy/index.ts +++ b/server/supabase/functions/stt-proxy/index.ts @@ -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' } })