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,124 @@
import { describe, expect, it } from 'vitest'
import {
CHAT_TRUNCATION_MARKER,
fitChatRequest,
LLM_PROXY_CHAT_LIMITS,
toChatRequest,
toRoleMessages,
} from '../src/llm-chat'
describe('toChatRequest', () => {
it('separates system messages from turns and keeps turn order', () => {
expect(toChatRequest([
{ role: 'system', content: 'persona' },
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: 'hello' },
{ role: 'user', content: 'again' },
])).toEqual({
system: 'persona',
turns: [
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: 'hello' },
{ role: 'user', content: 'again' },
],
})
})
it('joins several system messages and drops non-chat roles', () => {
expect(toChatRequest([
{ role: 'system', content: 'a' },
{ role: 'tool', content: 'ignored' },
{ role: 'system', content: 'b' },
{ role: 'system', content: ' ' },
{ role: 'user', content: 'q' },
])).toEqual({ system: 'a\n\nb', turns: [{ role: 'user', content: 'q' }] })
})
it('omits system when there is none', () => {
expect(toChatRequest([{ role: 'user', content: 'q' }])).toEqual({
turns: [{ role: 'user', content: 'q' }],
})
})
})
describe('toRoleMessages', () => {
it('round-trips a canonical Ollama message list unchanged', () => {
const messages = [
{ role: 'system', content: 'persona' },
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: 'hello' },
]
expect(toRoleMessages(toChatRequest(messages))).toEqual(messages)
})
it('puts the system message first', () => {
expect(toRoleMessages({ system: 's', turns: [{ role: 'user', content: 'u' }] })).toEqual([
{ role: 'system', content: 's' },
{ role: 'user', content: 'u' },
])
})
})
describe('fitChatRequest', () => {
const limits = { maxSystemChars: 50, maxMessageChars: 20, maxMessages: 3, maxTotalChars: 40 }
it('keeps the head of an over-long system prompt within the limit', () => {
const system = `INSTRUCTIONS ${'x'.repeat(200)}`
const fitted = fitChatRequest({ system, turns: [{ role: 'user', content: 'q' }] }, limits)
expect(fitted.system?.length).toBeLessThanOrEqual(limits.maxSystemChars)
expect(fitted.system?.startsWith('INSTRUCTIONS')).toBe(true)
expect(fitted.system?.endsWith(CHAT_TRUNCATION_MARKER)).toBe(true)
})
it('drops empty turns such as an empty assistant reply', () => {
const fitted = fitChatRequest({
turns: [
{ role: 'user', content: 'q1' },
{ role: 'assistant', content: '' },
{ role: 'user', content: 'q2' },
],
}, limits)
expect(fitted.turns).toEqual([
{ role: 'user', content: 'q1' },
{ role: 'user', content: 'q2' },
])
})
it('keeps the newest turns within count and size limits and never starts with assistant', () => {
const fitted = fitChatRequest({
turns: [
{ role: 'user', content: 'u1' },
{ role: 'assistant', content: 'a1' },
{ role: 'user', content: 'u2' },
{ role: 'assistant', content: 'a2' },
{ role: 'user', content: 'u3' },
],
}, limits)
// maxMessages=3 keeps [u2, a2, u3]
expect(fitted.turns.map((t) => t.content)).toEqual(['u2', 'a2', 'u3'])
const trimmed = fitChatRequest({
turns: [
{ role: 'user', content: 'u'.repeat(20) },
{ role: 'assistant', content: 'a'.repeat(15) },
{ role: 'user', content: 'q'.repeat(20) },
],
}, limits)
// total 40: [a(15), q(20)] fits, then the leading assistant turn is dropped
expect(trimmed.turns).toEqual([{ role: 'user', content: 'q'.repeat(20) }])
})
it('truncates a single turn over the per-message limit', () => {
const fitted = fitChatRequest({ turns: [{ role: 'user', content: 'z'.repeat(100) }] }, limits)
expect(fitted.turns[0].content.length).toBeLessThanOrEqual(limits.maxMessageChars)
})
it('exposes the llm-proxy limits', () => {
expect(LLM_PROXY_CHAT_LIMITS).toEqual({
maxSystemChars: 8_000,
maxMessageChars: 8_000,
maxMessages: 40,
maxTotalChars: 50_000,
})
})
})

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
}