@dotdrelle/wiki-manager 0.15.29 → 0.15.34

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,56 +1,160 @@
1
+ /**
2
+ * Découverte des modèles disponibles, pour alimenter le wizard.
3
+ *
4
+ * Deux chemins, correspondant aux deux valeurs de `llm.provider` :
5
+ *
6
+ * - `openai-compatible` : un serveur unique. L'endpoint et les en-têtes
7
+ * dépendent du moteur (`engine`), d'où les tables ci-dessous.
8
+ * - `ai-gateway` : un seul chemin, `GET /v1/models`, plus `GET /model/info`
9
+ * quand il est disponible — c'est lui qui porte le type de chaque modèle
10
+ * (chat, embedding, rerank) et permet de filtrer les listes du wizard.
11
+ */
12
+
1
13
  const FALLBACK_MODELS = {
2
14
  openai: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-4.1', 'gpt-4.1-mini'],
3
15
  anthropic: ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-3-7-sonnet-latest'],
4
16
  ollama: ['llama3.2', 'qwen2.5', 'mistral', 'nomic-embed-text'],
5
- 'openai-compatible': ['gpt-4.1-mini', 'llama3.2'],
6
- other: ['gpt-4.1-mini', 'llama3.2'],
17
+ vllm: ['Qwen/Qwen2.5-7B-Instruct', 'meta-llama/Llama-3.1-8B-Instruct'],
18
+ mlx: ['mlx-community/Qwen2.5-7B-Instruct-4bit'],
19
+ albert: ['albert-large', 'albert-small'],
20
+ generic: ['gpt-4.1-mini', 'llama3.2'],
7
21
  };
8
22
 
9
23
  const FALLBACK_EMBEDDINGS = {
10
24
  openai: ['text-embedding-3-small', 'text-embedding-3-large'],
11
25
  anthropic: ['text-embedding-3-small'],
12
26
  ollama: ['nomic-embed-text', 'mxbai-embed-large'],
13
- 'openai-compatible': ['BAAI/bge-m3', 'text-embedding-3-small', 'nomic-embed-text'],
14
- other: ['text-embedding-3-small', 'nomic-embed-text'],
27
+ vllm: ['BAAI/bge-m3'],
28
+ mlx: ['BAAI/bge-m3'],
29
+ albert: ['BAAI/bge-m3'],
30
+ generic: ['BAAI/bge-m3', 'text-embedding-3-small', 'nomic-embed-text'],
15
31
  };
16
32
 
33
+ export const PROVIDERS = ['openai-compatible', 'ai-gateway'];
34
+
35
+ export const ENGINES = [
36
+ 'ollama',
37
+ 'vllm',
38
+ 'mlx',
39
+ 'albert',
40
+ 'openai',
41
+ 'anthropic',
42
+ 'generic',
43
+ ];
44
+
45
+ /** Moteurs qui exigent une baseUrl explicite — il n'existe pas de défaut sensé. */
46
+ const ENGINES_REQUIRING_BASE_URL = new Set(['ollama', 'vllm', 'mlx', 'generic']);
47
+
48
+ const ENGINE_DEFAULT_BASE_URL = {
49
+ openai: 'https://api.openai.com/v1',
50
+ anthropic: 'https://api.anthropic.com/v1',
51
+ albert: 'https://albert.api.etalab.gouv.fr/v1',
52
+ ollama: 'http://127.0.0.1:11434/v1',
53
+ vllm: 'http://127.0.0.1:8000/v1',
54
+ mlx: 'http://127.0.0.1:8080/v1',
55
+ };
56
+
57
+ export function requiresBaseUrl(provider, engine) {
58
+ if (normalizeProvider(provider) === 'ai-gateway') return true;
59
+ return ENGINES_REQUIRING_BASE_URL.has(normalizeEngine(engine));
60
+ }
61
+
62
+ export function defaultBaseUrl(provider, engine) {
63
+ if (normalizeProvider(provider) === 'ai-gateway') return '';
64
+ return ENGINE_DEFAULT_BASE_URL[normalizeEngine(engine)] ?? '';
65
+ }
66
+
67
+ /** Routage. Tolérant aux libellés du wizard. */
17
68
  export function normalizeProvider(provider) {
18
69
  const value = String(provider ?? '').toLowerCase();
19
- if (value.includes('compatible') || value.includes('other')) return 'openai-compatible';
20
- if (value.includes('anthropic')) return 'anthropic';
21
- if (value.includes('ollama')) return 'ollama';
22
- if (value.includes('openai')) return 'openai';
70
+ if (value.includes('gateway')) return 'ai-gateway';
23
71
  return 'openai-compatible';
24
72
  }
