daemon-llm.test.mjs
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