amicus 3.0.0 → 3.1.1

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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +66 -0
  3. package/README.md +13 -4
  4. package/electron/setup-ui-aliases.js +1 -1
  5. package/electron/setup-ui.js +27 -3
  6. package/package.json +2 -1
  7. package/skills/second-opinion/SKILL.md +2 -0
  8. package/skills/sidecar/SKILL.md +66 -38
  9. package/src/cli-handlers-resume-continue.js +31 -4
  10. package/src/cli-handlers-run.js +15 -4
  11. package/src/cli.js +17 -0
  12. package/src/mcp-server.js +99 -12
  13. package/src/mcp-tools.js +26 -4
  14. package/src/opencode-client.js +18 -2
  15. package/src/sidecar/continue.js +10 -3
  16. package/src/sidecar/fanout-leg.js +26 -1
  17. package/src/sidecar/fanout-output.js +5 -0
  18. package/src/sidecar/fanout-validate.js +81 -0
  19. package/src/sidecar/fanout.js +65 -77
  20. package/src/sidecar/models.js +39 -17
  21. package/src/sidecar/session-utils.js +6 -0
  22. package/src/sidecar/setup.js +2 -1
  23. package/src/utils/alias-resolver.js +6 -35
  24. package/src/utils/api-key-store.js +1 -9
  25. package/src/utils/auth-json.js +1 -1
  26. package/src/utils/config.js +98 -16
  27. package/src/utils/curated-models.js +99 -8
  28. package/src/utils/gateway-route-audit.js +103 -0
  29. package/src/utils/gateway-route-catalog.js +92 -0
  30. package/src/utils/gateway-router.js +131 -0
  31. package/src/utils/input-validators.js +12 -42
  32. package/src/utils/model-classification.js +65 -0
  33. package/src/utils/model-descriptor.js +72 -0
  34. package/src/utils/model-fetcher.js +46 -14
  35. package/src/utils/model-input-default.js +32 -0
  36. package/src/utils/model-validator.js +68 -84
  37. package/src/utils/provider-registry.js +57 -0
  38. package/src/utils/quick-picks.js +11 -3
  39. package/src/utils/result-schema-rebuild.js +98 -0
  40. package/src/utils/result-schema.js +15 -78
  41. package/src/utils/route-error.js +154 -0
  42. package/src/utils/route-launch.js +219 -0
  43. package/src/utils/start-helpers.js +96 -43
  44. package/src/utils/validators.js +1 -8
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Model-descriptor grammar + RouteResult factories (#61).
3
+ * Pure string classification — no I/O, no provider lookups. The resolver
4
+ * (gateway-router.js) consumes Descriptors and returns RouteResults.
5
+ */
6
+ 'use strict';
7
+
8
+ const GATEWAY_MODES = ['auto', 'direct', 'openrouter'];
9
+ const OR_PREFIX = 'openrouter/';
10
+
11
+ /**
12
+ * Classify a raw model string into a normalized descriptor.
13
+ * Grammar:
14
+ * - `openrouter/<vendor>/<model>` -> openrouter-literal (explicit force-OR)
15
+ * - `<vendor>/<model...>` -> canonical (policy-routed)
16
+ * - known no-slash alias -> alias (resolution deferred to caller)
17
+ * - anything else -> invalid (incl. unknown no-slash token)
18
+ * @param {string} raw
19
+ * @param {{aliases: Object<string,string>}} ctx
20
+ * @returns {{raw:string, kind:string, vendor?:string, model?:string, isExplicitOpenRouter:boolean, error?:string}}
21
+ */
22
+ function parseDescriptor(raw, ctx = {}) {
23
+ const aliases = ctx.aliases || {};
24
+ const trimmed = typeof raw === 'string' ? raw.trim() : '';
25
+ if (!trimmed) {
26
+ return { raw, kind: 'invalid', isExplicitOpenRouter: false, error: 'Empty model identifier' };
27
+ }
28
+ if (trimmed.startsWith(OR_PREFIX)) {
29
+ const rest = trimmed.slice(OR_PREFIX.length);
30
+ const parts = rest.split('/');
31
+ if (parts.length < 2 || !parts[0] || !parts.slice(1).join('/')) {
32
+ return { raw: trimmed, kind: 'invalid', isExplicitOpenRouter: true,
33
+ error: `Malformed OpenRouter model id '${trimmed}' (expected openrouter/vendor/model)` };
34
+ }
35
+ return { raw: trimmed, kind: 'openrouter-literal', vendor: parts[0],
36
+ model: parts.slice(1).join('/'), isExplicitOpenRouter: true };
37
+ }
38
+ if (trimmed.includes('/')) {
39
+ const parts = trimmed.split('/');
40
+ if (parts.length < 2 || !parts[0] || !parts.slice(1).join('/')) {
41
+ return { raw: trimmed, kind: 'invalid', isExplicitOpenRouter: false,
42
+ error: `Malformed model id '${trimmed}' (expected vendor/model)` };
43
+ }
44
+ return { raw: trimmed, kind: 'canonical', vendor: parts[0],
45
+ model: parts.slice(1).join('/'), isExplicitOpenRouter: false };
46
+ }
47
+ if (Object.prototype.hasOwnProperty.call(aliases, trimmed)) {
48
+ return { raw: trimmed, kind: 'alias', isExplicitOpenRouter: false };
49
+ }
50
+ return { raw: trimmed, kind: 'invalid', isExplicitOpenRouter: false,
51
+ error: `Unknown model alias '${trimmed}'. Run 'amicus setup' to configure aliases, or use a vendor/model id.` };
52
+ }
53
+
54
+ /** @returns {{kind:'resolved', model:string, gateway:string, executableId:string, provenance:object, notice?:string}} */
55
+ function resolved({ model, gateway, executableId, provenance, notice }) {
56
+ const out = { kind: 'resolved', model, gateway, executableId, provenance: provenance || {} };
57
+ if (notice) { out.notice = notice; }
58
+ return out;
59
+ }
60
+
61
+ /** @returns {{kind:'selection_required', requested:string, suggestions:Array}} */
62
+ function selectionRequired({ requested, suggestions }) {
63
+ return { kind: 'selection_required', requested, suggestions: suggestions || [] };
64
+ }
65
+
66
+ /** @returns {{kind:'error', type:'model_route_error', ...}} */
67
+ function routeError({ field, requested, reason, preferredGateway, suggestions }) {
68
+ return { kind: 'error', type: 'model_route_error', field: field || 'model',
69
+ requested, reason, preferredGateway, suggestions: suggestions || [] };
70
+ }
71
+
72
+ module.exports = { GATEWAY_MODES, parseDescriptor, resolved, selectionRequired, routeError };
@@ -7,22 +7,22 @@
7
7
 
8
8
  const https = require('https');
9
9
 
10
- /** Hardcoded Anthropic models (no public listing endpoint) */
10
+ /**
11
+ * Hardcoded Anthropic models (no public listing endpoint). This is the
12
+ * DIRECT-API floor only — Fable is OpenRouter-only (see curated-models.js
13
+ * DIVERGENT_VENDORS / CARDLESS 'fable' entry, which has no `anthropic` route)
14
+ * and must never appear here: classifyModel() returns 'valid' on a floor HIT
15
+ * before it ever checks `authoritative`, so listing an OR-only model here
16
+ * would mislabel a direct-API request for it as valid.
17
+ */
11
18
  const ANTHROPIC_MODELS = [
12
- { id: 'anthropic/claude-opus-4-6', name: 'Claude Opus 4.6', contextLength: null, pricing: null },
13
- { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextLength: null, pricing: null },
19
+ { id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
20
+ { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
14
21
  { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
15
- { id: 'anthropic/claude-sonnet-4-5', name: 'Claude Sonnet 4.5', contextLength: null, pricing: null },
16
- { id: 'anthropic/claude-3-5-haiku', name: 'Claude 3.5 Haiku', contextLength: null, pricing: null }
22
+ { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextLength: null, pricing: null }
17
23
  ];
18
24
 
19
- const PROVIDER_FAMILY_NAMES = {
20
- openrouter: 'OpenRouter',
21
- google: 'Google',
22
- openai: 'OpenAI',
23
- anthropic: 'Anthropic',
24
- deepseek: 'DeepSeek'
25
- };
25
+ const { PROVIDER_FAMILY_NAMES } = require('./provider-registry');
26
26
 
27
27
  /** Provider API configs for fetching model lists */
28
28
  const PROVIDER_FETCH_CONFIG = {
@@ -82,7 +82,20 @@ const PROVIDER_FETCH_CONFIG = {
82
82
  pricing: null
83
83
  }));
84
84
  }
85
- }
85
+ },
86
+ anthropic: {
87
+ url: 'https://api.anthropic.com/v1/models',
88
+ authHeader: (key) => ({ 'x-api-key': key, 'anthropic-version': '2023-06-01' }),
89
+ normalize: (body) => {
90
+ const data = JSON.parse(body);
91
+ return (data.data || []).map(m => ({
92
+ id: `anthropic/${m.id}`,
93
+ name: m.display_name || m.id,
94
+ contextLength: null,
95
+ pricing: null,
96
+ }));
97
+ },
98
+ },
86
99
  };
