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

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,
}))