@dotdrelle/wiki-manager 0.15.32 → 0.15.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,16 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { fallbackModels, fetchModels } from './modelFetch.js';
3
+ import {
4
+ DISCOVERY_TIMEOUT_MS,
5
+ classifyModelEntry,
6
+ describeFetchError,
7
+ fetchServerCatalog,
8
+ fallbackModels,
9
+ fetchGatewayCatalog,
10
+ fetchModels,
11
+ requiresBaseUrl,
12
+ transportSummary,
13
+ } from './modelFetch.js';
4
14
 
5
15
  test('fetchModels returns remote OpenAI-compatible model ids', async () => {
6
16
  const originalFetch = globalThis.fetch;
@@ -34,5 +44,283 @@ test('fetchModels falls back on invalid remote response', async () => {
34
44
  });
35
45
 
36
46
  test('fallbackModels leaves custom-model to the wizard append action', () => {
37
- assert.deepEqual(fallbackModels('openai-compatible'), ['gpt-4.1-mini', 'llama3.2']);
47
+ assert.deepEqual(fallbackModels('generic'), ['gpt-4.1-mini', 'llama3.2']);
48
+ });
49
+
50
+ test('fetchGatewayCatalog types models from /model/info', async () => {
51
+ const originalFetch = globalThis.fetch;
52
+ globalThis.fetch = async (url) => {
53
+ assert.equal(url, 'http://gw:4000/model/info');
54
+ return {
55
+ ok: true,
56
+ json: async () => ({
57
+ data: [
58
+ { model_name: 'anthropic/claude-sonnet-4-5', model_info: { mode: 'chat' } },
59
+ { model_name: 'infinity/bge-m3', model_info: { mode: 'embedding' } },
60
+ { model_name: 'infinity/bge-reranker', model_info: { mode: 'rerank' } },
61
+ { model_name: 'dalle', model_info: { mode: 'image_generation' } },
62
+ ],
63
+ }),
64
+ };
65
+ };
66
+ try {
67
+ const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
68
+ assert.equal(result.ok, true);
69
+ assert.equal(result.typed, true);
70
+ assert.deepEqual(result.chat, ['anthropic/claude-sonnet-4-5']);
71
+ assert.deepEqual(result.embedding, ['infinity/bge-m3']);
72
+ assert.deepEqual(result.rerank, ['infinity/bge-reranker']);
73
+ } finally {
74
+ globalThis.fetch = originalFetch;
75
+ }
76
+ });
77
+
78
+ test('fetchGatewayCatalog degrades to an untyped /v1/models list', async () => {
79
+ const originalFetch = globalThis.fetch;
80
+ globalThis.fetch = async (url) => {
81
+ if (url.endsWith('/model/info')) return { ok: false, status: 404 };
82
+ return { ok: true, json: async () => ({ data: [{ id: 'b' }, { id: 'a' }] }) };
83
+ };
84
+ try {
85
+ const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
86
+ assert.equal(result.ok, true);
87
+ assert.equal(result.typed, false);
88
+ // Non typé : les trois listes reçoivent la même chose, à charge du wizard
89
+ // de le signaler.
90
+ assert.deepEqual(result.chat, ['a', 'b']);
91
+ assert.deepEqual(result.embedding, ['a', 'b']);
92
+ assert.deepEqual(result.rerank, ['a', 'b']);
93
+ assert.match(result.error, /HTTP 404/);
94
+ } finally {
95
+ globalThis.fetch = originalFetch;
96
+ }
97
+ });
98
+
99
+ test('fetchGatewayCatalog reports an unreachable gateway without inventing models', async () => {
100
+ const originalFetch = globalThis.fetch;
101
+ globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); };
102
+ try {
103
+ const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
104
+ assert.equal(result.ok, false);
105
+ assert.equal(result.source, 'unreachable');
106
+ assert.deepEqual(result.chat, []);
107
+ assert.match(result.error, /ECONNREFUSED/);
108
+ } finally {
109
+ globalThis.fetch = originalFetch;
110
+ }
111
+ });
112
+
113
+ test('fetchGatewayCatalog queries /model/info and /v1/models in parallel', async () => {
114
+ const originalFetch = globalThis.fetch;
115
+ const started = [];
116
+ let releaseModelInfo;
117
+ const modelInfoGate = new Promise((resolve) => { releaseModelInfo = resolve; });
118
+ globalThis.fetch = async (url) => {
119
+ started.push(url);
120
+ if (url.endsWith('/model/info')) {
121
+ await modelInfoGate;
122
+ return { ok: false, status: 404 };
123
+ }
124
+ return { ok: true, json: async () => ({ data: [{ id: 'a' }] }) };
125
+ };
126
+ try {
127
+ const pending = fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
128
+ // Le repli ne doit pas attendre la fin du chemin typé : les deux requêtes
129
+ // sont déjà parties quand /model/info est encore en vol.
130
+ await new Promise((resolve) => setImmediate(resolve));
131
+ assert.equal(started.length, 2);
132
+ releaseModelInfo();
133
+ const result = await pending;
134
+ assert.equal(result.ok, true);
135
+ assert.equal(result.typed, false);
136
+ assert.deepEqual(result.chat, ['a']);
137
+ } finally {
138
+ globalThis.fetch = originalFetch;
139
+ }
140
+ });
141
+
142
+ test('HTTP failures name the likely cause instead of a bare status', async () => {
143
+ const originalFetch = globalThis.fetch;
144
+ globalThis.fetch = async () => ({ ok: false, status: 401 });
145
+ try {
146
+ const result = await fetchModels('openai', 'https://api.openai.com/v1', 'bad', {
147
+ timeoutMs: 50,
148
+ });
149
+ assert.equal(result.ok, false);
150
+ assert.match(result.error, /HTTP 401/);
151
+ assert.match(result.error, /API key rejected/);
152
+ } finally {
153
+ globalThis.fetch = originalFetch;
154
+ }
155
+ });
156
+
157
+ test('describeFetchError turns transport codes into an actionable sentence', () => {
158
+ const tls = Object.assign(new Error('fetch failed'), {
159
+ cause: { code: 'SELF_SIGNED_CERT_IN_CHAIN' },
160
+ });
161
+ assert.match(describeFetchError(tls, { url: 'https://gw:4000/v1/models' }), /--cacert/);
162
+
163
+ const refused = Object.assign(new Error('fetch failed'), {
164
+ cause: { code: 'ECONNREFUSED' },
165
+ });
166
+ assert.match(describeFetchError(refused, { url: 'http://gw:4000/v1' }), /gw:4000/);
167
+
168
+ const dns = Object.assign(new Error('fetch failed'), { cause: { code: 'ENOTFOUND' } });
169
+ assert.match(describeFetchError(dns, { url: 'http://nope.local/v1' }), /host not found/);
170
+
171
+ // Le délai en millisecondes est un détail d'implémentation : le message
172
+ // nomme l'hôte muet et les causes probables, pas la valeur du timeout.
173
+ const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' });
174
+ const timedOut = describeFetchError(aborted, { url: 'http://gw:4000/v1', timeoutMs: 8000 });
175
+ assert.match(timedOut, /gw:4000 did not answer in time/);
176
+ assert.match(timedOut, /proxy or firewall/);
177
+ assert.doesNotMatch(timedOut, /\d+ ms/);
178
+ });
179
+
180
+ test('transportSummary warns when a declared proxy is not actually used', () => {
181
+ assert.match(
182
+ transportSummary({ HTTPS_PROXY: 'http://proxy:3128' }),
183
+ /NODE_USE_ENV_PROXY not set/,
184
+ );
185
+ assert.match(
186
+ transportSummary({ HTTPS_PROXY: 'http://proxy:3128', NODE_USE_ENV_PROXY: '1' }),
187
+ /proxy http:\/\/proxy:3128/,
188
+ );
189
+ assert.match(transportSummary({}), /direct connection/);
190
+ assert.match(transportSummary({ WIKI_MANAGER_CACERT_PATH: '/ca.pem' }), /CA \/ca\.pem/);
191
+ });
192
+
193
+ test('the discovery timeout only bounds silent failures, never a healthy endpoint', () => {
194
+ // Assez long pour un endpoint distant lent, assez court pour qu'un proxy qui
195
+ // avale la connexion finisse par se dénoncer. Il n'est jamais attendu par
196
+ // une étape du wizard : la découverte tourne en tâche de fond.
197
+ assert.ok(DISCOVERY_TIMEOUT_MS >= 5000 && DISCOVERY_TIMEOUT_MS <= 15000);
198
+ });
199
+
200
+ test('the gateway hands over the flat list before /model/info completes', async () => {
201
+ const originalFetch = globalThis.fetch;
202
+ let releaseModelInfo;
203
+ const modelInfoGate = new Promise((resolve) => { releaseModelInfo = resolve; });
204
+ globalThis.fetch = async (url) => {
205
+ if (url.endsWith('/model/info')) {
206
+ await modelInfoGate;
207
+ return {
208
+ ok: true,
209
+ json: async () => ({
210
+ data: [{ model_name: 'chat-a', model_info: { mode: 'chat' } }],
211
+ }),
212
+ };
213
+ }
214
+ return { ok: true, json: async () => ({ data: [{ id: 'chat-a' }, { id: 'embed-b' }] }) };
215
+ };
216
+ try {
217
+ const partials = [];
218
+ const pending = fetchGatewayCatalog('http://gw:4000/v1', 'key', {
219
+ timeoutMs: 100,
220
+ onPartial: (partial) => partials.push(partial),
221
+ });
222
+ // La liste plate doit être livrée pendant que /model/info est encore en vol.
223
+ await new Promise((resolve) => setTimeout(resolve, 5));
224
+ assert.equal(partials.length, 1);
225
+ assert.equal(partials[0].typed, false);
226
+ assert.deepEqual(partials[0].chat, ['chat-a', 'embed-b']);
227
+
228
+ releaseModelInfo();
229
+ const result = await pending;
230
+ assert.equal(result.typed, true);
231
+ assert.deepEqual(result.chat, ['chat-a']);
232
+ // Le typage arrivé, plus aucune livraison partielle ne doit suivre.
233
+ assert.equal(partials.length, 1);
234
+ } finally {
235
+ globalThis.fetch = originalFetch;
236
+ }
237
+ });
238
+
239
+ test('classifyModelEntry reads the type hints real servers actually send', () => {
240
+ // Albert.
241
+ assert.equal(classifyModelEntry({ type: 'text-generation' }), 'chat');
242
+ assert.equal(classifyModelEntry({ type: 'text-embeddings-inference' }), 'embedding');
243
+ assert.equal(classifyModelEntry({ type: 'text-classification' }), 'rerank');
244
+ // Hors périmètre : absent des trois listes plutôt que mal classé.
245
+ assert.equal(classifyModelEntry({ type: 'automatic-speech-recognition' }), null);
246
+ // LiteLLM.
247
+ assert.equal(classifyModelEntry({ model_info: { mode: 'embedding' } }), 'embedding');
248
+ // OpenAI ne type rien : `object: "model"` ne doit pas être pris pour un type.
249
+ assert.equal(classifyModelEntry({ id: 'gpt-4.1', object: 'model' }), null);
250
+ });
251
+
252
+ test('fetchServerCatalog splits a direct server catalog by model type', async () => {
253
+ const originalFetch = globalThis.fetch;
254
+ globalThis.fetch = async () => ({
255
+ ok: true,
256
+ json: async () => ({
257
+ data: [
258
+ { id: 'openai/gpt-oss-120b', type: 'text-generation' },
259
+ { id: 'bge-m3', type: 'text-embeddings-inference' },
260
+ { id: 'bge-reranker-v2-m3', type: 'text-classification' },
261
+ { id: 'whisper-large-v3', type: 'automatic-speech-recognition' },
262
+ ],
263
+ }),
264
+ });
265
+ try {
266
+ const result = await fetchServerCatalog('openai-compatible', 'https://albert.example/v1', 'key', {
267
+ engine: 'albert',
268
+ timeoutMs: 100,
269
+ });
270
+ assert.equal(result.ok, true);
271
+ assert.equal(result.typed, true);
272
+ assert.deepEqual(result.chat, ['openai/gpt-oss-120b']);
273
+ assert.deepEqual(result.embedding, ['bge-m3']);
274
+ assert.deepEqual(result.rerank, ['bge-reranker-v2-m3']);
275
+ } finally {
276
+ globalThis.fetch = originalFetch;
277
+ }
278
+ });
279
+
280
+ test('fetchServerCatalog stays untyped when the server types nothing', async () => {
281
+ const originalFetch = globalThis.fetch;
282
+ globalThis.fetch = async () => ({
283
+ ok: true,
284
+ json: async () => ({ data: [{ id: 'b', object: 'model' }, { id: 'a', object: 'model' }] }),
285
+ });
286
+ try {
287
+ const result = await fetchServerCatalog('openai-compatible', 'https://api.openai.com/v1', 'key', {
288
+ engine: 'openai',
289
+ timeoutMs: 100,
290
+ });
291
+ assert.equal(result.typed, false);
292
+ assert.deepEqual(result.chat, ['a', 'b']);
293
+ assert.deepEqual(result.embedding, ['a', 'b']);
294
+ } finally {
295
+ globalThis.fetch = originalFetch;
296
+ }
297
+ });
298
+
299
+ test('a partially typed catalog falls back to the full list, never to an empty one', async () => {
300
+ const originalFetch = globalThis.fetch;
301
+ globalThis.fetch = async () => ({
302
+ ok: true,
303
+ json: async () => ({
304
+ data: [{ id: 'embed-only', type: 'text-embeddings-inference' }, { id: 'mystery' }],
305
+ }),
306
+ });
307
+ try {
308
+ const result = await fetchServerCatalog('openai-compatible', 'https://x/v1', 'key', {
309
+ timeoutMs: 100,
310
+ });
311
+ assert.equal(result.typed, true);
312
+ assert.deepEqual(result.embedding, ['embed-only']);
313
+ // Aucun modèle annoncé comme chat : proposer toute la liste vaut mieux
314
+ // qu'une étape sans aucune suggestion.
315
+ assert.deepEqual(result.chat, ['embed-only', 'mystery']);
316
+ } finally {
317
+ globalThis.fetch = originalFetch;
318
+ }
319
+ });
320
+
321
+ test('requiresBaseUrl follows the engine, and the gateway always needs one', () => {
322
+ assert.equal(requiresBaseUrl('openai-compatible', 'ollama'), true);
323
+ assert.equal(requiresBaseUrl('openai-compatible', 'openai'), false);
324
+ assert.equal(requiresBaseUrl('openai-compatible', 'anthropic'), false);
325
+ assert.equal(requiresBaseUrl('ai-gateway', 'openai'), true);
38
326
  });
