282 lines
9.5 KiB
TypeScript
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
|
|
}
|