feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
51
server/supabase/functions/stt-proxy/index.contract.test.ts
Normal file
51
server/supabase/functions/stt-proxy/index.contract.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
const source = await Deno.readTextFile(new URL('./index.ts', import.meta.url))
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
Deno.test('STT proxy has no synthetic success fallback', () => {
|
||||
for (const forbidden of [
|
||||
'd3ro-cloud-mock',
|
||||
'음성 전사 완료',
|
||||
'D3RO Cloud STT',
|
||||
]) {
|
||||
assert(!source.includes(forbidden), `forbidden synthetic fallback remains: ${forbidden}`)
|
||||
}
|
||||
|
||||
assert(source.includes("const error = status === 503 ? 'stt_provider_unavailable' : 'stt_upstream_failed'"),
|
||||
'provider failures must produce an explicit 502/503 error')
|
||||
})
|
||||
|
||||
Deno.test('gateway uses a dedicated D3RO API token', () => {
|
||||
assert(source.includes("Deno.env.get('D3RO_API_TOKEN')"), 'dedicated backend token is required')
|
||||
assert(source.includes('if (apiServerUrl && apiServerToken)'), 'gateway must be skipped without its own token')
|
||||
assert(!source.includes("req.headers.get('Authorization')"), 'Supabase user JWT must not be forwarded to D3RO API')
|
||||
assert(source.includes('createInternalSttGatewayUrl(apiServerUrl)'),
|
||||
'quota-owning internal endpoint must use the canonical URL guard')
|
||||
assert(source.includes("'X-D3RO-STT-Gateway-Token': apiServerToken"),
|
||||
'dedicated token must use the internal gateway header')
|
||||
assert(!source.includes('headers: { Authorization: authorization }'),
|
||||
'dedicated gateway token must not be accepted as a user JWT')
|
||||
})
|
||||
|
||||
Deno.test('atomic quota is reserved before provider work and finalized before success', () => {
|
||||
const reserveIndex = source.indexOf('const quota = await reserveSttQuota(')
|
||||
const denialIndex = source.indexOf('if (!quota.allowed', reserveIndex)
|
||||
const providerIndex = source.indexOf("const apiServerUrl = Deno.env.get('D3RO_API_URL')", denialIndex)
|
||||
const finalizeIndex = source.indexOf('await finalizeSttQuota(quotaReservation.id, true', providerIndex)
|
||||
const successIndex = source.indexOf('return new Response(JSON.stringify(result)', finalizeIndex)
|
||||
|
||||
assert(reserveIndex >= 0, 'atomic quota reservation is missing')
|
||||
assert(denialIndex > reserveIndex, 'quota denial is not checked')
|
||||
assert(providerIndex > denialIndex, 'provider work begins before quota denial')
|
||||
assert(finalizeIndex > providerIndex, 'successful provider work does not finalize quota')
|
||||
assert(successIndex > finalizeIndex, 'transcript success precedes quota finalization')
|
||||
assert(source.includes('finalizeSttQuota(quotaReservation.id, false'),
|
||||
'failed provider work does not release quota')
|
||||
})
|
||||
|
||||
Deno.test('unexpected errors are sanitized', () => {
|
||||
assert(source.includes("JSON.stringify({ error: 'internal_error' })"), 'generic internal error response is missing')
|
||||
assert(!source.includes('JSON.stringify({ error: message })'), 'raw exception messages must not be returned')
|
||||
})
|
||||
|
|
@ -4,17 +4,18 @@
|
|||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { checkQuota, consumeQuota, createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { createServiceRoleClient, finalizeSttQuota, reserveSttQuota } from '../_shared/quota.ts'
|
||||
import {
|
||||
buildDictionaryHints,
|
||||
createInternalSttGatewayUrl,
|
||||
createDeepgramSttUrl,
|
||||
normalizeSttResult,
|
||||
type NormalizedSttResult,
|
||||
SttInputError,
|
||||
STT_PROVIDER_TIMEOUT_MS,
|
||||
validateSttAudio,
|
||||
} from '../_shared/stt-contract.ts'
|
||||
|
||||
interface SttResult {
|
||||
transcript: string
|
||||
confidence: number
|
||||
language_code: string
|
||||
duration_seconds: number
|
||||
provider?: string
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
|
@ -26,32 +27,40 @@ Deno.serve(async (req: Request) => {
|
|||
})
|
||||
}
|
||||
|
||||
let quotaReservation: {
|
||||
id: string
|
||||
client: ReturnType<typeof createServiceRoleClient>
|
||||
} | null = null
|
||||
|
||||
try {
|
||||
// 1) 인증
|
||||
const user = await requireUser(req)
|
||||
let user: Awaited<ReturnType<typeof requireUser>>
|
||||
try {
|
||||
user = await requireUser(req)
|
||||
} catch (err) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'status' in err &&
|
||||
(err.status === 401 || err.status === 403) &&
|
||||
'message' in err &&
|
||||
typeof err.message === 'string'
|
||||
) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
}
|
||||
|
||||
// 2) 쿼터 체크
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const quota = await checkQuota(user.id, 'stt_transcribe', serviceClient)
|
||||
if (!quota.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'quota_exceeded',
|
||||
current: quota.current,
|
||||
limit: quota.limit,
|
||||
tier: quota.tier
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
return new Response(JSON.stringify({ error: 'internal_error' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// 2) service client
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
// 3) 입력 파싱
|
||||
const formData = await req.formData()
|
||||
const audio = formData.get('audio') ?? formData.get('file')
|
||||
const sampleRate = Number(formData.get('sample_rate') ?? 16000)
|
||||
const languageCode = String(formData.get('language_code') ?? 'ko')
|
||||
|
||||
if (!(audio instanceof Blob)) {
|
||||
|
|
@ -61,51 +70,112 @@ Deno.serve(async (req: Request) => {
|
|||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno.env
|
||||
let audioInput: ReturnType<typeof validateSttAudio>
|
||||
try {
|
||||
audioInput = validateSttAudio(audio, languageCode)
|
||||
} catch (error) {
|
||||
if (error instanceof SttInputError) {
|
||||
return new Response(JSON.stringify({ error: error.code }), {
|
||||
status: error.status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Reserve before provider work. The DB advisory lock closes concurrent
|
||||
// quota races; failed or crashed provider work is refunded/reclaimed.
|
||||
const quota = await reserveSttQuota(user.id, crypto.randomUUID(), serviceClient)
|
||||
if (!quota.allowed || !quota.reservationId || quota.status !== 'reserved') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'quota_exceeded',
|
||||
current: quota.current,
|
||||
limit: quota.limit,
|
||||
period: quota.period,
|
||||
tier: quota.tier,
|
||||
overage_credits: quota.overageCredits,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
quotaReservation = { id: quota.reservationId, client: serviceClient }
|
||||
|
||||
const dictionaryQuery = await serviceClient
|
||||
.from('dictionary')
|
||||
.select('word,pronunciation')
|
||||
.eq('user_id', user.id)
|
||||
.order('usage_count', { ascending: false })
|
||||
.limit(50)
|
||||
const dictionaryHints = buildDictionaryHints(
|
||||
dictionaryQuery.error ? [] : (dictionaryQuery.data ?? []),
|
||||
)
|
||||
|
||||
const apiServerUrl = Deno.env.get('D3RO_API_URL') ?? Deno.env.get('BACKEND_ORIGIN') ?? ''
|
||||
// @ts-expect-error — Deno.env
|
||||
// Supabase user JWTs are not valid D3RO API JWTs. Configure a dedicated backend token.
|
||||
const apiServerToken = Deno.env.get('D3RO_API_TOKEN') ?? ''
|
||||
const groqKey = Deno.env.get('GROQ_API_KEY') ?? ''
|
||||
// @ts-expect-error — Deno.env
|
||||
const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? ''
|
||||
// @ts-expect-error — Deno.env
|
||||
const deepgramKey = Deno.env.get('DEEPGRAM_API_KEY') ?? ''
|
||||
|
||||
let result: SttResult | null = null
|
||||
let result: NormalizedSttResult | null = null
|
||||
let attemptedProvider = false
|
||||
let sawBadGatewayFailure = false
|
||||
let sawServiceUnavailable = false
|
||||
|
||||
// 4) Forward to D3RO API Gateway Orchestrator if available
|
||||
if (apiServerUrl) {
|
||||
if (apiServerUrl && apiServerToken) {
|
||||
attemptedProvider = true
|
||||
try {
|
||||
const forwardForm = new FormData()
|
||||
forwardForm.append('file', audio, 'audio.webm')
|
||||
forwardForm.append('language', languageCode)
|
||||
forwardForm.append('file', audio, audioInput.fileName)
|
||||
forwardForm.append('language', audioInput.languageCode)
|
||||
if (dictionaryHints.prompt) forwardForm.append('prompt', dictionaryHints.prompt)
|
||||
|
||||
const apiResp = await fetch(`${apiServerUrl.replace(/\/$/, '')}/api/stt/transcribe`, {
|
||||
const apiResp = await fetch(createInternalSttGatewayUrl(apiServerUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'X-D3RO-STT-Gateway-Token': apiServerToken },
|
||||
body: forwardForm,
|
||||
signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (apiResp.ok) {
|
||||
const apiData = await apiResp.json()
|
||||
result = {
|
||||
transcript: apiData.text ?? '',
|
||||
if (typeof apiData?.text !== 'string') {
|
||||
throw new Error('Invalid STT gateway response')
|
||||
}
|
||||
result = normalizeSttResult({
|
||||
transcript: apiData.text,
|
||||
confidence: apiData.confidence ?? 0.98,
|
||||
language_code: apiData.language ?? languageCode,
|
||||
language_code: apiData.language
|
||||
?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode),
|
||||
duration_seconds: apiData.durationSeconds ?? (audio.size / 4000),
|
||||
provider: apiData.provider ?? 'd3ro-gateway',
|
||||
}
|
||||
})
|
||||
} else if (apiResp.status === 503) {
|
||||
sawServiceUnavailable = true
|
||||
} else {
|
||||
sawBadGatewayFailure = true
|
||||
}
|
||||
} catch {
|
||||
// continue to direct providers fallback
|
||||
sawBadGatewayFailure = true
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Direct Groq Whisper LPU Fallback (Sub-200ms)
|
||||
if (!result && groqKey) {
|
||||
attemptedProvider = true
|
||||
try {
|
||||
const groqForm = new FormData()
|
||||
groqForm.append('file', audio, 'audio.webm')
|
||||
groqForm.append('file', audio, audioInput.fileName)
|
||||
groqForm.append('model', 'whisper-large-v3-turbo')
|
||||
groqForm.append('language', languageCode)
|
||||
if (audioInput.languageCode !== 'auto' && audioInput.languageCode !== 'multi') {
|
||||
groqForm.append('language', audioInput.languageCode)
|
||||
}
|
||||
if (dictionaryHints.prompt) groqForm.append('prompt', dictionaryHints.prompt)
|
||||
groqForm.append('response_format', 'verbose_json')
|
||||
|
||||
const groqResp = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
|
||||
|
|
@ -114,30 +184,41 @@ Deno.serve(async (req: Request) => {
|
|||
Authorization: `Bearer ${groqKey}`,
|
||||
},
|
||||
body: groqForm,
|
||||
signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (groqResp.ok) {
|
||||
const groqData = await groqResp.json()
|
||||
result = {
|
||||
transcript: groqData.text ?? '',
|
||||
if (typeof groqData?.text !== 'string') {
|
||||
throw new Error('Invalid Groq STT response')
|
||||
}
|
||||
result = normalizeSttResult({
|
||||
transcript: groqData.text,
|
||||
confidence: 0.98,
|
||||
language_code: groqData.language ?? languageCode,
|
||||
language_code: groqData.language
|
||||
?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode),
|
||||
duration_seconds: groqData.duration ?? (audio.size / 4000),
|
||||
provider: 'groq',
|
||||
}
|
||||
})
|
||||
} else {
|
||||
sawBadGatewayFailure = true
|
||||
}
|
||||
} catch {
|
||||
// continue to openai fallback
|
||||
sawBadGatewayFailure = true
|
||||
}
|
||||
}
|
||||
|
||||
// 6) Direct OpenAI Whisper Fallback
|
||||
if (!result && openaiKey) {
|
||||
attemptedProvider = true
|
||||
try {
|
||||
const openAiForm = new FormData()
|
||||
openAiForm.append('file', audio, 'audio.webm')
|
||||
openAiForm.append('file', audio, audioInput.fileName)
|
||||
openAiForm.append('model', 'whisper-1')
|
||||
openAiForm.append('language', languageCode)
|
||||
if (audioInput.languageCode !== 'auto' && audioInput.languageCode !== 'multi') {
|
||||
openAiForm.append('language', audioInput.languageCode)
|
||||
}
|
||||
if (dictionaryHints.prompt) openAiForm.append('prompt', dictionaryHints.prompt)
|
||||
openAiForm.append('response_format', 'verbose_json')
|
||||
|
||||
const openAiResp = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
||||
|
|
@ -146,77 +227,99 @@ Deno.serve(async (req: Request) => {
|
|||
Authorization: `Bearer ${openaiKey}`,
|
||||
},
|
||||
body: openAiForm,
|
||||
signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (openAiResp.ok) {
|
||||
const openAiData = await openAiResp.json()
|
||||
result = {
|
||||
transcript: openAiData.text ?? '',
|
||||
if (typeof openAiData?.text !== 'string') {
|
||||
throw new Error('Invalid OpenAI STT response')
|
||||
}
|
||||
result = normalizeSttResult({
|
||||
transcript: openAiData.text,
|
||||
confidence: 0.98,
|
||||
language_code: openAiData.language ?? languageCode,
|
||||
language_code: openAiData.language
|
||||
?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode),
|
||||
duration_seconds: openAiData.duration ?? (audio.size / 4000),
|
||||
provider: 'openai',
|
||||
}
|
||||
})
|
||||
} else {
|
||||
sawBadGatewayFailure = true
|
||||
}
|
||||
} catch {
|
||||
// continue to deepgram fallback
|
||||
sawBadGatewayFailure = true
|
||||
}
|
||||
}
|
||||
|
||||
// 7) Direct Deepgram Nova-3 Fallback
|
||||
if (!result && deepgramKey) {
|
||||
attemptedProvider = true
|
||||
try {
|
||||
const audioBuffer = await audio.arrayBuffer()
|
||||
const dgResp = await fetch(`https://api.deepgram.com/v1/listen?model=nova-3&language=${languageCode}&smart_format=true&punctuate=true`, {
|
||||
const dgResp = await fetch(createDeepgramSttUrl(
|
||||
audioInput.languageCode,
|
||||
dictionaryHints.keyterms,
|
||||
), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Token ${deepgramKey}`,
|
||||
'Content-Type': 'audio/webm',
|
||||
'Content-Type': audioInput.contentType,
|
||||
},
|
||||
body: audioBuffer,
|
||||
signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (dgResp.ok) {
|
||||
const dgData = await dgResp.json()
|
||||
const transcript = dgData.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? ''
|
||||
const transcript = dgData.results?.channels?.[0]?.alternatives?.[0]?.transcript
|
||||
if (typeof transcript !== 'string') {
|
||||
throw new Error('Invalid Deepgram STT response')
|
||||
}
|
||||
const confidence = dgData.results?.channels?.[0]?.alternatives?.[0]?.confidence ?? 0.95
|
||||
result = {
|
||||
result = normalizeSttResult({
|
||||
transcript,
|
||||
confidence,
|
||||
language_code: languageCode,
|
||||
language_code: dgData.results?.channels?.[0]?.detected_language
|
||||
?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode),
|
||||
duration_seconds: dgData.metadata?.duration ?? (audio.size / 4000),
|
||||
provider: 'deepgram',
|
||||
}
|
||||
})
|
||||
} else {
|
||||
sawBadGatewayFailure = true
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
sawBadGatewayFailure = true
|
||||
}
|
||||
}
|
||||
|
||||
// 8) Fallback placeholder if no keys configured
|
||||
// 8) Fail closed if no provider produced a real transcription.
|
||||
if (!result) {
|
||||
result = {
|
||||
transcript: `[D3RO Cloud STT] 음성 전사 완료 (${languageCode})`,
|
||||
confidence: 0.98,
|
||||
language_code: languageCode,
|
||||
duration_seconds: audio.size / sampleRate / 2,
|
||||
provider: 'd3ro-cloud-mock',
|
||||
}
|
||||
await finalizeSttQuota(quotaReservation.id, false, quotaReservation.client).catch(() => undefined)
|
||||
quotaReservation = null
|
||||
const status = !attemptedProvider || (!sawBadGatewayFailure && sawServiceUnavailable) ? 503 : 502
|
||||
const error = status === 503 ? 'stt_provider_unavailable' : 'stt_upstream_failed'
|
||||
return new Response(JSON.stringify({ error }), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// 9) 쿼터 소비
|
||||
await consumeQuota(user.id, 'stt_transcribe', serviceClient, quota.limit)
|
||||
// 9) Mark the exact pre-provider reservation as consumed.
|
||||
const finalStatus = await finalizeSttQuota(quotaReservation.id, true, quotaReservation.client)
|
||||
if (finalStatus !== 'completed') {
|
||||
throw new Error('STT quota reservation was reclaimed before completion.')
|
||||
}
|
||||
quotaReservation = null
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
} catch {
|
||||
if (quotaReservation) {
|
||||
await finalizeSttQuota(quotaReservation.id, false, quotaReservation.client).catch(() => undefined)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
return new Response(JSON.stringify({ error: 'internal_error' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue