nemoris 0.1.19 → 0.1.20

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": "nemoris",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "description": "Personal AI agent runtime — persistent memory, delivery guarantees, task contracts, self-healing. Local-first, no cloud.",
6
6
  "license": "MIT",
@@ -73,7 +73,9 @@
73
73
  "publish:check": "node scripts/check-publish-dry-run.js",
74
74
  "status": "node src/cli.js runtime-status",
75
75
  "test": "node --test",
76
- "test:e2e": "node tests/e2e/run-report.js"
76
+ "test:e2e": "node tests/e2e/run-report.js",
77
+ "test:e2e:interactive": "node --test tests/e2e/onboarding-interactive.test.js",
78
+ "test:e2e:setup-sweep": "node scripts/e2e-setup-sweep.js"
77
79
  },
78
80
  "engines": {
79
81
  "node": ">=22.5.0"
@@ -143,19 +143,59 @@ export async function validateApiKey(provider, key, options = {}) {
143
143
  }
144
144
 
145
145
  const fetch = options.fetchImpl || globalThis.fetch;
146
- const providerTargets = {
147
- anthropic: {
148
- url: "https://api.anthropic.com/v1/messages/count_tokens",
149
- method: "POST",
150
- headers: {
151
- "content-type": "application/json",
152
- ...buildAnthropicAuthHeaders(key)
146
+ if (provider === "anthropic") {
147
+ const probes = [
148
+ {
149
+ url: "https://api.anthropic.com/v1/models",
150
+ method: "GET",
151
+ headers: {
152
+ "x-api-key": key,
153
+ "anthropic-version": "2023-06-01"
154
+ }
153
155
  },
154
- body: JSON.stringify({
155
- model: "claude-haiku-4-5",
156
- messages: [{ role: "user", content: "ping" }]
157
- })
158
- },
156
+ {
157
+ url: "https://api.anthropic.com/v1/models",
158
+ method: "GET",
159
+ headers: {
160
+ Authorization: `Bearer ${key}`,
161
+ "anthropic-version": "2023-06-01"
162
+ }
163
+ },
164
+ {
165
+ url: "https://api.anthropic.com/v1/messages/count_tokens",
166
+ method: "POST",
167
+ headers: {
168
+ "content-type": "application/json",
169
+ ...buildAnthropicAuthHeaders(key)
170
+ },
171
+ body: JSON.stringify({
172
+ model: "claude-haiku-4-5",
173
+ messages: [{ role: "user", content: "ping" }]
174
+ })
175
+ }
176
+ ];
177
+
178
+ let lastFailure = { ok: false, error: "request failed" };
179
+ for (const probe of probes) {
180
+ try {
181
+ const response = await fetch(probe.url, {
182
+ method: probe.method,
183
+ headers: probe.headers,
184
+ body: probe.body,
185
+ signal: AbortSignal.timeout(10000)
186
+ });
187
+ if (response.ok) {
188
+ return { ok: true, status: response.status };
189
+ }
190
+ lastFailure = { ok: false, status: response.status };
191
+ } catch (error) {
192
+ lastFailure = { ok: false, error: error.message };
193
+ }
194
+ }
195
+ return lastFailure;
196
+ }
197
+
198
+ const providerTargets = {
159
199
  openai: {
160
200
  url: "https://api.openai.com/v1/models",
161
201
  method: "GET",
@@ -23,6 +23,27 @@ function normalizeOptions(options = []) {
23
23
  }));
24
24
  }
25
25
 
26
+ function normalizeSearchTokens(search = "") {
27
+ return String(search)
28
+ .toLowerCase()
29
+ .split(/\s+/)
30
+ .map((token) => token.trim())
31
+ .filter(Boolean);
32
+ }
33
+
34
+ function buildOptionSearchText(option = {}) {
35
+ return `${option.label || ""} ${option.hint || ""} ${option.value || ""}`.toLowerCase();
36
+ }
37
+
38
+ function tokenizedOptionFilter(search, option) {
39
+ const tokens = normalizeSearchTokens(search);
40
+ if (tokens.length === 0) {
41
+ return true;
42
+ }
43
+ const haystack = buildOptionSearchText(option);
44
+ return tokens.every((token) => haystack.includes(token));
45
+ }
46
+
26
47
  export function createClackPrompter() {
27
48
  return {
28
49
  intro(message) {
@@ -48,12 +69,20 @@ export function createClackPrompter() {
48
69
  }));
49
70
  },
50
71
  async multiselect({ message, options, initialValues = [], required = false }) {
51
- const value = guardCancel(await p.multiselect({
52
- message,
53
- options: normalizeOptions(options),
54
- initialValues,
55
- required,
56
- }));
72
+ const normalized = normalizeOptions(options);
73
+ const value = guardCancel(await (required || !p.autocompleteMultiselect
74
+ ? p.multiselect({
75
+ message,
76
+ options: normalized,
77
+ initialValues,
78
+ required,
79
+ })
80
+ : p.autocompleteMultiselect({
81
+ message,
82
+ options: normalized,
83
+ initialValues,
84
+ filter: tokenizedOptionFilter,
85
+ })));
57
86
  return Array.isArray(value) ? value : [];
58
87
  },
59
88
  async text({ message, placeholder, initialValue, validate }) {
@@ -9,6 +9,7 @@
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import { providerTemplate, routerTemplate } from "../templates.js";
12
+ import { parseToml } from "../../config/toml-lite.js";
12
13
  import {
13
14
  detectExistingKeys,
14
15
  validateApiKey,
@@ -57,6 +58,10 @@ const PROVIDER_CONFIGS = {
57
58
  };
58
59
 
59
60
  const MODEL_ROLE_ORDER = ["cheap_interactive", "fallback", "manual_bump"];
61
+ const MODEL_ROLE_INDEX = new Map(MODEL_ROLE_ORDER.map((role, index) => [role, index]));
62
+ const KEEP_VALUE = "__keep__";
63
+ const MANUAL_VALUE = "__manual__";
64
+ const SHOW_ALL_VALUE = "__show_all__";
60
65
  const PROVIDER_MODEL_PRESETS = {
61
66
  anthropic: [
62
67
  {
@@ -209,6 +214,28 @@ function providerDisplayName(provider) {
209
214
  return provider;
210
215
  }
211
216
 
217
+ function readConfiguredProviderModels(installDir, provider) {
218
+ const providerId = PROVIDER_CONFIGS[provider]?.providerId || provider;
219
+ const providerPath = path.join(installDir, "config", "providers", `${providerId}.toml`);
220
+
221
+ try {
222
+ const parsed = parseToml(fs.readFileSync(providerPath, "utf8"));
223
+ return Object.values(parsed?.models || {})
224
+ .sort((left, right) => {
225
+ const leftRank = MODEL_ROLE_INDEX.get(left?.role) ?? Number.MAX_SAFE_INTEGER;
226
+ const rightRank = MODEL_ROLE_INDEX.get(right?.role) ?? Number.MAX_SAFE_INTEGER;
227
+ if (leftRank !== rightRank) {
228
+ return leftRank - rightRank;
229
+ }
230
+ return String(left?.id || "").localeCompare(String(right?.id || ""));
231
+ })
232
+ .map((entry) => String(entry?.id || "").trim())
233
+ .filter(Boolean);
234
+ } catch {
235
+ return [];
236
+ }
237
+ }
238
+
212
239
  const OPENAI_CONTEXT_WINDOWS = {
213
240
  "gpt-4.1": 1000000,
214
241
  "gpt-4o": 128000,
@@ -380,15 +407,77 @@ async function buildProviderSelectionOptions(provider, key, { fetchImpl = global
380
407
  return { options, fetchedModels, extra };
381
408
  }
382
409
 
383
- async function promptForProviderModels(provider, key, tui, { fetchImpl = globalThis.fetch } = {}) {
384
- const { select, prompt, dim, cyan } = tui;
385
- if (!select) return { chosen: [], fetchedModels: [] };
410
+ function normalizePickerOption(option) {
411
+ return {
412
+ value: option.value,
413
+ label: option.label,
414
+ description: option.description || option.hint || "",
415
+ };
416
+ }
417
+
418
+ function buildExpandedPickerOptions(options, extra = []) {
419
+ return [
420
+ ...options.filter((option) => !String(option.value).startsWith("__")).map(normalizePickerOption),
421
+ ...extra.map(normalizePickerOption),
422
+ ];
423
+ }
424
+
425
+ function buildCurrentModelLabel(provider, modelId) {
426
+ const stripped = stripProviderPrefix(provider, modelId);
427
+ return stripped || modelId;
428
+ }
429
+
430
+ function ensureFallbackOption(options, provider, modelId) {
431
+ if (!modelId || options.some((option) => option.value === modelId)) {
432
+ return options;
433
+ }
434
+ return [
435
+ ...options,
436
+ {
437
+ value: modelId,
438
+ label: buildCurrentModelLabel(provider, modelId),
439
+ description: "current (not in catalog)",
440
+ },
441
+ ];
442
+ }
443
+
444
+ async function promptManualProviderModel(provider, key, tui, fetchedModels, {
445
+ fetchImpl = globalThis.fetch,
446
+ initialValue = "",
447
+ } = {}) {
448
+ const { prompt } = tui;
449
+ if (!prompt) {
450
+ return null;
451
+ }
452
+
453
+ while (true) {
454
+ const custom = await prompt("Model id", stripProviderPrefix(provider, initialValue));
455
+ const modelId = ensureProviderModelPrefix(provider, custom);
456
+ if (!modelId) {
457
+ return null;
458
+ }
459
+
460
+ const check = await validateModelId(provider, modelId, key, { fetchImpl, fetchedModels });
461
+ if (check.ok) {
462
+ return modelId;
463
+ }
464
+
465
+ console.log(` \u274c ${check.error || "Model not found."} Try again or press Enter to cancel.`);
466
+ }
467
+ }
468
+
469
+ async function promptForProviderModels(provider, key, tui, {
470
+ fetchImpl = globalThis.fetch,
471
+ currentModels = [],
472
+ } = {}) {
473
+ const { select, multiselect, dim, cyan } = tui;
474
+ if (!select) return { chosen: currentModels.slice(0, 3), fetchedModels: [] };
386
475
 
387
476
  const { options, fetchedModels, extra } = await buildProviderSelectionOptions(provider, key, { fetchImpl });
388
- let activeOptions = options;
477
+ let activeOptions = options.map(normalizePickerOption);
389
478
  const chosen = [];
390
- const manualOptionValue = "__custom__";
391
- // Use first curated model as placeholder for custom entry, not a "new" or extra model
479
+ const currentDefault = currentModels[0] || "";
480
+ const currentFallbacks = currentModels.slice(1);
392
481
  const defaultModelValue = options.find((item) => {
393
482
  const v = String(item.value);
394
483
  return !v.startsWith("__") && !v.startsWith("\u2605");
@@ -397,81 +486,111 @@ async function promptForProviderModels(provider, key, tui, { fetchImpl = globalT
397
486
  console.log(`\n ${cyan(`Choose ${provider === "openrouter" ? "OpenRouter" : provider === "openai" ? "OpenAI" : "Anthropic"} models`)}`);
398
487
  console.log(` ${dim("Pick up to three models. The first is your default. The others are fallbacks when the default is slow or unavailable.")}`);
399
488
 
400
- while (chosen.length < 3) {
401
- const remaining = activeOptions.filter((item) => !chosen.includes(item.value));
402
- const pickerOptions = remaining.map((item) => ({
403
- label: item.label,
404
- value: item.value,
405
- description: item.description,
406
- }));
407
-
408
- if (chosen.length > 0) {
409
- // Insert "Done" before the custom-entry option at the end
410
- const customIndex = pickerOptions.findIndex((item) => item.value === "__custom__");
411
- const doneOption = { label: "Done", value: "__done__", description: "Continue setup with the models already selected." };
412
- if (customIndex >= 0) {
413
- pickerOptions.splice(customIndex, 0, doneOption);
414
- } else {
415
- pickerOptions.push(doneOption);
416
- }
489
+ let defaultModel = "";
490
+ while (!defaultModel) {
491
+ const defaultOptions = [];
492
+ if (currentDefault) {
493
+ defaultOptions.push({
494
+ value: KEEP_VALUE,
495
+ label: `Keep current (${buildCurrentModelLabel(provider, currentDefault)})`,
496
+ description: "Use the current configured default model.",
497
+ });
417
498
  }
499
+ defaultOptions.push({
500
+ value: MANUAL_VALUE,
501
+ label: "Enter model manually",
502
+ description: "Use a specific model id not shown in the list.",
503
+ });
504
+ defaultOptions.push(...activeOptions.filter((option) =>
505
+ option.value !== MANUAL_VALUE && option.value !== "__custom__",
506
+ ));
418
507
 
419
- const picked = await select(
420
- chosen.length === 0 ? "Default model:" : `Add another model (${chosen.length}/3 selected):`,
421
- pickerOptions,
422
- );
423
-
424
- if (picked === "__done__") {
508
+ const picked = await select("Default model:", defaultOptions);
509
+ if (picked === KEEP_VALUE) {
510
+ defaultModel = currentDefault;
425
511
  break;
426
512
  }
427
-
428
- if (picked === "__show_all__") {
429
- // Expand to full filtered list + custom entry
430
- activeOptions = [
431
- ...activeOptions.filter((o) => !o.value.startsWith("__")),
432
- ...(extra || []),
433
- { value: "__custom__", label: "Enter a different model name...", description: "Use a specific model id not shown in the list." },
434
- ];
513
+ if (picked === SHOW_ALL_VALUE) {
514
+ activeOptions = buildExpandedPickerOptions(options, extra);
435
515
  continue;
436
516
  }
517
+ if (picked === MANUAL_VALUE || picked === "__custom__") {
518
+ defaultModel = await promptManualProviderModel(provider, key, tui, fetchedModels, {
519
+ fetchImpl,
520
+ initialValue: currentDefault || defaultModelValue,
521
+ });
522
+ continue;
523
+ }
524
+ defaultModel = picked;
525
+ }
437
526
 
438
- let modelId = picked;
439
- if (picked === manualOptionValue) {
440
- let validated = false;
441
- while (!validated) {
442
- const custom = await prompt("Model id", stripProviderPrefix(provider, defaultModelValue));
443
- modelId = ensureProviderModelPrefix(provider, custom);
444
- if (!modelId) {
445
- break;
446
- }
447
- const check = await validateModelId(provider, modelId, key, { fetchImpl, fetchedModels });
448
- if (check.ok) {
449
- validated = true;
450
- } else {
451
- console.log(` \u274c ${check.error || "Model not found."} Try again or press Enter to skip.`);
452
- const retry = await prompt("Model id (or empty to cancel)", "");
453
- if (!retry) {
454
- modelId = null;
455
- break;
456
- }
457
- modelId = ensureProviderModelPrefix(provider, retry);
458
- const recheck = await validateModelId(provider, modelId, key, { fetchImpl, fetchedModels });
459
- if (recheck.ok) {
460
- validated = true;
461
- } else {
462
- console.log(` \u274c ${recheck.error || "Model not found."}`);
463
- modelId = null;
464
- break;
465
- }
466
- }
527
+ if (defaultModel) {
528
+ chosen.push(defaultModel);
529
+ }
530
+
531
+ const fullCatalogOptions = buildExpandedPickerOptions(options, extra)
532
+ .filter((option) => option.value !== chosen[0]);
533
+ let fallbackOptions = fullCatalogOptions;
534
+ for (const fallback of currentFallbacks) {
535
+ fallbackOptions = ensureFallbackOption(fallbackOptions, provider, fallback);
536
+ }
537
+
538
+ if (typeof multiselect === "function" && fallbackOptions.length > 0) {
539
+ const initialValues = currentFallbacks.filter((modelId) =>
540
+ fallbackOptions.some((option) => option.value === modelId),
541
+ );
542
+ const selectedFallbacks = await multiselect(
543
+ "Backup models (optional)",
544
+ fallbackOptions,
545
+ initialValues,
546
+ );
547
+ for (const modelId of selectedFallbacks) {
548
+ if (modelId && !chosen.includes(modelId)) {
549
+ chosen.push(modelId);
467
550
  }
468
- if (!modelId) {
469
- continue;
551
+ if (chosen.length === 3) {
552
+ break;
470
553
  }
471
554
  }
555
+ return { chosen, fetchedModels };
556
+ }
472
557
 
473
- if (!chosen.includes(modelId)) {
474
- chosen.push(modelId);
558
+ while (chosen.length < 3) {
559
+ const remaining = fallbackOptions.filter((option) => !chosen.includes(option.value));
560
+ if (remaining.length === 0) {
561
+ break;
562
+ }
563
+
564
+ const pickerOptions = [
565
+ ...remaining,
566
+ {
567
+ label: "Done",
568
+ value: "__done__",
569
+ description: "Continue setup with the models already selected.",
570
+ },
571
+ {
572
+ label: "Enter model manually",
573
+ value: MANUAL_VALUE,
574
+ description: "Use a specific model id not shown in the list.",
575
+ },
576
+ ];
577
+
578
+ const picked = await select(`Add another model (${chosen.length}/3 selected):`, pickerOptions);
579
+ if (picked === "__done__") {
580
+ break;
581
+ }
582
+ if (picked === MANUAL_VALUE) {
583
+ const manualModel = await promptManualProviderModel(provider, key, tui, fetchedModels, {
584
+ fetchImpl,
585
+ initialValue: chosen.at(-1) || defaultModelValue,
586
+ });
587
+ if (manualModel && !chosen.includes(manualModel)) {
588
+ chosen.push(manualModel);
589
+ }
590
+ continue;
591
+ }
592
+ if (picked && !chosen.includes(picked)) {
593
+ chosen.push(picked);
475
594
  }
476
595
  }
477
596
 
@@ -566,6 +685,16 @@ async function promptForProviderKey(provider, tui, validateApiKeyImpl = validate
566
685
  }
567
686
  }
568
687
 
688
+ function formatProviderValidationFailure(provider, result = {}) {
689
+ if (result.status === 401 || result.status === 403) {
690
+ return "authentication failed";
691
+ }
692
+ if (result.status) {
693
+ return `HTTP ${result.status}`;
694
+ }
695
+ return result.error || `${providerDisplayName(provider)} key verification failed`;
696
+ }
697
+
569
698
  async function confirmLocalOnlySetup(tui) {
570
699
  const { confirm, yellow = (value) => value, dim = (value) => value } = tui;
571
700
  if (!confirm) {
@@ -579,6 +708,7 @@ async function confirmLocalOnlySetup(tui) {
579
708
 
580
709
  async function maybeResolveOpenAIOAuth({
581
710
  tui,
711
+ authMethod = null,
582
712
  enableOpenAIOAuthChoice = false,
583
713
  openAIOAuthImpl = initiateOpenAICodexOAuthFlow,
584
714
  }) {
@@ -597,22 +727,29 @@ async function maybeResolveOpenAIOAuth({
597
727
  };
598
728
  }
599
729
 
600
- if (!enableOpenAIOAuthChoice || !tui?.select) {
730
+ if (authMethod === "api_key") {
601
731
  return null;
602
732
  }
603
733
 
604
- const method = await tui.select("OpenAI auth method:", [
605
- {
606
- label: "API key",
607
- value: "api_key",
608
- description: "Paste an OpenAI API key from platform.openai.com.",
609
- },
610
- {
611
- label: "ChatGPT OAuth",
612
- value: "oauth",
613
- description: "Open browser login and store a refreshable local profile.",
614
- },
615
- ]);
734
+ let method = authMethod;
735
+ if (!method) {
736
+ if (!enableOpenAIOAuthChoice || !tui?.select) {
737
+ return null;
738
+ }
739
+
740
+ method = await tui.select("OpenAI auth method:", [
741
+ {
742
+ label: "API key",
743
+ value: "api_key",
744
+ description: "Paste an OpenAI API key from platform.openai.com.",
745
+ },
746
+ {
747
+ label: "ChatGPT OAuth",
748
+ value: "oauth",
749
+ description: "Open browser login and store a refreshable local profile.",
750
+ },
751
+ ]);
752
+ }
616
753
 
617
754
  if (method !== "oauth") {
618
755
  return null;
@@ -642,6 +779,7 @@ export async function runAuthPhase(installDir, options = {}) {
642
779
  const {
643
780
  tui,
644
781
  detectionCache,
782
+ authMethod = null,
645
783
  validateApiKeyImpl = validateApiKey,
646
784
  fetchImpl = globalThis.fetch,
647
785
  providerOrder = ["openrouter", "anthropic", "openai"],
@@ -655,6 +793,7 @@ export async function runAuthPhase(installDir, options = {}) {
655
793
  const selectedModels = {};
656
794
  const providerAuthRefs = {};
657
795
  const providerSecrets = {};
796
+ const validationFailures = {};
658
797
 
659
798
  if (tui) {
660
799
  let promptingComplete = false;
@@ -664,6 +803,7 @@ export async function runAuthPhase(installDir, options = {}) {
664
803
  if (provider === "openai") {
665
804
  const oauthResult = await maybeResolveOpenAIOAuth({
666
805
  tui,
806
+ authMethod,
667
807
  enableOpenAIOAuthChoice,
668
808
  openAIOAuthImpl,
669
809
  });
@@ -697,17 +837,51 @@ export async function runAuthPhase(installDir, options = {}) {
697
837
  const validatedKeys = {};
698
838
  const providerFlags = { anthropic: false, openrouter: false, openai: false, ollama: ollamaResult.ok };
699
839
 
700
- for (const [provider, key] of Object.entries(keys)) {
840
+ for (const provider of Object.keys(keys)) {
841
+ let key = keys[provider];
701
842
  if (!key) continue;
702
- const format = validateApiKeyFormat(provider, key);
703
- if (!format.ok) continue;
704
- const result = prevalidatedKeys.has(provider)
843
+ let format = validateApiKeyFormat(provider, key);
844
+ if (!format.ok) {
845
+ validationFailures[provider] = format.error;
846
+ if (tui && providerOrder.includes(provider)) {
847
+ console.log(` ${tui.yellow("!")} Found a ${providerDisplayName(provider)} key, but it looks malformed: ${format.error}`);
848
+ const replacement = await promptForProviderKey(provider, tui, validateApiKeyImpl);
849
+ if (!replacement) {
850
+ continue;
851
+ }
852
+ key = replacement;
853
+ keys[provider] = replacement;
854
+ prevalidatedKeys.add(provider);
855
+ format = validateApiKeyFormat(provider, key);
856
+ if (!format.ok) {
857
+ validationFailures[provider] = format.error;
858
+ continue;
859
+ }
860
+ } else {
861
+ continue;
862
+ }
863
+ }
864
+
865
+ let result = prevalidatedKeys.has(provider)
705
866
  ? { ok: true, status: 200 }
706
867
  : await validateApiKeyImpl(provider, key);
868
+ if (!result.ok && tui && providerOrder.includes(provider)) {
869
+ validationFailures[provider] = formatProviderValidationFailure(provider, result);
870
+ console.log(` ${tui.yellow("!")} Found a ${providerDisplayName(provider)} key, but Nemoris could not verify it (${validationFailures[provider]}).`);
871
+ const replacement = await promptForProviderKey(provider, tui, validateApiKeyImpl);
872
+ if (!replacement) {
873
+ continue;
874
+ }
875
+ key = replacement;
876
+ keys[provider] = replacement;
877
+ prevalidatedKeys.add(provider);
878
+ result = { ok: true, status: 200 };
879
+ }
707
880
  if (!result.ok) continue;
708
881
 
709
882
  validatedKeys[provider] = key;
710
883
  providerSecrets[provider] = key;
884
+ delete validationFailures[provider];
711
885
  if (provider in providerFlags) {
712
886
  providerFlags[provider] = true;
713
887
  }
@@ -722,7 +896,10 @@ export async function runAuthPhase(installDir, options = {}) {
722
896
  for (const provider of ["openrouter", "anthropic", "openai"]) {
723
897
  const providerToken = providerSecrets[provider];
724
898
  if (!providerToken) continue;
725
- const { chosen, fetchedModels } = await promptForProviderModels(provider, providerToken, tui, { fetchImpl });
899
+ const { chosen, fetchedModels } = await promptForProviderModels(provider, providerToken, tui, {
900
+ fetchImpl,
901
+ currentModels: readConfiguredProviderModels(installDir, provider),
902
+ });
726
903
  selectedModels[provider] = chosen;
727
904
  fetchedModelsCache[provider] = fetchedModels;
728
905
  }
@@ -777,5 +954,5 @@ export async function runAuthPhase(installDir, options = {}) {
777
954
  }])));
778
955
  writeRouter(installDir, providerFlags, selectedModels);
779
956
 
780
- return { providers: providerIds, providerFlags, keys: validatedKeys, selectedModels };
957
+ return { providers: providerIds, providerFlags, keys: validatedKeys, selectedModels, validationFailures };
781
958
  }