// Conversation text uses an exact character budget, not an estimated model token limit. const DEFAULT_CONVERSATION_CHARS = 120000; const MAX_SAVED_CHAT_BYTES = 8 * 1024 * 1024; function failure(message, statusCode, code) { return Object.assign(new Error(message), { statusCode, code }); } function conversationLimit(value) { if (value == null || value === '') return DEFAULT_CONVERSATION_CHARS; const limit = Number(value); if (!['string', 'number'].includes(typeof value) || !Number.isInteger(limit) || limit < 1000 || limit > 1000000) { throw failure('Conversation budget must be an integer from 1,000 to 1,000,000 characters.', 400, 'INVALID_CONVERSATION_BUDGET'); } return limit; } function conversationBudget(env = process.env) { const name = 'CLINICAL_ASSISTANT_CONVERSATION_CHARS'; const value = env[name]; try { return { limit: conversationLimit(value), unit: 'characters', measure: 'UTF-16 code units', env: name, source: value == null || value === '' ? 'default' : 'environment' }; } catch (_) { throw failure('Conversation budget environment is invalid. No request was sent to an AI provider.', 503, 'INVALID_CONVERSATION_BUDGET'); } } function validateMessages(messages) { if (messages === undefined) return []; if (!Array.isArray(messages)) throw failure('Conversation history must be a list.', 400, 'INVALID_CONVERSATION'); return messages.map(function(message) { if (!message || !['user', 'assistant'].includes(message.role) || typeof message.content !== 'string') { throw failure('Each conversation turn must have a user/assistant role and text content.', 400, 'INVALID_CONVERSATION'); } return { role: message.role, content: message.content }; }); } function checkConversation(history, message, limit, handoff) { history = validateMessages(history); if (typeof message !== 'string' || (!handoff && !message.trim())) { throw failure('Question is required.', 400, 'INVALID_CONVERSATION'); } const used = history.reduce((total, turn) => total + turn.content.length, message.length); const budget = { used, limit, unit: 'characters', remaining: Math.max(0, limit - used) }; if (used > limit) { const error = failure('Conversation limit reached. Nothing was truncated or sent to an AI provider. Save or download this chat, then start a new chat or explicitly request a handoff summary.', 413, 'CONVERSATION_LIMIT'); error.budget = budget; throw error; } return { history, message, budget }; } function savedSources(sources) { if (sources === undefined) return []; if (!Array.isArray(sources) || sources.some(source => !source || typeof source !== 'object' || Array.isArray(source))) { throw failure('Invalid saved source metadata.', 400, 'INVALID_SAVED_CHAT'); } return sources.map(function(source) { const copy = { ...source }; delete copy.image_path; delete copy.file_path; return copy; }); } function savedImage(image) { if (image == null || image === '') return ''; if (typeof image !== 'string') throw failure('Invalid saved image.', 400, 'INVALID_SAVED_CHAT'); const match = image.match(/^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/); if (match && image.length <= MAX_SAVED_CHAT_BYTES) { const bytes = Buffer.from(match[2], 'base64'); const headerMatches = match[1] === 'png' ? bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) : match[1] === 'jpeg' ? bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255])) : bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP'; if (headerMatches && bytes.toString('base64').replace(/=+$/, '') === match[2].replace(/=+$/, '')) return image; } try { const url = new URL(image); if (url.protocol === 'https:' || url.protocol === 'http:') return image; } catch (_) {} throw failure('Saved images must be PNG, JPEG, WebP or HTTP(S) image URLs.', 400, 'INVALID_SAVED_CHAT'); } function savedChatPayload(body) { const messages = validateMessages(body.messages).map(function(message, index) { const original = body.messages[index]; const copy = { ...message, sources: savedSources(original.sources) }; // Preserve legacy loss provenance and an existing retained raw field across // a v2 re-save/follow-up; neither is inference history or replacement text. if (original.legacyClipped === true && message.content.length === 12000 && !/[\r\n]/.test(message.content)) { copy.legacyClipped = true; if (message.role === 'assistant' && typeof original.retainedAnswer === 'string' && original.retainedAnswer.length > 12000 && original.retainedAnswer.length <= 30000 && !/[\r\n]/.test(original.retainedAnswer) && original.retainedAnswer.startsWith(message.content)) copy.retainedAnswer = original.retainedAnswer; } return copy; }); if (body.lastAnswer !== undefined && typeof body.lastAnswer !== 'string') { throw failure('Invalid saved answer.', 400, 'INVALID_SAVED_CHAT'); } const payload = { version: 2, messages, sources: savedSources(body.sources), lastAnswer: body.lastAnswer || '', generatedImage: savedImage(body.generatedImage), savedAt: new Date().toISOString() }; if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > MAX_SAVED_CHAT_BYTES) { throw failure('Saved chat exceeds the 8 MiB storage limit. Nothing was saved or truncated; download the complete transcript instead.', 413, 'SAVED_CHAT_LIMIT'); } return payload; } module.exports = { DEFAULT_CONVERSATION_CHARS, MAX_SAVED_CHAT_BYTES, conversationLimit, conversationBudget, checkConversation, savedChatPayload };