export type LlmRole = 'user' | 'assistant' export interface LlmMessage { role: LlmRole content: string } export interface ValidatedLlmRequest { messages: LlmMessage[] system?: string max_tokens: number model?: string stream: boolean } const MAX_MESSAGES = 40 const MAX_MESSAGE_CHARS = 8_000 const MAX_TOTAL_CHARS = 50_000 const MAX_SYSTEM_CHARS = 8_000 const DEFAULT_MAX_TOKENS = 1_024 const MAX_OUTPUT_TOKENS = 4_096 export class LlmRequestError extends Error { readonly status = 400 constructor(message: string) { super(message) this.name = 'LlmRequestError' } } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } export function parseLlmRequest(value: unknown): ValidatedLlmRequest { if (!isRecord(value)) throw new LlmRequestError('Invalid JSON body') if (!Array.isArray(value.messages) || value.messages.length === 0) { throw new LlmRequestError('At least one message is required') } if (value.messages.length > MAX_MESSAGES) { throw new LlmRequestError(`At most ${MAX_MESSAGES} messages are allowed`) } let totalChars = 0 const messages = value.messages.map((entry, index): LlmMessage => { if (!isRecord(entry) || (entry.role !== 'user' && entry.role !== 'assistant')) { throw new LlmRequestError(`Invalid role at message ${index}`) } if (typeof entry.content !== 'string') { throw new LlmRequestError(`Invalid content at message ${index}`) } const content = entry.content.trim() if (content.length === 0 || content.length > MAX_MESSAGE_CHARS) { throw new LlmRequestError(`Invalid content length at message ${index}`) } totalChars += content.length if (totalChars > MAX_TOTAL_CHARS) throw new LlmRequestError('Conversation is too large') return { role: entry.role, content } }) let system: string | undefined if (value.system !== undefined) { if (typeof value.system !== 'string') throw new LlmRequestError('Invalid system prompt') system = value.system.trim() if (system.length === 0 || system.length > MAX_SYSTEM_CHARS) { throw new LlmRequestError('Invalid system prompt length') } } const maxTokens = value.max_tokens === undefined ? DEFAULT_MAX_TOKENS : value.max_tokens if (!Number.isSafeInteger(maxTokens) || Number(maxTokens) < 1 || Number(maxTokens) > MAX_OUTPUT_TOKENS) { throw new LlmRequestError(`max_tokens must be between 1 and ${MAX_OUTPUT_TOKENS}`) } if (value.model !== undefined && (typeof value.model !== 'string' || value.model.trim().length === 0)) { throw new LlmRequestError('Invalid model') } if (value.stream !== undefined && typeof value.stream !== 'boolean') { throw new LlmRequestError('Invalid stream flag') } return { messages, system, max_tokens: Number(maxTokens), model: typeof value.model === 'string' ? value.model.trim() : undefined, stream: value.stream === true, } } export function hasAssistantText(value: unknown): boolean { if (!isRecord(value) || !Array.isArray(value.content)) return false return value.content.some((block) => ( isRecord(block) && block.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 0 )) }