d3ro-voice/server/supabase/functions/stt-proxy/providers.ts

282 lines
9.5 KiB
TypeScript

// 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<SttOutcome>
}
export interface SttProviderLogger {
warn(message: string, meta: Record<string, unknown>): 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<string | number, unknown>)[key]
}
function path(value: unknown, ...keys: Array<string | number>): unknown {
return keys.reduce<unknown>((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<RequestInit>
/** 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<string, unknown>
}
async function callProvider(call: ProviderCall, options: SttProviderOptions): Promise<SttOutcome> {
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<string, unknown> {
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
}