|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +// ============================================================================= |
| 4 | +// LLM side-by-side comparison handler (DAK-6845) |
| 5 | +// ============================================================================= |
| 6 | +// Implements POST /v1/playground/llm-compare: calls the same free OpenRouter |
| 7 | +// model twice in parallel — once with no context (raw question) and once with |
| 8 | +// relevant Dakera memories injected as a system prompt — so users can see what |
| 9 | +// memory-augmented AI looks like vs. a plain LLM. |
| 10 | +// |
| 11 | +// Internalfunctions are exported individually so unit tests can inject mocks |
| 12 | +// via the opts._callXxx pattern without patching require() globals. |
| 13 | +// ============================================================================= |
| 14 | + |
| 15 | +const https = require('https'); |
| 16 | +const http = require('http'); |
| 17 | +const { URL } = require('url'); |
| 18 | +const { sessionNamespace } = require('./namespace'); |
| 19 | + |
| 20 | +// Seed memories covering medical, engineering, legal, and financial domains. |
| 21 | +// Stored into the session namespace on the FIRST llm-compare call so that |
| 22 | +// "with memory" immediately shows a meaningful improvement over "without". |
| 23 | +const SEED_MEMORIES = [ |
| 24 | + // Medical |
| 25 | + { |
| 26 | + content: |
| 27 | + 'Patient John Smith (DOB: 1978-03-15) is currently taking Metformin 500mg twice daily and Lisinopril 10mg once daily for Type 2 diabetes and hypertension. Next review: 2026-09-01.', |
| 28 | + importance: 0.9, |
| 29 | + }, |
| 30 | + { |
| 31 | + content: |
| 32 | + 'Patient Sarah Johnson has a documented penicillin allergy causing anaphylaxis. Alternative antibiotics on file: azithromycin or clindamycin. Allergy flagged across all records.', |
| 33 | + importance: 0.9, |
| 34 | + }, |
| 35 | + { |
| 36 | + content: |
| 37 | + "Dr. Martinez noted that patient Maria Garcia's HbA1c improved from 9.2% to 7.4% after switching to insulin glargine in January 2026. Follow-up: 2026-07-15.", |
| 38 | + importance: 0.8, |
| 39 | + }, |
| 40 | + // Engineering |
| 41 | + { |
| 42 | + content: |
| 43 | + 'The API gateway uses a Redis cluster with 3 nodes: primary at 10.0.1.50:6379, replicas at 10.0.1.51 and 10.0.1.52. Failover timeout 5 s, max connections per node: 500.', |
| 44 | + importance: 0.85, |
| 45 | + }, |
| 46 | + { |
| 47 | + content: |
| 48 | + 'The deployment pipeline uses blue-green strategy. Health-check window: 10 minutes. Auto-rollback triggers if error rate exceeds 1% in the first 30 minutes post-deploy.', |
| 49 | + importance: 0.85, |
| 50 | + }, |
| 51 | + // Legal |
| 52 | + { |
| 53 | + content: |
| 54 | + 'Contract #2026-ACME-0042 with ACME Corp expires 2026-12-31. Terms: 90-day termination notice, $50,000 early-exit penalty, auto-renewal unless cancelled 60 days before expiry.', |
| 55 | + importance: 0.9, |
| 56 | + }, |
| 57 | + { |
| 58 | + content: |
| 59 | + 'Rodriguez & Associates signed NDA on 2026-01-15 covering all product roadmap discussions and unreleased pricing. NDA expires 2029-01-15. Liquidated damages: $250,000.', |
| 60 | + importance: 0.9, |
| 61 | + }, |
| 62 | + // Financial |
| 63 | + { |
| 64 | + content: |
| 65 | + 'Q1 2026 budget: Engineering $2.4M, Marketing $800K, Sales $1.2M, Operations $600K. Total $5.0M approved. YTD burn as of March: $1.1M (22%). Q2 forecast: $1.4M.', |
| 66 | + importance: 0.85, |
| 67 | + }, |
| 68 | + { |
| 69 | + content: |
| 70 | + 'Portfolio rebalancing target: 60% equities (30% US, 20% international, 10% emerging), 30% fixed income, 10% alternatives. Last rebalanced 2026-02-28. Next review: Q3 2026.', |
| 71 | + importance: 0.8, |
| 72 | + }, |
| 73 | + { |
| 74 | + content: |
| 75 | + 'Client ABC Partners wire transfer of $125,000 received 2026-06-01, reference TXN-20260601-0042. Applied to invoice INV-2026-0318. Remaining balance: $0.', |
| 76 | + importance: 0.85, |
| 77 | + }, |
| 78 | +]; |
| 79 | + |
| 80 | +const DEFAULT_MODEL = 'meta-llama/llama-4-maverick:free'; |
| 81 | +const SEED_TIMEOUT_MS = 8_000; |
| 82 | + |
| 83 | +// --------------------------------------------------------------------------- |
| 84 | +// Low-level I/O helpers (replaceable in unit tests via opts._callXxx) |
| 85 | +// --------------------------------------------------------------------------- |
| 86 | + |
| 87 | +function _callOpenRouter(apiKey, model, messages, timeoutMs) { |
| 88 | + return new Promise((resolve, reject) => { |
| 89 | + const body = JSON.stringify({ model, messages, max_tokens: 512 }); |
| 90 | + const req = https.request( |
| 91 | + { |
| 92 | + hostname: 'openrouter.ai', |
| 93 | + path: '/api/v1/chat/completions', |
| 94 | + method: 'POST', |
| 95 | + headers: { |
| 96 | + 'Content-Type': 'application/json', |
| 97 | + Authorization: `Bearer ${apiKey}`, |
| 98 | + 'Content-Length': Buffer.byteLength(body), |
| 99 | + 'HTTP-Referer': 'https://dakera.ai', |
| 100 | + 'X-Title': 'Dakera AI Playground', |
| 101 | + }, |
| 102 | + }, |
| 103 | + (res) => { |
| 104 | + const chunks = []; |
| 105 | + res.on('data', (c) => chunks.push(c)); |
| 106 | + res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') })); |
| 107 | + res.on('error', reject); |
| 108 | + }, |
| 109 | + ); |
| 110 | + req.setTimeout(timeoutMs, () => req.destroy(Object.assign(new Error('OpenRouter timeout'), { timedOut: true }))); |
| 111 | + req.on('error', reject); |
| 112 | + req.write(body); |
| 113 | + req.end(); |
| 114 | + }); |
| 115 | +} |
| 116 | + |
| 117 | +function _callDakeraRecall(upstreamUrl, apiKey, agentId, query, timeoutMs) { |
| 118 | + return new Promise((resolve, reject) => { |
| 119 | + const body = JSON.stringify({ agent_id: agentId, query, top_k: 5 }); |
| 120 | + const u = new URL(upstreamUrl + '/v1/memory/recall'); |
| 121 | + const req = http.request( |
| 122 | + { |
| 123 | + hostname: u.hostname, |
| 124 | + port: Number(u.port) || 80, |
| 125 | + path: u.pathname, |
| 126 | + method: 'POST', |
| 127 | + headers: { |
| 128 | + 'Content-Type': 'application/json', |
| 129 | + Authorization: `Bearer ${apiKey}`, |
| 130 | + 'Content-Length': Buffer.byteLength(body), |
| 131 | + }, |
| 132 | + }, |
| 133 | + (res) => { |
| 134 | + const chunks = []; |
| 135 | + res.on('data', (c) => chunks.push(c)); |
| 136 | + res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') })); |
| 137 | + res.on('error', reject); |
| 138 | + }, |
| 139 | + ); |
| 140 | + req.setTimeout(timeoutMs, () => req.destroy(Object.assign(new Error('recall timeout'), { timedOut: true }))); |
| 141 | + req.on('error', reject); |
| 142 | + req.write(body); |
| 143 | + req.end(); |
| 144 | + }); |
| 145 | +} |
| 146 | + |
| 147 | +function _callDakeraStoreBatch(upstreamUrl, apiKey, agentId, memories, timeoutMs) { |
| 148 | + return new Promise((resolve, reject) => { |
| 149 | + const body = JSON.stringify({ |
| 150 | + agent_id: agentId, |
| 151 | + memories: memories.map((m) => ({ agent_id: agentId, content: m.content, importance: m.importance || 0.8 })), |
| 152 | + }); |
| 153 | + const u = new URL(upstreamUrl + '/v1/memories/store/batch'); |
| 154 | + const req = http.request( |
| 155 | + { |
| 156 | + hostname: u.hostname, |
| 157 | + port: Number(u.port) || 80, |
| 158 | + path: u.pathname, |
| 159 | + method: 'POST', |
| 160 | + headers: { |
| 161 | + 'Content-Type': 'application/json', |
| 162 | + Authorization: `Bearer ${apiKey}`, |
| 163 | + 'Content-Length': Buffer.byteLength(body), |
| 164 | + }, |
| 165 | + }, |
| 166 | + (res) => { |
| 167 | + res.resume(); // drain and discard — fire-and-forget caller handles the result |
| 168 | + res.on('end', () => resolve({ status: res.statusCode })); |
| 169 | + res.on('error', reject); |
| 170 | + }, |
| 171 | + ); |
| 172 | + req.setTimeout(timeoutMs, () => req.destroy()); |
| 173 | + req.on('error', reject); |
| 174 | + req.write(body); |
| 175 | + req.end(); |
| 176 | + }); |
| 177 | +} |
| 178 | + |
| 179 | +function _parseOrResponse(rawBody, fallbackModel) { |
| 180 | + try { |
| 181 | + const parsed = JSON.parse(rawBody); |
| 182 | + if (parsed.error) { |
| 183 | + return { error: 'openrouter_error', message: parsed.error.message || 'OpenRouter error', model: fallbackModel }; |
| 184 | + } |
| 185 | + const msg = parsed.choices && parsed.choices[0] && parsed.choices[0].message; |
| 186 | + return { response: (msg && msg.content) || '', model: parsed.model || fallbackModel }; |
| 187 | + } catch { |
| 188 | + return { error: 'parse_error', message: 'Could not parse OpenRouter response.', model: fallbackModel }; |
| 189 | + } |
| 190 | +} |
| 191 | + |
| 192 | +// --------------------------------------------------------------------------- |
| 193 | +// Public handler |
| 194 | +// --------------------------------------------------------------------------- |
| 195 | + |
| 196 | +/** |
| 197 | + * Handle POST /v1/playground/llm-compare |
| 198 | + * |
| 199 | + * Returns: |
| 200 | + * { status: 200, without_memory, with_memory, processing_time_ms } |
| 201 | + * { status: 4xx|5xx, error, message [, retryAfterSec] } |
| 202 | + * |
| 203 | + * opts (for unit tests): |
| 204 | + * _callOpenRouter, _callDakeraRecall, _callDakeraStoreBatch |
| 205 | + */ |
| 206 | +async function handleLlmCompare(config, store, resolved, bodyBuf, opts) { |
| 207 | + const callOR = (opts && opts._callOpenRouter) || _callOpenRouter; |
| 208 | + const callRecall = (opts && opts._callDakeraRecall) || _callDakeraRecall; |
| 209 | + const callSeed = (opts && opts._callDakeraStoreBatch) || _callDakeraStoreBatch; |
| 210 | + |
| 211 | + if (!config.openRouterApiKey) { |
| 212 | + return { status: 503, error: 'llm_not_configured', message: 'LLM comparison is not available (OpenRouter API key not configured).' }; |
| 213 | + } |
| 214 | + |
| 215 | + // LLM-specific rate limit: max 5 calls per 10 min per session. |
| 216 | + const llmRate = store.checkLlmRate(resolved.session); |
| 217 | + if (!llmRate.ok) { |
| 218 | + return { |
| 219 | + status: 429, |
| 220 | + error: 'llm_rate_limit_exceeded', |
| 221 | + message: `LLM comparison is limited to 5 calls per 10 minutes per session. Retry in ${llmRate.retryAfterSec}s.`, |
| 222 | + retryAfterSec: llmRate.retryAfterSec, |
| 223 | + }; |
| 224 | + } |
| 225 | + |
| 226 | + // Validate request body. |
| 227 | + let parsed; |
| 228 | + try { |
| 229 | + parsed = JSON.parse(bodyBuf.toString('utf8')); |
| 230 | + } catch { |
| 231 | + return { status: 400, error: 'bad_request', message: 'Request body must be valid JSON.' }; |
| 232 | + } |
| 233 | + const question = typeof parsed.question === 'string' ? parsed.question.trim() : ''; |
| 234 | + if (!question) { |
| 235 | + return { status: 400, error: 'bad_request', message: 'Field "question" is required and must be a non-empty string.' }; |
| 236 | + } |
| 237 | + const model = typeof parsed.model === 'string' && parsed.model.trim() ? parsed.model.trim() : DEFAULT_MODEL; |
| 238 | + |
| 239 | + const ns = sessionNamespace(resolved.id); |
| 240 | + const timeout = config.llmCompareTimeoutMs || 30_000; |
| 241 | + |
| 242 | + // Seed demo memories on the FIRST llm-compare call for this session. |
| 243 | + // Synchronous so memories are available for the recall that immediately follows. |
| 244 | + if (!resolved.session.llmSeeded) { |
| 245 | + resolved.session.llmSeeded = true; |
| 246 | + try { |
| 247 | + await Promise.race([ |
| 248 | + callSeed(config.upstreamUrl, config.rootApiKey, ns, SEED_MEMORIES, timeout), |
| 249 | + new Promise((_, rej) => setTimeout(() => rej(new Error('seed timeout')), SEED_TIMEOUT_MS)), |
| 250 | + ]); |
| 251 | + } catch { |
| 252 | + // Seeding failed or timed out — proceed without pre-populated memories. |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + const startMs = Date.now(); |
| 257 | + |
| 258 | + // Step 1: Recall relevant memories from the session's private Dakera namespace. |
| 259 | + let memories = []; |
| 260 | + let recallWarning = null; |
| 261 | + try { |
| 262 | + const recallRes = await callRecall(config.upstreamUrl, config.rootApiKey, ns, question, timeout); |
| 263 | + if (recallRes.status === 200) { |
| 264 | + const r = JSON.parse(recallRes.body); |
| 265 | + memories = (r.results || r.memories || []) |
| 266 | + .map((m) => (typeof m === 'string' ? m : m.content || m.text || '')) |
| 267 | + .filter(Boolean); |
| 268 | + } |
| 269 | + } catch { |
| 270 | + recallWarning = 'Dakera recall failed; response may not reflect stored memories.'; |
| 271 | + } |
| 272 | + |
| 273 | + // Steps 2 + 3: OpenRouter calls — without and with memory context (parallel). |
| 274 | + const withoutMessages = [{ role: 'user', content: question }]; |
| 275 | + const withMessages = |
| 276 | + memories.length > 0 |
| 277 | + ? [ |
| 278 | + { |
| 279 | + role: 'system', |
| 280 | + content: |
| 281 | + 'You have access to the following relevant records and memories:\n\n' + |
| 282 | + memories.join('\n\n') + |
| 283 | + '\n\nUse this context to provide an accurate, specific answer.', |
| 284 | + }, |
| 285 | + { role: 'user', content: question }, |
| 286 | + ] |
| 287 | + : withoutMessages; |
| 288 | + |
| 289 | + const [withoutSettled, withSettled] = await Promise.allSettled([ |
| 290 | + callOR(config.openRouterApiKey, model, withoutMessages, timeout), |
| 291 | + callOR(config.openRouterApiKey, model, withMessages, timeout), |
| 292 | + ]); |
| 293 | + |
| 294 | + const processingTimeMs = Date.now() - startMs; |
| 295 | + |
| 296 | + function resolveResult(settled, includeMemories) { |
| 297 | + if (settled.status === 'rejected') { |
| 298 | + const base = { error: 'request_failed', message: 'Failed to call OpenRouter.', model }; |
| 299 | + return includeMemories ? { ...base, memories_used: memories } : base; |
| 300 | + } |
| 301 | + const { status: httpStatus, body } = settled.value; |
| 302 | + if (httpStatus === 402) { |
| 303 | + const base = { error: 'credits_exhausted', message: 'OpenRouter free-tier credits exhausted. Please try again later.', model }; |
| 304 | + return includeMemories ? { ...base, memories_used: memories } : base; |
| 305 | + } |
| 306 | + if (httpStatus >= 400) { |
| 307 | + let msg = `OpenRouter returned HTTP ${httpStatus}.`; |
| 308 | + try { |
| 309 | + const p = JSON.parse(body); |
| 310 | + if (p.error && p.error.message) msg = p.error.message; |
| 311 | + } catch { /**/ } |
| 312 | + const base = { error: 'openrouter_error', message: msg, model }; |
| 313 | + return includeMemories ? { ...base, memories_used: memories } : base; |
| 314 | + } |
| 315 | + const base = _parseOrResponse(body, model); |
| 316 | + return includeMemories ? { ...base, memories_used: memories } : base; |
| 317 | + } |
| 318 | + |
| 319 | + const withoutMemory = resolveResult(withoutSettled, false); |
| 320 | + let withMemory = resolveResult(withSettled, true); |
| 321 | + if (recallWarning) withMemory = { ...withMemory, recall_warning: recallWarning }; |
| 322 | + |
| 323 | + return { status: 200, without_memory: withoutMemory, with_memory: withMemory, processing_time_ms: processingTimeMs }; |
| 324 | +} |
| 325 | + |
| 326 | +module.exports = { handleLlmCompare, SEED_MEMORIES, DEFAULT_MODEL }; |
0 commit comments