fix(llm-proxy): reserve quota before the provider call and stop cutting off long streams

This commit is contained in:
Yun Chan 2026-09-28 02:16:17 +09:00
parent 2f94d24c99
commit b306034bfc
7 changed files with 1489 additions and 307 deletions

View file

@ -60,7 +60,8 @@ export interface QuotaConsumeResult {
consumedFrom: QuotaConsumedFrom
}
export interface SttQuotaReservation {
/** reserve_*_quota RPC 결과 — 공급자 호출 전에 한 단위를 잡아 두는 리스(lease). */
export interface QuotaReservation {
allowed: boolean
reservationId: string | null
status: 'reserved' | 'completed' | 'released' | 'denied'
@ -72,6 +73,59 @@ export interface SttQuotaReservation {
consumedFrom: 'base' | 'overage' | 'unlimited' | 'none'
}
export type SttQuotaReservation = QuotaReservation
export type QuotaReservationFinalStatus = 'completed' | 'released'
const RESERVATION_STATUSES: readonly unknown[] = ['reserved', 'completed', 'released', 'denied']
const QUOTA_PERIODS: readonly unknown[] = ['daily', 'weekly']
const QUOTA_TIERS: readonly unknown[] = ['free', 'pro', 'pro_plus', 'team', 'enterprise']
const RESERVATION_SOURCES: readonly unknown[] = ['base', 'overage', 'unlimited', 'none']
/** reserve_stt_quota / reserve_llm_quota 공통 응답 검증. 형식이 어긋나면 throw. */
export function parseQuotaReservationResponse(data: unknown, label: string): QuotaReservation {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error(`Invalid ${label} quota reservation response.`)
}
const result = data as Record<string, unknown>
if (
typeof result.allowed !== 'boolean'
|| (result.reservation_id !== null && typeof result.reservation_id !== 'string')
|| !RESERVATION_STATUSES.includes(result.status)
|| typeof result.current !== 'number'
|| typeof result.limit !== 'number'
|| !QUOTA_PERIODS.includes(result.period)
|| !QUOTA_TIERS.includes(result.tier)
|| typeof result.overage_credits !== 'number'
|| !RESERVATION_SOURCES.includes(result.consumed_from)
) {
throw new Error(`Invalid ${label} quota reservation response.`)
}
return {
allowed: result.allowed,
reservationId: result.reservation_id as string | null,
status: result.status as QuotaReservation['status'],
current: result.current,
limit: result.limit,
period: result.period as QuotaPeriod,
tier: result.tier as Tier,
overageCredits: result.overage_credits,
consumedFrom: result.consumed_from as QuotaReservation['consumedFrom'],
}
}
/** finalize_*_quota RPC 공통 응답 검증. */
export function parseQuotaFinalizeResponse(data: unknown, label: string): QuotaReservationFinalStatus {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error(`Invalid ${label} quota finalization response.`)
}
const status = (data as Record<string, unknown>).status
if (status !== 'completed' && status !== 'released') {
throw new Error(`Invalid ${label} quota finalization response.`)
}
return status
}
export async function reserveSttQuota(
userId: string,
reservationId: string,
@ -84,38 +138,14 @@ export async function reserveSttQuota(
if (error || !data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Failed to reserve STT quota.')
}
const result = data as Record<string, unknown>
if (
typeof result.allowed !== 'boolean'
|| (result.reservation_id !== null && typeof result.reservation_id !== 'string')
|| !['reserved', 'completed', 'released', 'denied'].includes(String(result.status))
|| typeof result.current !== 'number'
|| typeof result.limit !== 'number'
|| !['daily', 'weekly'].includes(String(result.period))
|| !['free', 'pro', 'pro_plus', 'team', 'enterprise'].includes(String(result.tier))
|| typeof result.overage_credits !== 'number'
|| !['base', 'overage', 'unlimited', 'none'].includes(String(result.consumed_from))
) {
throw new Error('Invalid STT quota reservation response.')
}
return {
allowed: result.allowed,
reservationId: result.reservation_id as string | null,
status: result.status as SttQuotaReservation['status'],
current: result.current,
limit: result.limit,
period: result.period as QuotaPeriod,
tier: result.tier as Tier,
overageCredits: result.overage_credits,
consumedFrom: result.consumed_from as SttQuotaReservation['consumedFrom'],
}
return parseQuotaReservationResponse(data, 'STT')
}
export async function finalizeSttQuota(
reservationId: string,
succeeded: boolean,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<'completed' | 'released'> {
): Promise<QuotaReservationFinalStatus> {
const { data, error } = await serviceRoleClient.rpc('finalize_stt_quota', {
p_reservation_id: reservationId,
p_succeeded: succeeded,
@ -123,11 +153,50 @@ export async function finalizeSttQuota(
if (error || !data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Failed to finalize STT quota.')
}
const status = (data as Record<string, unknown>).status
if (status !== 'completed' && status !== 'released') {
throw new Error('Invalid STT quota finalization response.')
return parseQuotaFinalizeResponse(data, 'STT')
}
/**
* LLM 쿼터 한 단위를 공급자 호출 **전에** 잡는다 (`reserve_llm_quota`).
* consume_quota 와 같은 per-user/feature advisory lock 아래에서 진행 중인 요청까지
* 사용량에 포함하므로, 동시 요청이 한도를 넘어 공급자 비용을 쓰지 못한다.
* 성공/실패는 finalizeLlmQuota 로 확정하고, 확정되지 않은 리스는 10분 뒤 환불된다.
*/
export async function reserveLlmQuota(
userId: string,
reservationId: string,
feature: QuotaFeature,
baseLimit: number,
period: QuotaPeriod,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<QuotaReservation> {
const { data, error } = await serviceRoleClient.rpc('reserve_llm_quota', {
p_user_id: userId,
p_reservation_id: reservationId,
p_feature: feature,
p_base_limit: baseLimit,
p_period: period,
})
if (error || !data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Failed to reserve LLM quota.')
}
return status
return parseQuotaReservationResponse(data, 'LLM')
}
/** 예약을 확정한다 — succeeded=true 면 소비 유지, false 면 사용량과 overage 크레딧 환불. */
export async function finalizeLlmQuota(
reservationId: string,
succeeded: boolean,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<QuotaReservationFinalStatus> {
const { data, error } = await serviceRoleClient.rpc('finalize_llm_quota', {
p_reservation_id: reservationId,
p_succeeded: succeeded,
})
if (error || !data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Failed to finalize LLM quota.')
}
return parseQuotaFinalizeResponse(data, 'LLM')
}
interface SubscriptionQuotaState {
@ -151,6 +220,14 @@ async function readSubscriptionQuotaState(
}
}
/** 구독 티어만 조회 (행이 없으면 free). 사용량은 읽지 않는다. */
export async function readSubscriptionTier(
userId: string,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<Tier> {
return (await readSubscriptionQuotaState(userId, serviceRoleClient)).tier
}
/** 기간 창(daily=오늘, weekly=오늘-6..오늘) 안의 daily_usage 합계. */
async function readWindowUsage(
userId: string,