87
100
 
88
101
  const FETCH_TIMEOUT_MS = 5000;
@@ -95,7 +108,14 @@ const FETCH_TIMEOUT_MS = 5000;
95
108
  */
96
109
  function fetchModelsFromProvider(provider, key) {
97
110
  if (provider === 'anthropic') {
98
- return Promise.resolve(ANTHROPIC_MODELS);
111
+ // No key -> hardcoded floor, no network. With a key -> try live, fall back to floor.
112
+ // Floor-fallback rows are tagged authoritative:false (#61 4.3) so classifyModel
113
+ // never hard-blocks a miss against a stale/hardcoded list -- it returns
114
+ // 'unknown' instead. Rows from a successful live fetch are NOT tagged (they
115
+ // are authoritative). Map to new objects; never mutate ANTHROPIC_MODELS in place.
116
+ if (!key) { return Promise.resolve(ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }))); }
117
+ return fetchViaConfig('anthropic', key).then(rows =>
118
+ (rows.length > 0 ? rows : ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }))));
99
119
  }
100
120
 
101
121
  const config = PROVIDER_FETCH_CONFIG[provider];
@@ -103,6 +123,18 @@ function fetchModelsFromProvider(provider, key) {
103
123
  return Promise.resolve([]);
104
124
  }
105
125
 
