// 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 = { 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 = { 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' }, }) } })