@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.
- package/.env.example +5 -2
- package/agents.docker-compose.yml +10 -0
- package/bin/wiki-manager.js +1 -1
- package/docker-compose.override.example.yml +1 -0
- package/package.json +2 -2
- package/src/agent/graph.test.js +17 -4
- package/src/cli/runtimeStartup.test.js +14 -0
- package/src/cli/wiki-manager.js +79 -13
- package/src/cli/wiki-manager.test.js +157 -0
- package/src/commands/slash.js +9 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +113 -38
- package/src/core/mcp.test.js +181 -20
- package/src/core/modelFetch.js +229 -41
- package/src/core/modelFetch.test.js +77 -2
- package/src/core/wikiSetup.js +32 -2
- package/src/core/wikiWorkspace.test.js +16 -0
- package/src/core/wikirc.test.js +59 -5
- package/src/orchestrator/agentRegistry.js +35 -7
- package/src/orchestrator/agentRegistry.test.js +73 -0
- package/src/orchestrator/dispatcher.js +6 -1
- package/src/orchestrator/dispatcher.test.js +24 -1
- package/src/runtime/auth.test.js +1 -65
- package/src/runtime/donna-contract.test.js +2 -1
- package/src/runtime/lifecycle.js +0 -33
- package/src/runtime/runner.test.js +10 -1
- package/src/runtime/supervisor.test.js +11 -1
- package/src/shell/SetupWizard.tsx +282 -31
- package/src/shell/setupWizardPlaceholders.test.js +1 -1
- package/src/shell/setupWizardSuggestions.test.js +24 -0
- package/src/shell/tui.tsx +10 -18
- package/wiki-workspace +70 -0
package/src/core/modelFetch.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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('
|
|
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
|
-
|
|
27
|
-
|
|
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.
|
|
114
|
+
return source[normalized] ?? source.generic;
|
|
30
115
|
}
|
|
31
116
|
|
|
32
|
-
function
|
|
33
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
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(
|
|
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
|
-
|
|
96
|
-
|
|
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 {
|
|
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('
|
|
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
|
});
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/core/wikirc.test.js
CHANGED
|
@@ -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 {
|
|
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://
|
|
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://
|
|
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://
|
|
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://
|
|
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
|
|
45
|
-
if (!
|
|
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(
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
??
|
|
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;
|