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,234 @@
import {
type ClientSecretResult,
createRealtimeTokenHandler,
type RealtimeProviderPort,
type RealtimeQuotaConsumeResult,
type RealtimeQuotaPort,
type RealtimeQuotaSnapshot,
} from './handler.ts'
import type { RealtimeSessionConfig, RealtimeTier } from './policy.ts'
import { createOpenAiRealtimeProvider } from './openai-provider.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
function assertEquals(actual: unknown, expected: unknown, message: string): void {
const a = JSON.stringify(actual)
const e = JSON.stringify(expected)
if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
}
const LEAKY_BODY = JSON.stringify({
error: {
message: 'Rate limit reached for org-SECRETORG123 on requests. Incorrect API key provided: sk-proj-****abcd',
},
})
interface Harness {
handler: (req: Request) => Promise<Response>
consumeCalls: Array<{ userId: string; tier: RealtimeTier }>
mintCalls: RealtimeSessionConfig[]
logs: Array<{ message: string; meta: Record<string, unknown> }>
}
interface HarnessOptions {
tier?: RealtimeTier
checkAllowed?: boolean
consumeAllowed?: boolean
configured?: boolean
mint?: () => Promise<ClientSecretResult>
provider?: RealtimeProviderPort
consumeThrows?: Error
}
function harness(options: HarnessOptions = {}): Harness {
const consumeCalls: Harness['consumeCalls'] = []
const mintCalls: RealtimeSessionConfig[] = []
const logs: Harness['logs'] = []
const tier = options.tier ?? 'pro'
const quota: RealtimeQuotaPort = {
check(): Promise<RealtimeQuotaSnapshot> {
return Promise.resolve({
allowed: options.checkAllowed ?? true,
current: 3,
limit: 30,
period: 'daily',
tier,
overageCredits: 0,
})
},
consume(userId: string, consumeTier: RealtimeTier): Promise<RealtimeQuotaConsumeResult> {
consumeCalls.push({ userId, tier: consumeTier })
if (options.consumeThrows) return Promise.reject(options.consumeThrows)
return Promise.resolve({
allowed: options.consumeAllowed ?? true,
current: 4,
limit: 30,
overageCredits: 0,
})
},
}
const provider: RealtimeProviderPort = options.provider ?? {
isConfigured: () => options.configured ?? true,
mintClientSecret(_userId: string, session: RealtimeSessionConfig): Promise<ClientSecretResult> {
mintCalls.push(session)
return options.mint?.() ??
Promise.resolve({ ok: true, data: { value: 'ek_test', expires_at: 123 } })
},
}
const handler = createRealtimeTokenHandler({
authenticate: () => Promise.resolve({ ok: true, user: { id: 'user-1' } }),
quota,
provider,
logger: { error: (message, meta) => logs.push({ message, meta }) },
})
return { handler, consumeCalls, mintCalls, logs }
}
function post(body: unknown = {}): Request {
return new Request('http://localhost/realtime-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
Deno.test('missing provider key returns 503 without consuming the session quota', async () => {
const h = harness({ configured: false })
const res = await h.handler(post())
assertEquals(res.status, 503, 'status')
assertEquals((await res.json()).error, 'not_configured', 'error code')
assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed')
assertEquals(h.mintCalls.length, 0, 'provider must not be called')
})
Deno.test('upstream 4xx/5xx returns a fixed code, keeps quota, and never leaks the provider body', async () => {
for (const status of [401, 429, 500, 503]) {
const fetchImpl: typeof fetch = () => Promise.resolve(new Response(LEAKY_BODY, { status }))
const h = harness({
provider: createOpenAiRealtimeProvider({ readApiKey: () => 'sk-test', fetchImpl }),
})
const res = await h.handler(post())
const text = await res.text()
assertEquals(res.status, 502, `status for upstream ${status}`)
assertEquals(JSON.parse(text), { error: 'provider_request_failed' }, 'fixed error body')
assert(!text.includes('org-SECRETORG123'), 'org id leaked')
assert(!text.includes('sk-proj'), 'key hint leaked')
assertEquals(h.consumeCalls.length, 0, `quota consumed on upstream ${status}`)
assertEquals(h.logs[0]?.meta, { reason: 'upstream_status', status }, 'status is logged server-side')
assert(!JSON.stringify(h.logs).includes('SECRETORG'), 'provider body must not reach logs either')
}
})
Deno.test('network failure returns 502 without consuming quota', async () => {
const fetchImpl: typeof fetch = () => Promise.reject(new TypeError('dns failure for api.openai.com'))
const h = harness({
provider: createOpenAiRealtimeProvider({ readApiKey: () => 'sk-test', fetchImpl }),
})
const res = await h.handler(post())
assertEquals(res.status, 502, 'status')
assertEquals(await res.json(), { error: 'provider_request_failed' }, 'body')
assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed')
})
Deno.test('non-object upstream success body returns provider_invalid_response without consuming quota', async () => {
const fetchImpl: typeof fetch = () => Promise.resolve(new Response('not json', { status: 200 }))
const h = harness({
provider: createOpenAiRealtimeProvider({ readApiKey: () => 'sk-test', fetchImpl }),
})
const res = await h.handler(post())
assertEquals(res.status, 502, 'status')
assertEquals(await res.json(), { error: 'provider_invalid_response' }, 'body')
assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed')
})
Deno.test('successful mint consumes exactly one session and returns the secret with model and tier', async () => {
const h = harness({ tier: 'pro' })
const res = await h.handler(post({ voice: 'cedar', instructions: 'be brief' }))
assertEquals(res.status, 200, 'status')
assertEquals(
await res.json(),
{ value: 'ek_test', expires_at: 123, model: 'gpt-realtime-2.1-mini', tier: 'pro' },
'body',
)
assertEquals(h.consumeCalls, [{ userId: 'user-1', tier: 'pro' }], 'consume once')
assertEquals(h.mintCalls[0], {
type: 'realtime',
model: 'gpt-realtime-2.1-mini',
instructions: 'be brief',
audio: { output: { voice: 'cedar' } },
}, 'session config')
})
Deno.test('a consume race lost after minting returns 429 and discards the secret', async () => {
const h = harness({ consumeAllowed: false })
const res = await h.handler(post())
const text = await res.text()
assertEquals(res.status, 429, 'status')
assertEquals(JSON.parse(text).error, 'quota_exceeded', 'error code')
assert(!text.includes('ek_test'), 'minted secret must not be returned when quota is denied')
})
Deno.test('tier and quota denials happen before the provider is called', async () => {
const free = harness({ tier: 'free' })
const freeRes = await free.handler(post())
assertEquals(freeRes.status, 403, 'free status')
assertEquals((await freeRes.json()).error, 'tier_not_allowed', 'free error')
const wrongModel = harness({ tier: 'pro' })
const wrongRes = await wrongModel.handler(post({ model: 'gpt-realtime-2.1' }))
assertEquals(wrongRes.status, 403, 'model status')
assertEquals(await wrongRes.json(), {
error: 'model_not_allowed',
tier: 'pro',
requested: 'gpt-realtime-2.1',
allowed: ['gpt-realtime-2.1-mini'],
}, 'model body')
const exhausted = harness({ checkAllowed: false })
const exhaustedRes = await exhausted.handler(post())
assertEquals(exhaustedRes.status, 429, 'exhausted status')
await exhaustedRes.body?.cancel()
for (const h of [free, wrongModel, exhausted]) {
assertEquals(h.mintCalls.length, 0, 'provider must not be called')
assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed')
}
})
Deno.test('unexpected errors return a generic internal_error without the exception message', async () => {
const h = harness({ consumeThrows: new Error('Failed to consume quota: relation daily_usage secret detail') })
const res = await h.handler(post())
const text = await res.text()
assertEquals(res.status, 500, 'status')
assertEquals(JSON.parse(text), { error: 'internal_error' }, 'body')
assert(!text.includes('secret detail'), 'exception message leaked')
})
Deno.test('auth rejection response is returned as-is and non-POST is 405', async () => {
const handler = createRealtimeTokenHandler({
authenticate: () =>
Promise.resolve({ ok: false, response: new Response('{"error":"Invalid auth token"}', { status: 401 }) }),
quota: {
check: () => Promise.reject(new Error('must not be called')),
consume: () => Promise.reject(new Error('must not be called')),
},
provider: { isConfigured: () => true, mintClientSecret: () => Promise.reject(new Error('unused')) },
logger: { error: () => undefined },
})
const res = await handler(post())
assertEquals(res.status, 401, 'auth status')
await res.body?.cancel()
const getRes = await handler(new Request('http://localhost/realtime-token', { method: 'GET' }))
assertEquals(getRes.status, 405, 'method status')
await getRes.body?.cancel()
const preflight = await handler(new Request('http://localhost/realtime-token', { method: 'OPTIONS' }))
assertEquals(preflight.status, 200, 'preflight status')
await preflight.body?.cancel()
})

View file

@ -0,0 +1,176 @@
// server/supabase/functions/realtime-token/handler.ts
// realtime-token 유스케이스. IO는 모두 포트(인터페이스)로 주입받는다.
//
// 순서가 핵심이다:
// 1. 인증
// 2. 쿼터 조회(읽기 전용) → 티어/모델 403, 소진 429
// 3. 공급자 키 확인 → 없으면 503 (쿼터를 건드리기 전)
// 4. OpenAI client secret 발급 → 실패하면 502 (쿼터를 건드리기 전)
// 5. 발급에 성공한 뒤에만 세션 쿼터를 원자적으로 소비 → 거부되면 429, 토큰은 버린다
// 발급된 ephemeral secret은 연결에 쓰이기 전까지 비용이 없고 곧 만료되므로,
// 소비가 거부되어 버려져도 손실이 없다. 반대로 소비를 먼저 하면 공급자 장애 시
// 환불 수단 없이 세션이 차감된다.
//
// 공급자 에러 본문(조직 ID, 마스킹된 키 힌트 등)은 절대 응답에 싣지 않는다.
// 상태 코드만 서버 로그에 남기고 고정 코드를 반환한다.
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import {
buildRealtimeSessionConfig,
type RealtimeSessionConfig,
type RealtimeTier,
type RealtimeTokenRequest,
resolveRealtimeModel,
} from './policy.ts'
export interface RealtimeUser {
id: string
}
export type AuthenticateResult =
| { ok: true; user: RealtimeUser }
| { ok: false; response: Response }
export interface RealtimeQuotaSnapshot {
allowed: boolean
current: number
limit: number
period: string
tier: RealtimeTier
overageCredits: number
}
export interface RealtimeQuotaConsumeResult {
allowed: boolean
current: number
limit: number
overageCredits: number
}
/** 세션 쿼터 포트 — check는 부작용 없음, consume은 원자적 1회 차감. */
export interface RealtimeQuotaPort {
check(userId: string): Promise<RealtimeQuotaSnapshot>
consume(userId: string, tier: RealtimeTier): Promise<RealtimeQuotaConsumeResult>
}
export type ClientSecretResult =
| { ok: true; data: Record<string, unknown> }
| { ok: false; reason: 'upstream_status'; status: number }
| { ok: false; reason: 'network' | 'invalid_response' }
/** Realtime 공급자 포트 — 에러 본문은 포트 밖으로 내보내지 않는다. */
export interface RealtimeProviderPort {
isConfigured(): boolean
mintClientSecret(userId: string, session: RealtimeSessionConfig): Promise<ClientSecretResult>
}
export interface RealtimeLogger {
error(message: string, meta: Record<string, unknown>): void
}
export interface RealtimeTokenDeps {
authenticate(req: Request): Promise<AuthenticateResult>
quota: RealtimeQuotaPort
provider: RealtimeProviderPort
logger: RealtimeLogger
}
function json(
status: number,
payload: Record<string, unknown>,
): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
async function readBody(req: Request): Promise<RealtimeTokenRequest> {
const parsed: unknown = await req.json().catch(() => ({}))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as RealtimeTokenRequest
: {}
}
export function createRealtimeTokenHandler(deps: RealtimeTokenDeps): (req: Request) => Promise<Response> {
const { quota, provider, logger } = deps
return async (req: Request): Promise<Response> => {
const preflight = handleCorsPreflightRequest(req)
if (preflight) return preflight
if (req.method !== 'POST') {
return json(405, { error: 'Method not allowed' })
}
try {
const auth = await deps.authenticate(req)
if (!auth.ok) return auth.response
const user = auth.user
const body = await readBody(req)
// 1단계: 티어 + 세션 쿼터 확인 (읽기 전용)
const snapshot = await quota.check(user.id)
const tier = snapshot.tier
const resolution = resolveRealtimeModel(tier, body.model)
if (!resolution.allowed) {
return json(403, {
error: resolution.error,
tier,
requested: resolution.requested,
allowed: resolution.allowedModels,
})
}
if (!snapshot.allowed) {
return json(429, {
error: 'quota_exceeded',
current: snapshot.current,
limit: snapshot.limit,
period: snapshot.period,
tier,
overage_credits: snapshot.overageCredits,
})
}
// 2단계: 공급자 가용성 — 쿼터 차감 전에 확인
if (!provider.isConfigured()) {
return json(503, { error: 'not_configured', message: 'OPENAI_API_KEY 미설정' })
}
// 3단계: OpenAI ephemeral client secret 발급
const session = buildRealtimeSessionConfig(body, resolution.model)
const minted = await provider.mintClientSecret(user.id, session)
if (!minted.ok) {
logger.error('realtime-token provider failed', {
reason: minted.reason,
...(minted.reason === 'upstream_status' ? { status: minted.status } : {}),
})
const error = minted.reason === 'invalid_response'
? 'provider_invalid_response'
: 'provider_request_failed'
return json(502, { error })
}
// 4단계: 발급 성공 후에만 세션 1회를 원자적으로 소비
const consumed = await quota.consume(user.id, tier)
if (!consumed.allowed) {
return json(429, {
error: 'quota_exceeded',
current: consumed.current,
limit: consumed.limit,
tier,
overage_credits: consumed.overageCredits,
})
}
return json(200, { ...minted.data, model: resolution.model, tier })
} catch (err) {
logger.error('realtime-token failed', {
kind: err instanceof Error ? err.name : typeof err,
})
return json(500, { error: 'internal_error' })
}
}
}

View file

@ -0,0 +1,32 @@
// Source-order contract for realtime-token: provider availability and minting
// must precede the quota spend, and raw errors must never reach the response.
const index = await Deno.readTextFile(new URL('./index.ts', import.meta.url))
const handler = await Deno.readTextFile(new URL('./handler.ts', import.meta.url))
const provider = await Deno.readTextFile(new URL('./openai-provider.ts', import.meta.url))
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
Deno.test('index is only a composition root over the tested handler', () => {
assert(index.includes('createRealtimeTokenHandler('), 'index must wire the tested handler')
assert(index.includes('Deno.serve(handler)'), 'index must serve the composed handler')
assert(!index.includes('api.openai.com'), 'provider IO must live in openai-provider.ts')
})
Deno.test('provider checks and minting precede the quota spend', () => {
const configuredIndex = handler.indexOf('provider.isConfigured()')
const mintIndex = handler.indexOf('provider.mintClientSecret(', configuredIndex)
const consumeIndex = handler.indexOf('quota.consume(', mintIndex)
assert(configuredIndex >= 0, 'provider availability check missing')
assert(mintIndex > configuredIndex, 'minting must follow the availability check')
assert(consumeIndex > mintIndex, 'quota must be consumed only after a successful mint')
})
Deno.test('raw provider and exception text are never returned', () => {
for (const source of [index, handler, provider]) {
assert(!source.includes('JSON.stringify({ error: message })'), 'raw exception message returned')
assert(!source.includes('errText'), 'provider error body is read into the response path')
}
assert(handler.includes("json(500, { error: 'internal_error' })"), 'generic internal error missing')
})

View file

@ -3,174 +3,61 @@
// 렌더러가 이 토큰으로 OpenAI와 직접 WebRTC 연결한다 (서버 키 비노출). // 렌더러가 이 토큰으로 OpenAI와 직접 WebRTC 연결한다 (서버 키 비노출).
// 요청: application/json { model?, voice?, instructions? } // 요청: application/json { model?, voice?, instructions? }
// 응답: OpenAI client_secrets 응답 그대로 + { model, tier } // 응답: OpenAI client_secrets 응답 그대로 + { model, tier }
//
// 이 파일은 조립 루트(composition root)다. 유스케이스는 handler.ts,
// 정책은 policy.ts, OpenAI 호출은 openai-provider.ts에 있다.
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { corsHeaders } from '../_shared/cors.ts'
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' import { authErrorResponse, requireUser, type AuthError } from '../_shared/auth.ts'
import { import {
checkQuota, checkQuota,
consumeQuota, consumeQuota,
createServiceRoleClient, createServiceRoleClient,
getQuotaPolicy, getQuotaPolicy,
type Tier,
} from '../_shared/quota.ts' } from '../_shared/quota.ts'
import { readProviderKey } from '../_shared/provider-key.ts' import { readProviderKey } from '../_shared/provider-key.ts'
import {
type AuthenticateResult,
createRealtimeTokenHandler,
type RealtimeQuotaPort,
} from './handler.ts'
import { createOpenAiRealtimeProvider } from './openai-provider.ts'
interface RealtimeTokenRequest { function isAuthError(err: unknown): err is AuthError {
model?: string return !!err && typeof err === 'object' && 'status' in err && 'message' in err
voice?: string
instructions?: string
} }
/** 티어별 허용 Realtime 모델 — free 차단, pro는 mini만, 상위 티어는 풀 모델까지 */ async function authenticate(req: Request): Promise<AuthenticateResult> {
const TIER_MODELS: Record<Tier, string[]> = {
free: [],
pro: ['gpt-realtime-2.1-mini'],
pro_plus: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'],
team: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'],
enterprise: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'],
}
const DEFAULT_MODEL: Record<Tier, string | null> = {
free: null,
pro: 'gpt-realtime-2.1-mini',
pro_plus: 'gpt-realtime-2.1',
team: 'gpt-realtime-2.1',
enterprise: 'gpt-realtime-2.1',
}
const DEFAULT_VOICE = 'marin'
const MAX_INSTRUCTIONS_LENGTH = 2000
Deno.serve(async (req: Request) => {
const preflight = handleCorsPreflightRequest(req)
if (preflight) return preflight
if (req.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
try { try {
const user = await requireUser(req) const user = await requireUser(req)
const serviceClient = createServiceRoleClient() return { ok: true, user: { id: user.id } }
const body = (await req.json().catch(() => ({}))) as RealtimeTokenRequest
// 1단계: 티어 + 세션 쿼터 확인
const quota = await checkQuota(user.id, 'realtime_session', serviceClient)
const tier = quota.tier
const requestedModel = body.model ?? DEFAULT_MODEL[tier]
if (!requestedModel || !TIER_MODELS[tier].includes(requestedModel)) {
return new Response(
JSON.stringify({
error: tier === 'free' ? 'tier_not_allowed' : 'model_not_allowed',
tier,
requested: body.model ?? null,
allowed: TIER_MODELS[tier],
}),
{
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
if (!quota.allowed) {
return new Response(
JSON.stringify({
error: 'quota_exceeded',
current: quota.current,
limit: quota.limit,
period: quota.period,
tier,
overage_credits: quota.overageCredits,
}),
{
status: 429,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
// 2단계: 쿼터 소비 (세션 시작 = 1회)
const policy = getQuotaPolicy(tier, 'realtime_session')
const consume = await consumeQuota(user.id, 'realtime_session', serviceClient, policy.limit)
if (!consume.allowed) {
return new Response(
JSON.stringify({
error: 'quota_exceeded',
current: consume.current,
limit: consume.limit,
tier,
overage_credits: consume.overageCredits,
}),
{
status: 429,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
const openaiKey = readProviderKey('OPENAI_API_KEY')
if (!openaiKey) {
return new Response(
JSON.stringify({ error: 'not_configured', message: 'OPENAI_API_KEY 미설정' }),
{
status: 503,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
// 3단계: OpenAI ephemeral client secret 발급
const instructions =
typeof body.instructions === 'string'
? body.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)
: undefined
const openaiResp = await fetch('https://api.openai.com/v1/realtime/client_secrets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${openaiKey}`,
'OpenAI-Safety-Identifier': user.id,
},
body: JSON.stringify({
session: {
type: 'realtime',
model: requestedModel,
...(instructions ? { instructions } : {}),
audio: {
output: { voice: body.voice ?? DEFAULT_VOICE },
},
},
}),
})
if (!openaiResp.ok) {
const errText = await openaiResp.text()
throw new Error(`OpenAI ${openaiResp.status}: ${errText.slice(0, 500)}`)
}
const data = await openaiResp.json()
return new Response(
JSON.stringify({ ...data, model: requestedModel, tier }),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
} catch (err) { } catch (err) {
if (err && typeof err === 'object' && 'status' in err && 'message' in err) { if (isAuthError(err)) return { ok: false, response: authErrorResponse(err, corsHeaders) }
return authErrorResponse(err as AuthError, corsHeaders) throw err
} }
const message = err instanceof Error ? err.message : 'Unknown error' }
return new Response(JSON.stringify({ error: message }), {
status: 500, /** 서비스 롤 클라이언트는 요청 처리 중에 만든다 — 환경 누락이 부팅 실패가 아닌 500이 되도록. */
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, function createSupabaseRealtimeQuota(): RealtimeQuotaPort {
}) return {
check: (userId) => checkQuota(userId, 'realtime_session', createServiceRoleClient()),
consume: (userId, tier) =>
consumeQuota(
userId,
'realtime_session',
createServiceRoleClient(),
getQuotaPolicy(tier, 'realtime_session').limit,
),
} }
}
const handler = createRealtimeTokenHandler({
authenticate,
quota: createSupabaseRealtimeQuota(),
provider: createOpenAiRealtimeProvider({ readApiKey: () => readProviderKey('OPENAI_API_KEY') }),
logger: {
error: (message, meta) => console.error(message, meta),
},
}) })
Deno.serve(handler)

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

View file

@ -0,0 +1,52 @@
import {
buildRealtimeSessionConfig,
DEFAULT_VOICE,
MAX_INSTRUCTIONS_LENGTH,
resolveRealtimeModel,
} from './policy.ts'
function assertEquals(actual: unknown, expected: unknown, message: string): void {
const a = JSON.stringify(actual)
const e = JSON.stringify(expected)
if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
}
Deno.test('resolveRealtimeModel applies tier defaults and allow-lists', () => {
assertEquals(resolveRealtimeModel('pro', undefined), { allowed: true, model: 'gpt-realtime-2.1-mini' }, 'pro default')
assertEquals(resolveRealtimeModel('pro_plus', undefined), { allowed: true, model: 'gpt-realtime-2.1' }, 'pro+ default')
assertEquals(
resolveRealtimeModel('team', 'gpt-realtime-2.1-mini'),
{ allowed: true, model: 'gpt-realtime-2.1-mini' },
'team explicit mini',
)
assertEquals(resolveRealtimeModel('free', undefined), {
allowed: false,
error: 'tier_not_allowed',
requested: null,
allowedModels: [],
}, 'free blocked')
assertEquals(resolveRealtimeModel('pro', 'gpt-realtime-2.1'), {
allowed: false,
error: 'model_not_allowed',
requested: 'gpt-realtime-2.1',
allowedModels: ['gpt-realtime-2.1-mini'],
}, 'pro cannot pick full model')
})
Deno.test('buildRealtimeSessionConfig clamps instructions and defaults voice', () => {
const long = 'x'.repeat(MAX_INSTRUCTIONS_LENGTH + 50)
const config = buildRealtimeSessionConfig({ instructions: long }, 'gpt-realtime-2.1-mini')
assertEquals(config.instructions?.length, MAX_INSTRUCTIONS_LENGTH, 'instructions clamped')
assertEquals(config.audio.output.voice, DEFAULT_VOICE, 'default voice')
assertEquals(
buildRealtimeSessionConfig({ voice: 'cedar', instructions: '' }, 'm'),
{ type: 'realtime', model: 'm', audio: { output: { voice: 'cedar' } } },
'empty instructions omitted',
)
assertEquals(
buildRealtimeSessionConfig({ voice: 42, instructions: 7 }, 'm'),
{ type: 'realtime', model: 'm', audio: { output: { voice: DEFAULT_VOICE } } },
'non-string fields ignored',
)
})

View file

@ -0,0 +1,82 @@
// server/supabase/functions/realtime-token/policy.ts
// Realtime 세션 발급 정책 — 순수 함수만 둔다 (IO·Deno 전역·네트워크 import 없음).
// 티어별 허용 모델 결정과 OpenAI client_secrets 세션 설정 조립을 담당한다.
import type { PlanQuotaTier } from '../_shared/core-contract.generated.ts'
export type RealtimeTier = PlanQuotaTier
export interface RealtimeTokenRequest {
model?: unknown
voice?: unknown
instructions?: unknown
}
/** 티어별 허용 Realtime 모델 — free 차단, pro는 mini만, 상위 티어는 풀 모델까지 */
export const TIER_MODELS: Readonly<Record<RealtimeTier, readonly string[]>> = {
free: [],
pro: ['gpt-realtime-2.1-mini'],
pro_plus: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'],
team: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'],
enterprise: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'],
}
export const DEFAULT_MODEL: Readonly<Record<RealtimeTier, string | null>> = {
free: null,
pro: 'gpt-realtime-2.1-mini',
pro_plus: 'gpt-realtime-2.1',
team: 'gpt-realtime-2.1',
enterprise: 'gpt-realtime-2.1',
}
export const DEFAULT_VOICE = 'marin'
export const MAX_INSTRUCTIONS_LENGTH = 2000
export type ModelResolution =
| { allowed: true; model: string }
| {
allowed: false
error: 'tier_not_allowed' | 'model_not_allowed'
requested: string | null
allowedModels: readonly string[]
}
/** 요청 모델(없으면 티어 기본값)이 티어에서 허용되는지 판정한다. */
export function resolveRealtimeModel(tier: RealtimeTier, requested: unknown): ModelResolution {
const requestedModel = typeof requested === 'string' ? requested : null
const allowedModels = TIER_MODELS[tier] ?? []
const model = requestedModel ?? DEFAULT_MODEL[tier] ?? null
if (model && allowedModels.includes(model)) {
return { allowed: true, model }
}
return {
allowed: false,
error: tier === 'free' ? 'tier_not_allowed' : 'model_not_allowed',
requested: requestedModel,
allowedModels,
}
}
export interface RealtimeSessionConfig {
type: 'realtime'
model: string
instructions?: string
audio: { output: { voice: string } }
}
/** OpenAI client_secrets 요청의 session 객체를 조립한다. */
export function buildRealtimeSessionConfig(
body: RealtimeTokenRequest,
model: string,
): RealtimeSessionConfig {
const instructions = typeof body.instructions === 'string'
? body.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)
: undefined
const voice = typeof body.voice === 'string' && body.voice ? body.voice : DEFAULT_VOICE
return {
type: 'realtime',
model,
...(instructions ? { instructions } : {}),
audio: { output: { voice } },
}
}