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

@ -0,0 +1,144 @@
// packages/core/src/llm-chat.ts
// 채팅 LLM 요청 계약 — 로컬(Ollama)·프리미엄(llm-proxy) 어댑터가 같은 의미로 따르는 모델.
//
// 계약:
// 1. system 지시문은 대화 턴과 분리된다. 호출자가 Ollama 스타일로 넘긴
// `{ role: 'system' }` 메시지는 어댑터가 버리지 않고 반드시 system 으로 전달한다.
// 2. 스트림은 공급자가 종료 프레임(Ollama `done`, Anthropic `message_stop`)을 보낸
// 경우에만 정상 종료한다. 그 전에 끊기거나 공급자가 오류 프레임을 보내면 throw 한다.
// 부분 응답을 완료로 돌려주지 않는다.
// 3. signal / timeoutMs / maxTokens 는 호출 단위로 적용된다.
/** 대화 턴의 역할. system 은 턴이 아니라 ChatRequest.system 으로 분리된다. */
export type ChatTurnRole = 'user' | 'assistant'
export interface ChatTurn {
role: ChatTurnRole
content: string
}
/** system 과 대화 턴이 분리된 채팅 요청. */
export interface ChatRequest {
system?: string
turns: ChatTurn[]
}
/** 호출 단위 채팅 스트림 옵션. */
export interface ChatStreamOptions {
model?: string
temperature?: number
maxTokens?: number
signal?: AbortSignal
timeoutMs?: number
}
/** 호출자가 쓰는 Ollama 스타일 역할 메시지. */
export interface RoleMessage {
role: string
content: string
}
const SYSTEM_JOINER = '\n\n'
function isChatTurnRole(role: string): role is ChatTurnRole {
return role === 'user' || role === 'assistant'
}
/**
* Ollama 스타일 역할 메시지를 ChatRequest 로 바꾼다.
* 모든 system 메시지는 순서대로 이어 붙여 system 하나로 합친다.
* user/assistant 이외의 역할은 대화 턴이 아니므로 제외한다.
*/
export function toChatRequest(messages: readonly RoleMessage[]): ChatRequest {
const systemParts: string[] = []
const turns: ChatTurn[] = []
for (const message of messages) {
if (message.role === 'system') {
if (message.content.trim().length > 0) systemParts.push(message.content)
continue
}
if (isChatTurnRole(message.role)) {
turns.push({ role: message.role, content: message.content })
}
}
const request: ChatRequest = { turns }
if (systemParts.length > 0) request.system = systemParts.join(SYSTEM_JOINER)
return request
}
/** ChatRequest 를 Ollama `/api/chat` 형식(선두 system 메시지 + 턴)으로 되돌린다. */
export function toRoleMessages(request: ChatRequest): RoleMessage[] {
const messages: RoleMessage[] = []
if (request.system !== undefined && request.system.length > 0) {
messages.push({ role: 'system', content: request.system })
}
for (const turn of request.turns) {
messages.push({ role: turn.role, content: turn.content })
}
return messages
}
/** 요청 크기 한도. */
export interface ChatRequestLimits {
maxSystemChars: number
maxMessageChars: number
maxMessages: number
maxTotalChars: number
}
/**
* llm-proxy 요청 한도.
* server/supabase/functions/_shared/llm-contract.ts 의 MAX_* 상수와 같은 값이어야 한다.
* (계약 일치는 desktop premium-llm 계약 테스트가 parseLlmRequest 로 검증한다.)
*/
export const LLM_PROXY_CHAT_LIMITS: Readonly<ChatRequestLimits> = Object.freeze({
maxSystemChars: 8_000,
maxMessageChars: 8_000,
maxMessages: 40,
maxTotalChars: 50_000,
})
/** 한도를 넘어 잘린 텍스트 끝에 붙는 모델용 표식 (UI 문자열 아님). */
export const CHAT_TRUNCATION_MARKER = '\n…[truncated]'
function truncateText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text
if (maxChars <= CHAT_TRUNCATION_MARKER.length) return text.slice(0, maxChars)
return text.slice(0, maxChars - CHAT_TRUNCATION_MARKER.length).trimEnd() + CHAT_TRUNCATION_MARKER
}
/**
* 요청을 한도 안으로 맞춘다 (순수 함수).
* - system 은 앞부분을 남기고 잘라 한도를 지킨다 (지시문이 앞에 오므로 지시가 보존된다).
* - 빈 턴은 제거한다. 빈 assistant 턴은 공급자가 거부해 이후 모든 턴을 깨뜨린다.
* - 한 턴이 한도를 넘으면 잘라 낸다.
* - 개수·총량 한도를 넘으면 오래된 턴부터 버리고, 선두 assistant 턴은 제거한다.
*/
export function fitChatRequest(request: ChatRequest, limits: Readonly<ChatRequestLimits>): ChatRequest {
const system = request.system?.trim()
const fittedSystem = system && system.length > 0
? truncateText(system, limits.maxSystemChars)
: undefined
const normalized: ChatTurn[] = []
for (const turn of request.turns) {
const content = turn.content.trim()
if (content.length === 0) continue
normalized.push({ role: turn.role, content: truncateText(content, limits.maxMessageChars) })
}
const kept: ChatTurn[] = []
let totalChars = 0
for (let i = normalized.length - 1; i >= 0; i--) {
const turn = normalized[i]
if (kept.length >= limits.maxMessages) break
if (totalChars + turn.content.length > limits.maxTotalChars) break
totalChars += turn.content.length
kept.unshift(turn)
}
while (kept.length > 0 && kept[0].role === 'assistant') kept.shift()
const fitted: ChatRequest = { turns: kept }
if (fittedSystem !== undefined) fitted.system = fittedSystem
return fitted
}