25
73
 
26
- function fallbackFor(provider, kind) {
27
- const normalized = normalizeProvider(provider);
74
+ /**
75
+ * Libellés du wizard vers moteur. Correspondance **exacte**, pas par sous-chaîne.
76
+ *
77
+ * Une recherche par sous-chaîne était fausse : « Other (generic
78
+ * OpenAI-compatible) » contient « openai », qui était testé avant « generic »
79
+ * — l'option « serveur générique » persistait donc `engine: openai`, avec les
80
+ * contournements inversés. Et aucun libellé ne pouvait plus résoudre vers
81
+ * `generic`, ce qui cassait la présélection à la réouverture du wizard.
82
+ */
83
+ const ENGINE_LABELS = new Map([
84
+ ['openai', 'openai'],
85
+ ['anthropic', 'anthropic'],
86
+ ['ollama (local)', 'ollama'],
87
+ ['vllm (local)', 'vllm'],
88
+ ['mlx (local)', 'mlx'],
89
+ ['albert', 'albert'],
90
+ ['other (generic openai-compatible)', 'generic'],
91
+ ]);
92
+
93
+ /**
94
+ * Moteur. Accepte les libellés du wizard, les valeurs canoniques, et les
95
+ * anciennes valeurs de `provider` (`openai`, `ollama`, `anthropic`) devenues
96
+ * des moteurs.
97
+ */
98
+ export function normalizeEngine(engine) {
99
+ const value = String(engine ?? '').trim().toLowerCase();
100
+ const fromLabel = ENGINE_LABELS.get(value);
101
+ if (fromLabel) return fromLabel;
102
+ if (ENGINES.includes(value)) return value;
103
+ // Repli tolérant, utile pour les valeurs libres ; l'ordre importe donc les
104
+ // moteurs les plus spécifiques passent avant les plus génériques.
105
+ for (const candidate of ENGINES) {
106
+ if (candidate !== 'generic' && value.includes(candidate)) return candidate;
107
+ }
108
+ return 'generic';
109
+ }
110
+
111
+ function fallbackFor(engine, kind) {
112
+ const normalized = normalizeEngine(engine);
28
113
  const source = kind === 'embedding' ? FALLBACK_EMBEDDINGS : FALLBACK_MODELS;
29
- return source[normalized] ?? source.other;
114
+ return source[normalized] ?? source.generic;
30
115
  }
31
116
 
32
- function endpointFor(provider, baseUrl) {
33
- const normalized = normalizeProvider(provider);
117
+ function trimUrl(url) {
118
+ return String(url ?? '').replace(/\/+$/g, '');
119
+ }
120
+
121
+ /**
122
+ * `baseUrl` est écrite avec son suffixe `/v1` dans le wikirc. Les endpoints de
123
+ * listing vivent tantôt sous `/v1` (OpenAI), tantôt à la racine (Ollama,
124
+ * `/model/info` de LiteLLM) — d'où cette racine sans suffixe.
125
+ */
126
+ function rootOf(baseUrl) {
127
+ return trimUrl(baseUrl).replace(/\/v1$/, '');
128
+ }
129
+
130
+ function endpointFor(provider, engine, baseUrl) {
131
+ if (normalizeProvider(provider) === 'ai-gateway') {
132
+ return `${rootOf(baseUrl)}/v1/models`;
133
+ }
134
+ const normalized = normalizeEngine(engine);
34
135
  if (normalized === 'anthropic') return 'https://api.anthropic.com/v1/models';
35
- const root = String(baseUrl || (normalized === 'ollama' ? 'http://localhost:11434' : 'https://api.openai.com')).replace(/\/+$/g, '');
136
+ const root = rootOf(baseUrl) || 'https://api.openai.com';
36
137
  return normalized === 'ollama' ? `${root}/api/tags` : `${root}/v1/models`;
37
138
  }
38
139
 
39
- function headersFor(provider, apiKey) {
40
- const normalized = normalizeProvider(provider);
140
+ function headersFor(provider, engine, apiKey) {
141
+ if (normalizeProvider(provider) === 'ai-gateway') {
142
+ return { Authorization: `Bearer ${apiKey}` };
143
+ }
144
+ const normalized = normalizeEngine(engine);
41
145
  if (normalized === 'ollama') return {};
42
146
  if (normalized === 'anthropic') {
43
- return {
44
- 'x-api-key': apiKey,
45
- 'anthropic-version': '2023-06-01',
46
- };
147
+ return { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
47
148
  }
48
149
  return { Authorization: `Bearer ${apiKey}` };
49
150
  }
50
151
 
51
- function parseModelNames(provider, payload) {
52
- const normalized = normalizeProvider(provider);
53
- const items = normalized === 'ollama' ? payload?.models : payload?.data;
152
+ function parseModelNames(provider, engine, payload) {
153
+ const items =
154
+ normalizeProvider(provider) === 'openai-compatible' &&
155
+ normalizeEngine(engine) === 'ollama'
156
+ ? payload?.models
157
+ : payload?.data;
54
158
  if (!Array.isArray(items)) return [];
55
159
  return items
56
160
  .map((item) => item?.id ?? item?.name ?? item?.model)
@@ -59,39 +163,123 @@ function parseModelNames(provider, payload) {
59
163
  .sort((a, b) => a.localeCompare(b));
60
164
  }
61
165
 
166
+ async function getJson(url, headers, timeoutMs) {
167
+ const controller = new AbortController();
168
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
169
+ try {
170
+ const response = await fetch(url, { headers, signal: controller.signal });
171
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
172
+ return await response.json();
173
+ } finally {
174
+ clearTimeout(timer);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Liste plate des modèles.
180
+ *
181
+ * `options.engine` porte le moteur ; à défaut, le premier argument est
182
+ * réinterprété comme tel, ce qui garde les appels historiques valides.
183
+ */
62
184
  export async function fetchModels(provider, baseUrl, apiKey, options = {}) {
63
- const normalized = normalizeProvider(provider);
64
- if (normalized === 'anthropic') {
65
- return { ok: false, models: fallbackFor(normalized, options.kind), source: 'fallback', error: 'Anthropic model listing is not supported' };
185
+ const routing = normalizeProvider(provider);
186
+ const normalizedEngine = normalizeEngine(options.engine ?? provider);
187
+
188
+ if (routing === 'openai-compatible' && normalizedEngine === 'anthropic') {
189
+ return {
190
+ ok: false,
191
+ models: fallbackFor(normalizedEngine, options.kind),
192
+ source: 'fallback',
193
+ error: 'Anthropic model listing is not supported',
194
+ };
66
195
  }
196
+
67
197
  const timeoutMs = options.timeoutMs ?? 10000;
68
- const controller = new AbortController();
69
- const timer = setTimeout(() => controller.abort(), timeoutMs);
70
198
  try {
71
- if (normalized !== 'ollama' && !apiKey) {
199
+ const needsKey = !(routing === 'openai-compatible' && normalizedEngine === 'ollama');
200
+ if (needsKey && !apiKey) {
72
201
  throw new Error('API key is required to fetch remote models');
73
202
  }
74
- const response = await fetch(endpointFor(normalized, baseUrl), {
75
- headers: headersFor(normalized, apiKey),
76
- signal: controller.signal,
77
- });
78
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
79
- const payload = await response.json();
80
- const models = parseModelNames(normalized, payload);
203
+ const payload = await getJson(
204
+ endpointFor(provider, normalizedEngine, baseUrl),
205
+ headersFor(provider, normalizedEngine, apiKey),
206
+ timeoutMs,
207
+ );
208
+ const models = parseModelNames(provider, normalizedEngine, payload);
81
209
  if (models.length === 0) throw new Error('No models returned');
82
210
  return { ok: true, models, source: 'remote' };
83
211
  } catch (err) {
84
212
  return {
85
213
  ok: false,
86
- models: fallbackFor(normalized, options.kind),
214
+ models: fallbackFor(normalizedEngine, options.kind),
87
215
  source: 'fallback',
88
216
  error: err instanceof Error ? err.message : String(err),
89
217
  };
90
- } finally {
91
- clearTimeout(timer);
92
218
  }
93
219
  }
94
220
 
95
- export function fallbackModels(provider, kind) {
96
- return fallbackFor(provider, kind);
221
+ /**
222
+ * Catalogue typé d'une gateway.
223
+ *
224
+ * Dégradation gracieuse en trois temps — jamais un catch silencieux vers un
225
+ * défaut :
226
+ *
227
+ * 1. `GET /model/info` porte `model_info.mode` : on sait quel modèle est un
228
+ * chat, un embedding ou un reranker, et le wizard filtre ses listes.
229
+ * 2. `GET /v1/models` ne renvoie qu'une liste plate : les trois listes
230
+ * reçoivent la même chose, et `typed: false` permet à l'appelant de le
231
+ * dire à l'utilisateur.
232
+ * 3. Injoignable : listes vides, `error` renseignée. Le wizard garde sa
233
+ * saisie libre, qui fait foi de toute façon.
234
+ */
235
+ export async function fetchGatewayCatalog(baseUrl, apiKey, options = {}) {
236
+ const timeoutMs = options.timeoutMs ?? 10000;
237
+ const headers = { Authorization: `Bearer ${apiKey}` };
238
+
239
+ try {
240
+ const payload = await getJson(`${rootOf(baseUrl)}/model/info`, headers, timeoutMs);
241
+ const items = Array.isArray(payload?.data) ? payload.data : [];
242
+ const typed = { chat: [], embedding: [], rerank: [] };
243
+ for (const item of items) {
244
+ const name = item?.model_name ?? item?.id ?? item?.model_info?.id;
245
+ const mode = item?.model_info?.mode;
246
+ if (!name || !mode || !(mode in typed)) continue;
247
+ typed[mode].push(String(name));
248
+ }
249
+ const total = typed.chat.length + typed.embedding.length + typed.rerank.length;
250
+ if (total === 0) throw new Error('No typed models returned by /model/info');
251
+ for (const key of Object.keys(typed)) {
252
+ typed[key] = [...new Set(typed[key])].sort((a, b) => a.localeCompare(b));
253
+ }
254
+ return { ok: true, typed: true, source: 'model-info', ...typed };
255
+ } catch (modelInfoError) {
256
+ const flat = await fetchModels('ai-gateway', baseUrl, apiKey, { timeoutMs });
257
+ if (!flat.ok) {
258
+ return {
259
+ ok: false,
260
+ typed: false,
261
+ source: 'unreachable',
262
+ chat: [],
263
+ embedding: [],
264
+ rerank: [],
265
+ error: flat.error,
266
+ };
267
+ }
268
+ return {
269
+ ok: true,
270
+ typed: false,
271
+ source: 'models',
272
+ chat: flat.models,
273
+ embedding: flat.models,
274
+ rerank: flat.models,
275
+ // Conservée pour l'affichage : elle explique pourquoi les listes ne sont
276
+ // pas filtrées.
277
+ error:
278
+ modelInfoError instanceof Error ? modelInfoError.message : String(modelInfoError),
279
+ };
280
+ }
281
+ }
282
+
283
+ export function fallbackModels(engine, kind) {
284
+ return fallbackFor(engine, kind);
97
285
  }
@@ -1,6 +1,11 @@
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
+ fallbackModels,
5
+ fetchGatewayCatalog,
6
+ fetchModels,
7
+ requiresBaseUrl,
8
+ } from './modelFetch.js';
4
9
 
5
10
  test('fetchModels returns remote OpenAI-compatible model ids', async () => {
6
11
  const originalFetch = globalThis.fetch;
@@ -34,5 +39,75 @@ test('fetchModels falls back on invalid remote response', async () => {
34
39
  });
35
40
 
36
41
  test('fallbackModels leaves custom-model to the wizard append action', () => {
37
- assert.deepEqual(fallbackModels('openai-compatible'), ['gpt-4.1-mini', 'llama3.2']);
42
+ assert.deepEqual(fallbackModels('generic'), ['gpt-4.1-mini', 'llama3.2']);
43
+ });
44
+
45
+ test('fetchGatewayCatalog types models from /model/info', async () => {
46
+ const originalFetch = globalThis.fetch;
47
+ globalThis.fetch = async (url) => {
48
+ assert.equal(url, 'http://gw:4000/model/info');
49
+ return {
50
+ ok: true,
51
+ json: async () => ({
52
+ data: [
53
+ { model_name: 'anthropic/claude-sonnet-4-5', model_info: { mode: 'chat' } },
54
+ { model_name: 'infinity/bge-m3', model_info: { mode: 'embedding' } },
55
+ { model_name: 'infinity/bge-reranker', model_info: { mode: 'rerank' } },
56
+ { model_name: 'dalle', model_info: { mode: 'image_generation' } },
57
+ ],
58
+ }),
59
+ };
60
+ };
61
+ try {
62
+ const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
63
+ assert.equal(result.ok, true);
64
+ assert.equal(result.typed, true);
65
+ assert.deepEqual(result.chat, ['anthropic/claude-sonnet-4-5']);
66
+ assert.deepEqual(result.embedding, ['infinity/bge-m3']);
67
+ assert.deepEqual(result.rerank, ['infinity/bge-reranker']);
68
+ } finally {
69
+ globalThis.fetch = originalFetch;
70
+ }
71
+ });
72
+
73
+ test('fetchGatewayCatalog degrades to an untyped /v1/models list', async () => {
74
+ const originalFetch = globalThis.fetch;
75
+ globalThis.fetch = async (url) => {
76
+ if (url.endsWith('/model/info')) return { ok: false, status: 404 };
77
+ return { ok: true, json: async () => ({ data: [{ id: 'b' }, { id: 'a' }] }) };
78
+ };
79
+ try {
80
+ const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
81
+ assert.equal(result.ok, true);
82
+ assert.equal(result.typed, false);
83
+ // Non typé : les trois listes reçoivent la même chose, à charge du wizard
84
+ // de le signaler.
85
+ assert.deepEqual(result.chat, ['a', 'b']);
86
+ assert.deepEqual(result.embedding, ['a', 'b']);
87
+ assert.deepEqual(result.rerank, ['a', 'b']);
88
+ assert.match(result.error, /HTTP 404/);
89
+ } finally {
90
+ globalThis.fetch = originalFetch;
91
+ }
92
+ });
93
+
94
+ test('fetchGatewayCatalog reports an unreachable gateway without inventing models', async () => {
95
+ const originalFetch = globalThis.fetch;
96
+ globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); };
97
+ try {
98
+ const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
99
+ assert.equal(result.ok, false);
100
+ assert.equal(result.source, 'unreachable');
101
+ assert.deepEqual(result.chat, []);
102
+ assert.match(result.error, /ECONNREFUSED/);
103
+ } finally {
104
+ globalThis.fetch = originalFetch;
105
+ }
106
+ });
107
+
108
+ test('requiresBaseUrl follows the engine, and the gateway always needs one', () => {
109
+ assert.equal(requiresBaseUrl('openai-compatible', 'ollama'), true);
110
+ assert.equal(requiresBaseUrl('openai-compatible', 'openai'), false);
111
+ assert.equal(requiresBaseUrl('openai-compatible', 'anthropic'), false);
112
+ assert.equal(requiresBaseUrl('ai-gateway', 'openai'), true);
38
113
  });
@@ -261,11 +261,37 @@ export function writeLanguageConfig(workspacePath, profileName, language) {
261
261
  return patchWikircProfile(workspacePath, profileName || 'default', { language });
262
262
  }
263
263
 
264
+ /**
265
+ * Le wizard tourne sur l'hôte, les services dans Docker : une baseUrl saisie
266
+ * en `localhost` répond au wizard et échoue dans le container. On la réécrit
267
+ * vers `host.docker.internal`, que les services déclarent déjà en
268
+ * `extra_hosts`. Jamais sur un hostname réel — seulement sur les trois formes
269
+ * de boucle locale. L'appelant affiche la réécriture : la faire en silence
270
+ * rend le diagnostic impossible quand elle se trompe.
271
+ */
272
+ export function containerReachableUrl(baseUrl) {
273
+ if (!baseUrl) return { url: baseUrl, rewritten: false };
274
+ try {
275
+ const parsed = new URL(baseUrl);
276
+ if (!['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname)) {
277
+ return { url: baseUrl, rewritten: false };
278
+ }
279
+ const original = parsed.hostname;
280
+ parsed.hostname = 'host.docker.internal';
281
+ return { url: parsed.toString().replace(/\/$/, ''), rewritten: true, from: original };
282
+ } catch {
283
+ return { url: baseUrl, rewritten: false };
284
+ }
285
+ }
286
+
264
287
  export function writeLlmConfig(workspacePath, profileName, config) {
265
288
  const patches = {
266
289
  llm: {
267
290
  provider: config.provider,
268
- ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
291
+ // Absent derrière une gateway : l'endpoint est opaque, il n'y a pas un
292
+ // moteur mais un par modèle.
293
+ ...(config.engine ? { engine: config.engine } : {}),
294
+ ...(config.baseUrl ? { baseUrl: containerReachableUrl(config.baseUrl).url } : {}),
269
295
  ...(config.apiKey ? { apiKey: config.apiKey } : {}),
270
296
  model: config.model,
271
297
  },
@@ -278,7 +304,11 @@ export function writeVectorConfig(workspacePath, profileName, config) {
278
304
  retrieval: {
279
305
  vector: {
280
306
  enabled: true,
281
- ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
307
+ // baseUrl et apiKey absents = hérités du bloc llm par resolveConfig.
308
+ // Le wizard ne les transmet que lorsqu'ils divergent réellement, pour
309
+ // que le wikirc reste lisible et que la clé du LLM ne soit pas
310
+ // recopiée vers un autre hôte.
311
+ ...(config.baseUrl ? { baseUrl: containerReachableUrl(config.baseUrl).url } : {}),
282
312
  ...(config.apiKey ? { apiKey: config.apiKey } : {}),
283
313
  timeoutMs: config.timeoutMs ?? 600_000,
284
314
  embeddingModel: config.embeddingModel,
@@ -28,6 +28,15 @@ test('wiki-workspace checks runtime pid command before killing', async () => {
28
28
  assert.match(script, /kill "\$\(cat "\$pid_file"\)"/);
29
29
  });
30
30
 
31
+ test('project refresh shuts down runtime and removes only compose-owned images', async () => {
32
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
33
+
34
+ assert.match(script, /if \[\[ \$# -eq 1 && "\$1" == "refresh" \]\]; then\n refresh_project/);
35
+ assert.match(script, /compose_for_workspace "\$workspace" down --rmi all --remove-orphans/);
36
+ assert.match(script, /_agents_dc down --rmi all --remove-orphans/);
37
+ assert.doesNotMatch(script, /docker (?:image )?prune/);
38
+ });
39
+
31
40
  test('wiki-workspace regenerates CA compose overrides instead of retaining removed services', async () => {
32
41
  const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
33
42
 
@@ -57,6 +66,13 @@ test('wiki-workspace provisions connector secrets and persistent state', async (
57
66
  assert.match(script, /delete config\.chatAccess\.servers\.connectors/);
58
67
  });
59
68
 
69
+ test('local connector builds require both public Desktop OAuth values', async () => {
70
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
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/);
74
+ });
75
+
60
76
  test('workspace creation keeps mutable manager files outside the installed package', async () => {
61
77
  const source = await readFile(new URL('./workspaces.js', import.meta.url), 'utf8');
62
78
 
@@ -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
+ });
@@ -41,13 +41,20 @@ async function discoverServerAgent(session, serverName, endpoint = {}, { callToo
41
41
  return legacyAgent(serverName, endpoint, { health: UNAVAILABLE, lastSeenAt });
42
42
  }
43
43
 
44
- const toolName = findAgentDescribeTool(serverName, endpoint.tools ?? []);
45
- if (!toolName) {
44
+ const tool = findAgentDescribeTool(serverName, endpoint.tools ?? []);
45
+ if (!tool) {
46
46
  return legacyAgent(serverName, endpoint, { health: AVAILABLE, lastSeenAt });
47
47
  }
48
+ const toolName = tool.name;
48
49
 
49
50
  try {
50
- const result = await callTool(session.mcp, serverName, toolName, {}, signal);
51
+ const result = await callTool(
52
+ session.mcp,
53
+ serverName,
54
+ toolName,
55
+ describeArguments(tool, session?.workspace),
56
+ signal,
57
+ );
51
58
  const description = assertContract('agentDescription', parseToolJsonResult(result));
52
59
  return {
53
60
  serverName,
@@ -108,13 +115,34 @@ function dispatchRegistryEvent(session, type, payload) {
108
115
  }
109
116
 
110
117
  function findAgentDescribeTool(serverName, tools) {
111
- const names = tools.map((tool) => String(tool.name ?? '')).filter(Boolean);
112
- return names.find((name) => name === 'agent_describe')
113
- ?? names.find((name) => name === `${serverName}__agent_describe`)
114
- ?? names.find((name) => name.endsWith('__agent_describe'))
118
+ const named = tools.filter((tool) => String(tool?.name ?? ''));
119
+ const byName = (predicate) => named.find((tool) => predicate(String(tool.name)));
120
+ return byName((name) => name === 'agent_describe')
121
+ ?? byName((name) => name === `${serverName}__agent_describe`)
122
+ ?? byName((name) => name.endsWith('__agent_describe'))
115
123
  ?? null;
116
124
  }
117
125
 
126
+ // Parts of a contract are workspace-scoped — typically the closed vocabulary of
127
+ // an argument (the sources declared in THIS workspace). Published as a bare
128
+ // string, such a field is unverifiable and a planner fills it with any noun
129
+ // from the objective, so it is worth telling the agent which workspace we are
130
+ // asking about.
131
+ //
132
+ // But the orchestrator must not assume an agent accepts an argument it never
133
+ // declared: an agent whose agent_describe schema is `additionalProperties:
134
+ // false` REJECTS the call, drops out of the registry, and its capabilities
135
+ // silently vanish — the objective then resolves to whatever agent is left.
136
+ // Send the workspace only to agents whose own schema says they can take it.
137
+ function describeArguments(tool, workspace) {
138
+ if (!workspace) return {};
139
+ const schema = tool?.inputSchema;
140
+ if (!schema || typeof schema !== 'object') return {};
141
+ const declaresWorkspace = Object.hasOwn(schema.properties ?? {}, 'workspace');
142
+ const acceptsExtra = schema.additionalProperties !== false;
143
+ return declaresWorkspace || acceptsExtra ? { workspace: String(workspace) } : {};
144
+ }
145
+
118
146
  function parseToolJsonResult(result) {
119
147
  if (result && typeof result === 'object' && !Array.isArray(result) && !Array.isArray(result.content)) {
120
148
  return result;