81 lines
3 KiB
TypeScript
81 lines
3 KiB
TypeScript
// server/supabase/functions/stt-proxy/index.ts
|
|
// D3RO Voice — Multi-Provider Cloud Speech-to-Text Proxy
|
|
// Routes through the self-hosted D3RO API gateway, then direct providers (Groq, OpenAI, Deepgram).
|
|
//
|
|
// This file is the composition root. The use case and fallback policy live in
|
|
// handler.ts, the provider adapters in providers.ts, the wire contract in
|
|
// ../_shared/stt-contract.ts.
|
|
|
|
import { corsHeaders } from '../_shared/cors.ts'
|
|
import { authErrorResponse, type AuthError, requireUser } from '../_shared/auth.ts'
|
|
import { createServiceRoleClient, finalizeSttQuota, reserveSttQuota } from '../_shared/quota.ts'
|
|
import { buildDictionaryHints } from '../_shared/stt-contract.ts'
|
|
import { readProviderKey } from '../_shared/provider-key.ts'
|
|
import {
|
|
type AuthenticateResult,
|
|
createSttProxyHandler,
|
|
type SttDictionaryPort,
|
|
type SttQuotaPort,
|
|
} from './handler.ts'
|
|
import { createSttProviders, type SttProvider } from './providers.ts'
|
|
|
|
function isAuthError(err: unknown): err is AuthError {
|
|
return !!err
|
|
&& typeof err === 'object'
|
|
&& 'status' in err
|
|
&& (err.status === 401 || err.status === 403)
|
|
&& 'message' in err
|
|
&& typeof err.message === 'string'
|
|
}
|
|
|
|
async function authenticate(req: Request): Promise<AuthenticateResult> {
|
|
try {
|
|
const user = await requireUser(req)
|
|
return { ok: true, user: { id: user.id } }
|
|
} catch (err) {
|
|
if (isAuthError(err)) return { ok: false, response: authErrorResponse(err, corsHeaders) }
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/** The service-role client is created per call so a missing env becomes a 500, not a boot failure. */
|
|
const supabaseSttQuota: SttQuotaPort = {
|
|
reserve: (userId, reservationId) => reserveSttQuota(userId, reservationId, createServiceRoleClient()),
|
|
finalize: (reservationId, succeeded) => finalizeSttQuota(reservationId, succeeded, createServiceRoleClient()),
|
|
}
|
|
|
|
const supabaseDictionary: SttDictionaryPort = {
|
|
async hintsFor(userId) {
|
|
const query = await createServiceRoleClient()
|
|
.from('dictionary')
|
|
.select('word,pronunciation')
|
|
.eq('user_id', userId)
|
|
.order('usage_count', { ascending: false })
|
|
.limit(50)
|
|
return buildDictionaryHints(query.error ? [] : (query.data ?? []))
|
|
},
|
|
}
|
|
|
|
const logger = {
|
|
warn: (message: string, meta: Record<string, unknown>) => console.warn(message, meta),
|
|
error: (message: string, meta: Record<string, unknown>) => console.error(message, meta),
|
|
}
|
|
|
|
function providersFromEnv(): SttProvider[] {
|
|
return createSttProviders({
|
|
gatewayUrl: 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.
|
|
gatewayToken: Deno.env.get('D3RO_API_TOKEN') ?? '',
|
|
groqKey: readProviderKey('GROQ_API_KEY'),
|
|
openaiKey: readProviderKey('OPENAI_API_KEY'),
|
|
deepgramKey: readProviderKey('DEEPGRAM_API_KEY'),
|
|
}, { logger })
|
|
}
|
|
|
|
Deno.serve(createSttProxyHandler({
|
|
authenticate,
|
|
quota: supabaseSttQuota,
|
|
dictionary: supabaseDictionary,
|
|
providers: providersFromEnv,
|
|
logger,
|
|
}))
|