llm-complete-openrouter-unit.test.mjs
172 lines 6.4 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tier 1 — UNIT: OpenRouter provider lane in completeChat (lib/llm-complete.mjs).
3 *
4 * Scope: the smallest behavioural contract of the openrouter lane in isolation —
5 * provider selection, key requirement, model resolution precedence, default model,
6 * optional attribution headers, and response extraction. All network is mocked; no
7 * real OpenRouter endpoint is contacted.
8 *
9 * Why this lane exists: docs/COMPANION-APP-MODEL-ROUTING-AND-ENRICHMENT-ARCHITECTURE.md §4/§6
10 * defines OpenRouter as a "bring your own key" lane (user pays the provider directly; never
11 * metered against Knowtation packs).
12 */
13 import { describe, it, beforeEach, afterEach } from 'node:test';
14 import assert from 'node:assert';
15 import { completeChat } from '../lib/llm-complete.mjs';
16
17 const ORIG = {
18 fetch: globalThis.fetch,
19 OPENAI_API_KEY: process.env.OPENAI_API_KEY,
20 ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
21 DEEPINFRA_API_KEY: process.env.DEEPINFRA_API_KEY,
22 OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
23 OPENROUTER_CHAT_MODEL: process.env.OPENROUTER_CHAT_MODEL,
24 OPENROUTER_SITE_URL: process.env.OPENROUTER_SITE_URL,
25 OPENROUTER_APP_TITLE: process.env.OPENROUTER_APP_TITLE,
26 KNOWTATION_CHAT_PROVIDER: process.env.KNOWTATION_CHAT_PROVIDER,
27 KNOWTATION_CHAT_PREFER_ANTHROPIC: process.env.KNOWTATION_CHAT_PREFER_ANTHROPIC,
28 };
29
30 const CHAT_ENV_KEYS = [
31 'OPENAI_API_KEY',
32 'ANTHROPIC_API_KEY',
33 'DEEPINFRA_API_KEY',
34 'OPENROUTER_API_KEY',
35 'OPENROUTER_CHAT_MODEL',
36 'OPENROUTER_SITE_URL',
37 'OPENROUTER_APP_TITLE',
38 'KNOWTATION_CHAT_PROVIDER',
39 'KNOWTATION_CHAT_PREFER_ANTHROPIC',
40 ];
41
42 function clearChatEnv() {
43 for (const k of CHAT_ENV_KEYS) delete process.env[k];
44 }
45
46 function restoreEnv() {
47 for (const k of CHAT_ENV_KEYS) {
48 if (ORIG[k] === undefined) delete process.env[k];
49 else process.env[k] = ORIG[k];
50 }
51 }
52
53 /**
54 * Capture the single fetch call and return a fixed completion.
55 * @param {string} content
56 * @returns {{ calls: { url: string, init: RequestInit }[] }}
57 */
58 function mockOpenRouterOk(content) {
59 const calls = [];
60 globalThis.fetch = async (url, init) => {
61 calls.push({ url: String(url), init });
62 return {
63 ok: true,
64 json: async () => ({ choices: [{ message: { content } }] }),
65 };
66 };
67 return calls;
68 }
69
70 describe('OpenRouter lane — unit', () => {
71 beforeEach(() => {
72 clearChatEnv();
73 });
74
75 afterEach(() => {
76 globalThis.fetch = ORIG.fetch;
77 restoreEnv();
78 });
79
80 it('routes to OpenRouter when KNOWTATION_CHAT_PROVIDER=openrouter and key is set', async () => {
81 process.env.KNOWTATION_CHAT_PROVIDER = 'openrouter';
82 process.env.OPENROUTER_API_KEY = 'or-test';
83 const calls = mockOpenRouterOk('from-openrouter');
84 const out = await completeChat({}, { system: 's', user: 'u' });
85 assert.strictEqual(out, 'from-openrouter');
86 assert.strictEqual(calls.length, 1);
87 assert.ok(calls[0].url.includes('openrouter.ai/api/v1/chat/completions'));
88 });
89
90 it('throws an actionable error when OPENROUTER_API_KEY is missing', async () => {
91 process.env.KNOWTATION_CHAT_PROVIDER = 'openrouter';
92 globalThis.fetch = async () => {
93 throw new Error('fetch must not be called when the key is missing');
94 };
95 await assert.rejects(
96 () => completeChat({}, { system: 's', user: 'u' }),
97 /OPENROUTER_API_KEY is not set/,
98 );
99 });
100
101 it('uses the default model openai/gpt-4o-mini when no override is set', async () => {
102 process.env.KNOWTATION_CHAT_PROVIDER = 'openrouter';
103 process.env.OPENROUTER_API_KEY = 'or-test';
104 const calls = mockOpenRouterOk('ok');
105 await completeChat({}, { system: 's', user: 'u' });
106 assert.strictEqual(JSON.parse(calls[0].init.body).model, 'openai/gpt-4o-mini');
107 });
108
109 it('honours OPENROUTER_CHAT_MODEL env override', async () => {
110 process.env.KNOWTATION_CHAT_PROVIDER = 'openrouter';
111 process.env.OPENROUTER_API_KEY = 'or-test';
112 process.env.OPENROUTER_CHAT_MODEL = 'anthropic/claude-3.5-haiku';
113 const calls = mockOpenRouterOk('ok');
114 await completeChat({}, { system: 's', user: 'u' });
115 assert.strictEqual(JSON.parse(calls[0].init.body).model, 'anthropic/claude-3.5-haiku');
116 });
117
118 it('honours config.llm.openrouter_chat_model over the env override (caller wins)', async () => {
119 process.env.KNOWTATION_CHAT_PROVIDER = 'openrouter';
120 process.env.OPENROUTER_API_KEY = 'or-test';
121 process.env.OPENROUTER_CHAT_MODEL = 'anthropic/claude-3.5-haiku';
122 const calls = mockOpenRouterOk('ok');
123 await completeChat(
124 { llm: { openrouter_chat_model: 'meta-llama/llama-3.1-8b-instruct' } },
125 { system: 's', user: 'u' },
126 );
127 assert.strictEqual(
128 JSON.parse(calls[0].init.body).model,
129 'meta-llama/llama-3.1-8b-instruct',
130 );
131 });
132
133 it('omits attribution headers when neither OPENROUTER_SITE_URL nor OPENROUTER_APP_TITLE is set', async () => {
134 process.env.KNOWTATION_CHAT_PROVIDER = 'openrouter';
135 process.env.OPENROUTER_API_KEY = 'or-test';
136 const calls = mockOpenRouterOk('ok');
137 await completeChat({}, { system: 's', user: 'u' });
138 const headers = calls[0].init.headers;
139 assert.strictEqual(headers['HTTP-Referer'], undefined);
140 assert.strictEqual(headers['X-Title'], undefined);
141 });
142
143 it('adds attribution headers only when the env vars are set', async () => {
144 process.env.KNOWTATION_CHAT_PROVIDER = 'openrouter';
145 process.env.OPENROUTER_API_KEY = 'or-test';
146 process.env.OPENROUTER_SITE_URL = 'https://knowtation.example';
147 process.env.OPENROUTER_APP_TITLE = 'Knowtation';
148 const calls = mockOpenRouterOk('ok');
149 await completeChat({}, { system: 's', user: 'u' });
150 const headers = calls[0].init.headers;
151 assert.strictEqual(headers['HTTP-Referer'], 'https://knowtation.example');
152 assert.strictEqual(headers['X-Title'], 'Knowtation');
153 });
154
155 it('trims surrounding whitespace from the model output', async () => {
156 process.env.KNOWTATION_CHAT_PROVIDER = 'openrouter';
157 process.env.OPENROUTER_API_KEY = 'or-test';
158 mockOpenRouterOk(' spaced answer\n');
159 const out = await completeChat({}, { system: 's', user: 'u' });
160 assert.strictEqual(out, 'spaced answer');
161 });
162
163 it('throws on an empty completion (no silent empty string)', async () => {
164 process.env.KNOWTATION_CHAT_PROVIDER = 'openrouter';
165 process.env.OPENROUTER_API_KEY = 'or-test';
166 globalThis.fetch = async () => ({ ok: true, json: async () => ({ choices: [] }) });
167 await assert.rejects(
168 () => completeChat({}, { system: 's', user: 'u' }),
169 /OpenRouter chat: empty response/,
170 );
171 });
172 });
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 1 day ago