126
+ return fetchViaConfig(provider, key);
127
+ }
128
+
129
+ /**
130
+ * Perform the HTTPS fetch + normalize for a single configured provider.
131
+ * Resolves to `[]` on any non-200 response, network error, timeout, or parse error.
132
+ * @param {string} provider - Key into PROVIDER_FETCH_CONFIG
133
+ * @param {string} key - API key
134
+ * @returns {Promise<Array>} Normalized model rows, or [] on any failure
135
+ */
136
+ function fetchViaConfig(provider, key) {
137
+ const config = PROVIDER_FETCH_CONFIG[provider];
106
138
  const url = config.buildUrl ? config.buildUrl(key) : config.url;
107
139
  const headers = config.authHeader(key);
108
140
 
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module model-input-default
5
+ * Shared "no --model given" default-lookup, extracted so it lives in exactly
6
+ * one place (#61 Task 6.2 review follow-up): both the CLI's resolveLaunchModel
7
+ * (start-helpers.js) and the MCP amicus_start handler (mcp-server.js) need to
8
+ * fall back to the configured default before handing a model off to
9
+ * resolveRouteForLaunch — without it, an omitted model reaches
10
+ * resolveRouteForLaunch as undefined -> parseDescriptor(undefined) -> an
11
+ * `invalid` descriptor, breaking the common "no --model" launch case.
12
+ * A leaf module (no other src/ module requires/mocks it), so introducing it
13
+ * doesn't reshape any existing jest.doMock('../src/utils/route-launch', ...)
14
+ * or jest.doMock('../src/utils/config', ...) call shape.
15
+ */
16
+
17
+ /**
18
+ * Resolve the raw model input for a launch: an explicit value (including one
19
+ * a caller may have already normalized) passes through unchanged; otherwise
20
+ * falls back to the configured default.
21
+ * @param {string|null|undefined} inputModel
22
+ * @returns {string|undefined} inputModel as-is, the configured default, or
23
+ * undefined if neither exists (the caller decides how to report that).
24
+ */
25
+ function resolveModelInputOrDefault(inputModel) {
26
+ if (inputModel !== undefined && inputModel !== null) { return inputModel; }
27
+ const { loadConfig } = require('./config');
28
+ const cfg = loadConfig();
29
+ return (cfg && cfg.default) ? cfg.default : undefined;
30
+ }
31
+
32
+ module.exports = { resolveModelInputOrDefault };
@@ -7,10 +7,7 @@
7
7
  */
8
8
 
9
9
  const readline = require('readline');
10
- const { fetchModelsFromProvider } = require('./model-fetcher');
11
- const { readApiKeyValues } = require('./api-key-store');
12
10
  const { loadConfig, saveConfig, getConfigPath } = require('./config');
13
- const { logger } = require('./logger');
14
11
 
15
12
  /**
16
13
  * Normalize a model ID to include the provider prefix.
@@ -33,54 +30,6 @@ const ALIAS_SEARCH_TERMS = {
33
30
  'deepseek': 'deepseek',
34
31
  };
35
32
 
36
- /**
37
- * Validate a direct-API fallback model exists on the provider.
38
- * Returns silently if valid. On failure: prompts (interactive) or throws (headless).
39
- *
40
- * @param {string} resolvedModel - e.g. 'google/gemini-3.1-flash-lite-preview'
41
- * @param {string} alias - Original alias name (e.g. 'gemini')
42
- * @param {object} [options]
43
- * @param {boolean} [options.headless] - If true, throw instead of prompting
44
- * @returns {Promise<string>} Confirmed model string
45
- */
46
- async function validateDirectModel(resolvedModel, alias, options = {}) {
47
- const parts = resolvedModel.split('/');
48
- if (parts.length < 2) { return resolvedModel; }
49
-
50
- const provider = parts[0];
51
- const modelId = parts.slice(1).join('/');
52
-
53
- const keys = readApiKeyValues();
54
- const providerKey = keys[provider];
55
- if (!providerKey) { return resolvedModel; }
56
-
57
- let models;
58
- try {
59
- models = await fetchModelsFromProvider(provider, providerKey);
60
- } catch (err) {
61
- logger.debug({ msg: 'Model fetch failed, skipping validation', error: err.message });
62
- return resolvedModel;
63
- }
64
-
65
- if (!models || models.length === 0) { return resolvedModel; }
66
-
67
- const found = models.some(m => m.id === resolvedModel || m.id === modelId);
68
- if (found) { return resolvedModel; }
69
-
70
- const relevant = filterRelevantModels(models, alias);
71
-
72
- if (options.headless || !process.stdin.isTTY) {
73
- const list = relevant.slice(0, 10).map(m => ` ${normalizeModelId(provider, m.id)}`).join('\n');
74
- throw new Error(
75
- `Model '${modelId}' not found on ${provider} API.\n` +
76
- `Available models:\n${list}\n` +
77
- `Fix with: amicus setup --add-alias ${alias}=${relevant[0] ? normalizeModelId(provider, relevant[0].id) : 'provider/model'}`
78
- );
79
- }
80
-
81
- return promptModelSelection(relevant, alias, provider, modelId);
82
- }
83
-
84
33
  /**
85
34
  * Filter models to those relevant to the alias
86
35
  * @param {Array<{id: string, name: string}>} models
@@ -101,54 +50,83 @@ function filterRelevantModels(models, alias) {
101
50
  return filtered.slice(0, 15);
102
51
  }
103
52
 
104
- /** Interactive prompt — ask user to pick from available models */
105
- async function promptModelSelection(models, alias, provider, failedModelId) {
106
- process.stderr.write(`\n Model '${failedModelId}' not found on ${provider} API.\n`);
107
- process.stderr.write(' Available models:\n');
108
- models.forEach((m, i) => {
109
- const label = (m.name && m.name !== m.id) ? `${m.name} (${m.id})` : m.id;
110
- process.stderr.write(` ${i + 1}. ${label}\n`);
53
+ /**
54
+ * Interactive alternatives picker for a direct-model miss (#61 Task 6.3, spec
55
+ * Decision 10). Presents `selectionResult.suggestions` (built upstream by
56
+ * route-launch.js's buildSuggestions) as a labeled numbered menu and lets the
57
+ * user pick one, or cancel. Uses the same readline + persist pattern (same
58
+ * save-with-malformed-config guard) as other model pickers in this module,
59
+ * adapted to the `{model, gateway, note}` suggestion shape instead of
60
+ * provider `{id, name}` rows.
61
+ *
62
+ * Never auto-selects — even a single suggestion still requires an explicit
63
+ * pick. Cancellation (empty input, an out-of-range number, or no suggestions
64
+ * to offer) always throws; the caller (resolveLaunchModel) is expected to
65
+ * catch and translate that into a "cancelled" stderr message + exit(1).
66
+ *
67
+ * @param {{requested: string, suggestions: Array<{model:string, gateway:string, note?:string}>}} selectionResult
68
+ * @param {string|undefined} alias - alias to persist the choice under, if any
69
+ * @returns {Promise<{model: string, gateway: string}>}
70
+ */
71
+ async function promptRouteSelection(selectionResult, alias) {
72
+ const suggestions = (selectionResult && Array.isArray(selectionResult.suggestions))
73
+ ? selectionResult.suggestions : [];
74
+ const requested = selectionResult && selectionResult.requested;
75
+
76
+ process.stderr.write(`\n Model '${requested}' isn't available on the direct API.\n`);
77
+
78
+ if (suggestions.length === 0) {
79
+ process.stderr.write(' No alternatives available.\n\n');
80
+ throw new Error('Model selection cancelled.');
81
+ }
82
+
83
+ process.stderr.write(' Alternatives:\n');
84
+ suggestions.forEach((s, i) => {
85
+ const note = s && s.note ? ` — ${s.note}` : '';
86
+ process.stderr.write(` ${i + 1}. ${s && s.model} [${s && s.gateway}]${note}\n`);
111
87
  });
112
88
  process.stderr.write('\n');
113
89
 
114
90
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
115
91
 
116
92
  const answer = await new Promise(resolve => {
117
- rl.question(` Select a model (1-${models.length}) or press Enter to cancel: `, resolve);
93
+ rl.question(` Select (1-${suggestions.length}) or Enter to cancel: `, resolve);
118
94
  });
119
95
  rl.close();
120
96
 
121
97
  const idx = parseInt(answer, 10) - 1;
122
- if (isNaN(idx) || idx < 0 || idx >= models.length) {
98
+ if (isNaN(idx) || idx < 0 || idx >= suggestions.length) {
123
99
  throw new Error('Model selection cancelled.');
124
100
  }
125
101
 
126
- const selected = models[idx];
127
- const newModel = normalizeModelId(provider, selected.id);
128
-
129
- let config = loadConfig();
130
- if (!config) {
131
- const fs = require('fs');
132
- const configPath = getConfigPath();
133
- if (fs.existsSync(configPath)) {
134
- throw new Error(
135
- `Cannot save model selection: config file at ${configPath} is malformed. ` +
136
- 'Fix it manually or run \'amicus setup\'.'
137
- );
102
+ const chosen = suggestions[idx];
103
+ const newModel = chosen.model;
104
+
105
+ if (alias) {
106
+ let config = loadConfig();
107
+ if (!config) {
108
+ const fs = require('fs');
109
+ const configPath = getConfigPath();
110
+ if (fs.existsSync(configPath)) {
111
+ throw new Error(
112
+ `Cannot save model selection: config file at ${configPath} is malformed. ` +
113
+ 'Fix it manually or run \'amicus setup\'.'
114
+ );
115
+ }
116
+ config = {};
117
+ }
118
+ if (!config.aliases) { config.aliases = {}; }
119
+ config.aliases[alias] = newModel;
120
+ try {
121
+ saveConfig(config);
122
+ process.stderr.write(` Saved: ${alias} -> ${newModel}\n`);
123
+ } catch (err) {
124
+ process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
138
125
  }
139
- config = {};
140
- }
141
- if (!config.aliases) { config.aliases = {}; }
142
- config.aliases[alias] = newModel;
143
- try {
144
- saveConfig(config);
145
- process.stderr.write(` Saved: ${alias} → ${newModel}\n`);
146
- } catch (err) {
147
- process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
148
126
  }
149
- process.stderr.write(` (To change later: amicus setup --add-alias ${alias}=...)\n\n`);
127
+ process.stderr.write('\n');
150
128
 
151
- return newModel;
129
+ return { model: newModel, gateway: chosen.gateway };
152
130
  }
153
131
 
154
132
  /**
@@ -204,4 +182,10 @@ async function warnIfNotInCatalog(model) {
204
182
  }
205
183
  }
206
184
 
207
- module.exports = { validateDirectModel, filterRelevantModels, normalizeModelId, validateAgainstCatalog, warnIfNotInCatalog };
185
+ module.exports = {
186
+ filterRelevantModels,
187
+ normalizeModelId,
188
+ validateAgainstCatalog,
189
+ warnIfNotInCatalog,
190
+ promptRouteSelection,
191
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Provider-capability registry — the single source of truth for provider
3
+ * identity, credentials, direct-vs-gateway role, and display names.
4
+ * The historical maps (PROVIDER_ENV_MAP, PROVIDER_KEY_MAP, KNOWN_PROVIDERS,
5
+ * PROVIDER_FAMILY_NAMES) are DERIVED from PROVIDERS below so they can never
6
+ * drift apart again. Leaf module: requires nothing internal (no circular deps).
7
+ */
8
+ 'use strict';
9
+
10
+ /**
11
+ * @typedef {Object} ProviderDescriptor
12
+ * @property {string} id provider id (namespace)
13
+ * @property {string} envVar env var holding the key
14
+ * @property {string} keyDisplayName human name used in missing-key errors
15
+ * @property {string} familyName short name used for optgroup grouping
16
+ * @property {boolean} direct can be a DIRECT route target (false for the gateway)
17
+ * @property {boolean} gateway is the OpenRouter gateway itself
18
+ * @property {boolean} hasLiveFetch has a live GET /models endpoint
19
+ * @property {string} authJsonKey key used in OpenCode auth.json
20
+ */
21
+
22
+ /** @type {ProviderDescriptor[]} */
23
+ const PROVIDERS = [
24
+ { id: 'openrouter', envVar: 'OPENROUTER_API_KEY', keyDisplayName: 'OpenRouter', familyName: 'OpenRouter', direct: false, gateway: true, hasLiveFetch: true, authJsonKey: 'openrouter' },
25
+ { id: 'google', envVar: 'GOOGLE_GENERATIVE_AI_API_KEY', keyDisplayName: 'Google Gemini', familyName: 'Google', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'google' },
26
+ { id: 'openai', envVar: 'OPENAI_API_KEY', keyDisplayName: 'OpenAI', familyName: 'OpenAI', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'openai' },
27
+ { id: 'anthropic', envVar: 'ANTHROPIC_API_KEY', keyDisplayName: 'Anthropic', familyName: 'Anthropic', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'anthropic' },
28
+ { id: 'deepseek', envVar: 'DEEPSEEK_API_KEY', keyDisplayName: 'DeepSeek', familyName: 'DeepSeek', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'deepseek' },
29
+ ];
30
+
31
+ const _byId = new Map(PROVIDERS.map(p => [p.id, p]));
32
+
33
+ /** @param {string} id @returns {ProviderDescriptor|undefined} */
34
+ function getProvider(id) { return _byId.get(id); }
35
+
36
+ /** @param {string} id @returns {boolean} true only for direct-route vendors (never the gateway) */
37
+ function isDirectProvider(id) { const p = _byId.get(id); return !!p && p.direct; }
38
+
39
+ /** @returns {string[]} ids of direct-route vendors (excludes openrouter) */
40
+ function listDirectProviders() { return PROVIDERS.filter(p => p.direct).map(p => p.id); }
41
+
42
+ // --- Derived compatibility maps (do not hand-edit; edit PROVIDERS above) ---
43
+ const PROVIDER_ENV_MAP = Object.fromEntries(PROVIDERS.map(p => [p.id, p.envVar]));
44
+ const PROVIDER_KEY_MAP = Object.fromEntries(PROVIDERS.map(p => [p.id, { key: p.envVar, name: p.keyDisplayName }]));
45
+ const KNOWN_PROVIDERS = PROVIDERS.map(p => p.id);
46
+ const PROVIDER_FAMILY_NAMES = Object.fromEntries(PROVIDERS.map(p => [p.id, p.familyName]));
47
+
48
+ module.exports = {
49
+ PROVIDERS,
50
+ getProvider,
51
+ isDirectProvider,
52
+ listDirectProviders,
53
+ PROVIDER_ENV_MAP,
54
+ PROVIDER_KEY_MAP,
55
+ KNOWN_PROVIDERS,
56
+ PROVIDER_FAMILY_NAMES,
57
+ };
@@ -10,7 +10,7 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const { getFamilies, toDefaultAliases } = require('./curated-models');
13
+ const { getFamilies, toDefaultAliases, toCanonicalDefault } = require('./curated-models');
14
14
 
15
15
  const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
16
16
 
@@ -67,13 +67,21 @@ function resolveQuickPicks(catalog) {
67
67
 
68
68
  /**
69
69
  * Seed map for fresh configs: static defaults overlaid with live family
70
- * openrouter routes (cardless aliases stay pinned).
70
+ * routes (cardless aliases stay pinned). The overlaid route is run through
71
+ * `toCanonicalDefault` so a direct-capable vendor (e.g. google, openai)
72
+ * lands as bare `vendor/model` (direct-first via the gateway router)
73
+ * instead of the raw `openrouter/<vendor>/<rest>` pick — otherwise a fresh
74
+ * `amicus setup` with a live catalog would silently defeat the direct-first
75
+ * default `toDefaultAliases()` establishes. Gateway-only vendors are
76
+ * returned unchanged by `toCanonicalDefault`.
71
77
  * @returns {Object<string,string>}
72
78
  */
73
79
  function toLiveSeedAliases(catalog) {
74
80
  const seeds = toDefaultAliases();
75
81
  for (const r of resolveQuickPicks(catalog || [])) {
76
- if (r.source === 'live' && r.routes.openrouter) { seeds[r.alias] = r.routes.openrouter; }
82
+ if (r.source === 'live' && r.routes.openrouter) {
83
+ seeds[r.alias] = toCanonicalDefault(r.routes.openrouter);
84
+ }
77
85
  }
78
86
  return seeds;
79
87
  }
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module result-schema-rebuild
5
+ * Rebuild run/wave documents from persisted session directories (as opposed to
6
+ * `result-schema.js`'s builders, which assemble a document from in-memory
7
+ * state right after a run/wave finishes). Split out of result-schema.js to
8
+ * stay under the 300-line size gate (#61 whole-branch review housekeeping) —
9
+ * re-exported from result-schema.js so every existing caller's import path is
10
+ * unaffected.
11
+ *
12
+ * `buildRunResult`/`buildWaveResult` are required LAZILY inside each function
13
+ * body (not at module load time) so this file can depend on result-schema.js
14
+ * without a circular-require ordering hazard: result-schema.js requires this
15
+ * module too (to re-export these two functions), and a top-level require here
16
+ * would see result-schema.js's exports mid-assembly (see result-schema.js's
17
+ * module doc for why abort-result.js already avoids the same trap).
18
+ */
19
+
20
+ /**
21
+ * Rebuild a run document from a persisted session directory.
22
+ * @param {string} project - Project dir
23
+ * @param {string} taskId
24
+ * @returns {object} run document
25
+ * @throws {Error} if the session does not exist or metadata.json is missing/corrupt
26
+ */
27
+ function buildRunResultFromSession(project, taskId) {
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const { resolveExistingSessionDir } = require('../session-manager');
31
+ const { buildRunResult } = require('./result-schema');
32
+ const sessionDir = resolveExistingSessionDir(project, taskId);
33
+ const metaPath = path.join(sessionDir, 'metadata.json');
34
+ if (!fs.existsSync(metaPath)) {
35
+ throw new Error(`Session ${taskId} not found`);
36
+ }
37
+ let metadata;
38
+ try {
39
+ metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
40
+ } catch (err) {
41
+ throw new Error(`Session ${taskId}: metadata is corrupt (${err.message})`);
42
+ }
43
+ const summaryPath = path.join(sessionDir, 'summary.md');
44
+ const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : null;
45
+ return buildRunResult({ taskId, metadata, summary, sessionDir });
46
+ }
47
+
48
+ /**
49
+ * Rebuild a wave document. Prefers the stored wave.json (written atomically at
50
+ * fanout exit); falls back to a live rebuild from leg sessions (e.g. after a
51
+ * hard kill of the fanout process).
52
+ * @param {string} project
53
+ * @param {string} waveId
54
+ * @returns {object} wave document
55
+ * @throws {Error} if the wave session does not exist or metadata.json is missing/corrupt
56
+ */
57
+ function buildWaveResultFromSession(project, waveId) {
58
+ const fs = require('fs');
59
+ const path = require('path');
60
+ const { resolveExistingSessionDir } = require('../session-manager');
61
+ const { buildRunResult, buildWaveResult } = require('./result-schema');
62
+ const waveDir = resolveExistingSessionDir(project, waveId);
63
+ const wavePath = path.join(waveDir, 'wave.json');
64
+ if (fs.existsSync(wavePath)) {
65
+ try {
66
+ return JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
67
+ } catch {
68
+ // Corrupt wave.json (e.g. hard-kill mid-write) — fall through to live rebuild
69
+ }
70
+ }
71
+ const metaPath = path.join(waveDir, 'metadata.json');
72
+ if (!fs.existsSync(metaPath)) {
73
+ throw new Error(`Wave ${waveId} not found`);
74
+ }
75
+ let meta;
76
+ try {
77
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
78
+ } catch (err) {
79
+ throw new Error(`Wave ${waveId}: metadata is corrupt (${err.message})`);
80
+ }
81
+ const legs = (meta.legs || []).map((legId) => {
82
+ try { return buildRunResultFromSession(project, legId); }
83
+ catch (err) {
84
+ const { logger } = require('./logger');
85
+ logger.warn('Failed to rebuild leg session; using unknown stub', { legId, error: err.message });
86
+ return buildRunResult({ taskId: legId, metadata: { status: 'unknown', parentWave: waveId } });
87
+ }
88
+ });
89
+ return buildWaveResult({
90
+ waveId,
91
+ legs,
92
+ promptMeta: meta.promptMeta || null,
93
+ createdAt: meta.createdAt || null,
94
+ completedAt: meta.completedAt || null,
95
+ });
96
+ }
97
+
98
+ module.exports = { buildRunResultFromSession, buildWaveResultFromSession };