All checks were successful
ci / 정본·보안·린트·타입·테스트 (push) Successful in 52s
ci / 모바일 린트·타입·Jest (push) Successful in 41s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 22s
ci / .NET API 서버 테스트 (push) Successful in 14s
deploy-site / deploy (push) Successful in 41s
ci / 워크스페이스 빌드 검증 (push) Successful in 36s
A five-second phone recording took over a minute: stt-proxy waited up to 60 s for the self-hosted gateway, whose GPU endpoint was off and whose NAS CPU Whisper needs 30-90 s per clip. With a direct provider configured the gateway now gets 5 s plus the clip length (30 s cap). The direct OpenAI fallback never produced a result. The production key held characters that are not valid in an HTTP header, so every request threw while being built; provider keys are now stripped of BOM/zero-width characters and a still-invalid key counts as not configured. whisper-1 verbose_json reports the language by name, which the result contract rejected; names now map to codes. Fail-closed responses list each provider's failure (status or error class, no secrets) so an outage can be diagnosed without log access.
267 lines
9.1 KiB
TypeScript
267 lines
9.1 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'
|
||
import { readProviderKey } from '../_shared/provider-key.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 = readProviderKey('ANTHROPIC_API_KEY')
|
||
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' },
|
||
})
|
||
}
|
||
})
|