d3ro-voice/server/supabase/functions/llm-proxy/index.ts
Yun Chan 708e20f747
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
2026-08-20 11:12:05 +09:00

237 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// server/supabase/functions/llm-proxy/index.ts
// Anthropic Claude Messages API 프록시.
// Phase 3.2: 모델별 쿼터 (Haiku/Sonnet/Opus × Free/Pro/Pro+)
// 요청: application/json { messages, system?, max_tokens?, model? }
// 응답: JSON (non-stream) 또는 SSE (stream=true)
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
import {
checkQuota,
consumeQuota,
createServiceRoleClient,
modelToQuotaKey,
getQuotaPolicy,
type Tier,
} from '../_shared/quota.ts'
interface LlmRequest {
messages: Array<{ role: 'user' | 'assistant'; content: string }>
system?: string
max_tokens?: number
model?: string
stream?: boolean
}
/** 티어별 허용 모델 — free는 Haiku만, pro는 +Sonnet, pro_plus는 +Opus, team/enterprise는 전 모델 */
const TIER_MODELS: Record<Tier, string[]> = {
free: ['claude-haiku-4-5-20251001'],
pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'],
pro_plus: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'],
team: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'],
enterprise: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'],
}
const DEFAULT_MODEL: Record<Tier, string> = {
free: 'claude-haiku-4-5-20251001',
pro: 'claude-sonnet-4-6',
pro_plus: 'claude-sonnet-4-6',
team: 'claude-sonnet-4-6',
enterprise: 'claude-sonnet-4-6',
}
// @ts-expect-error — Deno 런타임 전역
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 {
const user = await requireUser(req)
const serviceClient = createServiceRoleClient()
const body = (await req.json()) as LlmRequest
// 1단계: 티어 조회 (어떤 모델이든 한 번만 읽으면 됨 — haiku로 대리 조회)
const tierCheck = await checkQuota(user.id, 'llm_haiku', serviceClient)
const tier = tierCheck.tier
// 모델 선택 + 티어 검증
const requestedModel = body.model ?? DEFAULT_MODEL[tier]
if (!TIER_MODELS[tier].includes(requestedModel)) {
return new Response(
JSON.stringify({
error: 'model_not_allowed',
tier,
requested: requestedModel,
allowed: TIER_MODELS[tier],
}),
{
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
// 2단계: 해당 모델의 쿼터 확인 (모델별 일간/주간)
const quotaKey = modelToQuotaKey(requestedModel)
const modelQuota = await checkQuota(user.id, quotaKey, serviceClient)
if (!modelQuota.allowed) {
return new Response(
JSON.stringify({
error: 'quota_exceeded',
model: requestedModel,
current: modelQuota.current,
limit: modelQuota.limit,
period: modelQuota.period,
tier,
overage_credits: modelQuota.overageCredits,
}),
{
status: 429,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
// 3단계: 쿼터 소비 (원자적 base → overage fallback)
const policy = getQuotaPolicy(tier, quotaKey)
const consume = await consumeQuota(user.id, quotaKey, serviceClient, policy.limit)
if (!consume.allowed) {
return new Response(
JSON.stringify({
error: 'quota_exceeded',
model: requestedModel,
current: consume.current,
limit: consume.limit,
tier,
overage_credits: consume.overageCredits,
}),
{
status: 429,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
// Anthropic API 호출
// @ts-expect-error — Deno.env
const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY') ?? ''
if (!anthropicKey) {
// Placeholder 응답 (키 미설정 시)
if (body.stream) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
const msg = '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]'
for (const ch of msg) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ch } })}\n\n`,
),
)
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
})
return new Response(stream, {
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
return new Response(
JSON.stringify({
id: `msg_placeholder_${Date.now()}`,
model: requestedModel,
role: 'assistant',
content: [
{
type: 'text',
text: '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]',
},
],
stop_reason: 'end_turn',
usage: { input_tokens: 0, output_tokens: 0 },
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
// 실제 Anthropic API 호출 (Prompt Caching 2024-07-31 활성화)
const systemPayload = body.system
? [
{
type: 'text',
text: body.system,
cache_control: { type: 'ephemeral' },
},
]
: undefined
const anthropicResp = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': anthropicKey,
'anthropic-version': '2023-06-01',
'anthropic-beta': 'prompt-caching-2024-07-31',
},
body: JSON.stringify({
model: requestedModel,
max_tokens: body.max_tokens ?? 2048,
system: systemPayload,
messages: body.messages,
stream: body.stream ?? false,
}),
})
if (!anthropicResp.ok) {
const errText = await anthropicResp.text()
throw new Error(`Anthropic ${anthropicResp.status}: ${errText.slice(0, 500)}`)
}
if (body.stream && anthropicResp.body) {
return new Response(anthropicResp.body, {
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
const data = await anthropicResp.json()
return new Response(JSON.stringify(data), {
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)
}
const message = err instanceof Error ? err.message : 'Unknown error'
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
})