// server/supabase/functions/llm-proxy/index.ts // Anthropic Claude Messages API 프록시. // 요청: 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, type Tier } from '../_shared/quota.ts' interface LlmRequest { messages: Array<{ role: 'user' | 'assistant'; content: string }> system?: string max_tokens?: number model?: string stream?: boolean } /** 티어별 허용 모델 */ const TIER_MODELS: Record = { free: ['claude-haiku-4-5-20251001'], pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'], team: ['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', team: '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 quota = await checkQuota(user.id, 'llm_process', serviceClient) if (!quota.allowed) { return new Response( JSON.stringify({ error: 'quota_exceeded', current: quota.current, limit: quota.limit, tier: quota.tier }), { status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } const body = (await req.json()) as LlmRequest // 모델 선택 + 티어 검증 const requestedModel = body.model ?? DEFAULT_MODEL[quota.tier] if (!TIER_MODELS[quota.tier].includes(requestedModel)) { return new Response( JSON.stringify({ error: 'model_not_allowed', tier: quota.tier, requested: requestedModel, allowed: TIER_MODELS[quota.tier] }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Anthropic API 호출 // @ts-expect-error — Deno.env const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY') ?? '' await consumeQuota(user.id, 'llm_process', serviceClient, 1) if (!anthropicKey) { // Placeholder 응답 (키 미설정 시) if (body.stream) { // 스트리밍 placeholder — SSE로 "설정되지 않음" 메시지 전송 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 호출 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' }, body: JSON.stringify({ model: requestedModel, max_tokens: body.max_tokens ?? 2048, system: body.system, 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) { // SSE 스트림을 그대로 전달 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' } }) } })