fix(realtime-token): spend the session quota only after a token is minted and stop leaking provider errors

This commit is contained in:
Yun Chan 2026-09-28 00:54:00 +09:00
parent 83c2deb561
commit 2251fe2da1
7 changed files with 673 additions and 155 deletions

View file

@ -0,0 +1,55 @@
// server/supabase/functions/realtime-token/openai-provider.ts
// RealtimeProviderPort의 OpenAI 어댑터. 공급자 에러 본문은 읽지 않고 버린다
// (조직 ID·마스킹된 키 힌트가 섞여 있을 수 있음) — 상태 코드만 돌려준다.
import type { RealtimeSessionConfig } from './policy.ts'
import type { ClientSecretResult, RealtimeProviderPort } from './handler.ts'
export const OPENAI_CLIENT_SECRETS_URL = 'https://api.openai.com/v1/realtime/client_secrets'
export interface OpenAiRealtimeProviderOptions {
/** 호출마다 읽는다 — 시크릿 교체가 재배포 없이 반영되도록. 빈 문자열이면 미설정. */
readApiKey(): string
fetchImpl?: typeof fetch
}
export function createOpenAiRealtimeProvider(options: OpenAiRealtimeProviderOptions): RealtimeProviderPort {
const fetchImpl = options.fetchImpl ?? fetch
return {
isConfigured(): boolean {
return options.readApiKey() !== ''
},
async mintClientSecret(userId: string, session: RealtimeSessionConfig): Promise<ClientSecretResult> {
const apiKey = options.readApiKey()
if (!apiKey) return { ok: false, reason: 'network' }
let response: Response
try {
response = await fetchImpl(OPENAI_CLIENT_SECRETS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
'OpenAI-Safety-Identifier': userId,
},
body: JSON.stringify({ session }),
})
} catch {
return { ok: false, reason: 'network' }
}
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
return { ok: false, reason: 'upstream_status', status: response.status }
}
const data: unknown = await response.json().catch(() => null)
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return { ok: false, reason: 'invalid_response' }
}
return { ok: true, data: data as Record<string, unknown> }
},
}
}