llm-complete-config-provider.test.mjs
175 lines 7.0 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 /**
2 * Config-driven chat-provider selection for completeChat (lib/llm-complete.mjs).
3 *
4 * Covers the new Hub-Settings-driven path: when KNOWTATION_CHAT_PROVIDER is unset, completeChat
5 * honours config.llm.provider (persisted via the Settings UI → config/local.yaml). The env var
6 * always takes precedence (operator lock), and selecting `ollama` forces the local lane even when
7 * cloud keys are present. Also guards daemon delegation isolation (buildDelegateConfig).
8 *
9 * Tiers exercised here: unit + integration + security (provider isolation) + data-integrity
10 * (precedence determinism). Network is mocked.
11 */
12 import { describe, it, beforeEach, afterEach } from 'node:test';
13 import assert from 'node:assert';
14 import { completeChat } from '../lib/llm-complete.mjs';
15 import { buildDelegateConfig } from '../lib/daemon-llm.mjs';
16
17 const ORIG = { ...process.env };
18 const origFetch = globalThis.fetch;
19
20 const CHAT_ENV_KEYS = [
21 'OPENAI_API_KEY',
22 'ANTHROPIC_API_KEY',
23 'DEEPINFRA_API_KEY',
24 'OPENROUTER_API_KEY',
25 'OPENROUTER_CHAT_MODEL',
26 'KNOWTATION_CHAT_PROVIDER',
27 'KNOWTATION_CHAT_PREFER_ANTHROPIC',
28 'OLLAMA_URL',
29 'OLLAMA_CHAT_MODEL',
30 'OLLAMA_MODEL',
31 ];
32
33 function clearChatEnv() {
34 for (const k of CHAT_ENV_KEYS) delete process.env[k];
35 }
36
37 function restoreEnv() {
38 for (const k of CHAT_ENV_KEYS) {
39 if (ORIG[k] === undefined) delete process.env[k];
40 else process.env[k] = ORIG[k];
41 }
42 }
43
44 function mockByHost(handlers) {
45 const hits = [];
46 globalThis.fetch = async (url, init) => {
47 const u = String(url);
48 hits.push(u);
49 for (const [needle, body] of Object.entries(handlers)) {
50 if (u.includes(needle)) return { ok: true, json: async () => body, text: async () => '' };
51 }
52 return { ok: false, status: 599, text: async () => `unexpected host: ${u}` };
53 };
54 return hits;
55 }
56
57 const OPENAI_OK = { 'api.openai.com': { choices: [{ message: { content: 'openai' } }] } };
58 const ANTHROPIC_OK = { 'api.anthropic.com': { content: [{ text: 'anthropic' }] } };
59 const DEEPINFRA_OK = { 'api.deepinfra.com': { choices: [{ message: { content: 'deepinfra' } }] } };
60 const OPENROUTER_OK = { 'openrouter.ai': { choices: [{ message: { content: 'openrouter' } }] } };
61 const OLLAMA_OK = { '11434': { message: { content: 'ollama' } } };
62
63 describe('completeChat config.llm.provider resolution', () => {
64 beforeEach(() => {
65 clearChatEnv();
66 });
67 afterEach(() => {
68 globalThis.fetch = origFetch;
69 restoreEnv();
70 });
71
72 it('routes by config.llm.provider when the env var is unset (openai)', async () => {
73 process.env.OPENAI_API_KEY = 'sk-openai';
74 process.env.ANTHROPIC_API_KEY = 'sk-ant';
75 mockByHost(OPENAI_OK);
76 const out = await completeChat({ llm: { provider: 'openai' } }, { system: 's', user: 'u' });
77 assert.strictEqual(out, 'openai');
78 });
79
80 it('routes by config.llm.provider = anthropic', async () => {
81 process.env.OPENAI_API_KEY = 'sk-openai';
82 process.env.ANTHROPIC_API_KEY = 'sk-ant';
83 mockByHost(ANTHROPIC_OK);
84 const out = await completeChat({ llm: { provider: 'anthropic' } }, { system: 's', user: 'u' });
85 assert.strictEqual(out, 'anthropic');
86 });
87
88 it('routes by config.llm.provider = deepinfra', async () => {
89 process.env.DEEPINFRA_API_KEY = 'di';
90 mockByHost(DEEPINFRA_OK);
91 const out = await completeChat({ llm: { provider: 'deepinfra' } }, { system: 's', user: 'u' });
92 assert.strictEqual(out, 'deepinfra');
93 });
94
95 it('routes by config.llm.provider = openrouter', async () => {
96 process.env.OPENROUTER_API_KEY = 'or';
97 mockByHost(OPENROUTER_OK);
98 const out = await completeChat({ llm: { provider: 'openrouter' } }, { system: 's', user: 'u' });
99 assert.strictEqual(out, 'openrouter');
100 });
101
102 it('config.llm.provider = ollama forces the local lane even when OPENAI_API_KEY is set', async () => {
103 process.env.OPENAI_API_KEY = 'sk-openai';
104 const hits = mockByHost(OLLAMA_OK);
105 const out = await completeChat({ llm: { provider: 'ollama' } }, { system: 's', user: 'u' });
106 assert.strictEqual(out, 'ollama');
107 assert.ok(hits.every((u) => !u.includes('api.openai.com')));
108 });
109
110 it('env KNOWTATION_CHAT_PROVIDER wins over config.llm.provider (operator lock)', async () => {
111 process.env.KNOWTATION_CHAT_PROVIDER = 'anthropic';
112 process.env.OPENAI_API_KEY = 'sk-openai';
113 process.env.ANTHROPIC_API_KEY = 'sk-ant';
114 const hits = mockByHost(ANTHROPIC_OK);
115 const out = await completeChat({ llm: { provider: 'openai' } }, { system: 's', user: 'u' });
116 assert.strictEqual(out, 'anthropic');
117 assert.ok(hits.every((u) => !u.includes('api.openai.com')));
118 });
119
120 it('config provider openai without a key throws a source-agnostic, actionable error', async () => {
121 globalThis.fetch = async () => {
122 throw new Error('must not be called');
123 };
124 await assert.rejects(
125 () => completeChat({ llm: { provider: 'openai' } }, { system: 's', user: 'u' }),
126 /provider 'openai'.*OPENAI_API_KEY is not set/s,
127 );
128 });
129
130 it('empty config provider falls through to the default chain (no behaviour change)', async () => {
131 process.env.OPENAI_API_KEY = 'sk-openai';
132 mockByHost(OPENAI_OK);
133 const out = await completeChat({ llm: { provider: '' } }, { system: 's', user: 'u' });
134 assert.strictEqual(out, 'openai');
135 });
136 });
137
138 describe('buildDelegateConfig provider isolation (daemon vs global chat provider)', () => {
139 it('pins provider=ollama for the ollama delegate path', () => {
140 const merged = buildDelegateConfig({ llm: { provider: 'openai' } }, { provider: 'ollama', model: null });
141 assert.strictEqual(merged.llm.provider, 'ollama');
142 });
143
144 it('pins provider=anthropic for the anthropic delegate path', () => {
145 const merged = buildDelegateConfig({ llm: { provider: 'openrouter' } }, { provider: 'anthropic', model: 'claude-x' });
146 assert.strictEqual(merged.llm.provider, 'anthropic');
147 assert.strictEqual(merged.llm.anthropic_chat_model, 'claude-x');
148 });
149
150 it('leaves provider unset for the null/openai delegate path (completeChat precedence applies)', () => {
151 const merged = buildDelegateConfig({ llm: { provider: 'openrouter' } }, { provider: null, model: 'gpt' });
152 assert.strictEqual(merged.llm.provider, 'openrouter');
153 assert.strictEqual(merged.llm.openai_chat_model, 'gpt');
154 });
155
156 it('daemon ollama delegation forces ollama even when global chat provider is openai', async () => {
157 const savedFetch = globalThis.fetch;
158 const savedEnv = { ...process.env };
159 for (const k of CHAT_ENV_KEYS) delete process.env[k];
160 process.env.OPENAI_API_KEY = 'sk-openai';
161 const hits = mockByHost(OLLAMA_OK);
162 try {
163 const merged = buildDelegateConfig({ llm: { provider: 'openai' } }, { provider: 'ollama', model: null });
164 const out = await completeChat(merged, { system: 's', user: 'u' });
165 assert.strictEqual(out, 'ollama');
166 assert.ok(hits.every((u) => !u.includes('api.openai.com')));
167 } finally {
168 globalThis.fetch = savedFetch;
169 for (const k of CHAT_ENV_KEYS) {
170 if (savedEnv[k] === undefined) delete process.env[k];
171 else process.env[k] = savedEnv[k];
172 }
173 }
174 });
175 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago