266 lines
9 KiB
TypeScript
266 lines
9 KiB
TypeScript
// 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'
|
||
import {
|
||
hasAssistantText,
|
||
LlmRequestError,
|
||
parseLlmRequest,
|
||
} from '../_shared/llm-contract.ts'
|
||
import {
|
||
GENERATION_ID_HEADER,
|
||
GENERATION_PURPOSE_HEADER,
|
||
GenerationReceiptError,
|
||
parseGenerationPurpose,
|
||
parseGenerationReceiptId,
|
||
type GenerationPurpose,
|
||
} from '../_shared/generation-receipt.ts'
|
||
import { buildAnthropicSystemBlocks } from '../_shared/generative-ai-safety.ts'
|
||
|
||
/** 티어별 허용 모델 — 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',
|
||
}
|
||
|
||
async function issueGenerationReceipt(
|
||
serviceClient: ReturnType<typeof createServiceRoleClient>,
|
||
userId: string,
|
||
purpose: GenerationPurpose | null,
|
||
model: string,
|
||
): Promise<string | null> {
|
||
if (purpose === null) return null
|
||
const { data, error } = await serviceClient.rpc('issue_content_generation_receipt_v1', {
|
||
p_actor_id: userId,
|
||
p_purpose: purpose,
|
||
p_model: model,
|
||
})
|
||
if (error) {
|
||
console.error('Generation receipt issuance failed', { code: error.code ?? 'unknown' })
|
||
throw new GenerationReceiptError('generation_receipt_unavailable')
|
||
}
|
||
return parseGenerationReceiptId(data)
|
||
}
|
||
|
||
function generationHeaders(generationId: string | null): Record<string, string> {
|
||
return generationId === null ? {} : { [GENERATION_ID_HEADER]: generationId }
|
||
}
|
||
|
||
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()
|
||
|
||
let rawBody: unknown
|
||
try {
|
||
rawBody = await req.json()
|
||
} catch {
|
||
throw new LlmRequestError('Invalid JSON body')
|
||
}
|
||
const body = parseLlmRequest(rawBody)
|
||
const generationPurpose = parseGenerationPurpose(
|
||
req.headers.get(GENERATION_PURPOSE_HEADER),
|
||
)
|
||
|
||
// A deployment without a provider must not consume quota or fabricate an answer.
|
||
const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY')?.trim() ?? ''
|
||
if (!anthropicKey) {
|
||
return new Response(JSON.stringify({ error: 'provider_unavailable' }), {
|
||
status: 503,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
||
})
|
||
}
|
||
|
||
// 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 호출 (Prompt Caching 2024-07-31 활성화)
|
||
const systemPayload = buildAnthropicSystemBlocks(body.system)
|
||
|
||
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,
|
||
system: systemPayload,
|
||
messages: body.messages,
|
||
stream: body.stream,
|
||
}),
|
||
signal: AbortSignal.timeout(45_000),
|
||
})
|
||
|
||
if (!anthropicResp.ok) {
|
||
console.error('Anthropic request failed', { status: anthropicResp.status })
|
||
return new Response(JSON.stringify({ error: 'provider_request_failed' }), {
|
||
status: 502,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
||
})
|
||
}
|
||
|
||
if (body.stream && anthropicResp.body) {
|
||
const generationId = await issueGenerationReceipt(
|
||
serviceClient,
|
||
user.id,
|
||
generationPurpose,
|
||
requestedModel,
|
||
)
|
||
return new Response(anthropicResp.body, {
|
||
status: 200,
|
||
headers: {
|
||
...corsHeaders,
|
||
...generationHeaders(generationId),
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-store',
|
||
Connection: 'keep-alive',
|
||
},
|
||
})
|
||
}
|
||
|
||
const data: unknown = await anthropicResp.json()
|
||
if (!hasAssistantText(data)) {
|
||
console.error('Anthropic returned an invalid response shape')
|
||
return new Response(JSON.stringify({ error: 'provider_invalid_response' }), {
|
||
status: 502,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
||
})
|
||
}
|
||
const generationId = await issueGenerationReceipt(
|
||
serviceClient,
|
||
user.id,
|
||
generationPurpose,
|
||
requestedModel,
|
||
)
|
||
return new Response(JSON.stringify(data), {
|
||
status: 200,
|
||
headers: {
|
||
...corsHeaders,
|
||
...generationHeaders(generationId),
|
||
'Content-Type': 'application/json',
|
||
'Cache-Control': 'no-store',
|
||
},
|
||
})
|
||
} catch (err) {
|
||
if (err instanceof GenerationReceiptError) {
|
||
const invalidPurpose = err.code === 'invalid_generation_purpose'
|
||
return new Response(JSON.stringify({
|
||
error: invalidPurpose ? 'invalid_request' : 'generation_receipt_unavailable',
|
||
}), {
|
||
status: invalidPurpose ? 400 : 503,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
||
})
|
||
}
|
||
if (err instanceof LlmRequestError) {
|
||
return new Response(JSON.stringify({ error: 'invalid_request', message: err.message }), {
|
||
status: err.status,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
||
})
|
||
}
|
||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||
return authErrorResponse(err as AuthError, corsHeaders)
|
||
}
|
||
const timedOut = err instanceof DOMException && err.name === 'TimeoutError'
|
||
console.error('llm-proxy failed', { kind: timedOut ? 'provider_timeout' : 'internal_error' })
|
||
return new Response(JSON.stringify({ error: timedOut ? 'provider_timeout' : 'internal_error' }), {
|
||
status: timedOut ? 504 : 500,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
||
})
|
||
}
|
||
})
|