@dotdrelle/wiki-manager 0.15.32 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.32",
3
+ "version": "0.15.34",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.32",
2
+ "version": "0.15.34",
3
3
  "commit": "8b6b1aa"
4
4
  }
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.15.32';
4
+ const WIKI_MANAGER_VERSION = '0.15.34';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -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,
@@ -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
+ });
@@ -3,7 +3,15 @@ import { execFileSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
  import { useKeyboard, usePaste } from '@opentui/solid';
5
5
  import { createEffect, createMemo, createSignal, For, Show } from 'solid-js';
6
- import { fallbackModels, normalizeProvider } from '../core/modelFetch.js';
6
+ import {
7
+ defaultBaseUrl,
8
+ fallbackModels,
9
+ fetchGatewayCatalog,
10
+ fetchModels,
11
+ normalizeEngine,
12
+ normalizeProvider,
13
+ requiresBaseUrl,
14
+ } from '../core/modelFetch.js';
7
15
  import { checkInternetConnectivity } from '../core/startupCheck.js';
8
16
  import {
9
17
  createNewWorkspace,
@@ -24,11 +32,40 @@ type Step =
24
32
  | { kind: 'menu'; title: string; items: Array<{ label: string; value: string; muted?: boolean }> }
25
33
  | { kind: 'confirm'; title: string; message: string; yesLabel: string; noLabel: string }
26
34
  | { kind: 'select'; title: string; label: string; options: string[]; note?: string }
27
- | { kind: 'text'; title: string; label: string; note?: string; placeholder?: string; prefill?: string; secret?: boolean }
35
+ | {
36
+ kind: 'text';
37
+ title: string;
38
+ label: string;
39
+ note?: string;
40
+ placeholder?: string;
41
+ prefill?: string;
42
+ secret?: boolean;
43
+ /**
44
+ * Catalogue découvert. Purement indicatif : le champ texte fait foi, ce
45
+ * qui garde l'étape utilisable quand l'endpoint est injoignable ou quand
46
+ * le modèle voulu n'y figure pas.
47
+ */
48
+ suggestions?: string[];
49
+ }
28
50
  | { kind: 'done' };
29
51
  type LogEntry = { icon: string; label: string; detail?: string };
30
52
 
31
- const PROVIDERS = ['OpenAI', 'Anthropic', 'Ollama (local)', 'Other (OpenAI-compatible)'];
53
+ // Deux axes, deux questions. `provider` dit où l'on tape, `engine` dit
54
+ // comment se comporte le serveur en face. Les fusionner était précisément ce
55
+ // qui empêchait de décrire une gateway.
56
+ const PROVIDERS = [
57
+ 'Direct server (OpenAI-compatible)',
58
+ 'AI gateway (LiteLLM, Bifrost, Portkey…)',
59
+ ];
60
+ const ENGINE_OPTIONS = [
61
+ 'OpenAI',
62
+ 'Anthropic',
63
+ 'Ollama (local)',
64
+ 'vLLM (local)',
65
+ 'MLX (local)',
66
+ 'Albert',
67
+ 'Other (generic OpenAI-compatible)',
68
+ ];
32
69
  // The scaffolded .wikirc.yaml ships fake endpoints and secrets so the file
33
70
  // documents its own shape (`https://mon-provider.example.com/v1`,
34
71
  // `http://infinity.local:7997/v1`, `YOUR_LLM_API_KEY`…). Preloading them into
@@ -43,12 +80,10 @@ function configuredValue(value: unknown) {
43
80
  }
44
81
  const MAIN_MENU = ['Agents', 'Workspaces', 'LLM configuration', 'Vector search', '---', 'Close'];
45
82
 
46
- function defaultBaseUrl(provider: string) {
47
- if (provider === 'ollama') return 'http://localhost:11434';
48
- if (provider === 'anthropic') return 'https://api.anthropic.com';
49
- if (provider === 'openai') return 'https://api.openai.com';
50
- if (provider === 'openai-compatible') return 'http://localhost:8000';
51
- return '';
83
+ // Les défauts et la question « faut-il demander une baseUrl ? » vivent dans
84
+ // core/modelFetch.js, source unique partagée avec la découverte.
85
+ function exampleBaseUrl(provider: string, engine: string) {
86
+ return defaultBaseUrl(provider, engine);
52
87
  }
53
88
 
54
89
  function currentWorkspaceContext(session: any, fallback?: any) {
@@ -239,17 +274,30 @@ export function SetupWizard(props: {
239
274
  kind: 'select',
240
275
  title: 'LLM configuration',
241
276
  label: context?.configError
242
- ? `${context.configError} Select a provider after creating or fixing the config:`
243
- : `No LLM configured${context?.workspaceName ? ` for ${context.workspaceName}` : ''}. Select a provider:`,
277
+ ? `${context.configError} Select how requests are routed after creating or fixing the config:`
278
+ : `No LLM configured${context?.workspaceName ? ` for ${context.workspaceName}` : ''}. How are requests routed?`,
244
279
  options: PROVIDERS,
280
+ note: 'A gateway is external infrastructure you deploy yourself; llm-wiki only reads its model catalog.',
281
+ };
282
+ }
283
+ if (currentRoute === 'llm-engine') {
284
+ return {
285
+ kind: 'select',
286
+ title: 'LLM configuration',
287
+ label: 'Which server is answering?',
288
+ options: ENGINE_OPTIONS,
289
+ note: 'Drives request-shaping workarounds and the `wiki doctor` calibration.',
245
290
  };
246
291
  }
247
292
  if (currentRoute === 'llm-baseurl') {
248
- const example = defaultBaseUrl(llm().provider);
293
+ const isGateway = llm().provider === 'ai-gateway';
294
+ const example = exampleBaseUrl(llm().provider, llm().engine);
249
295
  return {
250
296
  kind: 'text',
251
297
  title: 'LLM configuration',
252
- label: `Base URL${example ? ` (example: ${example})` : ''}`,
298
+ label: isGateway
299
+ ? 'Gateway base URL (example: http://gateway:4000/v1)'
300
+ : `Base URL${example ? ` (example: ${example})` : ''}`,
253
301
  prefill: llm().baseUrl || '',
254
302
  };
255
303
  }
@@ -257,13 +305,15 @@ export function SetupWizard(props: {
257
305
  return { kind: 'text', title: 'LLM configuration', label: 'API key (required)', secret: true };
258
306
  }
259
307
  if (currentRoute === 'llm-model') {
260
- const example = fallbackModels(llm().provider)[0] || 'provider-agentic-model';
308
+ const discovered = catalog()?.chat ?? [];
309
+ const example = discovered[0] || fallbackModels(llm().engine)[0] || 'provider-agentic-model';
261
310
  return {
262
311
  kind: 'text',
263
312
  title: 'LLM configuration',
264
313
  label: `Model (example: ${example})`,
265
- note: 'Required: an agentic model with tool/function calling support.',
266
- prefill: llm().model || '',
314
+ note: catalogNote('Required: an agentic model with tool/function calling support.'),
315
+ prefill: llm().model || example,
316
+ suggestions: discovered,
267
317
  };
268
318
  }
269
319
  if (currentRoute === 'vector-confirm') {
@@ -274,19 +324,50 @@ export function SetupWizard(props: {
274
324
  return { kind: 'text', title: 'Vector search', label: 'Embeddings/rerank base URL', prefill: baseUrl, placeholder: baseUrl };
275
325
  }
276
326
  if (currentRoute === 'vector-apikey') {
277
- const hint = llm().apiKey ? '(leave empty to reuse LLM key)' : undefined;
278
- return { kind: 'text', title: 'Vector search', label: 'Vector API key', placeholder: hint, secret: true };
327
+ // L'héritage n'est proposé que tant que l'URL n'a pas divergé : sinon la
328
+ // clé du LLM — celle de la gateway, qui ouvre tous les providers —
329
+ // partirait vers un autre hôte.
330
+ const diverged = vectorBaseUrlDiverged();
331
+ const hint = !diverged && llm().apiKey ? '(leave empty to reuse LLM key)' : undefined;
332
+ return {
333
+ kind: 'text',
334
+ title: 'Vector search',
335
+ label: diverged ? 'Vector API key (required: different host)' : 'Vector API key',
336
+ placeholder: hint,
337
+ secret: true,
338
+ };
279
339
  }
280
340
  if (currentRoute === 'vector-model') {
281
- const defaultEmbedding = vector().embeddingModel || fallbackModels(vector().provider || llm().provider, 'embedding')[0] || '';
282
- return { kind: 'text', title: 'Vector search', label: 'Embedding model', prefill: defaultEmbedding };
341
+ const discovered = catalog()?.embedding ?? [];
342
+ const defaultEmbedding =
343
+ vector().embeddingModel ||
344
+ discovered[0] ||
345
+ fallbackModels(llm().engine, 'embedding')[0] ||
346
+ '';
347
+ return {
348
+ kind: 'text',
349
+ title: 'Vector search',
350
+ label: 'Embedding model',
351
+ note: catalogNote('Embeddings endpoint model.'),
352
+ prefill: defaultEmbedding,
353
+ suggestions: discovered,
354
+ };
283
355
  }
284
356
  if (currentRoute === 'vector-rerank') {
285
357
  return { kind: 'confirm', title: 'Vector search', message: 'Enable reranking?', yesLabel: 'Enable', noLabel: 'Skip' };
286
358
  }
287
359
  if (currentRoute === 'vector-rerank-model') {
288
- const defaultReranker = vector().rerankerModel || 'BAAI/bge-reranker-v2-m3';
289
- return { kind: 'text', title: 'Vector search', label: 'Rerank model', prefill: defaultReranker };
360
+ const discovered = catalog()?.rerank ?? [];
361
+ const defaultReranker =
362
+ vector().rerankerModel || discovered[0] || 'BAAI/bge-reranker-v2-m3';
363
+ return {
364
+ kind: 'text',
365
+ title: 'Vector search',
366
+ label: 'Rerank model',
367
+ note: catalogNote('Leave reranking disabled if no rerank model is available.'),
368
+ prefill: defaultReranker,
369
+ suggestions: discovered,
370
+ };
290
371
  }
291
372
  if (currentRoute === 'unregister-confirm') {
292
373
  const workspace = targetWorkspace();
@@ -315,12 +396,93 @@ export function SetupWizard(props: {
315
396
  setInput((s as any).prefill ?? '');
316
397
  const items = (s as any).items ?? (s as any).options?.map((label: string) => ({ label })) ?? [{ label: 'x' }];
317
398
  let preferred = -1;
399
+ // La question de routage est reposée à chaque passage — c'est plus simple
400
+ // et plus honnête que de la mémoriser dans un champ dédié. On se contente
401
+ // de présélectionner ce que le wikirc déclare déjà.
318
402
  if (route() === 'llm-provider' && llm().provider) {
319
- preferred = PROVIDERS.findIndex((p) => normalizeProvider(p) === normalizeProvider(llm().provider));
403
+ preferred = PROVIDERS.findIndex(
404
+ (p) => normalizeProvider(p) === normalizeProvider(llm().provider),
405
+ );
406
+ }
407
+ if (route() === 'llm-engine' && llm().engine) {
408
+ preferred = ENGINE_OPTIONS.findIndex(
409
+ (e) => normalizeEngine(e) === normalizeEngine(llm().engine),
410
+ );
320
411
  }
321
412
  setSelected(preferred >= 0 ? preferred : firstSelectableIndex(items));
322
413
  });
323
414
 
415
+ /**
416
+ * Catalogue découvert auprès du serveur ou de la gateway. Il ne sert qu'à
417
+ * préremplir : le champ texte reste la vérité, ce qui garde le wizard
418
+ * utilisable quand l'endpoint est injoignable ou quand le modèle voulu n'y
419
+ * figure pas.
420
+ */
421
+ const [catalog, setCatalog] = createSignal<any>(null);
422
+ const [catalogError, setCatalogError] = createSignal<string | null>(null);
423
+
424
+ function catalogNote(base: string) {
425
+ const error = catalogError();
426
+ if (error) return `${base} (model discovery unavailable: ${error})`;
427
+ const found = catalog();
428
+ if (!found) return base;
429
+ if (!found.typed) {
430
+ return `${base} (gateway has no /model/info: the three lists are unfiltered)`;
431
+ }
432
+ return base;
433
+ }
434
+
435
+ async function discoverModels(target?: {
436
+ provider?: string;
437
+ engine?: string;
438
+ baseUrl?: string;
439
+ apiKey?: string;
440
+ }) {
441
+ setCatalog(null);
442
+ setCatalogError(null);
443
+ const source = target ?? llm();
444
+ const { provider, engine, baseUrl, apiKey } = source as any;
445
+ if (!baseUrl) return;
446
+ try {
447
+ if (normalizeProvider(provider) === 'ai-gateway') {
448
+ const found = await fetchGatewayCatalog(baseUrl, apiKey);
449
+ if (!found.ok) {
450
+ setCatalogError(found.error ?? 'gateway unreachable');
451
+ return;
452
+ }
453
+ setCatalog(found);
454
+ return;
455
+ }
456
+ const chat = await fetchModels(provider, baseUrl, apiKey, { engine });
457
+ const embedding = await fetchModels(provider, baseUrl, apiKey, {
458
+ engine,
459
+ kind: 'embedding',
460
+ });
461
+ if (!chat.ok && !embedding.ok) {
462
+ setCatalogError(chat.error ?? 'server unreachable');
463
+ return;
464
+ }
465
+ // Un serveur direct ne type pas ses modèles : les mêmes entrées
466
+ // alimentent les trois questions.
467
+ setCatalog({
468
+ typed: false,
469
+ chat: chat.models,
470
+ embedding: embedding.models,
471
+ rerank: embedding.models,
472
+ });
473
+ } catch (err) {
474
+ setCatalogError(err instanceof Error ? err.message : String(err));
475
+ }
476
+ }
477
+
478
+ /** Vrai quand l'URL vecteur ne pointe plus le même hôte que le LLM. */
479
+ function vectorBaseUrlDiverged() {
480
+ const vectorUrl = vector().baseUrl;
481
+ const llmUrl = llm().baseUrl;
482
+ if (!vectorUrl || !llmUrl) return false;
483
+ return vectorUrl.replace(/\/+$/, '') !== llmUrl.replace(/\/+$/, '');
484
+ }
485
+
324
486
  function preloadWikirc(context: any) {
325
487
  const workspacePath = context?.workspacePath;
326
488
  if (!workspacePath) return;
@@ -330,6 +492,15 @@ export function SetupWizard(props: {
330
492
  if (config?.llm?.provider) {
331
493
  setLlm({
332
494
  provider: normalizeProvider(config.llm.provider),
495
+ // Un wikirc pré-0.16 porte le moteur dans `provider`
496
+ // (`ollama`, `anthropic`, `openai`). Sans cette déduction, l'étape
497
+ // moteur ne présélectionne rien et propose OpenAI en tête — au
498
+ // risque d'écraser une configuration qui marchait.
499
+ engine: config.llm.engine
500
+ ? normalizeEngine(config.llm.engine)
501
+ : normalizeProvider(config.llm.provider) === 'ai-gateway'
502
+ ? null
503
+ : normalizeEngine(config.llm.provider),
333
504
  baseUrl: configuredValue(config.llm.baseUrl),
334
505
  apiKey: configuredValue(config.llm.apiKey),
335
506
  model: configuredValue(config.llm.model),
@@ -500,14 +671,28 @@ export function SetupWizard(props: {
500
671
  }
501
672
  if (currentRoute === 'llm-provider') {
502
673
  const provider = normalizeProvider(value);
674
+ setLlm((old: any) => ({
675
+ ...old,
676
+ provider,
677
+ // Derrière une gateway il n'y a pas de moteur : l'endpoint est opaque
678
+ // et chaque modèle peut en avoir un différent.
679
+ engine: provider === 'ai-gateway' ? null : old.engine,
680
+ baseUrl: old.provider === provider ? old.baseUrl : '',
681
+ }));
682
+ // La gateway exige toujours une baseUrl, et n'a pas de moteur à choisir.
683
+ if (provider === 'ai-gateway') return navigate('llm-baseurl');
684
+ return navigate('llm-engine');
685
+ }
686
+ if (currentRoute === 'llm-engine') {
687
+ const engine = normalizeEngine(value);
503
688
  setLlm((old: any) => {
504
- const asksForBaseUrl = provider === 'ollama' || provider === 'openai-compatible';
505
- const baseUrl = (old.provider === provider && old.baseUrl)
689
+ const asksForBaseUrl = requiresBaseUrl(old.provider, engine);
690
+ const baseUrl = (old.engine === engine && old.baseUrl)
506
691
  ? old.baseUrl
507
- : asksForBaseUrl ? '' : defaultBaseUrl(provider);
508
- return { ...old, provider, baseUrl, ...(provider === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
692
+ : asksForBaseUrl ? '' : defaultBaseUrl(old.provider, engine);
693
+ return { ...old, engine, baseUrl, ...(engine === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
509
694
  });
510
- if (provider === 'ollama' || provider === 'openai-compatible') return navigate('llm-baseurl');
695
+ if (requiresBaseUrl(llm().provider, engine)) return navigate('llm-baseurl');
511
696
  return navigate('llm-apikey');
512
697
  }
513
698
  if (currentRoute === 'unregister-confirm') {
@@ -569,7 +754,7 @@ export function SetupWizard(props: {
569
754
  setVector((old: any) => ({
570
755
  ...old,
571
756
  provider: old.provider || llm().provider,
572
- baseUrl: old.baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider),
757
+ baseUrl: old.baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider, llm().engine),
573
758
  }));
574
759
  return navigate('vector-baseurl');
575
760
  }
@@ -612,24 +797,46 @@ export function SetupWizard(props: {
612
797
  if (currentRoute === 'llm-baseurl') {
613
798
  if (!value) return setError('Base URL is required.');
614
799
  setLlm((old: any) => ({ ...old, baseUrl: value }));
615
- if (llm().provider === 'ollama') return navigate('llm-model');
800
+ // Ollama n'exige pas de clé : on peut découvrir tout de suite.
801
+ if (llm().engine === 'ollama') {
802
+ await discoverModels();
803
+ return navigate('llm-model');
804
+ }
616
805
  return navigate('llm-apikey');
617
806
  }
618
807
  if (currentRoute === 'llm-apikey') {
619
808
  if (!value) return setError('API key is required.');
620
809
  setLlm((old: any) => ({ ...old, apiKey: value }));
810
+ await discoverModels();
621
811
  return navigate('llm-model');
622
812
  }
623
813
  if (currentRoute === 'vector-baseurl') {
624
814
  const baseUrl = value || vector().baseUrl || llm().baseUrl;
625
815
  if (!baseUrl) return setError('Embeddings/rerank base URL is required.');
626
- setVector((old: any) => ({ ...old, provider: llm().provider, baseUrl }));
816
+ setVector((old: any) => ({
817
+ ...old,
818
+ provider: llm().provider,
819
+ engine: llm().engine,
820
+ baseUrl,
821
+ }));
627
822
  return navigate('vector-apikey');
628
823
  }
629
824
  if (currentRoute === 'vector-apikey') {
825
+ if (vectorBaseUrlDiverged() && !value) {
826
+ return setError(
827
+ 'API key is required: the vector base URL differs from the LLM one, so the LLM key is not reused.',
828
+ );
829
+ }
630
830
  const apiKey = value || llm().apiKey || undefined;
631
831
  if (!apiKey) return setError('API key is required (or set LLM key first).');
632
832
  setVector((old: any) => ({ ...old, apiKey }));
833
+ // Le catalogue affiché aux étapes embeddings et rerank doit venir de
834
+ // l'endpoint vecteur, pas du LLM : ce sont deux serveurs distincts dès
835
+ // que l'URL diverge, et proposer les modèles de chat de l'un pour les
836
+ // embeddings de l'autre n'a aucun sens.
837
+ if (vectorBaseUrlDiverged()) {
838
+ await discoverModels({ ...vector(), apiKey });
839
+ }
633
840
  return navigate('vector-model');
634
841
  }
635
842
  if (currentRoute === 'llm-model') {
@@ -754,6 +961,31 @@ export function SetupWizard(props: {
754
961
  return value || (s.secret ? (s.placeholder ?? '') : '');
755
962
  };
756
963
  const inputHasValue = () => step().kind === 'text' && input().length > 0;
964
+
965
+ /**
966
+ * Suggestions filtrées par ce qui est tapé.
967
+ *
968
+ * Une gateway correctement remplie expose plusieurs centaines de modèles :
969
+ * une liste brute est inutilisable, et un select classique interdirait de
970
+ * saisir un modèle absent du catalogue. On garde donc le champ texte comme
971
+ * seule vérité et on n'affiche qu'un rappel filtré — ce qui règle d'un coup
972
+ * les trois cas : liste énorme, modèle absent, endpoint injoignable.
973
+ */
974
+ const SUGGESTION_ROWS = 4;
975
+ const filteredSuggestions = createMemo(() => {
976
+ const current = step() as any;
977
+ const all: string[] = current?.suggestions ?? [];
978
+ if (all.length === 0) return { rows: [] as string[], total: 0, matched: 0 };
979
+ const needle = input().trim().toLowerCase();
980
+ const matches = needle
981
+ ? all.filter((item) => item.toLowerCase().includes(needle))
982
+ : all;
983
+ return {
984
+ rows: matches.slice(0, SUGGESTION_ROWS),
985
+ total: all.length,
986
+ matched: matches.length,
987
+ };
988
+ });
757
989
  const lineWidth = () => Math.max(10, dialogWidth() - 10);
758
990
  const displayLine1 = () => displayValue().slice(0, lineWidth());
759
991
  const displayLine2 = () => displayValue().slice(lineWidth(), lineWidth() * 2);
@@ -839,6 +1071,25 @@ export function SetupWizard(props: {
839
1071
  </box>
840
1072
  </box>
841
1073
  </Show>
1074
+ <Show when={step().kind === 'text' && filteredSuggestions().total > 0}>
1075
+ <text height={1} fg="#7F8C8D">
1076
+ {filteredSuggestions().matched === 0
1077
+ ? `no match among ${filteredSuggestions().total} discovered model(s) — the typed value is used as-is`
1078
+ : `${filteredSuggestions().matched}/${filteredSuggestions().total} discovered model(s) — type to filter, the typed value wins`}
1079
+ </text>
1080
+ <For each={filteredSuggestions().rows}>
1081
+ {(suggestion) => (
1082
+ <text height={1} fg={suggestion === input().trim() ? '#8BD5CA' : '#9CA3AF'}>
1083
+ {` ${suggestion === input().trim() ? '*' : '·'} ${suggestion}`}
1084
+ </text>
1085
+ )}
1086
+ </For>
1087
+ <Show when={filteredSuggestions().matched > SUGGESTION_ROWS}>
1088
+ <text height={1} fg="#7F8C8D">
1089
+ {` … ${filteredSuggestions().matched - SUGGESTION_ROWS} more`}
1090
+ </text>
1091
+ </Show>
1092
+ </Show>
842
1093
  <Show when={step().kind !== 'text'}>
843
1094
  <For each={currentItems()}>
844
1095
  {(item, index) => (
@@ -48,7 +48,7 @@ test('preloading a profile filters placeholders out of every prefilled field', (
48
48
  // With the vector URL filtered to null, this is what makes the embeddings
49
49
  // step offer the base URL the operator just typed.
50
50
  assert.match(source, /const baseUrl = vector\(\)\.baseUrl \|\| llm\(\)\.baseUrl;/);
51
- assert.match(source, /baseUrl: old\.baseUrl \|\| llm\(\)\.baseUrl \|\| defaultBaseUrl\(llm\(\)\.provider\)/);
51
+ assert.match(source, /baseUrl: old\.baseUrl \|\| llm\(\)\.baseUrl \|\| defaultBaseUrl\(llm\(\)\.provider, llm\(\)\.engine\)/);
52
52
  });
53
53
 
54
54
  test('a real configuration is never mistaken for a placeholder', () => {
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ const source = readFileSync(new URL('./SetupWizard.tsx', import.meta.url), 'utf8');
6
+
7
+ test('the model steps feed the discovered catalog into the filter', () => {
8
+ // Trois étapes doivent porter des suggestions : modèle LLM, embeddings,
9
+ // reranker. Sans elles, le filtre n'a rien a filtrer.
10
+ const occurrences = source.match(/suggestions: discovered/g) ?? [];
11
+ assert.equal(occurrences.length, 3);
12
+ });
13
+
14
+ test('the typed value always wins over the suggestion list', () => {
15
+ // Garde-fou : la liste ne doit jamais devenir un select. Si ce message
16
+ // disparait, c'est que la saisie libre a ete perdue.
17
+ assert.match(source, /the typed value wins/);
18
+ assert.match(source, /the typed value is used as-is/);
19
+ });
20
+
21
+ test('suggestions are filtered by the current input, not shown raw', () => {
22
+ assert.match(source, /all\.filter\(\(item\) => item\.toLowerCase\(\)\.includes\(needle\)\)/);
23
+ assert.match(source, /matches\.slice\(0, SUGGESTION_ROWS\)/);
24
+ });
package/wiki-workspace CHANGED
@@ -1074,6 +1074,7 @@ cacert_compose_args() {
1074
1074
  run_wiki() {
1075
1075
  local workspace="$1"
1076
1076
  shift
1077
+ need_workspace_env "$workspace"
1077
1078
  local ws_path
1078
1079
  ws_path="$(normalize_path "$(workspace_value "$workspace" WIKI_WORKSPACE_PATH)")"
1079
1080
  ensure_workspace_dirs "$ws_path"