@@ -0,0 +1,146 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { PROFILE_FLAGS, profileServiceStatus } from './agentsCompose.js';
6
+
7
+ test('a disabled service says which flag, in which file, and what to do', () => {
8
+ const status = profileServiceStatus('connectors', { env: {} });
9
+ assert.equal(status.enabled, false);
10
+ assert.equal(status.flag, 'CONNECTORS_ENABLED');
11
+ // Le message d'origine — « unavailable or disabled » — ne disait ni lequel
12
+ // des deux, ni où regarder : Donna, faute de fait à citer, inventait un
13
+ // `cme.yaml` et un « manifeste des services actifs ».
14
+ assert.match(status.message, /CONNECTORS_ENABLED is not set/);
15
+ assert.match(status.message, /set CONNECTORS_ENABLED=true/);
16
+ assert.match(status.message, /\/start agents/);
17
+ assert.match(status.message, /\.env/);
18
+ // Le piège du profil Compose : le conteneur n'existe pas du tout, ce qui
19
+ // ressemble à un service planté.
20
+ assert.match(status.message, /does not exist at all/);
21
+ });
22
+
23
+ test('a disabled service quotes the value actually found', () => {
24
+ const status = profileServiceStatus('connectors', { env: { CONNECTORS_ENABLED: 'false' } });
25
+ assert.equal(status.enabled, false);
26
+ assert.match(status.message, /CONNECTORS_ENABLED is "false"/);
27
+ });
28
+
29
+ test('an enabled but unreachable service is a different diagnosis', () => {
30
+ const status = profileServiceStatus('connectors', { env: { CONNECTORS_ENABLED: 'true' } });
31
+ assert.equal(status.enabled, true);
32
+ assert.match(status.message, /enabled .* but not reachable/);
33
+ assert.doesNotMatch(status.message, /set CONNECTORS_ENABLED=true/);
34
+ });
35
+
36
+ test('an unknown service is not described as disabled', () => {
37
+ const status = profileServiceStatus('nope', { env: {} });
38
+ assert.equal(status.flag, null);
39
+ assert.match(status.message, /no "nope" service/);
40
+ });
41
+
42
+ test('/connector reports the reason instead of a bare "unavailable or disabled"', () => {
43
+ const source = readFileSync(
44
+ fileURLToPath(new URL('../commands/slash.js', import.meta.url)),
45
+ 'utf8',
46
+ );
47
+ assert.match(source, /profileServiceStatus\('connectors'\)\.message/);
48
+ assert.doesNotMatch(source, /The connectors service is unavailable or disabled\./);
49
+ });
50
+
51
+ test('every profile flag is documented where the operator will look', () => {
52
+ const doc = readFileSync(
53
+ fileURLToPath(new URL('../../../llm-wiki/help-doc/06-troubleshooting.md', import.meta.url)),
54
+ 'utf8',
55
+ );
56
+ // Donna répond aux questions produit depuis cette documentation : un drapeau
57
+ // absent d'ici est un drapeau qu'elle ne peut que deviner.
58
+ for (const flag of Object.values(PROFILE_FLAGS)) {
59
+ assert.ok(doc.includes(flag), `${flag} must be documented in help-doc/06-troubleshooting.md`);
60
+ }
61
+ });
62
+
63
+ test('stopping the agents resolves the same manager files as starting them', () => {
64
+ const source = readFileSync(
65
+ fileURLToPath(new URL('./wikiSetup.js', import.meta.url)),
66
+ 'utf8',
67
+ );
68
+ // `stopAgents` n'épinglait pas WIKI_MANAGER_ENV_FILE : le script résolvait
69
+ // `.env` contre le répertoire du paquet, `connectors_enabled` y lisait un
70
+ // profil inactif, et `docker compose down` partait sans
71
+ // `--profile connectors`. Un service à profil inactif étant invisible à
72
+ // Compose, le conteneur restait debout — `/stop agents` annonçait un succès
73
+ // en laissant tourner ce qu'il prétendait arrêter.
74
+ const stopBlock = source.slice(source.indexOf('export async function stopAgents'));
75
+ for (const key of ['WIKI_MANAGER_ENV_FILE: managerEnvFile()', 'WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile()']) {
76
+ assert.ok(stopBlock.includes(key), `stopAgents must pin ${key}`);
77
+ }
78
+ });
79
+
80
+ test('"all" means the same thing to start and to stop', () => {
81
+ const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
82
+ // `/start all` démarrait les agents, `/stop all` ne les arrêtait pas : la
83
+ // séquence start/stop ne revenait donc pas à l'état de départ.
84
+ assert.match(slash, /const startsAgents = service === 'all';/);
85
+ assert.match(slash, /const stopsAgents = service === 'all';/);
86
+ assert.match(slash, /if \(stopsAgents\) \{\s*\n\s*const agentsResult = await runAgentCommand\(stopAgents, 'stop'\);/);
87
+ });
88
+
89
+ test('the addressable agents are read from the compose file, never hard-coded', async () => {
90
+ const { agentServiceNames, togglableAgentNames } = await import('./agentsCompose.js');
91
+ const declared = agentServiceNames();
92
+
93
+ // Une liste figée annonçait `mailer`, qui ne vit que dans l'exemple
94
+ // d'override : proposer un service absent, c'est promettre un
95
+ // « no such service ».
96
+ assert.ok(declared.includes('connectors'));
97
+ assert.ok(declared.includes('cme'));
98
+ assert.ok(!declared.includes('mailer'), 'mailer is not declared until the operator uncomments it');
99
+
100
+ // Et seuls les agents à drapeau se proposent un par un dans l'interface.
101
+ assert.deepEqual(togglableAgentNames(), ['connectors']);
102
+ for (const name of togglableAgentNames()) {
103
+ assert.ok(declared.includes(name), `${name} must actually exist in the agents stack`);
104
+ }
105
+ });
106
+
107
+ test('an agent can be started and stopped on its own', () => {
108
+ const setup = readFileSync(fileURLToPath(new URL('./wikiSetup.js', import.meta.url)), 'utf8');
109
+ const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
110
+ const script = readFileSync(fileURLToPath(new URL('../../wiki-workspace', import.meta.url)), 'utf8');
111
+
112
+ assert.match(setup, /'agents', 'up', \.\.\.\(options\.services \?\? \[\]\)/);
113
+ assert.match(setup, /'agents', 'down', \.\.\.\(options\.services \?\? \[\]\)/);
114
+ // La pile agents est un projet Compose distinct : sans ce routage, `/start
115
+ // connectors` allait chercher un service inexistant côté workspace.
116
+ assert.match(slash, /agentServiceNames\(\)\.includes\(service\)/);
117
+ // `down <service>` supprimerait le réseau commun : Compose impose stop + rm.
118
+ assert.match(script, /_agents_dc stop "\$@"/);
119
+ assert.match(script, /_agents_dc rm -f "\$@"/);
120
+ });
121
+
122
+ test('a connector read tool is not mistaken for the limit of its agent', () => {
123
+ const graph = readFileSync(fileURLToPath(new URL('../agent/graph.js', import.meta.url)), 'utf8');
124
+ // « récupère ce mail » et « envoie un mail » ont été refusés comme
125
+ // impossibles, alors que l'agent connectors déclare `external-source.collect`
126
+ // et `communication.send-email`. Donna raisonnait sur les outils de lecture
127
+ // exposés au chat — qui ne rendent que des métadonnées — au lieu de déléguer.
128
+ assert.match(graph, /direct read tools of a connector are a preview/);
129
+ assert.match(graph, /delegated through runtime__delegate, not answered from a read tool/);
130
+ // Et un droit manquant doit être nommé comme tel, pas présenté comme une
131
+ // fonctionnalité absente.
132
+ assert.match(graph, /lack of an authorization grant or scope/);
133
+ });
134
+
135
+ test('the system prompt points product questions at the documentation tools', () => {
136
+ const graph = readFileSync(
137
+ fileURLToPath(new URL('../agent/graph.js', import.meta.url)),
138
+ 'utf8',
139
+ );
140
+ // Les outils help_* sont exposés (allow-list du mode chat) mais le prompt ne
141
+ // disait nulle part qu'ils sont LA source des questions produit : Donna
142
+ // répondait de mémoire, et inventait.
143
+ assert.match(graph, /bundled product documentation is your source/);
144
+ assert.match(graph, /call the documentation search\/read tools FIRST/);
145
+ assert.match(graph, /Configuration facts are never answered from memory/);
146
+ });
@@ -72,7 +72,7 @@ export async function startAgents(options = {}) {
72
72
  : await missingAgentImages(composeContext);
73
73
  if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
74
74
  const exec = options.exec ?? execFileAsync;
75
- const { stdout, stderr } = await exec(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
75
+ const { stdout, stderr } = await exec(join(managerRoot(), 'wiki-workspace'), ['agents', 'up', ...(options.services ?? [])], {
76
76
  cwd: managerRoot(),
77
77
  env: {
78
78
  ...process.env,
@@ -148,11 +148,20 @@ function agentsStartFailure({ downServices = [], missingServices = [], profiles
148
148
 
149
149
  export async function stopAgents(options = {}) {
150
150
  try {
151
- const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'down'], {
151
+ const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'down', ...(options.services ?? [])], {
152
152
  cwd: managerRoot(),
153
153
  env: {
154
154
  ...process.env,
155
155
  WIKI_WORKSPACES_DIR: workspacesDir(),
156
+ // Même épinglage que startAgents, et pour la même raison : cwd est la
157
+ // racine du paquet, donc sans ces variables le script résout `.env`
158
+ // contre $PWD. `connectors_enabled` lisait alors le .env du PAQUET,
159
+ // concluait que le profil était inactif, et `docker compose down`
160
+ // partait sans `--profile connectors` — un service à profil inactif est
161
+ // invisible à Compose, donc le conteneur restait debout. `/stop agents`
162
+ // annonçait un succès en laissant tourner ce qu'il prétendait arrêter.
163
+ WIKI_MANAGER_ENV_FILE: managerEnvFile(),
164
+ WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
156
165
  AGENTS_DATA_DIR: resolveAgentsDataDir(),
157
166
  },
158
167
  timeout: options.timeout ?? 120_000,
@@ -261,11 +270,37 @@ export function writeLanguageConfig(workspacePath, profileName, language) {
261
270
  return patchWikircProfile(workspacePath, profileName || 'default', { language });
262
271
  }
263
272
 
273
+ /**
274
+ * Le wizard tourne sur l'hôte, les services dans Docker : une baseUrl saisie
275
+ * en `localhost` répond au wizard et échoue dans le container. On la réécrit
276
+ * vers `host.docker.internal`, que les services déclarent déjà en
277
+ * `extra_hosts`. Jamais sur un hostname réel — seulement sur les trois formes
278
+ * de boucle locale. L'appelant affiche la réécriture : la faire en silence
279
+ * rend le diagnostic impossible quand elle se trompe.
280
+ */
281
+ export function containerReachableUrl(baseUrl) {
282
+ if (!baseUrl) return { url: baseUrl, rewritten: false };
283
+ try {
284
+ const parsed = new URL(baseUrl);
285
+ if (!['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname)) {
286
+ return { url: baseUrl, rewritten: false };
287
+ }
288
+ const original = parsed.hostname;
289
+ parsed.hostname = 'host.docker.internal';
290
+ return { url: parsed.toString().replace(/\/$/, ''), rewritten: true, from: original };
291
+ } catch {
292
+ return { url: baseUrl, rewritten: false };
293
+ }
294
+ }
295
+
264
296
  export function writeLlmConfig(workspacePath, profileName, config) {
265
297
  const patches = {
266
298
  llm: {
267
299
  provider: config.provider,
268
- ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
300
+ // Absent derrière une gateway : l'endpoint est opaque, il n'y a pas un
301
+ // moteur mais un par modèle.
302
+ ...(config.engine ? { engine: config.engine } : {}),
303
+ ...(config.baseUrl ? { baseUrl: containerReachableUrl(config.baseUrl).url } : {}),
269
304
  ...(config.apiKey ? { apiKey: config.apiKey } : {}),
270
305
  model: config.model,
271
306
  },
@@ -278,7 +313,11 @@ export function writeVectorConfig(workspacePath, profileName, config) {
278
313
  retrieval: {
279
314
  vector: {
280
315
  enabled: true,
281
- ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
316
+ // baseUrl et apiKey absents = hérités du bloc llm par resolveConfig.
317
+ // Le wizard ne les transmet que lorsqu'ils divergent réellement, pour
318
+ // que le wikirc reste lisible et que la clé du LLM ne soit pas
319
+ // recopiée vers un autre hôte.
320
+ ...(config.baseUrl ? { baseUrl: containerReachableUrl(config.baseUrl).url } : {}),
282
321
  ...(config.apiKey ? { apiKey: config.apiKey } : {}),
283
322
  timeoutMs: config.timeoutMs ?? 600_000,
284
323
  embeddingModel: config.embeddingModel,
@@ -66,11 +66,17 @@ test('wiki-workspace provisions connector secrets and persistent state', async (
66
66
  assert.match(script, /delete config\.chatAccess\.servers\.connectors/);
67
67
  });
68
68
 
69
- test('local connector builds require both public Desktop OAuth values', async () => {
69
+ test('the manager never builds an image, it only runs published ones', async () => {
70
70
  const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
71
71
 
72
- assert.match(script, /for build_key in WIKILLM_GOOGLE_OAUTH_CLIENT_ID WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET/);
73
- assert.match(script, /require_connectors_build_credentials/);
72
+ // Ce script est livré dans le paquet npm, où le dépôt agent-connectors
73
+ // n'existe pas : il ne peut ni construire l'image, ni lire le
74
+ // .env.build.local qui porte l'application OAuth. La construction appartient
75
+ // à build-and-push.sh. Pour utiliser sa propre application Google,
76
+ // l'opérateur surcharge GOOGLE_OAUTH_CLIENT_ID / _SECRET dans le .env.
77
+ assert.doesNotMatch(script, /--build\b/);
78
+ assert.doesNotMatch(script, /\.env\.build\.local/);
79
+ assert.doesNotMatch(script, /require_connectors_build_credentials/);
74
80
  });
75
81
 
76
82
  test('workspace creation keeps mutable manager files outside the installed package', async () => {
@@ -5,7 +5,12 @@ import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import YAML from 'yaml';
7
7
  import { loadWikircProfile, normalizeCapabilityRouting, patchWikircProfile } from './wikirc.js';
8
- import { finalizeCreatedWorkspace, writeVectorConfig } from './wikiSetup.js';
8
+ import {
9
+ containerReachableUrl,
10
+ finalizeCreatedWorkspace,
11
+ writeLlmConfig,
12
+ writeVectorConfig,
13
+ } from './wikiSetup.js';
9
14
 
10
15
  test('patchWikircProfile merges keys and preserves existing values', () => {
11
16
  const root = mkdtempSync(join(tmpdir(), 'wikirc-patch-'));
@@ -106,7 +111,7 @@ test('writeVectorConfig writes llm-wiki vector and rerank keys', () => {
106
111
  ].join('\n'), 'utf8');
107
112
 
108
113
  writeVectorConfig(root, 'default', {
109
- baseUrl: 'http://localhost:7997/v1',
114
+ baseUrl: 'http://host.docker.internal:7997/v1',
110
115
  apiKey: 'vector-key',
111
116
  embeddingModel: 'BAAI/bge-m3',
112
117
  rerankEnabled: true,
@@ -115,7 +120,7 @@ test('writeVectorConfig writes llm-wiki vector and rerank keys', () => {
115
120
 
116
121
  const parsed = YAML.parse(readFileSync(join(root, '.wikirc.yaml'), 'utf8'));
117
122
  assert.equal(parsed.retrieval.vector.enabled, true);
118
- assert.equal(parsed.retrieval.vector.baseUrl, 'http://localhost:7997/v1');
123
+ assert.equal(parsed.retrieval.vector.baseUrl, 'http://host.docker.internal:7997/v1');
119
124
  assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
120
125
  assert.equal(parsed.retrieval.vector.embeddingModel, 'BAAI/bge-m3');
121
126
  assert.equal(parsed.retrieval.vector.rerankEnabled, true);
@@ -143,7 +148,7 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
143
148
  ].join('\n'), 'utf8');
144
149
 
145
150
  writeVectorConfig(root, 'default', {
146
- baseUrl: 'http://localhost:7997/v1',
151
+ baseUrl: 'http://host.docker.internal:7997/v1',
147
152
  apiKey: 'vector-key',
148
153
  embeddingModel: 'BAAI/bge-m3',
149
154
  rerankEnabled: true,
@@ -154,7 +159,7 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
154
159
  const parsed = YAML.parse(raw);
155
160
  assert.doesNotMatch(raw, /^\s*#\s*baseUrl:/m);
156
161
  assert.doesNotMatch(raw, /^\s*#\s*apiKey:/m);
157
- assert.equal(parsed.retrieval.vector.baseUrl, 'http://localhost:7997/v1');
162
+ assert.equal(parsed.retrieval.vector.baseUrl, 'http://host.docker.internal:7997/v1');
158
163
  assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
159
164
  });
160
165
 
@@ -195,3 +200,52 @@ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc',
195
200
  else process.env.WIKI_WORKSPACES_DIR = previousDir;
196
201
  }
197
202
  });
203
+
204
+ test('writeLlmConfig writes provider + engine and rewrites loopback hosts for containers', () => {
205
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-llm-'));
206
+ writeFileSync(join(root, '.wikirc.yaml'), ['language: en', ''].join('\n'), 'utf8');
207
+
208
+ writeLlmConfig(root, 'default', {
209
+ provider: 'openai-compatible',
210
+ engine: 'ollama',
211
+ baseUrl: 'http://localhost:11434/v1',
212
+ apiKey: 'ollama',
213
+ model: 'qwen2.5',
214
+ });
215
+
216
+ const parsed = YAML.parse(readFileSync(join(root, '.wikirc.yaml'), 'utf8'));
217
+ assert.equal(parsed.llm.provider, 'openai-compatible');
218
+ assert.equal(parsed.llm.engine, 'ollama');
219
+ // Le wizard tourne sur l'hôte, serve dans Docker : localhost y désigne le
220
+ // container lui-meme.
221
+ assert.equal(parsed.llm.baseUrl, 'http://host.docker.internal:11434/v1');
222
+ });
223
+
224
+ test('writeLlmConfig omits engine behind a gateway and leaves real hostnames alone', () => {
225
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-llm-gw-'));
226
+ writeFileSync(join(root, '.wikirc.yaml'), ['language: en', ''].join('\n'), 'utf8');
227
+
228
+ writeLlmConfig(root, 'default', {
229
+ provider: 'ai-gateway',
230
+ engine: null,
231
+ baseUrl: 'https://gateway.internal.example/v1',
232
+ apiKey: 'sk-virtual',
233
+ model: 'anthropic/claude-sonnet-4-5',
234
+ });
235
+
236
+ const parsed = YAML.parse(readFileSync(join(root, '.wikirc.yaml'), 'utf8'));
237
+ assert.equal(parsed.llm.provider, 'ai-gateway');
238
+ assert.equal(parsed.llm.engine, undefined);
239
+ assert.equal(parsed.llm.baseUrl, 'https://gateway.internal.example/v1');
240
+ });
241
+
242
+ test('containerReachableUrl only touches loopback hosts', () => {
243
+ assert.deepEqual(containerReachableUrl('http://127.0.0.1:8000/v1'), {
244
+ url: 'http://host.docker.internal:8000/v1',
245
+ rewritten: true,
246
+ from: '127.0.0.1',
247
+ });
248
+ assert.equal(containerReachableUrl('https://api.openai.com/v1').rewritten, false);
249
+ assert.equal(containerReachableUrl('http://gateway:4000/v1').rewritten, false);
250
+ assert.equal(containerReachableUrl(undefined).rewritten, false);
251
+ });