feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -14,14 +14,20 @@ import {
|
|||
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
|
||||
}
|
||||
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[]> = {
|
||||
|
|
@ -40,7 +46,29 @@ const DEFAULT_MODEL: Record<Tier, string> = {
|
|||
enterprise: 'claude-sonnet-4-6',
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
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
|
||||
|
|
@ -56,7 +84,25 @@ Deno.serve(async (req: Request) => {
|
|||
const user = await requireUser(req)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
const body = (await req.json()) as LlmRequest
|
||||
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)
|
||||
|
|
@ -120,70 +166,8 @@ Deno.serve(async (req: Request) => {
|
|||
)
|
||||
}
|
||||
|
||||
// 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 systemPayload = buildAnthropicSystemBlocks(body.system)
|
||||
|
||||
const anthropicResp = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
|
|
@ -195,43 +179,88 @@ Deno.serve(async (req: Request) => {
|
|||
},
|
||||
body: JSON.stringify({
|
||||
model: requestedModel,
|
||||
max_tokens: body.max_tokens ?? 2048,
|
||||
max_tokens: body.max_tokens,
|
||||
system: systemPayload,
|
||||
messages: body.messages,
|
||||
stream: body.stream ?? false,
|
||||
stream: body.stream,
|
||||
}),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
})
|
||||
|
||||
if (!anthropicResp.ok) {
|
||||
const errText = await anthropicResp.text()
|
||||
throw new Error(`Anthropic ${anthropicResp.status}: ${errText.slice(0, 500)}`)
|
||||
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-cache',
|
||||
'Cache-Control': 'no-store',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const data = await anthropicResp.json()
|
||||
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, 'Content-Type': 'application/json' },
|
||||
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 message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
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' },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue