fix(llm): keep system prompts on premium chat, reject incomplete streams, stop double-charging quota

This commit is contained in:
Yun Chan 2026-09-28 00:53:44 +09:00
parent d96601a283
commit d311e8123f
10 changed files with 1225 additions and 237 deletions

View file

@ -66,6 +66,16 @@ async function issueGenerationReceipt(
return parseGenerationReceiptId(data)
}
/** 쓰지 않을 공급자 응답 본문을 닫아 연결과 생성을 정리한다. */
async function discardBody(resp: Response): Promise<void> {
if (!resp.body || resp.bodyUsed) return
try {
await resp.body.cancel()
} catch {
// 이미 닫힌 스트림 — 무시
}
}
function generationHeaders(generationId: string | null): Record<string, string> {
return generationId === null ? {} : { [GENERATION_ID_HEADER]: generationId }
}
@ -147,27 +157,9 @@ Deno.serve(async (req: Request) => {
)
}
// 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 활성화)
// 3단계: 공급자 호출 (Prompt Caching 2024-07-31 활성화)
// 쿼터는 공급자가 요청을 받아들인 뒤에만 소비한다. 공급자 5xx/과부하/타임아웃으로
// 답을 주지 못한 요청이 쿼터를 깎고, 클라이언트 재시도가 한 번 더 깎는 일을 막는다.
const systemPayload = buildAnthropicSystemBlocks(body.system)
const anthropicResp = await fetch('https://api.anthropic.com/v1/messages', {
@ -190,12 +182,54 @@ Deno.serve(async (req: Request) => {
if (!anthropicResp.ok) {
console.error('Anthropic request failed', { status: anthropicResp.status })
await discardBody(anthropicResp)
return new Response(JSON.stringify({ error: 'provider_request_failed' }), {
status: 502,
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
})
}
// 비스트리밍 응답은 형식 검증까지 통과한 뒤에 소비한다.
let nonStreamData: unknown = null
if (!body.stream || !anthropicResp.body) {
nonStreamData = await anthropicResp.json()
if (!hasAssistantText(nonStreamData)) {
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' },
})
}
}
// 4단계: 쿼터 소비 (원자적 base → overage fallback).
// 동시 요청이 checkQuota 를 함께 통과했다면 여기서 초과분이 거절된다.
const policy = getQuotaPolicy(tier, quotaKey)
let consume: Awaited<ReturnType<typeof consumeQuota>>
try {
consume = await consumeQuota(user.id, quotaKey, serviceClient, policy.limit)
} catch (err) {
await discardBody(anthropicResp)
throw err
}
if (!consume.allowed) {
await discardBody(anthropicResp)
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' },
},
)
}
if (body.stream && anthropicResp.body) {
const generationId = await issueGenerationReceipt(
serviceClient,
@ -215,21 +249,13 @@ Deno.serve(async (req: Request) => {
})
}
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), {
return new Response(JSON.stringify(nonStreamData), {
status: 200,
headers: {
...corsHeaders,