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