// server/supabase/functions/stt-proxy/providers.ts // SttProvider adapters: the self-hosted D3RO gateway, Groq, OpenAI and Deepgram. // // Each adapter maps an HTTP/transport result to an SttOutcome and never throws. // A 2xx answer is always terminal for billing purposes (the provider charged us): // an empty transcript becomes `no_speech`, a malformed body becomes a billed // `retryable`. Raw error text (which can contain the internal gateway URL or part // of a provider body) goes to the server log only, never into the outcome. import { createDeepgramSttUrl, createInternalSttGatewayUrl, type DictionaryHints, gatewayDeadlineMs, providerLanguageCode, STT_PROVIDER_TIMEOUT_MS, type SttFailureCode, sttHttpFailure, type SttOutcome, sttOutcomeFromBilledAnswer, sttTransportFailure, } from '../_shared/stt-contract.ts' export interface SttProviderInput { audio: Blob fileName: string contentType: string languageCode: string hints: DictionaryHints } /** Port: one speech-to-text backend. Implementations must not throw. */ export interface SttProvider { readonly id: string transcribe(input: SttProviderInput): Promise } export interface SttProviderLogger { warn(message: string, meta: Record): void } export interface SttProviderConfig { gatewayUrl: string /** Dedicated backend token. Supabase user JWTs are not valid D3RO API JWTs. */ gatewayToken: string groqKey: string openaiKey: string deepgramKey: string } export interface SttProviderOptions { logger: SttProviderLogger fetchImpl?: typeof fetch } export const GROQ_STT_URL = 'https://api.groq.com/openai/v1/audio/transcriptions' export const OPENAI_STT_URL = 'https://api.openai.com/v1/audio/transcriptions' /** Rough duration estimate used when a provider does not report one. */ function estimatedDurationSeconds(audio: Blob): number { return audio.size / 4000 } function field(value: unknown, key: string | number): unknown { if (!value || typeof value !== 'object') return undefined return (value as Record)[key] } function path(value: unknown, ...keys: Array): unknown { return keys.reduce((current, key) => field(current, key), value) } function failed(failure: SttFailureCode, options: { billed?: boolean; unavailable?: boolean } = {}): SttOutcome { return { kind: 'retryable', failure, billed: options.billed ?? false, unavailable: options.unavailable ?? false, } } interface ProviderCall { id: string /** May throw (e.g. a misconfigured gateway URL); reported as `misconfigured`. */ url(): string init(): Promise /** Status codes that mean "explicitly unavailable" rather than "failed". */ unavailableStatuses?: readonly number[] /** Build the result candidate from a parsed 2xx JSON body. */ candidate(data: unknown): Record } async function callProvider(call: ProviderCall, options: SttProviderOptions): Promise { const fetchImpl = options.fetchImpl ?? fetch const log = (stage: string, err: unknown): void => { options.logger.warn('stt-proxy provider failed', { provider: call.id, stage, name: err instanceof Error ? err.name : typeof err, message: err instanceof Error ? err.message : String(err), }) } let url: string try { url = call.url() } catch (err) { log('config', err) return failed('misconfigured') } let response: Response try { response = await fetchImpl(url, await call.init()) } catch (err) { log('request', err) return failed(sttTransportFailure(err)) } if (!response.ok) { await response.body?.cancel().catch(() => undefined) return failed(sttHttpFailure(response.status), { unavailable: call.unavailableStatuses?.includes(response.status) ?? false, }) } // From here on the provider has answered 2xx and billed the audio. let data: unknown try { data = await response.json() } catch (err) { log('body', err) const failure = sttTransportFailure(err) === 'timeout' ? 'timeout' : 'invalid_response' return failed(failure, { billed: true }) } return sttOutcomeFromBilledAnswer(call.candidate(data)) } function whisperForm(input: SttProviderInput, model: string): FormData { const form = new FormData() form.append('file', input.audio, input.fileName) form.append('model', model) if (input.languageCode !== 'auto' && input.languageCode !== 'multi') { form.append('language', input.languageCode) } if (input.hints.prompt) form.append('prompt', input.hints.prompt) form.append('response_format', 'verbose_json') return form } function whisperCandidate(data: unknown, input: SttProviderInput, provider: string): Record { return { transcript: field(data, 'text'), confidence: 0.98, language_code: providerLanguageCode(field(data, 'language'), input.languageCode), duration_seconds: field(data, 'duration') ?? estimatedDurationSeconds(input.audio), provider, } } export function createGatewayProvider( config: { url: string; token: string; hasDirectFallback: boolean }, options: SttProviderOptions, ): SttProvider { return { id: 'gateway', transcribe: (input) => callProvider({ id: 'gateway', url: () => createInternalSttGatewayUrl(config.url), init: () => { const form = new FormData() form.append('file', input.audio, input.fileName) form.append('language', input.languageCode) if (input.hints.prompt) form.append('prompt', input.hints.prompt) return Promise.resolve({ method: 'POST', headers: { 'X-D3RO-STT-Gateway-Token': config.token }, body: form, signal: AbortSignal.timeout(gatewayDeadlineMs(input.audio.size, config.hasDirectFallback)), }) }, unavailableStatuses: [503], candidate: (data) => ({ transcript: field(data, 'text'), confidence: field(data, 'confidence') ?? 0.98, language_code: providerLanguageCode(field(data, 'language'), input.languageCode), duration_seconds: field(data, 'durationSeconds') ?? estimatedDurationSeconds(input.audio), provider: field(data, 'provider') ?? 'd3ro-gateway', }), }, options), } } export function createGroqProvider(apiKey: string, options: SttProviderOptions): SttProvider { return { id: 'groq', transcribe: (input) => callProvider({ id: 'groq', url: () => GROQ_STT_URL, init: () => Promise.resolve({ method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, body: whisperForm(input, 'whisper-large-v3-turbo'), signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS), }), candidate: (data) => whisperCandidate(data, input, 'groq'), }, options), } } export function createOpenAiProvider(apiKey: string, options: SttProviderOptions): SttProvider { return { id: 'openai', transcribe: (input) => callProvider({ id: 'openai', url: () => OPENAI_STT_URL, init: () => Promise.resolve({ method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, body: whisperForm(input, 'whisper-1'), signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS), }), candidate: (data) => whisperCandidate(data, input, 'openai'), }, options), } } export function createDeepgramProvider(apiKey: string, options: SttProviderOptions): SttProvider { return { id: 'deepgram', transcribe: (input) => callProvider({ id: 'deepgram', url: () => createDeepgramSttUrl(input.languageCode, input.hints.keyterms), init: async () => ({ method: 'POST', headers: { Authorization: `Token ${apiKey}`, 'Content-Type': input.contentType, }, body: await input.audio.arrayBuffer(), signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS), }), candidate: (data) => { const channel = path(data, 'results', 'channels', 0) const alternative = path(channel, 'alternatives', 0) return { transcript: field(alternative, 'transcript'), confidence: field(alternative, 'confidence') ?? 0.95, language_code: field(channel, 'detected_language') ?? (input.languageCode === 'auto' || input.languageCode === 'multi' ? 'und' : input.languageCode), duration_seconds: path(data, 'metadata', 'duration') ?? estimatedDurationSeconds(input.audio), provider: 'deepgram', } }, }, options), } } /** * The provider chain in fallback order: self-hosted gateway, then Groq, OpenAI and * Deepgram. A provider without its secret is left out. */ export function createSttProviders(config: SttProviderConfig, options: SttProviderOptions): SttProvider[] { const providers: SttProvider[] = [] const hasDirectFallback = Boolean(config.groqKey || config.openaiKey || config.deepgramKey) if (config.gatewayUrl && config.gatewayToken) { providers.push(createGatewayProvider( { url: config.gatewayUrl, token: config.gatewayToken, hasDirectFallback }, options, )) } if (config.groqKey) providers.push(createGroqProvider(config.groqKey, options)) if (config.openaiKey) providers.push(createOpenAiProvider(config.openaiKey, options)) if (config.deepgramKey) providers.push(createDeepgramProvider(config.deepgramKey, options)) return providers }