amicus 1.3.0 → 1.4.0
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +12 -1
- package/electron/ipc-setup.js +18 -1
- package/electron/preload-setup.js +2 -1
- package/electron/setup-ui-council.js +77 -0
- package/electron/setup-ui.js +9 -3
- package/package.json +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +6 -0
- package/skills/second-opinion/SKILL.md +16 -0
- package/src/cli-handlers-run.js +24 -2
- package/src/cli.js +1 -0
- package/src/mcp-server.js +33 -4
- package/src/mcp-tools.js +5 -2
- package/src/sidecar/setup.js +129 -9
- package/src/utils/config.js +54 -0
- package/src/utils/free-models.js +50 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"author": { "name": "Christian Wagner" },
|
|
6
6
|
"homepage": "https://bourbondog.github.io/amicus/",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.4.0] - 2026-06-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Free OpenRouter council**: a new `amicus setup` option (readline wizard + Electron Models step)
|
|
12
|
+
that stands up a zero-cost council of free `:free` OpenRouter models, saved as a first-class
|
|
13
|
+
`councils` config primitive. Run it with `amicus fanout --council free` or the `amicus_fanout` MCP
|
|
14
|
+
`council` param; the second-opinion skill reads `councils.free`. Free-model picks are detected
|
|
15
|
+
live from the catalog (the `:free` suffix is authoritative), seeded under collision-safe `free-*`
|
|
16
|
+
aliases, and a delisted member degrades gracefully (dropped with a warning) instead of failing the
|
|
17
|
+
wave. Needs only an `OPENROUTER_API_KEY`; the wizard discloses the free-tier caveats (rate limits,
|
|
18
|
+
variable quality, the OpenRouter data-sharing prerequisite). `config.default` is left untouched.
|
|
19
|
+
|
|
8
20
|
## [1.3.0] - 2026-06-24
|
|
9
21
|
|
|
10
22
|
Making the mature council/fan-out engine legible: live per-leg progress, cost
|
package/README.md
CHANGED
|
@@ -152,6 +152,16 @@ Then the council waits for your confirmation.
|
|
|
152
152
|
|
|
153
153
|
The skill lives at **[`skills/second-opinion/SKILL.md`](./skills/second-opinion/SKILL.md)**; the design spec behind it is **[`skills/second-opinion/COUNCIL-DESIGN.md`](./skills/second-opinion/COUNCIL-DESIGN.md)**.
|
|
154
154
|
|
|
155
|
+
**Free council (zero-cost).** Want the cross-examination without the model spend? `amicus setup` offers a **Free OpenRouter council** mode — readline wizard option 2, and the Electron **Models** step. It detects the free `:free` models live from the catalog, lets you multi-pick (Enter takes a vendor-diverse default), and saves them as `councils.free` — a first-class `councils` config primitive seeded under collision-safe `free-*` aliases. Your `config.default` is left untouched, and all you need is an `OPENROUTER_API_KEY`.
|
|
156
|
+
|
|
157
|
+
Run it anywhere a council runs:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
amicus fanout --council free --prompt "Review this design"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The `amicus_fanout` MCP tool takes the same `council` parameter, and the `second-opinion` skill reads `councils.free` automatically. A member that gets delisted is dropped with a warning — the council still runs as long as ≥2 survive. Free models are **rate-limited and quality-variable**, and some return 404 unless you enable data-sharing at [openrouter.ai/settings/privacy](https://openrouter.ai/settings/privacy).
|
|
164
|
+
|
|
155
165
|
---
|
|
156
166
|
|
|
157
167
|
## The parallel window
|
|
@@ -246,7 +256,8 @@ amicus fanout --models gemini,deepseek,gpt --prompt "Review this design" --json
|
|
|
246
256
|
|
|
247
257
|
Fanout runs one **headless wave**: every leg gets the **same** prompt (this is the shared-prompt model the council's review stages are built on). When all legs are terminal it prints **one** JSON wave document on stdout.
|
|
248
258
|
|
|
249
|
-
- `--models <a,b,c>` — comma-separated aliases or `provider/model` IDs (required).
|
|
259
|
+
- `--models <a,b,c>` — comma-separated aliases or `provider/model` IDs (required, unless `--council`).
|
|
260
|
+
- `--council <name>` — run a saved council (e.g. `free`) instead of `--models`; mutually exclusive with `--models`.
|
|
250
261
|
- `--prompt <text>` / `--prompt-file <path>` — the shared briefing. `--prompt-file` avoids the ~32 KB Windows argument cap and is mutually exclusive with `--prompt`.
|
|
251
262
|
- `--wave-id <id>` — set the wave ID explicitly (leg IDs become `<id>-1..N`).
|
|
252
263
|
- `--json` — emit the wave document.
|
package/electron/ipc-setup.js
CHANGED
|
@@ -97,7 +97,8 @@ function registerSetupHandlers(getMainWindow) {
|
|
|
97
97
|
|
|
98
98
|
// Read-modify-write: never rewrite an alias the renderer didn't send.
|
|
99
99
|
// aliasWrites values: string = set, null = delete. First run seeds live.
|
|
100
|
-
|
|
100
|
+
// councilPicks (optional): when length >= 2, seeds the free council via seedFreeCouncil.
|
|
101
|
+
ipcMain.handle('sidecar:save-config', async (_event, defaultModel, aliasWrites, councilPicks) => {
|
|
101
102
|
try {
|
|
102
103
|
const { loadConfig, saveConfig } = require('../src/utils/config');
|
|
103
104
|
let cfg = loadConfig();
|
|
@@ -119,6 +120,9 @@ function registerSetupHandlers(getMainWindow) {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
saveConfig(cfg);
|
|
123
|
+
if (Array.isArray(councilPicks) && councilPicks.length >= 2) {
|
|
124
|
+
require('../src/sidecar/setup').seedFreeCouncil(councilPicks);
|
|
125
|
+
}
|
|
122
126
|
return { success: true };
|
|
123
127
|
} catch (err) {
|
|
124
128
|
logger.error('save-config handler error', { error: err.message });
|
|
@@ -192,6 +196,19 @@ function registerSetupHandlers(getMainWindow) {
|
|
|
192
196
|
return { models: [], fetchedAt: null };
|
|
193
197
|
}
|
|
194
198
|
});
|
|
199
|
+
|
|
200
|
+
// Free OpenRouter council: returns all free models from the catalog,
|
|
201
|
+
// marking those in the vendor-diverse suggested set with suggested:true.
|
|
202
|
+
ipcMain.handle('sidecar:fetch-free-models', async () => {
|
|
203
|
+
try {
|
|
204
|
+
const { getCatalog } = require('../src/utils/model-catalog');
|
|
205
|
+
const { listFreeModels, suggestFreeCouncil } = require('../src/utils/free-models');
|
|
206
|
+
const catalog = await getCatalog();
|
|
207
|
+
const free = listFreeModels(catalog);
|
|
208
|
+
const suggested = new Set(suggestFreeCouncil(free, 3).map(r => r.id));
|
|
209
|
+
return free.map(r => ({ id: r.id, suggested: suggested.has(r.id) }));
|
|
210
|
+
} catch (_err) { return []; }
|
|
211
|
+
});
|
|
195
212
|
}
|
|
196
213
|
|
|
197
214
|
module.exports = { registerSetupHandlers };
|
|
@@ -22,7 +22,8 @@ contextBridge.exposeInMainWorld('sidecarSetup', {
|
|
|
22
22
|
'sidecar:get-api-keys',
|
|
23
23
|
'sidecar:fetch-models',
|
|
24
24
|
'sidecar:get-catalog',
|
|
25
|
-
'sidecar:refresh-catalog'
|
|
25
|
+
'sidecar:refresh-catalog',
|
|
26
|
+
'sidecar:fetch-free-models'
|
|
26
27
|
];
|
|
27
28
|
if (!allowedChannels.includes(channel)) {
|
|
28
29
|
throw new Error(`IPC channel not allowed: ${channel}`);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup UI — Free OpenRouter council picker (mounted on the Models step).
|
|
3
|
+
* Collapsible section: a checkbox list of free models fetched via IPC. Gated
|
|
4
|
+
* on the OpenRouter key (recomputed on Step-2 entry by the orchestrator).
|
|
5
|
+
* window.collectCouncilPicks() returns the checked ids for the save payload.
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
function buildCouncilSectionHTML() {
|
|
10
|
+
return `<div id="free-council-section" class="council-section">
|
|
11
|
+
<label class="council-toggle"><input type="checkbox" id="free-council-toggle">
|
|
12
|
+
<span>Set up a free OpenRouter council (zero-cost)</span></label>
|
|
13
|
+
<div id="free-council-body" style="display:none">
|
|
14
|
+
<div id="free-council-meta" class="search-meta"></div>
|
|
15
|
+
<div id="free-council-results" class="council-results"></div>
|
|
16
|
+
<div class="council-note">Free tier: rate-limited & quality-variable; some models need
|
|
17
|
+
data-sharing enabled at openrouter.ai/settings/privacy.</div>
|
|
18
|
+
</div>
|
|
19
|
+
</div>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildCouncilScript() {
|
|
23
|
+
return `
|
|
24
|
+
(function() {
|
|
25
|
+
var toggle = document.getElementById('free-council-toggle');
|
|
26
|
+
var body = document.getElementById('free-council-body');
|
|
27
|
+
var results = document.getElementById('free-council-results');
|
|
28
|
+
var meta = document.getElementById('free-council-meta');
|
|
29
|
+
var loaded = false;
|
|
30
|
+
|
|
31
|
+
function hasOpenRouterKey() { return !!(window.configuredKeys && window.configuredKeys.openrouter); }
|
|
32
|
+
|
|
33
|
+
window.refreshCouncilGating = function() {
|
|
34
|
+
if (!toggle) { return; }
|
|
35
|
+
var ok = hasOpenRouterKey();
|
|
36
|
+
toggle.disabled = !ok;
|
|
37
|
+
if (meta && !ok) { meta.textContent = 'Add an OpenRouter API key (step 1) to enable a free council.'; }
|
|
38
|
+
else if (meta && !loaded) { meta.textContent = ''; }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function loadFree() {
|
|
42
|
+
if (loaded) { return; }
|
|
43
|
+
try {
|
|
44
|
+
var rows = await window.sidecarSetup.invoke('sidecar:fetch-free-models');
|
|
45
|
+
loaded = true;
|
|
46
|
+
results.innerHTML = '';
|
|
47
|
+
(rows || []).forEach(function(r, i) {
|
|
48
|
+
var id = 'fc-' + i;
|
|
49
|
+
var row = document.createElement('label');
|
|
50
|
+
row.className = 'council-row';
|
|
51
|
+
var cb = document.createElement('input');
|
|
52
|
+
cb.type = 'checkbox'; cb.value = r.id; cb.id = id; cb.checked = !!r.suggested;
|
|
53
|
+
var span = document.createElement('span'); span.textContent = r.id;
|
|
54
|
+
row.appendChild(cb); row.appendChild(span);
|
|
55
|
+
results.appendChild(row);
|
|
56
|
+
});
|
|
57
|
+
if (meta) { meta.textContent = (rows || []).length + ' free models'; }
|
|
58
|
+
} catch (_e) { if (meta) { meta.textContent = 'Could not load free models.'; } }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (toggle) {
|
|
62
|
+
toggle.addEventListener('change', function() {
|
|
63
|
+
body.style.display = toggle.checked ? '' : 'none';
|
|
64
|
+
if (toggle.checked) { loadFree(); }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
window.collectCouncilPicks = function() {
|
|
69
|
+
if (!toggle || !toggle.checked) { return []; }
|
|
70
|
+
return Array.prototype.slice.call(results.querySelectorAll('input[type=checkbox]:checked'))
|
|
71
|
+
.map(function(cb) { return cb.value; });
|
|
72
|
+
};
|
|
73
|
+
})();
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { buildCouncilSectionHTML, buildCouncilScript };
|
package/electron/setup-ui.js
CHANGED
|
@@ -5,6 +5,7 @@ const { buildAliasEditorHTML } = require('./setup-ui-aliases');
|
|
|
5
5
|
const { buildWizardCSS } = require('./setup-ui-styles');
|
|
6
6
|
const { buildKeysScript } = require('./setup-ui-keys-script');
|
|
7
7
|
const { buildAliasScript } = require('./setup-ui-alias-script');
|
|
8
|
+
const { buildCouncilSectionHTML, buildCouncilScript } = require('./setup-ui-council');
|
|
8
9
|
const { getDefaultAliases } = require('../src/utils/config');
|
|
9
10
|
const { getBrandName } = require('./toolbar');
|
|
10
11
|
const { resolveQuickPicks } = require('../src/utils/quick-picks');
|
|
@@ -36,7 +37,7 @@ function buildSetupHTML(options = {}) {
|
|
|
36
37
|
<div class="progress-bar"><div class="progress-step active" id="step-1"><span class="progress-dot">1</span><span>API Keys</span></div><div class="progress-connector"></div><div class="progress-step" id="step-2"><span class="progress-dot">2</span><span>Models</span></div><div class="progress-connector"></div><div class="progress-step" id="step-3"><span class="progress-dot">3</span><span>Routing</span></div><div class="progress-connector"></div><div class="progress-step" id="step-4"><span class="progress-dot">4</span><span>Review</span></div></div>
|
|
37
38
|
<div class="content">
|
|
38
39
|
<div class="wizard-step visible" id="wizard-step-1"><div id="import-notice"></div>${keysHtml}</div>
|
|
39
|
-
<div class="wizard-step" id="wizard-step-2">${modelHtml}</div>
|
|
40
|
+
<div class="wizard-step" id="wizard-step-2">${modelHtml}${buildCouncilSectionHTML()}</div>
|
|
40
41
|
<div class="wizard-step" id="wizard-step-3">${aliasHtml}</div>
|
|
41
42
|
<div class="wizard-step" id="wizard-step-4">
|
|
42
43
|
<div class="step-content">
|
|
@@ -57,6 +58,7 @@ ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultA
|
|
|
57
58
|
function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson) {
|
|
58
59
|
const keysJs = buildKeysScript();
|
|
59
60
|
const aliasJs = buildAliasScript();
|
|
61
|
+
const councilJs = buildCouncilScript();
|
|
60
62
|
return `<script>
|
|
61
63
|
window.onerror = function(msg, src, line, col, err) { console.error('WIZARD ERROR:', msg, 'at', src, line, col, err); };
|
|
62
64
|
window.onunhandledrejection = function(e) { console.error('WIZARD UNHANDLED REJECTION:', e.reason); };
|
|
@@ -91,6 +93,8 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
91
93
|
}
|
|
92
94
|
});
|
|
93
95
|
if (data.hints) { keyHints = data.hints; }
|
|
96
|
+
window.configuredKeys = configuredKeys;
|
|
97
|
+
window.refreshCouncilGating && window.refreshCouncilGating();
|
|
94
98
|
updateNextState();
|
|
95
99
|
if (data.imported && data.imported.length > 0) {
|
|
96
100
|
var notice = document.getElementById('import-notice');
|
|
@@ -171,7 +175,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
171
175
|
nextBtn.style.display = step < 4 ? '' : 'none';
|
|
172
176
|
finishBtn.style.display = step === 4 ? '' : 'none';
|
|
173
177
|
if (step === 4) { buildReview(); }
|
|
174
|
-
if (step === 2) { updateRoutingPills(); ensureCatalogLoaded(); }
|
|
178
|
+
if (step === 2) { updateRoutingPills(); ensureCatalogLoaded(); window.refreshCouncilGating && window.refreshCouncilGating(); }
|
|
175
179
|
if (step === 3) {
|
|
176
180
|
updateAliasRoutes();
|
|
177
181
|
if (!window.availableModels) { fetchAvailableModels(); }
|
|
@@ -383,7 +387,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
383
387
|
if (routeId) { aliasWrites[mc.alias] = routeId; }
|
|
384
388
|
}
|
|
385
389
|
}
|
|
386
|
-
await window.sidecarSetup.invoke('sidecar:save-config', dm, aliasWrites);
|
|
390
|
+
await window.sidecarSetup.invoke('sidecar:save-config', dm, aliasWrites, (window.collectCouncilPicks && window.collectCouncilPicks()) || []);
|
|
387
391
|
var kc = Object.values(configuredKeys).filter(function(v) { return v; }).length;
|
|
388
392
|
await window.sidecarSetup.invoke('sidecar:setup-done', dm, kc);
|
|
389
393
|
} catch (_e) { finishBtn.disabled = false; finishBtn.textContent = 'Finish'; }
|
|
@@ -512,6 +516,8 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
512
516
|
${aliasJs}
|
|
513
517
|
|
|
514
518
|
${keysJs}
|
|
519
|
+
|
|
520
|
+
${councilJs}
|
|
515
521
|
</script>`;
|
|
516
522
|
}
|
|
517
523
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -79,6 +79,12 @@ This section keeps only per-model **qualitative quirks** and **structural-confor
|
|
|
79
79
|
- **gpt** — thorough but verbose; peers have dinged it for volume-over-judgment. Self-ranked its own review #1 in the 2026-06-04 run → the peers-only street-cred rule (now enforced by `tally`) mitigates this. Conforms cleanly. Accessible via OpenRouter.
|
|
80
80
|
- **gemini** — fast, very large context; tends toward absolute severity labels ("blocker" inflation vs peers). Conforms cleanly; watch for preamble narration — instruct it to emit the JSON block verbatim after the prose.
|
|
81
81
|
|
|
82
|
+
## Free-tier models (OpenRouter `:free`)
|
|
83
|
+
- Heavily rate-limited (shared daily pool); a 3-leg parallel wave + cross-review can 429 mid-run.
|
|
84
|
+
- Quality-variable; weaker at strict structured (findings JSON) output.
|
|
85
|
+
- Some `:free` models 404 unless the account enables data-sharing at openrouter.ai/settings/privacy.
|
|
86
|
+
- No reliability history — chair selection can't use `council stats`; pick the strongest free model and disclose lower confidence.
|
|
87
|
+
|
|
82
88
|
## Cost guardrail
|
|
83
89
|
- The budget gate enforces this in code: a per-$/Mtok threshold (ON by default)
|
|
84
90
|
refuses o3/o3-pro-class models before a wave launches. This replaces the old
|
|
@@ -56,6 +56,22 @@ in this run is written here. Use its absolute path in all `--prompt-file` argume
|
|
|
56
56
|
|
|
57
57
|
**Pick the council.** Default: **3 models from different families (non-Claude)**. Recommend them ranked by fit, consulting both the reviewer-reliability data from `amicus council stats` (the authoritative quantitative source — runs, avg peers-only street-cred, confirm-rate, fact-error rate) and the qualitative quirks in `MODEL-NOTES.md`. State the estimated cost. The estimate is the budget gate's pre-flight figure (per-$/Mtok pricing from the cached catalog; direct-provider legs without catalog pricing are disclosed as "cost unknown"). State it as an estimate, not a guarantee. **Disclose the run shape up front** before asking for confirmation — e.g.:
|
|
58
58
|
|
|
59
|
+
**Free council (zero-cost).** If the user asks for a "free council" / "zero-cost council",
|
|
60
|
+
read `councils.free` from `~/.config/amicus/config.json` and run
|
|
61
|
+
`amicus fanout --council free --prompt-file <briefing>`. Free-tier handling:
|
|
62
|
+
- Cost ≈ $0 — skip the paid-run cost framing (the budget gate is a no-op at zero price).
|
|
63
|
+
- No reliability history: free models have no `amicus council stats` / `MODEL-NOTES` record,
|
|
64
|
+
so don't rank on street-cred. Pick the most capable free model as chair and state lower confidence.
|
|
65
|
+
- Weak structured output: small free models are less reliable at the strict findings JSON; expect
|
|
66
|
+
more `validateFindings` repair-loop hits.
|
|
67
|
+
- Throttled/truncated legs: a mid-stream 429 can yield a leg marked `complete` with a truncated,
|
|
68
|
+
unparseable review. When a free-council leg is `complete` but `validateFindings` returns
|
|
69
|
+
`NO_FENCED_BLOCK`/`NOT_PARSEABLE`, treat it as suspect/throttled — don't burn the repair loop on the
|
|
70
|
+
same throttled model; disclose it and apply the ≥2-reviews-survive wave-degrade rule.
|
|
71
|
+
- Prerequisite: free models require enabling data-sharing in OpenRouter privacy settings
|
|
72
|
+
(openrouter.ai/settings/privacy) or legs 404 at run time — catalog validation cannot catch this.
|
|
73
|
+
State this up front.
|
|
74
|
+
|
|
59
75
|
> This run uses 3 council models across 2 fanout waves + 1 chair call (~7 model runs), ~10 min.
|
|
60
76
|
|
|
61
77
|
Then **wait for confirmation**. Never launch without it. The budget gate enforces the cost guardrail in code: by default it refuses any leg whose price exceeds the per-$/Mtok threshold (the o3/o3-pro guard). To run an intentionally expensive model the user explicitly asked for by name, pass `--no-cost-gate`; to raise only the total ceiling, pass `--max-cost <$>`.
|
package/src/cli-handlers-run.js
CHANGED
|
@@ -108,8 +108,30 @@ async function handleFanout(args) {
|
|
|
108
108
|
if (promptRes.error) {
|
|
109
109
|
process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error }));
|
|
110
110
|
}
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
// Council preset: expand a saved council into args.models (mutually exclusive with --models).
|
|
112
|
+
const hasModels = typeof args.models === 'string' && args.models.trim();
|
|
113
|
+
const hasCouncil = args.council !== undefined && args.council !== false;
|
|
114
|
+
if (hasModels && hasCouncil) {
|
|
115
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: pass exactly one of --models / --council, not both' }));
|
|
116
|
+
}
|
|
117
|
+
if (!hasModels && !hasCouncil) {
|
|
118
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models is required (comma-separated aliases or provider/model IDs), or use --council <name>' }));
|
|
119
|
+
}
|
|
120
|
+
if (hasCouncil) {
|
|
121
|
+
if (typeof args.council !== 'string' || !args.council.trim()) {
|
|
122
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --council requires a council name (e.g. --council free)' }));
|
|
123
|
+
}
|
|
124
|
+
const { resolveCouncilMembers } = require('./utils/config');
|
|
125
|
+
const { readCache } = require('./utils/model-catalog');
|
|
126
|
+
const catalog = (readCache() || {}).models || [];
|
|
127
|
+
const expanded = resolveCouncilMembers(args.council.trim(), catalog);
|
|
128
|
+
if (expanded.error) {
|
|
129
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: ${expanded.error}` }));
|
|
130
|
+
}
|
|
131
|
+
if (expanded.dropped && expanded.dropped.length && !useJson) {
|
|
132
|
+
process.stderr.write(`Notice: dropped unavailable council member(s): ${expanded.dropped.join(', ')}\n`);
|
|
133
|
+
}
|
|
134
|
+
args.models = expanded.models.join(',');
|
|
113
135
|
}
|
|
114
136
|
if (args['wave-id']) {
|
|
115
137
|
const check = validateTaskId(String(args['wave-id']));
|
package/src/cli.js
CHANGED
|
@@ -356,6 +356,7 @@ Options for 'start':
|
|
|
356
356
|
|
|
357
357
|
Options for 'fanout':
|
|
358
358
|
--models <a,b,c> Required. Comma-separated aliases or provider/model IDs
|
|
359
|
+
--council <name> Run a saved council instead of --models (e.g. free). Mutually exclusive with --models
|
|
359
360
|
--prompt <text> Task briefing (or use --prompt-file)
|
|
360
361
|
--prompt-file <path> Read the briefing from a UTF-8 file (avoids the
|
|
361
362
|
~32KB Windows argument cap). Mutually exclusive
|
package/src/mcp-server.js
CHANGED
|
@@ -531,9 +531,38 @@ const handlers = {
|
|
|
531
531
|
async amicus_fanout(input, project) {
|
|
532
532
|
const cwd = project || getProjectDir(input.project);
|
|
533
533
|
const { generateTaskId } = require('./sidecar/start');
|
|
534
|
-
const { deriveLegIds } = require('./sidecar/fanout');
|
|
534
|
+
const { deriveLegIds, DEFAULT_MAX_LEGS } = require('./sidecar/fanout');
|
|
535
|
+
|
|
536
|
+
// Resolve a single effective models list (council OR models), validated
|
|
537
|
+
// BEFORE any wave dir / metadata is written so a bad request never strands
|
|
538
|
+
// a pid-less 'running' orphan wave.
|
|
539
|
+
const inputModels = Array.isArray(input.models) ? input.models : [];
|
|
540
|
+
const hasModels = inputModels.length > 0;
|
|
541
|
+
const hasCouncil = typeof input.council === 'string' && input.council.trim();
|
|
542
|
+
if (hasModels && hasCouncil) {
|
|
543
|
+
return textResult("Pass exactly one of 'models' / 'council', not both.", true);
|
|
544
|
+
}
|
|
545
|
+
let effectiveModels;
|
|
546
|
+
if (hasCouncil) {
|
|
547
|
+
const { resolveCouncilMembers } = require('./utils/config');
|
|
548
|
+
const { readCache } = require('./utils/model-catalog');
|
|
549
|
+
const catalog = (readCache() || {}).models || [];
|
|
550
|
+
const expanded = resolveCouncilMembers(input.council.trim(), catalog);
|
|
551
|
+
if (expanded.error) { return textResult(expanded.error, true); }
|
|
552
|
+
effectiveModels = expanded.models;
|
|
553
|
+
} else if (hasModels) {
|
|
554
|
+
effectiveModels = inputModels;
|
|
555
|
+
} else {
|
|
556
|
+
return textResult("Provide 'models' or 'council'.", true);
|
|
557
|
+
}
|
|
558
|
+
const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
|
|
559
|
+
const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
|
|
560
|
+
if (effectiveModels.length > maxLegs) {
|
|
561
|
+
return textResult(`Council/model list exceeds the fan-out cap of ${maxLegs} legs.`, true);
|
|
562
|
+
}
|
|
563
|
+
|
|
535
564
|
const waveId = generateTaskId();
|
|
536
|
-
const legIds = deriveLegIds(waveId,
|
|
565
|
+
const legIds = deriveLegIds(waveId, effectiveModels.length);
|
|
537
566
|
const waveDir = getSessionDir(cwd, waveId);
|
|
538
567
|
|
|
539
568
|
let briefingPath;
|
|
@@ -545,14 +574,14 @@ const handlers = {
|
|
|
545
574
|
fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
|
|
546
575
|
fs.writeFileSync(path.join(waveDir, 'metadata.json'), JSON.stringify({
|
|
547
576
|
taskId: waveId, type: 'wave', status: 'running', legs: legIds,
|
|
548
|
-
models:
|
|
577
|
+
models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
|
|
549
578
|
}, null, 2), { mode: 0o600 });
|
|
550
579
|
} catch (err) {
|
|
551
580
|
return textResult(`Failed to prepare fan-out wave: ${err.message}`, true);
|
|
552
581
|
}
|
|
553
582
|
|
|
554
583
|
const args = [
|
|
555
|
-
'fanout', '--models',
|
|
584
|
+
'fanout', '--models', effectiveModels.join(','),
|
|
556
585
|
'--prompt-file', briefingPath, '--wave-id', waveId,
|
|
557
586
|
'--json', '--client', 'cowork', '--cwd', cwd,
|
|
558
587
|
];
|
package/src/mcp-tools.js
CHANGED
|
@@ -255,8 +255,11 @@ function getTools() {
|
|
|
255
255
|
'document (per-leg summaries inside). Each leg is also an ordinary ' +
|
|
256
256
|
'session readable by taskId.',
|
|
257
257
|
inputSchema: {
|
|
258
|
-
models: z.array(safeModel).min(1).max(10).describe(
|
|
259
|
-
`1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed.`
|
|
258
|
+
models: z.array(safeModel).min(1).max(10).optional().describe(
|
|
259
|
+
`1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed. Omit when using 'council'.`
|
|
260
|
+
),
|
|
261
|
+
council: z.string().optional().describe(
|
|
262
|
+
"Run a saved council by name (e.g. 'free') instead of 'models'. Expands to the council's members. Mutually exclusive with 'models'."
|
|
260
263
|
),
|
|
261
264
|
prompt: z.string().describe(
|
|
262
265
|
'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
|
package/src/sidecar/setup.js
CHANGED
|
@@ -40,19 +40,23 @@ function addAlias(name, modelString) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
43
|
+
* Ensure a config exists with the chosen default model. Read-modify-write:
|
|
44
|
+
* preserves every pre-existing top-level key (aliases, councils, …) and only
|
|
45
|
+
* fills in the default + any missing default aliases. Never clobbers.
|
|
44
46
|
* @param {string} defaultModel - Default model alias or full model string
|
|
45
|
-
* @returns {object} The
|
|
47
|
+
* @returns {object} The resulting config object
|
|
46
48
|
*/
|
|
47
49
|
function createDefaultConfig(defaultModel) {
|
|
50
|
+
const existing = loadConfig() || {};
|
|
48
51
|
const cfg = {
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
...existing,
|
|
53
|
+
default: existing.default || defaultModel,
|
|
54
|
+
aliases: { ...getDefaultAliases(), ...(existing.aliases || {}) },
|
|
51
55
|
};
|
|
52
56
|
saveConfig(cfg);
|
|
53
|
-
logger.info('Default config
|
|
54
|
-
default:
|
|
55
|
-
aliasCount: Object.keys(cfg.aliases).length
|
|
57
|
+
logger.info('Default config ensured', {
|
|
58
|
+
default: cfg.default,
|
|
59
|
+
aliasCount: Object.keys(cfg.aliases).length,
|
|
56
60
|
});
|
|
57
61
|
return cfg;
|
|
58
62
|
}
|
|
@@ -152,13 +156,70 @@ async function seedCatalog(print) {
|
|
|
152
156
|
log('Model catalog unavailable (offline?) — it will refresh on first start.');
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Free OpenRouter council branch of the readline wizard. Requires
|
|
161
|
+
* OPENROUTER_API_KEY; lists free catalog models, lets the user multi-pick
|
|
162
|
+
* (Enter = the vendor-diverse default), seeds aliases + councils.free, and
|
|
163
|
+
* never touches config.default.
|
|
164
|
+
* @param {readline.Interface} rl
|
|
165
|
+
*/
|
|
166
|
+
async function runFreeCouncilBranch(rl) {
|
|
167
|
+
const keys = detectApiKeys();
|
|
168
|
+
if (!keys.openrouter) {
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log('A free council needs OPENROUTER_API_KEY (free models route only through OpenRouter).');
|
|
171
|
+
console.log('Set OPENROUTER_API_KEY and re-run: amicus setup. No changes made.');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const { getCatalog } = require('../utils/model-catalog');
|
|
175
|
+
const { listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS } = require('../utils/free-models');
|
|
176
|
+
let catalog = [];
|
|
177
|
+
try { catalog = await getCatalog(); } catch (_e) { /* offline */ }
|
|
178
|
+
let free = listFreeModels(catalog);
|
|
179
|
+
if (free.length === 0) {
|
|
180
|
+
console.log('Live free-model list unavailable (offline?) — using a small pinned set.');
|
|
181
|
+
free = PINNED_FREE_MODELS.map(id => ({ id }));
|
|
182
|
+
}
|
|
183
|
+
const defaults = new Set(suggestFreeCouncil(free, 3).map(r => r.id));
|
|
184
|
+
console.log('');
|
|
185
|
+
console.log('Free OpenRouter models (★ = default council):');
|
|
186
|
+
free.forEach((r, i) => {
|
|
187
|
+
const star = defaults.has(r.id) ? '★' : ' ';
|
|
188
|
+
console.log(` ${star} ${i + 1}) ${r.id}`);
|
|
189
|
+
});
|
|
190
|
+
console.log('');
|
|
191
|
+
const answer = await askQuestion(rl,
|
|
192
|
+
'Pick members (comma-separated numbers, or Enter for the ★ default): ');
|
|
193
|
+
let pickIds;
|
|
194
|
+
if (!answer) {
|
|
195
|
+
pickIds = free.filter(r => defaults.has(r.id)).map(r => r.id);
|
|
196
|
+
} else {
|
|
197
|
+
pickIds = answer.split(',').map(s => parseInt(s.trim(), 10))
|
|
198
|
+
.filter(n => n >= 1 && n <= free.length).map(n => free[n - 1].id);
|
|
199
|
+
}
|
|
200
|
+
if (pickIds.length < 2) {
|
|
201
|
+
console.log('A council needs at least 2 models. No changes made.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const { council } = seedFreeCouncil(pickIds);
|
|
205
|
+
await seedCatalog();
|
|
206
|
+
console.log('');
|
|
207
|
+
console.log(`Free council saved: councils.free = [${council.join(', ')}]`);
|
|
208
|
+
console.log('Run it: amicus fanout --council free --prompt "..."');
|
|
209
|
+
console.log('config.default left unchanged.');
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log('Heads up (free tier): rate-limited & quality-variable; some models 404');
|
|
212
|
+
console.log('unless you enable data-sharing at openrouter.ai/settings/privacy.');
|
|
213
|
+
}
|
|
214
|
+
|
|
155
215
|
/**
|
|
156
216
|
* Run the readline-based setup wizard (headless fallback)
|
|
157
217
|
*
|
|
158
218
|
* Guides the user through:
|
|
159
219
|
* 1. API key detection
|
|
160
|
-
* 2.
|
|
161
|
-
* 3.
|
|
220
|
+
* 2. Mode selection (standard or free council)
|
|
221
|
+
* 3. Default model selection from live quick-picks (read-modify-write, no clobber)
|
|
222
|
+
* 4. Config file save
|
|
162
223
|
*/
|
|
163
224
|
async function runReadlineSetup() {
|
|
164
225
|
const rl = readline.createInterface({
|
|
@@ -185,6 +246,13 @@ async function runReadlineSetup() {
|
|
|
185
246
|
}
|
|
186
247
|
console.log('');
|
|
187
248
|
|
|
249
|
+
const mode = await askQuestion(rl,
|
|
250
|
+
'Setup mode — 1) Standard (pick a default model) 2) Free OpenRouter council: ');
|
|
251
|
+
if (mode === '2') {
|
|
252
|
+
await runFreeCouncilBranch(rl);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
188
256
|
const { getCatalog } = require('../utils/model-catalog');
|
|
189
257
|
const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
|
|
190
258
|
let catalog = [];
|
|
@@ -282,12 +350,64 @@ async function runInteractiveSetup() {
|
|
|
282
350
|
|
|
283
351
|
/* eslint-enable no-console */
|
|
284
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Collision-safe alias name from a free model id. Strips the openrouter/
|
|
355
|
+
* prefix and trailing :free, sanitizes '/'/':' to '-', prefixes 'free-',
|
|
356
|
+
* and disambiguates against `taken` with a numeric suffix.
|
|
357
|
+
* @param {string} id e.g. openrouter/deepseek/deepseek-r1:free
|
|
358
|
+
* @param {Set<string>} taken alias names already in use
|
|
359
|
+
* @returns {string} e.g. free-deepseek-deepseek-r1
|
|
360
|
+
*/
|
|
361
|
+
function deriveFreeAlias(id, taken) {
|
|
362
|
+
const base = 'free-' + id
|
|
363
|
+
.replace(/^openrouter\//, '')
|
|
364
|
+
.replace(/:free$/, '')
|
|
365
|
+
.replace(/[/:]/g, '-')
|
|
366
|
+
.replace(/-+/g, '-');
|
|
367
|
+
let name = base;
|
|
368
|
+
let n = 2;
|
|
369
|
+
while (taken.has(name)) { name = `${base}-${n++}`; }
|
|
370
|
+
taken.add(name);
|
|
371
|
+
return name;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Seed free-model aliases + councils.free from chosen catalog ids.
|
|
376
|
+
* Single atomic read-modify-write. Reuses an existing alias that already
|
|
377
|
+
* maps to the same id (idempotent re-runs); never touches config.default.
|
|
378
|
+
* @param {string[]} pickIds full openrouter/.../...:free ids
|
|
379
|
+
* @returns {{added: Array<{alias:string, model:string}>, council: string[]}}
|
|
380
|
+
*/
|
|
381
|
+
function seedFreeCouncil(pickIds) {
|
|
382
|
+
const cfg = loadConfig() || { aliases: {} };
|
|
383
|
+
if (!cfg.aliases) { cfg.aliases = {}; }
|
|
384
|
+
const taken = new Set(Object.keys(cfg.aliases));
|
|
385
|
+
const council = [];
|
|
386
|
+
const added = [];
|
|
387
|
+
for (const id of pickIds) {
|
|
388
|
+
const existing = Object.entries(cfg.aliases).find(([, m]) => m === id);
|
|
389
|
+
if (existing) { if (!council.includes(existing[0])) { council.push(existing[0]); } continue; }
|
|
390
|
+
const alias = deriveFreeAlias(id, taken);
|
|
391
|
+
cfg.aliases[alias] = id;
|
|
392
|
+
added.push({ alias, model: id });
|
|
393
|
+
council.push(alias);
|
|
394
|
+
}
|
|
395
|
+
if (!cfg.councils) { cfg.councils = {}; }
|
|
396
|
+
cfg.councils.free = Array.from(new Set(council));
|
|
397
|
+
saveConfig(cfg);
|
|
398
|
+
logger.info('Free council seeded', { count: cfg.councils.free.length });
|
|
399
|
+
return { added, council: cfg.councils.free };
|
|
400
|
+
}
|
|
401
|
+
|
|
285
402
|
module.exports = {
|
|
286
403
|
addAlias,
|
|
287
404
|
createDefaultConfig,
|
|
405
|
+
deriveFreeAlias,
|
|
288
406
|
detectApiKeys,
|
|
407
|
+
runFreeCouncilBranch,
|
|
289
408
|
runInteractiveSetup,
|
|
290
409
|
runReadlineSetup,
|
|
291
410
|
runApiKeySetup,
|
|
292
411
|
seedCatalog,
|
|
412
|
+
seedFreeCouncil,
|
|
293
413
|
};
|
package/src/utils/config.js
CHANGED
|
@@ -273,6 +273,57 @@ function detectFallback(alias, resolvedModel) {
|
|
|
273
273
|
return !!(val && val.startsWith('openrouter/') && !resolvedModel.startsWith('openrouter/'));
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/** @returns {Object<string,string[]>} the councils map (empty if none) */
|
|
277
|
+
function getCouncils() {
|
|
278
|
+
const config = loadConfig();
|
|
279
|
+
return (config && config.councils) || {};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @param {string} name @returns {string[]|null} council members, or null if absent */
|
|
283
|
+
function getCouncil(name) {
|
|
284
|
+
return getCouncils()[name] || null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Expand a saved council into a runnable members list, degrading gracefully.
|
|
289
|
+
* Each member is resolved to its full model id (alias → id via effective
|
|
290
|
+
* aliases; a member containing '/' is taken as-is) and that id checked against
|
|
291
|
+
* the cached catalog. Unresolvable aliases and delisted ids are dropped with a
|
|
292
|
+
* warning rather than fail-fast-aborting the whole wave. The catalog check is
|
|
293
|
+
* skipped when the catalog is empty (offline). Returns members RAW (alias or
|
|
294
|
+
* id) — leg-time validation resolves them again.
|
|
295
|
+
* @param {string} name
|
|
296
|
+
* @param {Array<{id:string}>} [catalog]
|
|
297
|
+
* @returns {{models:string[], dropped:string[]} | {error:string}}
|
|
298
|
+
*/
|
|
299
|
+
function resolveCouncilMembers(name, catalog = []) {
|
|
300
|
+
const members = getCouncil(name);
|
|
301
|
+
if (!members) {
|
|
302
|
+
return { error: `Unknown council '${name}'. Run 'amicus setup' to create one.` };
|
|
303
|
+
}
|
|
304
|
+
if (!Array.isArray(members) || members.length === 0) {
|
|
305
|
+
return { error: `Council '${name}' is empty. Run 'amicus setup' to populate it.` };
|
|
306
|
+
}
|
|
307
|
+
const aliases = getEffectiveAliases();
|
|
308
|
+
const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
|
|
309
|
+
const models = [];
|
|
310
|
+
const dropped = [];
|
|
311
|
+
for (const member of members) {
|
|
312
|
+
const id = member.includes('/') ? member : aliases[member];
|
|
313
|
+
if (!id) { dropped.push(member); continue; } // alias no longer resolves
|
|
314
|
+
if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
|
|
315
|
+
models.push(member);
|
|
316
|
+
}
|
|
317
|
+
if (models.length < 2) {
|
|
318
|
+
return {
|
|
319
|
+
error: `Council '${name}' has fewer than 2 usable members` +
|
|
320
|
+
(dropped.length ? ` (dropped: ${dropped.join(', ')})` : '') +
|
|
321
|
+
'. Run \'amicus setup\' to refresh it.',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return { models, dropped };
|
|
325
|
+
}
|
|
326
|
+
|
|
276
327
|
module.exports = {
|
|
277
328
|
getConfigDir,
|
|
278
329
|
getConfigPath,
|
|
@@ -288,4 +339,7 @@ module.exports = {
|
|
|
288
339
|
formatAliasNames,
|
|
289
340
|
tryResolveModel,
|
|
290
341
|
buildProviderModels,
|
|
342
|
+
getCouncils,
|
|
343
|
+
getCouncil,
|
|
344
|
+
resolveCouncilMembers,
|
|
291
345
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free OpenRouter model detection (Unit A).
|
|
3
|
+
*
|
|
4
|
+
* A free model is an openrouter/* catalog id whose slug ends in ':free'.
|
|
5
|
+
* The ':free' suffix is OpenRouter's authoritative free-tier marker. A
|
|
6
|
+
* zero prompt/completion price is deliberately NOT used: the catalog
|
|
7
|
+
* normalizer keeps only {prompt, completion} and discards request/image
|
|
8
|
+
* pricing, so a per-request-charged model with prompt:'0' would be
|
|
9
|
+
* mislabeled. Pure + network-free.
|
|
10
|
+
*/
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
/** Offline last-resort free ids (used only when the live catalog is empty). */
|
|
14
|
+
const PINNED_FREE_MODELS = [
|
|
15
|
+
'openrouter/deepseek/deepseek-r1:free',
|
|
16
|
+
'openrouter/google/gemini-2.0-flash-exp:free',
|
|
17
|
+
'openrouter/qwen/qwen3-coder:free',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** @param {{id?:string}} row @returns {boolean} */
|
|
21
|
+
function isFreeModel(row) {
|
|
22
|
+
const id = row && typeof row.id === 'string' ? row.id : '';
|
|
23
|
+
return id.startsWith('openrouter/') && id.endsWith(':free');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {Array} catalog @returns {Array} free rows, sorted by vendor then id */
|
|
27
|
+
function listFreeModels(catalog) {
|
|
28
|
+
const rows = (Array.isArray(catalog) ? catalog : []).filter(isFreeModel);
|
|
29
|
+
return rows.sort((a, b) => {
|
|
30
|
+
const va = a.id.split('/')[1] || '';
|
|
31
|
+
const vb = b.id.split('/')[1] || '';
|
|
32
|
+
return va === vb ? a.id.localeCompare(b.id) : va.localeCompare(vb);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {Array} catalog @param {number} n @returns {Array} ≤n free rows, one per vendor */
|
|
37
|
+
function suggestFreeCouncil(catalog, n = 3) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const seenVendors = new Set();
|
|
40
|
+
for (const row of listFreeModels(catalog)) {
|
|
41
|
+
const vendor = row.id.split('/')[1] || '';
|
|
42
|
+
if (seenVendors.has(vendor)) { continue; }
|
|
43
|
+
seenVendors.add(vendor);
|
|
44
|
+
out.push(row);
|
|
45
|
+
if (out.length >= n) { break; }
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { isFreeModel, listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS };
|