daemon-llm.test.mjs
1,119 lines 39.6 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tests for lib/daemon-llm.mjs — Phase E: OpenAI-Compatible API Support.
3 *
4 * Covers:
5 * 1. resolveApiKey — reads from named env var or falls back to OPENAI_API_KEY
6 * 2. buildDelegateConfig — patches model into the right llm config field per provider
7 * 3. callOpenAiCompat — constructs URL, headers, body; handles HTTP errors and empty responses
8 * 4. daemonLlm routing:
9 * a. base_url passed through to fetch (OpenRouter, vLLM, LM Studio URLs)
10 * b. api_key_env resolution — reads from the named env var
11 * c. provider: null + base_url → openai-compat path
12 * d. provider: "openai" + base_url → openai-compat path
13 * e. provider: "openai" without base_url → default OpenAI URL
14 * f. provider: "anthropic" ignores base_url, delegates to completeChat (warns)
15 * g. provider: "ollama" delegates to completeChat
16 * h. no daemon config → falls through to completeChat
17 * i. missing API key → throws descriptive error
18 * j. HTTP error from endpoint → throws with URL in message
19 * k. model from daemon config passed in request body
20 * l. max_tokens from daemon config honoured
21 * m. trailing slash on base_url is stripped from the URL
22 * 5. loadDaemonConfig integration:
23 * a. KNOWTATION_DAEMON_LLM_BASE_URL env var is parsed and surfaced
24 * b. api_key_env from YAML passes through
25 * 6. consolidateMemory end-to-end via daemonLlm wrapper:
26 * a. daemon.llm.base_url routes fetch to the custom endpoint
27 * b. daemon.llm.api_key_env supplies the correct Authorization header
28 *
29 * All fetch calls are mocked via globalThis.fetch. No real HTTP requests are made.
30 */
31
32 import { describe, it, before, after } from 'node:test';
33 import assert from 'node:assert/strict';
34 import fs from 'fs';
35 import path from 'path';
36 import os from 'os';
37
38 import {
39 daemonLlm,
40 resolveApiKey,
41 buildDelegateConfig,
42 callOpenAiCompat,
43 } from '../lib/daemon-llm.mjs';
44
45 import { loadDaemonConfig } from '../lib/config.mjs';
46 import { consolidateMemory } from '../lib/memory-consolidate.mjs';
47 import { createMemoryManager } from '../lib/memory.mjs';
48
49 // ── Test fixtures ─────────────────────────────────────────────────────────────
50
51 let tmpDir;
52 let vaultDir;
53
54 before(() => {
55 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-daemon-llm-test-'));
56 vaultDir = path.join(tmpDir, 'vault');
57 fs.mkdirSync(vaultDir, { recursive: true });
58 fs.writeFileSync(path.join(vaultDir, 'note.md'), '---\ntitle: note\n---\nHello', 'utf8');
59 });
60
61 after(() => {
62 fs.rmSync(tmpDir, { recursive: true, force: true });
63 });
64
65 /** Create a minimal loadConfig()-shaped object with optional daemon.llm overrides. */
66 function makeConfig(daemonLlmOverrides = {}, extra = {}) {
67 const dataDir = path.join(tmpDir, `data-${Date.now()}-${Math.random().toString(36).slice(2)}`);
68 fs.mkdirSync(dataDir, { recursive: true });
69 return {
70 vault_path: vaultDir,
71 data_dir: dataDir,
72 memory: { enabled: true, provider: 'file' },
73 llm: {},
74 daemon: loadDaemonConfig({
75 llm: daemonLlmOverrides,
76 }),
77 ...extra,
78 };
79 }
80
81 /**
82 * Save current values of the given env var names, apply `patch`, run `fn`,
83 * then restore originals — even if `fn` throws.
84 *
85 * @param {Record<string, string|undefined>} patch — set value to undefined to delete the var
86 * @param {Function} fn
87 */
88 async function withEnv(patch, fn) {
89 const saved = {};
90 for (const [k, v] of Object.entries(patch)) {
91 saved[k] = process.env[k];
92 if (v === undefined) {
93 delete process.env[k];
94 } else {
95 process.env[k] = v;
96 }
97 }
98 try {
99 return await fn();
100 } finally {
101 for (const [k, orig] of Object.entries(saved)) {
102 if (orig === undefined) delete process.env[k];
103 else process.env[k] = orig;
104 }
105 }
106 }
107
108 /** Create a mock fetch that returns a successful OpenAI-compat response. */
109 function makeFetchOk(text = 'mocked response') {
110 const calls = [];
111 const fn = async (url, init) => {
112 calls.push({ url: String(url), init });
113 return {
114 ok: true,
115 status: 200,
116 json: async () => ({ choices: [{ message: { content: text } }] }),
117 text: async () => JSON.stringify({ choices: [{ message: { content: text } }] }),
118 };
119 };
120 fn.calls = calls;
121 return fn;
122 }
123
124 /** Create a mock fetch that returns an HTTP error. */
125 function makeFetchError(status = 401, body = 'Unauthorized') {
126 return async (url) => ({
127 ok: false,
128 status,
129 json: async () => ({}),
130 text: async () => body,
131 });
132 }
133
134 /** Create a mock fetch that returns OK but with no content in choices. */
135 function makeFetchEmpty() {
136 return async () => ({
137 ok: true,
138 status: 200,
139 json: async () => ({ choices: [{ message: { content: '' } }] }),
140 text: async () => '{"choices":[{"message":{"content":""}}]}',
141 });
142 }
143
144 // ── 1. resolveApiKey ──────────────────────────────────────────────────────────
145
146 describe('resolveApiKey', () => {
147 it('returns OPENAI_API_KEY when apiKeyEnv is null', async () => {
148 await withEnv({ OPENAI_API_KEY: 'sk-test-main' }, () => {
149 assert.equal(resolveApiKey(null), 'sk-test-main');
150 });
151 });
152
153 it('returns OPENAI_API_KEY when apiKeyEnv is undefined', async () => {
154 await withEnv({ OPENAI_API_KEY: 'sk-test-main' }, () => {
155 assert.equal(resolveApiKey(undefined), 'sk-test-main');
156 });
157 });
158
159 it('returns null when OPENAI_API_KEY is unset and no apiKeyEnv', async () => {
160 await withEnv({ OPENAI_API_KEY: undefined }, () => {
161 assert.equal(resolveApiKey(null), null);
162 });
163 });
164
165 it('reads from the named env var when apiKeyEnv is set', async () => {
166 await withEnv(
167 { OPENAI_API_KEY: 'sk-main', OPENROUTER_API_KEY: 'sk-openrouter' },
168 () => {
169 assert.equal(resolveApiKey('OPENROUTER_API_KEY'), 'sk-openrouter');
170 },
171 );
172 });
173
174 it('returns null when the named env var is unset', async () => {
175 await withEnv({ MY_DAEMON_KEY: undefined }, () => {
176 assert.equal(resolveApiKey('MY_DAEMON_KEY'), null);
177 });
178 });
179
180 it('named env var takes precedence over OPENAI_API_KEY', async () => {
181 await withEnv({ OPENAI_API_KEY: 'sk-main', DAEMON_KEY: 'sk-daemon' }, () => {
182 assert.equal(resolveApiKey('DAEMON_KEY'), 'sk-daemon');
183 });
184 });
185 });
186
187 // ── 2. buildDelegateConfig ────────────────────────────────────────────────────
188
189 describe('buildDelegateConfig', () => {
190 it('returns the original config when model is null', () => {
191 const config = makeConfig();
192 const result = buildDelegateConfig(config, { provider: 'openai', model: null });
193 assert.equal(result, config);
194 });
195
196 it('patches openai_chat_model for provider: openai', () => {
197 const config = makeConfig();
198 const result = buildDelegateConfig(config, { provider: 'openai', model: 'gpt-4o' });
199 assert.equal(result.llm.openai_chat_model, 'gpt-4o');
200 });
201
202 it('patches openai_chat_model for provider: null', () => {
203 const config = makeConfig();
204 const result = buildDelegateConfig(config, { provider: null, model: 'gpt-4o-mini' });
205 assert.equal(result.llm.openai_chat_model, 'gpt-4o-mini');
206 });
207
208 it('patches anthropic_chat_model for provider: anthropic', () => {
209 const config = makeConfig();
210 const result = buildDelegateConfig(config, { provider: 'anthropic', model: 'claude-3-5-haiku-20241022' });
211 assert.equal(result.llm.anthropic_chat_model, 'claude-3-5-haiku-20241022');
212 });
213
214 it('patches ollama_chat_model for provider: ollama', () => {
215 const config = makeConfig();
216 const result = buildDelegateConfig(config, { provider: 'ollama', model: 'llama3.2' });
217 assert.equal(result.llm.ollama_chat_model, 'llama3.2');
218 });
219
220 it('preserves other llm fields when patching', () => {
221 const config = { ...makeConfig(), llm: { some_other_field: 'preserved' } };
222 const result = buildDelegateConfig(config, { provider: 'anthropic', model: 'claude-3-5-haiku-20241022' });
223 assert.equal(result.llm.some_other_field, 'preserved');
224 assert.equal(result.llm.anthropic_chat_model, 'claude-3-5-haiku-20241022');
225 });
226
227 it('does not mutate the original config', () => {
228 const config = makeConfig();
229 buildDelegateConfig(config, { provider: 'openai', model: 'gpt-4o' });
230 assert.equal(config.llm.openai_chat_model, undefined);
231 });
232 });
233
234 // ── 3. callOpenAiCompat ───────────────────────────────────────────────────────
235
236 describe('callOpenAiCompat', () => {
237 it('calls fetch with the correct URL (base_url + /chat/completions)', async () => {
238 const mockFetch = makeFetchOk('hello');
239 const origFetch = globalThis.fetch;
240 globalThis.fetch = mockFetch;
241 try {
242 await callOpenAiCompat({
243 baseUrl: 'https://openrouter.ai/api/v1',
244 apiKey: 'sk-test',
245 model: 'mistral-7b',
246 maxTokens: 512,
247 system: 'sys',
248 user: 'usr',
249 });
250 assert.equal(mockFetch.calls.length, 1);
251 assert.equal(mockFetch.calls[0].url, 'https://openrouter.ai/api/v1/chat/completions');
252 } finally {
253 globalThis.fetch = origFetch;
254 }
255 });
256
257 it('strips a trailing slash from base_url before appending /chat/completions', async () => {
258 const mockFetch = makeFetchOk('ok');
259 const origFetch = globalThis.fetch;
260 globalThis.fetch = mockFetch;
261 try {
262 await callOpenAiCompat({
263 baseUrl: 'http://localhost:8000/v1/',
264 apiKey: 'sk-test',
265 model: 'llama',
266 maxTokens: 128,
267 system: 's',
268 user: 'u',
269 });
270 assert.equal(mockFetch.calls[0].url, 'http://localhost:8000/v1/chat/completions');
271 } finally {
272 globalThis.fetch = origFetch;
273 }
274 });
275
276 it('sends Authorization Bearer header with the apiKey', async () => {
277 const mockFetch = makeFetchOk('ok');
278 const origFetch = globalThis.fetch;
279 globalThis.fetch = mockFetch;
280 try {
281 await callOpenAiCompat({
282 baseUrl: 'https://openrouter.ai/api/v1',
283 apiKey: 'sk-secret-key',
284 model: 'x',
285 maxTokens: 10,
286 system: 's',
287 user: 'u',
288 });
289 assert.equal(
290 mockFetch.calls[0].init.headers['Authorization'],
291 'Bearer sk-secret-key',
292 );
293 } finally {
294 globalThis.fetch = origFetch;
295 }
296 });
297
298 it('sends model and max_tokens in the request body', async () => {
299 const mockFetch = makeFetchOk('ok');
300 const origFetch = globalThis.fetch;
301 globalThis.fetch = mockFetch;
302 try {
303 await callOpenAiCompat({
304 baseUrl: 'http://localhost:1234/v1',
305 apiKey: 'sk',
306 model: 'local-model-7b',
307 maxTokens: 256,
308 system: 'System prompt',
309 user: 'User prompt',
310 });
311 const body = JSON.parse(mockFetch.calls[0].init.body);
312 assert.equal(body.model, 'local-model-7b');
313 assert.equal(body.max_tokens, 256);
314 assert.equal(body.messages[0].role, 'system');
315 assert.equal(body.messages[0].content, 'System prompt');
316 assert.equal(body.messages[1].role, 'user');
317 assert.equal(body.messages[1].content, 'User prompt');
318 } finally {
319 globalThis.fetch = origFetch;
320 }
321 });
322
323 it('returns the trimmed content from choices[0].message.content', async () => {
324 const mockFetch = makeFetchOk(' trimmed result ');
325 const origFetch = globalThis.fetch;
326 globalThis.fetch = mockFetch;
327 try {
328 const result = await callOpenAiCompat({
329 baseUrl: 'https://openrouter.ai/api/v1',
330 apiKey: 'sk',
331 model: 'x',
332 maxTokens: 10,
333 system: 's',
334 user: 'u',
335 });
336 assert.equal(result, 'trimmed result');
337 } finally {
338 globalThis.fetch = origFetch;
339 }
340 });
341
342 it('throws with URL in message when fetch returns non-OK status', async () => {
343 const origFetch = globalThis.fetch;
344 globalThis.fetch = makeFetchError(401, 'Unauthorized');
345 try {
346 await assert.rejects(
347 () =>
348 callOpenAiCompat({
349 baseUrl: 'https://openrouter.ai/api/v1',
350 apiKey: 'bad-key',
351 model: 'x',
352 maxTokens: 10,
353 system: 's',
354 user: 'u',
355 }),
356 (err) => {
357 assert.ok(err.message.includes('https://openrouter.ai/api/v1/chat/completions'));
358 assert.ok(err.message.includes('401'));
359 assert.ok(err.message.includes('Unauthorized'));
360 return true;
361 },
362 );
363 } finally {
364 globalThis.fetch = origFetch;
365 }
366 });
367
368 it('throws with URL in message when response has empty content', async () => {
369 const origFetch = globalThis.fetch;
370 globalThis.fetch = makeFetchEmpty();
371 try {
372 await assert.rejects(
373 () =>
374 callOpenAiCompat({
375 baseUrl: 'http://localhost:8000/v1',
376 apiKey: 'sk',
377 model: 'x',
378 maxTokens: 10,
379 system: 's',
380 user: 'u',
381 }),
382 (err) => {
383 assert.ok(err.message.includes('http://localhost:8000/v1/chat/completions'));
384 assert.ok(err.message.toLowerCase().includes('empty'));
385 return true;
386 },
387 );
388 } finally {
389 globalThis.fetch = origFetch;
390 }
391 });
392 });
393
394 // ── 4. daemonLlm routing ──────────────────────────────────────────────────────
395
396 describe('daemonLlm', () => {
397 // 4a. base_url passed through to fetch
398 describe('base_url routing', () => {
399 it('OpenRouter: routes fetch to openrouter.ai/api/v1/chat/completions', async () => {
400 const config = makeConfig({ base_url: 'https://openrouter.ai/api/v1' });
401 const mockFetch = makeFetchOk('fact');
402 const origFetch = globalThis.fetch;
403 globalThis.fetch = mockFetch;
404 try {
405 await withEnv({ OPENAI_API_KEY: 'sk-test' }, () =>
406 daemonLlm(config, { system: 's', user: 'u' }),
407 );
408 assert.equal(mockFetch.calls[0].url, 'https://openrouter.ai/api/v1/chat/completions');
409 } finally {
410 globalThis.fetch = origFetch;
411 }
412 });
413
414 it('vLLM: routes fetch to localhost:8000/v1/chat/completions', async () => {
415 const config = makeConfig({ base_url: 'http://localhost:8000/v1' });
416 const mockFetch = makeFetchOk('fact');
417 const origFetch = globalThis.fetch;
418 globalThis.fetch = mockFetch;
419 try {
420 await withEnv({ OPENAI_API_KEY: 'sk-local' }, () =>
421 daemonLlm(config, { system: 's', user: 'u' }),
422 );
423 assert.equal(mockFetch.calls[0].url, 'http://localhost:8000/v1/chat/completions');
424 } finally {
425 globalThis.fetch = origFetch;
426 }
427 });
428
429 it('LM Studio: routes fetch to localhost:1234/v1/chat/completions', async () => {
430 const config = makeConfig({ base_url: 'http://localhost:1234/v1' });
431 const mockFetch = makeFetchOk('fact');
432 const origFetch = globalThis.fetch;
433 globalThis.fetch = mockFetch;
434 try {
435 await withEnv({ OPENAI_API_KEY: 'lm-studio' }, () =>
436 daemonLlm(config, { system: 's', user: 'u' }),
437 );
438 assert.equal(mockFetch.calls[0].url, 'http://localhost:1234/v1/chat/completions');
439 } finally {
440 globalThis.fetch = origFetch;
441 }
442 });
443
444 it('arbitrary OpenAI-compat URL is honoured', async () => {
445 const config = makeConfig({ base_url: 'https://my-proxy.example.com/openai/v1' });
446 const mockFetch = makeFetchOk('ok');
447 const origFetch = globalThis.fetch;
448 globalThis.fetch = mockFetch;
449 try {
450 await withEnv({ OPENAI_API_KEY: 'sk-proxy' }, () =>
451 daemonLlm(config, { system: 's', user: 'u' }),
452 );
453 assert.equal(
454 mockFetch.calls[0].url,
455 'https://my-proxy.example.com/openai/v1/chat/completions',
456 );
457 } finally {
458 globalThis.fetch = origFetch;
459 }
460 });
461
462 it('strips trailing slash from base_url', async () => {
463 const config = makeConfig({ base_url: 'https://openrouter.ai/api/v1/' });
464 const mockFetch = makeFetchOk('ok');
465 const origFetch = globalThis.fetch;
466 globalThis.fetch = mockFetch;
467 try {
468 await withEnv({ OPENAI_API_KEY: 'sk' }, () =>
469 daemonLlm(config, { system: 's', user: 'u' }),
470 );
471 assert.equal(mockFetch.calls[0].url, 'https://openrouter.ai/api/v1/chat/completions');
472 } finally {
473 globalThis.fetch = origFetch;
474 }
475 });
476 });
477
478 // 4b. api_key_env resolution
479 describe('api_key_env', () => {
480 it('reads from the named env var when api_key_env is set', async () => {
481 const config = makeConfig({
482 base_url: 'https://openrouter.ai/api/v1',
483 api_key_env: 'OPENROUTER_API_KEY',
484 });
485 const mockFetch = makeFetchOk('ok');
486 const origFetch = globalThis.fetch;
487 globalThis.fetch = mockFetch;
488 try {
489 await withEnv(
490 { OPENAI_API_KEY: 'sk-main', OPENROUTER_API_KEY: 'sk-openrouter-custom' },
491 () => daemonLlm(config, { system: 's', user: 'u' }),
492 );
493 assert.equal(
494 mockFetch.calls[0].init.headers['Authorization'],
495 'Bearer sk-openrouter-custom',
496 );
497 } finally {
498 globalThis.fetch = origFetch;
499 }
500 });
501
502 it('api_key_env takes precedence over OPENAI_API_KEY', async () => {
503 const config = makeConfig({
504 base_url: 'http://localhost:8000/v1',
505 api_key_env: 'MY_LOCAL_KEY',
506 });
507 const mockFetch = makeFetchOk('ok');
508 const origFetch = globalThis.fetch;
509 globalThis.fetch = mockFetch;
510 try {
511 await withEnv({ OPENAI_API_KEY: 'sk-main', MY_LOCAL_KEY: 'local-bearer-token' }, () =>
512 daemonLlm(config, { system: 's', user: 'u' }),
513 );
514 assert.equal(
515 mockFetch.calls[0].init.headers['Authorization'],
516 'Bearer local-bearer-token',
517 );
518 } finally {
519 globalThis.fetch = origFetch;
520 }
521 });
522
523 it('throws with descriptive error when named env var is unset', async () => {
524 const config = makeConfig({
525 base_url: 'https://openrouter.ai/api/v1',
526 api_key_env: 'MISSING_KEY_VAR',
527 });
528 const origFetch = globalThis.fetch;
529 globalThis.fetch = makeFetchOk('ok');
530 try {
531 await withEnv({ MISSING_KEY_VAR: undefined, OPENAI_API_KEY: undefined }, async () => {
532 await assert.rejects(
533 () => daemonLlm(config, { system: 's', user: 'u' }),
534 (err) => {
535 assert.ok(err.message.includes('MISSING_KEY_VAR'));
536 return true;
537 },
538 );
539 });
540 } finally {
541 globalThis.fetch = origFetch;
542 }
543 });
544
545 it('works without base_url when api_key_env is set (uses OpenAI default URL)', async () => {
546 const config = makeConfig({ api_key_env: 'MY_OPENAI_KEY' });
547 const mockFetch = makeFetchOk('answer');
548 const origFetch = globalThis.fetch;
549 globalThis.fetch = mockFetch;
550 try {
551 await withEnv({ MY_OPENAI_KEY: 'sk-custom-key' }, () =>
552 daemonLlm(config, { system: 's', user: 'u' }),
553 );
554 assert.equal(mockFetch.calls[0].url, 'https://api.openai.com/v1/chat/completions');
555 assert.equal(
556 mockFetch.calls[0].init.headers['Authorization'],
557 'Bearer sk-custom-key',
558 );
559 } finally {
560 globalThis.fetch = origFetch;
561 }
562 });
563 });
564
565 // 4c. provider: null + base_url → openai-compat
566 describe('provider: null + base_url', () => {
567 it('uses openai-compat path when provider is null and base_url is set', async () => {
568 const config = makeConfig({ provider: null, base_url: 'https://openrouter.ai/api/v1' });
569 const mockFetch = makeFetchOk('facts');
570 const origFetch = globalThis.fetch;
571 globalThis.fetch = mockFetch;
572 try {
573 await withEnv({ OPENAI_API_KEY: 'sk-test' }, () =>
574 daemonLlm(config, { system: 's', user: 'u' }),
575 );
576 assert.equal(mockFetch.calls[0].url, 'https://openrouter.ai/api/v1/chat/completions');
577 } finally {
578 globalThis.fetch = origFetch;
579 }
580 });
581 });
582
583 // 4d. provider: "openai" + base_url → openai-compat
584 describe('provider: "openai" + base_url', () => {
585 it('uses openai-compat path with the custom base_url', async () => {
586 const config = makeConfig({
587 provider: 'openai',
588 base_url: 'https://openrouter.ai/api/v1',
589 });
590 const mockFetch = makeFetchOk('ok');
591 const origFetch = globalThis.fetch;
592 globalThis.fetch = mockFetch;
593 try {
594 await withEnv({ OPENAI_API_KEY: 'sk-test' }, () =>
595 daemonLlm(config, { system: 's', user: 'u' }),
596 );
597 assert.equal(mockFetch.calls[0].url, 'https://openrouter.ai/api/v1/chat/completions');
598 } finally {
599 globalThis.fetch = origFetch;
600 }
601 });
602 });
603
604 // 4e. provider: "openai" without base_url → default OpenAI URL
605 describe('provider: "openai" without base_url', () => {
606 it('calls the default OpenAI URL', async () => {
607 const config = makeConfig({ provider: 'openai' });
608 const mockFetch = makeFetchOk('answer');
609 const origFetch = globalThis.fetch;
610 globalThis.fetch = mockFetch;
611 try {
612 await withEnv({ OPENAI_API_KEY: 'sk-openai' }, () =>
613 daemonLlm(config, { system: 's', user: 'u' }),
614 );
615 assert.equal(mockFetch.calls[0].url, 'https://api.openai.com/v1/chat/completions');
616 } finally {
617 globalThis.fetch = origFetch;
618 }
619 });
620
621 it('uses OPENAI_API_KEY when api_key_env is not set', async () => {
622 const config = makeConfig({ provider: 'openai' });
623 const mockFetch = makeFetchOk('ok');
624 const origFetch = globalThis.fetch;
625 globalThis.fetch = mockFetch;
626 try {
627 await withEnv({ OPENAI_API_KEY: 'sk-from-env' }, () =>
628 daemonLlm(config, { system: 's', user: 'u' }),
629 );
630 assert.equal(
631 mockFetch.calls[0].init.headers['Authorization'],
632 'Bearer sk-from-env',
633 );
634 } finally {
635 globalThis.fetch = origFetch;
636 }
637 });
638 });
639
640 // 4f. provider: "anthropic" ignores base_url, delegates to completeChat
641 describe('provider: "anthropic"', () => {
642 it('delegates to completeChat anthropic path even when base_url is set', async () => {
643 const config = makeConfig({
644 provider: 'anthropic',
645 base_url: 'https://openrouter.ai/api/v1',
646 model: 'claude-3-5-haiku-20241022',
647 });
648 const mockFetch = makeFetchOk('anthropic response');
649 // Stub fetch so completeChat anthropic path is intercepted
650 const origFetch = globalThis.fetch;
651 globalThis.fetch = async (url, init) => {
652 mockFetch.calls.push({ url: String(url), init });
653 return {
654 ok: true,
655 status: 200,
656 json: async () => ({
657 content: [{ type: 'text', text: 'anthropic response' }],
658 }),
659 text: async () => JSON.stringify({ content: [{ type: 'text', text: 'anthropic response' }] }),
660 };
661 };
662 mockFetch.calls = [];
663 try {
664 await withEnv(
665 { OPENAI_API_KEY: undefined, ANTHROPIC_API_KEY: 'sk-ant-test' },
666 async () => {
667 const result = await daemonLlm(config, { system: 's', user: 'u' });
668 assert.equal(result, 'anthropic response');
669 // Should have called anthropic URL, not openrouter
670 assert.ok(mockFetch.calls.length > 0, 'fetch was called');
671 assert.ok(
672 mockFetch.calls[0].url.includes('anthropic.com'),
673 `Expected anthropic URL, got: ${mockFetch.calls[0].url}`,
674 );
675 assert.ok(
676 !mockFetch.calls[0].url.includes('openrouter'),
677 'Should NOT call openrouter when provider is anthropic',
678 );
679 },
680 );
681 } finally {
682 globalThis.fetch = origFetch;
683 }
684 });
685
686 it('writes a warning to stderr when base_url is set with provider: anthropic', async () => {
687 const config = makeConfig({
688 provider: 'anthropic',
689 base_url: 'https://openrouter.ai/api/v1',
690 });
691 const origFetch = globalThis.fetch;
692 globalThis.fetch = async () => ({
693 ok: true,
694 status: 200,
695 json: async () => ({ content: [{ type: 'text', text: 'ok' }] }),
696 text: async () => '{}',
697 });
698 const origWrite = process.stderr.write.bind(process.stderr);
699 const stderrLines = [];
700 process.stderr.write = (data, ...args) => {
701 stderrLines.push(String(data));
702 return origWrite(data, ...args);
703 };
704 try {
705 await withEnv({ OPENAI_API_KEY: undefined, ANTHROPIC_API_KEY: 'sk-ant' }, async () => {
706 try {
707 await daemonLlm(config, { system: 's', user: 'u' });
708 } catch {
709 // ignore LLM errors — we only care about the warning
710 }
711 });
712 assert.ok(
713 stderrLines.some((l) => l.includes('base_url') && l.includes('anthropic')),
714 'Expected a warning mentioning base_url and anthropic',
715 );
716 } finally {
717 process.stderr.write = origWrite;
718 globalThis.fetch = origFetch;
719 }
720 });
721 });
722
723 // 4g. provider: "ollama" delegates to completeChat
724 describe('provider: "ollama"', () => {
725 it('delegates to completeChat ollama path (calls /api/chat)', async () => {
726 const config = makeConfig({ provider: 'ollama', model: 'llama3.2' });
727 const mockFetch = makeFetchOk('');
728 const origFetch = globalThis.fetch;
729 globalThis.fetch = async (url, init) => {
730 mockFetch.calls.push({ url: String(url), init });
731 return {
732 ok: true,
733 status: 200,
734 json: async () => ({ message: { content: 'ollama response' } }),
735 text: async () => '{}',
736 };
737 };
738 mockFetch.calls = [];
739 try {
740 await withEnv(
741 { OPENAI_API_KEY: undefined, ANTHROPIC_API_KEY: undefined },
742 async () => {
743 const result = await daemonLlm(config, { system: 's', user: 'u' });
744 assert.equal(result, 'ollama response');
745 assert.ok(mockFetch.calls.length > 0, 'fetch was called');
746 assert.ok(
747 mockFetch.calls[0].url.includes('/api/chat'),
748 `Expected ollama /api/chat, got: ${mockFetch.calls[0].url}`,
749 );
750 },
751 );
752 } finally {
753 globalThis.fetch = origFetch;
754 }
755 });
756 });
757
758 // 4h. no daemon config → falls through to completeChat
759 describe('no daemon-specific config', () => {
760 it('falls through to completeChat when no base_url, provider, or api_key_env', async () => {
761 // Daemon config with all nulls — should behave like completeChat
762 const config = makeConfig({ provider: null, base_url: null, api_key_env: null });
763 const mockFetch = makeFetchOk('openai-auto');
764 const origFetch = globalThis.fetch;
765 globalThis.fetch = mockFetch;
766 try {
767 await withEnv({ OPENAI_API_KEY: 'sk-auto' }, async () => {
768 const result = await daemonLlm(config, { system: 's', user: 'u' });
769 // completeChat uses OPENAI_API_KEY → calls OpenAI
770 assert.equal(result, 'openai-auto');
771 assert.ok(mockFetch.calls[0].url.includes('openai.com'));
772 });
773 } finally {
774 globalThis.fetch = origFetch;
775 }
776 });
777 });
778
779 // 4i. missing API key → throws descriptive error
780 describe('missing API key', () => {
781 it('throws with OPENAI_API_KEY mentioned when no key is configured', async () => {
782 const config = makeConfig({ base_url: 'https://openrouter.ai/api/v1' });
783 const origFetch = globalThis.fetch;
784 globalThis.fetch = makeFetchOk('ok');
785 try {
786 await withEnv({ OPENAI_API_KEY: undefined }, async () => {
787 await assert.rejects(
788 () => daemonLlm(config, { system: 's', user: 'u' }),
789 (err) => {
790 assert.ok(err.message.includes('OPENAI_API_KEY'));
791 return true;
792 },
793 );
794 });
795 } finally {
796 globalThis.fetch = origFetch;
797 }
798 });
799
800 it('throws with api_key_env name mentioned when that var is unset', async () => {
801 const config = makeConfig({
802 base_url: 'https://openrouter.ai/api/v1',
803 api_key_env: 'OPENROUTER_KEY',
804 });
805 const origFetch = globalThis.fetch;
806 globalThis.fetch = makeFetchOk('ok');
807 try {
808 await withEnv({ OPENROUTER_KEY: undefined, OPENAI_API_KEY: undefined }, async () => {
809 await assert.rejects(
810 () => daemonLlm(config, { system: 's', user: 'u' }),
811 (err) => {
812 assert.ok(err.message.includes('OPENROUTER_KEY'));
813 return true;
814 },
815 );
816 });
817 } finally {
818 globalThis.fetch = origFetch;
819 }
820 });
821 });
822
823 // 4j. HTTP error from custom endpoint → throws with URL in message
824 describe('HTTP errors', () => {
825 it('throws with the endpoint URL when fetch returns 401', async () => {
826 const config = makeConfig({ base_url: 'https://openrouter.ai/api/v1' });
827 const origFetch = globalThis.fetch;
828 globalThis.fetch = makeFetchError(401, 'Unauthorized');
829 try {
830 await withEnv({ OPENAI_API_KEY: 'bad-key' }, async () => {
831 await assert.rejects(
832 () => daemonLlm(config, { system: 's', user: 'u' }),
833 (err) => {
834 assert.ok(err.message.includes('https://openrouter.ai/api/v1/chat/completions'));
835 assert.ok(err.message.includes('401'));
836 return true;
837 },
838 );
839 });
840 } finally {
841 globalThis.fetch = origFetch;
842 }
843 });
844
845 it('throws with the endpoint URL when fetch returns 502', async () => {
846 const config = makeConfig({ base_url: 'http://localhost:8000/v1' });
847 const origFetch = globalThis.fetch;
848 globalThis.fetch = makeFetchError(502, 'Bad Gateway');
849 try {
850 await withEnv({ OPENAI_API_KEY: 'sk' }, async () => {
851 await assert.rejects(
852 () => daemonLlm(config, { system: 's', user: 'u' }),
853 (err) => {
854 assert.ok(err.message.includes('http://localhost:8000/v1/chat/completions'));
855 assert.ok(err.message.includes('502'));
856 return true;
857 },
858 );
859 });
860 } finally {
861 globalThis.fetch = origFetch;
862 }
863 });
864 });
865
866 // 4k. model from daemon config passed in request body
867 describe('model override', () => {
868 it('sends daemon.llm.model in the request body', async () => {
869 const config = makeConfig({ base_url: 'https://openrouter.ai/api/v1', model: 'mistralai/mixtral-8x7b' });
870 const mockFetch = makeFetchOk('ok');
871 const origFetch = globalThis.fetch;
872 globalThis.fetch = mockFetch;
873 try {
874 await withEnv({ OPENAI_API_KEY: 'sk' }, () =>
875 daemonLlm(config, { system: 's', user: 'u' }),
876 );
877 const body = JSON.parse(mockFetch.calls[0].init.body);
878 assert.equal(body.model, 'mistralai/mixtral-8x7b');
879 } finally {
880 globalThis.fetch = origFetch;
881 }
882 });
883
884 it('falls back to gpt-4o-mini when no model is configured', async () => {
885 const config = makeConfig({ base_url: 'https://openrouter.ai/api/v1', model: null });
886 const mockFetch = makeFetchOk('ok');
887 const origFetch = globalThis.fetch;
888 globalThis.fetch = mockFetch;
889 try {
890 await withEnv({ OPENAI_API_KEY: 'sk' }, () =>
891 daemonLlm(config, { system: 's', user: 'u' }),
892 );
893 const body = JSON.parse(mockFetch.calls[0].init.body);
894 assert.equal(body.model, 'gpt-4o-mini');
895 } finally {
896 globalThis.fetch = origFetch;
897 }
898 });
899 });
900
901 // 4l. max_tokens from daemon config
902 describe('max_tokens', () => {
903 it('sends daemon.llm.max_tokens in the request body', async () => {
904 const config = makeConfig({ base_url: 'https://openrouter.ai/api/v1', max_tokens: 2048 });
905 const mockFetch = makeFetchOk('ok');
906 const origFetch = globalThis.fetch;
907 globalThis.fetch = mockFetch;
908 try {
909 await withEnv({ OPENAI_API_KEY: 'sk' }, () =>
910 daemonLlm(config, { system: 's', user: 'u' }),
911 );
912 const body = JSON.parse(mockFetch.calls[0].init.body);
913 assert.equal(body.max_tokens, 2048);
914 } finally {
915 globalThis.fetch = origFetch;
916 }
917 });
918
919 it('opts.maxTokens overrides daemon.llm.max_tokens', async () => {
920 const config = makeConfig({ base_url: 'https://openrouter.ai/api/v1', max_tokens: 2048 });
921 const mockFetch = makeFetchOk('ok');
922 const origFetch = globalThis.fetch;
923 globalThis.fetch = mockFetch;
924 try {
925 await withEnv({ OPENAI_API_KEY: 'sk' }, () =>
926 daemonLlm(config, { system: 's', user: 'u', maxTokens: 512 }),
927 );
928 const body = JSON.parse(mockFetch.calls[0].init.body);
929 assert.equal(body.max_tokens, 512);
930 } finally {
931 globalThis.fetch = origFetch;
932 }
933 });
934 });
935
936 // 4m. trailing slash on base_url
937 describe('trailing slash handling', () => {
938 it('removes trailing slash before appending /chat/completions', async () => {
939 const config = makeConfig({ base_url: 'http://localhost:1234/v1/' });
940 const mockFetch = makeFetchOk('ok');
941 const origFetch = globalThis.fetch;
942 globalThis.fetch = mockFetch;
943 try {
944 await withEnv({ OPENAI_API_KEY: 'sk' }, () =>
945 daemonLlm(config, { system: 's', user: 'u' }),
946 );
947 assert.equal(mockFetch.calls[0].url, 'http://localhost:1234/v1/chat/completions');
948 } finally {
949 globalThis.fetch = origFetch;
950 }
951 });
952 });
953 });
954
955 // ── 5. loadDaemonConfig integration ──────────────────────────────────────────
956
957 describe('loadDaemonConfig integration with daemon-llm', () => {
958 it('KNOWTATION_DAEMON_LLM_BASE_URL env var is parsed and available at config.daemon.llm.base_url', async () => {
959 await withEnv(
960 { KNOWTATION_DAEMON_LLM_BASE_URL: 'https://my-endpoint.example.com/v1' },
961 () => {
962 const daemonCfg = loadDaemonConfig({});
963 assert.equal(daemonCfg.llm.base_url, 'https://my-endpoint.example.com/v1');
964 },
965 );
966 });
967
968 it('KNOWTATION_DAEMON_LLM_BASE_URL overrides YAML base_url value', async () => {
969 await withEnv(
970 { KNOWTATION_DAEMON_LLM_BASE_URL: 'https://env-override.example.com/v1' },
971 () => {
972 const daemonCfg = loadDaemonConfig({ llm: { base_url: 'https://yaml.example.com/v1' } });
973 assert.equal(daemonCfg.llm.base_url, 'https://env-override.example.com/v1');
974 },
975 );
976 });
977
978 it('YAML base_url is used when env var is not set', async () => {
979 await withEnv({ KNOWTATION_DAEMON_LLM_BASE_URL: undefined }, () => {
980 const daemonCfg = loadDaemonConfig({ llm: { base_url: 'https://yaml.example.com/v1' } });
981 assert.equal(daemonCfg.llm.base_url, 'https://yaml.example.com/v1');
982 });
983 });
984
985 it('api_key_env from YAML is preserved at config.daemon.llm.api_key_env', () => {
986 const daemonCfg = loadDaemonConfig({ llm: { api_key_env: 'OPENROUTER_API_KEY' } });
987 assert.equal(daemonCfg.llm.api_key_env, 'OPENROUTER_API_KEY');
988 });
989
990 it('api_key_env defaults to null when not set in YAML', () => {
991 const daemonCfg = loadDaemonConfig({});
992 assert.equal(daemonCfg.llm.api_key_env, null);
993 });
994
995 it('base_url defaults to null when not set in YAML or env', async () => {
996 await withEnv({ KNOWTATION_DAEMON_LLM_BASE_URL: undefined }, () => {
997 const daemonCfg = loadDaemonConfig({});
998 assert.equal(daemonCfg.llm.base_url, null);
999 });
1000 });
1001 });
1002
1003 // ── 6. consolidateMemory end-to-end via daemonLlm ────────────────────────────
1004
1005 describe('consolidateMemory end-to-end via daemonLlm', () => {
1006 /**
1007 * Seeds two events of the same topic into the memory store,
1008 * then runs consolidateMemory with daemonLlm as the llmFn.
1009 * Verifies that the fetch call goes to the configured base_url.
1010 */
1011 it('routes fetch to daemon.llm.base_url when daemonLlm is used as llmFn', async () => {
1012 const config = makeConfig({
1013 base_url: 'https://openrouter.ai/api/v1',
1014 model: 'openai/gpt-4o-mini',
1015 });
1016
1017 // Seed two events with the same topic so consolidation pass runs
1018 const mm = createMemoryManager(config);
1019 mm.store('search', { query: 'architecture notes', results: 1, topic: 'architecture' });
1020 mm.store('search', { query: 'architecture diagram', results: 2, topic: 'architecture' });
1021
1022 const fetchedUrls = [];
1023 const origFetch = globalThis.fetch;
1024 globalThis.fetch = async (url, init) => {
1025 fetchedUrls.push(String(url));
1026 return {
1027 ok: true,
1028 status: 200,
1029 json: async () => ({
1030 choices: [{ message: { content: '["Architecture involves layers."]' } }],
1031 }),
1032 text: async () => '{}',
1033 };
1034 };
1035
1036 try {
1037 await withEnv({ OPENAI_API_KEY: 'sk-e2e' }, async () => {
1038 const result = await consolidateMemory(config, {
1039 llmFn: daemonLlm,
1040 passes: ['consolidate'],
1041 });
1042 assert.ok(result.topics.length > 0, 'Expected at least one topic to be consolidated');
1043 assert.ok(fetchedUrls.length > 0, 'Expected at least one fetch call');
1044 assert.ok(
1045 fetchedUrls.every((u) => u.includes('openrouter.ai')),
1046 `Expected all fetch calls to go to openrouter.ai, got: ${fetchedUrls.join(', ')}`,
1047 );
1048 assert.ok(
1049 fetchedUrls.every((u) => u.endsWith('/chat/completions')),
1050 `Expected fetch to /chat/completions, got: ${fetchedUrls.join(', ')}`,
1051 );
1052 });
1053 } finally {
1054 globalThis.fetch = origFetch;
1055 }
1056 });
1057
1058 it('uses daemon.llm.api_key_env for the Authorization header in the LLM call', async () => {
1059 const config = makeConfig({
1060 base_url: 'http://localhost:8000/v1',
1061 api_key_env: 'VLLM_API_KEY',
1062 });
1063
1064 const mm = createMemoryManager(config);
1065 mm.store('search', { query: 'vllm test alpha', results: 1, topic: 'vllm' });
1066 mm.store('search', { query: 'vllm test beta', results: 2, topic: 'vllm' });
1067
1068 const authHeaders = [];
1069 const origFetch = globalThis.fetch;
1070 globalThis.fetch = async (url, init) => {
1071 if (init?.headers?.['Authorization']) {
1072 authHeaders.push(init.headers['Authorization']);
1073 }
1074 return {
1075 ok: true,
1076 status: 200,
1077 json: async () => ({
1078 choices: [{ message: { content: '["vLLM is fast."]' } }],
1079 }),
1080 text: async () => '{}',
1081 };
1082 };
1083
1084 try {
1085 await withEnv(
1086 { OPENAI_API_KEY: 'sk-should-not-use', VLLM_API_KEY: 'vllm-bearer-token' },
1087 async () => {
1088 await consolidateMemory(config, { llmFn: daemonLlm, passes: ['consolidate'] });
1089 assert.ok(authHeaders.length > 0, 'Expected Authorization header to be captured');
1090 assert.ok(
1091 authHeaders.every((h) => h === 'Bearer vllm-bearer-token'),
1092 `Expected Bearer vllm-bearer-token, got: ${authHeaders.join(', ')}`,
1093 );
1094 },
1095 );
1096 } finally {
1097 globalThis.fetch = origFetch;
1098 }
1099 });
1100
1101 it('returns empty topics when there are no events (dry_run: false, no crash)', async () => {
1102 const config = makeConfig({ base_url: 'https://openrouter.ai/api/v1' });
1103 // No events seeded — memory manager is empty for this fresh dataDir
1104 const origFetch = globalThis.fetch;
1105 globalThis.fetch = makeFetchOk('[]');
1106 try {
1107 await withEnv({ OPENAI_API_KEY: 'sk' }, async () => {
1108 const result = await consolidateMemory(config, {
1109 llmFn: daemonLlm,
1110 passes: ['consolidate'],
1111 });
1112 assert.equal(result.topics.length, 0);
1113 assert.equal(result.total_events, 0);
1114 });
1115 } finally {
1116 globalThis.fetch = origFetch;
1117 }
1118 });
1119 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago