Skip to content

Commit 031107f

Browse files
feat(playground): add LLM side-by-side comparison endpoint (DAK-6845) (#203)
Adds POST /v1/playground/llm-compare to the sandbox proxy. Given a question, it runs two OpenRouter free-model calls in parallel — one with no context and one with relevant Dakera memories as system prompt — returning a JSON comparison of both responses. Key design decisions: - Handled inside the proxy (not forwarded to engine) — appears before the deny-by-default allow-list check in server.js - Counts toward the general 30/min per-session rate limit - Has an additional 5/10min LLM-specific cap (checkLlmRate in sessions.js) - Seeds 10 demo memories (medical/engineering/legal/financial) on the first call for a session so the with-memory response is immediately compelling; seeding is synchronous so recall sees the data - OpenRouter API key sourced from OPENROUTER_API_KEY env (set as GitHub secret in dakera-deploy); graceful 503 when not configured - Parallel OpenRouter calls (steps 2+3); Dakera recall error tolerant - 11 new unit + integration tests; all 44 tests pass Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3777d11 commit 031107f

7 files changed

Lines changed: 626 additions & 1 deletion

File tree

docker/docker-compose.playground.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ services:
154154
- SANDBOX_MAX_SESSIONS_PER_IP=${SANDBOX_MAX_SESSIONS_PER_IP:-20}
155155
- SANDBOX_MAX_BODY_BYTES=${SANDBOX_MAX_BODY_BYTES:-262144}
156156
- SANDBOX_ALLOWED_ORIGINS=${SANDBOX_ALLOWED_ORIGINS:-https://dakera.ai,https://www.dakera.ai,https://playground.dakera.ai}
157+
# LLM side-by-side comparison (DAK-6845): OpenRouter free-model API key.
158+
# Required for POST /v1/playground/llm-compare to function.
159+
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
160+
- SANDBOX_LLM_RATE_LIMIT=${SANDBOX_LLM_RATE_LIMIT:-5}
157161
deploy:
158162
resources:
159163
limits:

docker/playground/proxy/config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ const config = {
6868
// How often to sweep expired sessions out of memory.
6969
sweepIntervalMs: intEnv('SANDBOX_SWEEP_INTERVAL_SEC', 60) * 1000,
7070

71+
// LLM comparison endpoint (DAK-6845) — OpenRouter free-model side-by-side.
72+
openRouterApiKey: process.env.OPENROUTER_API_KEY || '',
73+
llmCompareTimeoutMs: intEnv('LLM_COMPARE_TIMEOUT_SEC', 30) * 1000,
74+
llmRateLimitPer10Min: intEnv('SANDBOX_LLM_RATE_LIMIT', 5),
75+
7176
version: process.env.PROXY_VERSION || 'dak6713-1',
7277
};
7378

docker/playground/proxy/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function main() {
2222
memoryCap: config.memoryCapPerSession,
2323
ttlMs: config.sessionTtlMs,
2424
maxSessionsPerIp: config.maxSessionsPerIp,
25+
llmRateLimit: config.llmRateLimitPer10Min,
2526
});
2627

2728
const sweep = setInterval(() => {
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
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

Comments
 (0)