konsul-ai 0.2.4

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/dist/index.js ADDED
@@ -0,0 +1,616 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as readline from "node:readline";
5
+ import { Council, aggregateScores } from "./council.js";
6
+ import { Router } from "./router.js";
7
+ import { PRESETS, DEFAULT_PRESET, MODELS } from "./config.js";
8
+ const c = {
9
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
10
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
11
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
12
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
13
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
14
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
15
+ blue: (s) => `\x1b[34m${s}\x1b[0m`,
16
+ };
17
+ function fmtMs(ms) {
18
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
19
+ }
20
+ /** Strip ANSI escape sequences from untrusted model output before printing. */
21
+ function stripAnsi(s) {
22
+ return s
23
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "") // CSI sequences
24
+ .replace(/\x1b\][^\x07]*\x07/g, "") // OSC sequences (title, hyperlinks)
25
+ .replace(/\x1b[()][A-Z0-9]/g, "") // charset sequences
26
+ .replace(/\x1b[^[\]()][A-Za-z]?/g, ""); // remaining single-char escapes
27
+ }
28
+ function loadEnv() {
29
+ const candidates = [
30
+ path.join(process.cwd(), ".env"),
31
+ new URL("../.env", import.meta.url),
32
+ ];
33
+ for (const envPath of candidates) {
34
+ try {
35
+ const contents = readFileSync(envPath, "utf-8");
36
+ for (const line of contents.split("\n")) {
37
+ const match = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
38
+ if (match && !process.env[match[1]]) {
39
+ process.env[match[1]] = match[2];
40
+ }
41
+ }
42
+ return; // loaded successfully, stop trying
43
+ }
44
+ catch { /* try next candidate */ }
45
+ }
46
+ }
47
+ // ── Arg parsing ─────────────────────────────────────────────────
48
+ const BOOLEAN_FLAGS = new Set([
49
+ "verbose", "help", "models", "web", "serve",
50
+ "free", "budget", "balanced", "premium",
51
+ ]);
52
+ function parseArgs(argv) {
53
+ const args = argv.slice(2);
54
+ const flags = {};
55
+ const positional = [];
56
+ for (let i = 0; i < args.length; i++) {
57
+ if (args[i] === "--") {
58
+ positional.push(...args.slice(i + 1));
59
+ break;
60
+ }
61
+ if (args[i].startsWith("--")) {
62
+ const [key, ...rest] = args[i].slice(2).split("=");
63
+ if (rest.length) {
64
+ flags[key] = rest.join("=");
65
+ }
66
+ else if (BOOLEAN_FLAGS.has(key)) {
67
+ flags[key] = "true";
68
+ }
69
+ else if (i + 1 < args.length) {
70
+ flags[key] = args[++i];
71
+ }
72
+ else {
73
+ flags[key] = "true";
74
+ }
75
+ }
76
+ else {
77
+ positional.push(args[i]);
78
+ }
79
+ }
80
+ return { flags, positional };
81
+ }
82
+ function parsePort(value) {
83
+ if (value === undefined)
84
+ return 3000;
85
+ if (!/^\d+$/.test(value)) {
86
+ throw new Error(/^-/.test(value) || value === "true"
87
+ ? "Missing value for --port. Expected an integer between 0 and 65535."
88
+ : `Invalid --port value "${value}". Expected an integer between 0 and 65535.`);
89
+ }
90
+ const port = Number(value);
91
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
92
+ throw new Error("Invalid --port value. Expected an integer between 0 and 65535.");
93
+ }
94
+ return port;
95
+ }
96
+ async function main() {
97
+ loadEnv();
98
+ const { flags, positional } = parseArgs(process.argv);
99
+ if (flags.help !== undefined) {
100
+ printHelp();
101
+ process.exit(0);
102
+ }
103
+ if (flags.models !== undefined) {
104
+ printModels();
105
+ process.exit(0);
106
+ }
107
+ // API key
108
+ if (flags.key) {
109
+ console.error(c.yellow("Warning: --key exposes your API key in shell history and `ps`. Use OPENROUTER_API_KEY env var or .env file instead."));
110
+ }
111
+ const apiKey = flags.key ?? process.env.OPENROUTER_API_KEY;
112
+ if (!apiKey) {
113
+ console.error(c.red("Missing API key. Set OPENROUTER_API_KEY in your .env file or environment."));
114
+ process.exit(1);
115
+ }
116
+ // Web UI server mode
117
+ if (flags.serve !== undefined) {
118
+ let port;
119
+ try {
120
+ port = parsePort(flags.port);
121
+ }
122
+ catch (err) {
123
+ console.error(c.red(err instanceof Error ? err.message : String(err)));
124
+ process.exit(1);
125
+ }
126
+ const { startServer } = await import("./server.js");
127
+ startServer({ apiKey, port });
128
+ return;
129
+ }
130
+ const config = buildConfig(flags);
131
+ const verbose = flags.verbose !== undefined;
132
+ const outputPath = flags.output;
133
+ const presetName = flags.free !== undefined ? "free" :
134
+ flags.budget !== undefined ? "budget" :
135
+ flags.premium !== undefined ? "premium" :
136
+ flags.balanced !== undefined ? "balanced" :
137
+ (flags.members !== undefined || flags.chair !== undefined) ? "custom" :
138
+ DEFAULT_PRESET;
139
+ console.log(c.bold("\n⚖ Konsul\n"));
140
+ const webTag = config.web ? " · 🌐 web" : "";
141
+ console.log(c.dim(`${presetName} · ${config.members.map((m) => m.label).join(", ")} · chair: ${config.chair.label} · ${config.rounds} round${config.rounds > 1 ? "s" : ""}${webTag}`));
142
+ const router = new Router(apiKey);
143
+ // One-shot or interactive mode
144
+ const query = positional.join(" ").trim();
145
+ if (query) {
146
+ const council = createCouncil(router, config);
147
+ const ok = await runQuery(council, query, verbose, outputPath);
148
+ if (!ok)
149
+ process.exitCode = 1;
150
+ }
151
+ else {
152
+ await interactive(router, config, verbose, outputPath);
153
+ }
154
+ }
155
+ async function runQuery(council, query, verbose, outputPath) {
156
+ console.log(c.dim(`\n> ${query}\n`));
157
+ try {
158
+ const result = await council.run(query);
159
+ console.log("\n" + "═".repeat(60));
160
+ if (!council.streaming) {
161
+ console.log(c.bold(c.green("\nSynthesis\n")));
162
+ console.log(stripAnsi(result.synthesis));
163
+ console.log("\n" + "═".repeat(60));
164
+ }
165
+ const scores = aggregateScores(result.opinions, result.reviews);
166
+ console.log(c.dim("\nRankings: ") + scores.map((s, i) => `${i + 1}. ${s.label} (${s.avgRank.toFixed(1)})`).join(" "));
167
+ console.log(c.dim(`${fmtMs(result.totalLatencyMs)} · ~${result.tokenEstimate} tokens`));
168
+ if (verbose) {
169
+ console.log(c.bold("\n── Individual Opinions ──\n"));
170
+ for (const op of result.opinions) {
171
+ console.log(c.cyan(`▸ ${op.model.label}`) + c.dim(` (${fmtMs(op.latencyMs)})`));
172
+ console.log(stripAnsi(op.content));
173
+ console.log();
174
+ }
175
+ }
176
+ // Save to file
177
+ if (outputPath) {
178
+ const output = {
179
+ query: result.query,
180
+ synthesis: result.synthesis,
181
+ opinions: result.opinions.map((op) => ({
182
+ model: op.model.label,
183
+ modelId: op.model.id,
184
+ content: op.content,
185
+ latencyMs: op.latencyMs,
186
+ })),
187
+ reviews: result.reviews.map((rev) => ({
188
+ reviewer: rev.reviewer.label,
189
+ rankings: rev.rankings,
190
+ latencyMs: rev.latencyMs,
191
+ })),
192
+ scores: scores.map((s) => ({ model: s.label, modelId: s.modelId, avgRank: s.avgRank })),
193
+ totalLatencyMs: result.totalLatencyMs,
194
+ tokenEstimate: result.tokenEstimate,
195
+ };
196
+ writeFileSync(outputPath, JSON.stringify(output, null, 2) + "\n");
197
+ console.log(c.dim(`Saved to ${outputPath}`));
198
+ }
199
+ return true;
200
+ }
201
+ catch (err) {
202
+ console.error(c.red(`\nError: ${err instanceof Error ? err.message : String(err)}`));
203
+ return false;
204
+ }
205
+ }
206
+ function createCouncil(router, config) {
207
+ return new Council(router, config, (msg) => console.log(msg), (chunk) => process.stdout.write(stripAnsi(chunk)));
208
+ }
209
+ function printConfig(config, presetName) {
210
+ const webTag = config.web ? " · 🌐 web" : "";
211
+ console.log(c.dim(`${presetName} · ${config.members.map((m) => m.label).join(", ")} · chair: ${config.chair.label}${webTag}\n`));
212
+ }
213
+ async function interactive(router, initialConfig, verbose, outputPath) {
214
+ let config = initialConfig;
215
+ let council = createCouncil(router, config);
216
+ let currentVerbose = verbose;
217
+ const rl = readline.createInterface({
218
+ input: process.stdin,
219
+ output: process.stdout,
220
+ });
221
+ console.log(c.dim("\nCommands: /tier <free|budget|balanced|premium> /new-team /web /verbose /clear /quit|/exit\n"));
222
+ const prompt = () => {
223
+ rl.question(c.blue("→ "), async (input) => {
224
+ const q = input.trim();
225
+ if (q === "/quit" || q === "/exit") {
226
+ console.log(c.dim("Bye."));
227
+ rl.close();
228
+ return;
229
+ }
230
+ if (q === "/clear") {
231
+ council.clearHistory();
232
+ console.log(c.dim("History cleared.\n"));
233
+ prompt();
234
+ return;
235
+ }
236
+ if (q.startsWith("/tier")) {
237
+ const tier = q.split(/\s+/)[1];
238
+ if (tier && PRESETS[tier]) {
239
+ const savedHistory = council.getHistory();
240
+ config = { ...PRESETS[tier], web: config.web };
241
+ council = createCouncil(router, config);
242
+ council.setHistory(savedHistory);
243
+ printConfig(config, tier);
244
+ }
245
+ else {
246
+ console.log(c.dim(`Usage: /tier <${Object.keys(PRESETS).join("|")}>\n`));
247
+ }
248
+ prompt();
249
+ return;
250
+ }
251
+ if (q === "/new-team") {
252
+ const result = await selectTeam(router, rl);
253
+ if (result) {
254
+ const savedHistory = council.getHistory();
255
+ config = {
256
+ chair: result.chair,
257
+ members: result.members,
258
+ rounds: config.rounds,
259
+ temperature: config.temperature,
260
+ web: config.web,
261
+ };
262
+ council = createCouncil(router, config);
263
+ council.setHistory(savedHistory);
264
+ printConfig(config, "custom");
265
+ }
266
+ prompt();
267
+ return;
268
+ }
269
+ if (q === "/web") {
270
+ const enabled = council.toggleWeb();
271
+ console.log(c.dim(`Web search ${enabled ? "enabled 🌐" : "disabled"}\n`));
272
+ prompt();
273
+ return;
274
+ }
275
+ if (q === "/verbose") {
276
+ currentVerbose = !currentVerbose;
277
+ console.log(c.dim(`Verbose ${currentVerbose ? "on" : "off"}\n`));
278
+ prompt();
279
+ return;
280
+ }
281
+ if (!q) {
282
+ prompt();
283
+ return;
284
+ }
285
+ await runQuery(council, q, currentVerbose, outputPath);
286
+ console.log();
287
+ prompt();
288
+ });
289
+ };
290
+ prompt();
291
+ }
292
+ function buildConfig(flags) {
293
+ const presetName = flags.free !== undefined ? "free" :
294
+ flags.budget !== undefined ? "budget" :
295
+ flags.premium !== undefined ? "premium" :
296
+ flags.balanced !== undefined ? "balanced" :
297
+ DEFAULT_PRESET;
298
+ const preset = PRESETS[presetName];
299
+ const config = { ...preset };
300
+ if (flags.chair) {
301
+ config.chair = resolveModel(flags.chair, "chair");
302
+ }
303
+ if (flags.members) {
304
+ const names = flags.members.split(",").map((s) => s.trim());
305
+ config.members = names.map((n) => resolveModel(n, "member"));
306
+ }
307
+ if (flags.rounds) {
308
+ config.rounds = Math.max(1, parseInt(flags.rounds, 10) || 1);
309
+ }
310
+ if (flags.web !== undefined) {
311
+ config.web = true;
312
+ }
313
+ return config;
314
+ }
315
+ function resolveModel(name, role = "member") {
316
+ if (name.includes("/")) {
317
+ const parts = name.split("/");
318
+ const label = parts[parts.length - 1]
319
+ .replace(/-/g, " ")
320
+ .replace(/\b\w/g, (ch) => ch.toUpperCase());
321
+ return { id: name, label, role };
322
+ }
323
+ const lower = name.toLowerCase();
324
+ const entry = Object.entries(MODELS).find(([key]) => key.toLowerCase() === lower);
325
+ if (entry)
326
+ return entry[1];
327
+ const byLabel = Object.values(MODELS).find((m) => m.label.toLowerCase().includes(lower));
328
+ if (byLabel)
329
+ return byLabel;
330
+ const byId = Object.values(MODELS).find((m) => m.id.includes(lower));
331
+ if (byId)
332
+ return byId;
333
+ console.error(c.yellow(`Warning: "${name}" not in catalog, passing as-is to OpenRouter`));
334
+ return { id: name, label: name, role };
335
+ }
336
+ function printModels() {
337
+ console.log(c.bold("\n⚖ Konsul — Available Models\n"));
338
+ for (const [key, model] of Object.entries(MODELS)) {
339
+ const tag = model.role === "chair" ? c.yellow(" (strong)") : "";
340
+ console.log(` ${c.cyan(key.padEnd(16))} ${model.label.padEnd(20)} ${c.dim(model.id)}${tag}`);
341
+ }
342
+ console.log(c.dim("\nOr use any OpenRouter model ID directly: --chair=xiaomi/mimo-v2-pro\n"));
343
+ }
344
+ function printHelp() {
345
+ console.log(`
346
+ ${c.bold("⚖ Konsul")} — Multi-LLM debate and synthesis
347
+
348
+ ${c.bold("USAGE")}
349
+ konsul [options] "your question"
350
+ konsul ${c.dim("interactive mode")}
351
+
352
+ ${c.bold("PRESETS")}
353
+ --free 4 free models, Trinity Large chair ${c.dim("$0/run")}
354
+ --budget 4 cheap models, DeepSeek chair ${c.dim("~$0.002/run")}
355
+ --balanced cheap + mid mix, Gemini Pro chair ${c.dim("~$0.05/run (default)")}
356
+ --premium mid + strong, Opus chair ${c.dim("~$0.30+/run")}
357
+
358
+ ${c.bold("OPTIONS")}
359
+ --chair MODEL Override the chair (name or provider/model-id)
360
+ --members A,B,C Override members (names or provider/model-id)
361
+ --rounds N Number of review rounds (default: 1)
362
+ --output FILE Save full results as JSON
363
+ --web Enable web search for opinions & synthesis
364
+ --verbose Show all individual opinions
365
+ --models List available models
366
+ --serve Start web UI server on localhost (default port 3000)
367
+ --port N Set web UI port (use with --serve)
368
+ --help Show this help
369
+
370
+ ${c.bold("EXAMPLES")}
371
+ konsul --budget "What causes inflation?"
372
+ konsul --chair opus "Proofread this paragraph: ..."
373
+ konsul --members deepseekV3,sonnet,gpt4o --chair opus "Compare Rust vs Go"
374
+ konsul --chair xiaomi/mimo-v2-pro "Best neuroscience books?"
375
+ konsul --web "Latest findings on CRISPR gene therapy 2026"
376
+ konsul --premium --output result.json "Explain quantum entanglement"
377
+ `);
378
+ }
379
+ const TIER_ORDER = { free: 0, budget: 1, balanced: 2, premium: 3 };
380
+ function tierColor(tier) {
381
+ switch (tier) {
382
+ case "free": return c.green;
383
+ case "budget": return c.cyan;
384
+ case "balanced": return c.yellow;
385
+ case "premium": return c.red;
386
+ }
387
+ }
388
+ function estimateRunCost(m) {
389
+ // Per-call cost in $ assuming ~2000 input + ~1000 output tokens
390
+ return (m.promptPrice * 2000 + m.completionPrice * 1000) / 1_000_000;
391
+ }
392
+ function classifyTier(m) {
393
+ if (m.promptPrice === 0 && m.completionPrice === 0)
394
+ return "free";
395
+ const cost = estimateRunCost(m);
396
+ if (cost < 0.005)
397
+ return "budget";
398
+ if (cost <= 0.025)
399
+ return "balanced";
400
+ return "premium";
401
+ }
402
+ const EXCLUDE_PATTERNS = /\b(embed|moderat|tts|whisper|dall-e|stable-diffusion|image-gen|audio|speech)\b/i;
403
+ function filterForAcademic(models) {
404
+ return models.filter((m) => {
405
+ if (m.modality && !m.modality.includes("->text"))
406
+ return false;
407
+ if (m.contextLength < 16_384)
408
+ return false;
409
+ if (EXCLUDE_PATTERNS.test(m.name) || EXCLUDE_PATTERNS.test(m.id))
410
+ return false;
411
+ return true;
412
+ });
413
+ }
414
+ function sortByTierThenCost(models) {
415
+ return [...models].sort((a, b) => {
416
+ const ta = classifyTier(a);
417
+ const tb = classifyTier(b);
418
+ if (ta !== tb)
419
+ return TIER_ORDER[ta] - TIER_ORDER[tb];
420
+ return estimateRunCost(a) - estimateRunCost(b);
421
+ });
422
+ }
423
+ function clearScreen() {
424
+ process.stdout.write("\x1b[2J\x1b[H");
425
+ }
426
+ function computePageSize() {
427
+ const rows = process.stdout.rows || 24;
428
+ return Math.max(5, rows - 13);
429
+ }
430
+ function fmtContext(n) {
431
+ if (n >= 1_000_000)
432
+ return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
433
+ return `${Math.round(n / 1000)}K`;
434
+ }
435
+ function fmtPrice(p) {
436
+ if (p === 0)
437
+ return c.green("free".padStart(7));
438
+ return `$${p.toFixed(2)}`.padStart(7);
439
+ }
440
+ async function selectTeam(router, rl) {
441
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
442
+ console.log(c.dim("\n Fetching models from OpenRouter…"));
443
+ let allModels;
444
+ try {
445
+ allModels = await router.listModels();
446
+ }
447
+ catch (err) {
448
+ console.log(c.red(` ${err instanceof Error ? err.message : String(err)}\n`));
449
+ return null;
450
+ }
451
+ const academicModels = sortByTierThenCost(filterForAcademic(allModels));
452
+ if (academicModels.length === 0) {
453
+ console.log(c.red(" No suitable models available.\n"));
454
+ return null;
455
+ }
456
+ let filtered = academicModels;
457
+ let searchTerm = null;
458
+ let page = 0;
459
+ const picked = [];
460
+ const pickedIds = new Set();
461
+ const pageSize = () => computePageSize();
462
+ const pages = () => Math.ceil(filtered.length / pageSize());
463
+ const showPage = (hint) => {
464
+ clearScreen();
465
+ const ps = pageSize();
466
+ const start = page * ps;
467
+ const slice = filtered.slice(start, start + ps);
468
+ // Header
469
+ const pageInfo = `Page ${page + 1}/${pages()}`;
470
+ console.log(c.bold(`\n ⚖ Select Council Team`) + c.dim(` ${" ".repeat(Math.max(0, 40 - pageInfo.length))}${pageInfo}`));
471
+ if (searchTerm) {
472
+ console.log(c.dim(` Filtered: "${searchTerm}" (${filtered.length} matches) · /all to reset`));
473
+ }
474
+ console.log();
475
+ // Table with tier section headers
476
+ let lastTier = null;
477
+ for (let i = 0; i < slice.length; i++) {
478
+ const m = slice[i];
479
+ const globalIdx = start + i + 1;
480
+ const tier = classifyTier(m);
481
+ if (tier !== lastTier) {
482
+ if (lastTier !== null)
483
+ console.log();
484
+ console.log(` ${tierColor(tier)(tier.toUpperCase())}`);
485
+ lastTier = tier;
486
+ }
487
+ const pickIdx = picked.findIndex((p) => p.id === m.id);
488
+ const marker = pickIdx === 0 ? c.yellow("★") : pickIdx > 0 ? c.dim(`${pickIdx + 1}`) : " ";
489
+ const num = String(globalIdx).padStart(4);
490
+ const name = m.name.length > 34 ? m.name.slice(0, 33) + "…" : m.name.padEnd(34);
491
+ console.log(` ${marker}${num} ${name} ${fmtPrice(m.promptPrice)} ${fmtPrice(m.completionPrice)} ${fmtContext(m.contextLength).padStart(5)}`);
492
+ }
493
+ // Selection status
494
+ if (picked.length > 0) {
495
+ console.log();
496
+ const parts = picked.map((m, i) => {
497
+ const tag = i === 0 ? c.yellow("chair") : "member";
498
+ return `${c.cyan(m.name)} ${c.dim(`(${tag})`)}`;
499
+ });
500
+ console.log(` ${c.bold("Selected:")} ${parts.join(c.dim(", "))}`);
501
+ }
502
+ // Footer
503
+ console.log(c.dim(`\n Enter: next · /prev · /search <term> · /all · /undo · /done · /quit`));
504
+ if (hint)
505
+ console.log(c.dim(` ${hint}`));
506
+ };
507
+ showPage();
508
+ while (true) {
509
+ const promptText = picked.length === 0
510
+ ? " Add # (1st = chair, rest = members): "
511
+ : picked.length === 1
512
+ ? " Add members (or /done): "
513
+ : " Add more (or /done): ";
514
+ const input = (await ask(c.blue(promptText))).trim();
515
+ // Enter = next page
516
+ if (!input) {
517
+ page = page < pages() - 1 ? page + 1 : 0;
518
+ showPage();
519
+ continue;
520
+ }
521
+ // Slash commands
522
+ if (input === "/quit") {
523
+ clearScreen();
524
+ return null;
525
+ }
526
+ if (input === "/prev") {
527
+ if (page > 0)
528
+ page--;
529
+ showPage();
530
+ continue;
531
+ }
532
+ if (input === "/all") {
533
+ filtered = academicModels;
534
+ searchTerm = null;
535
+ page = 0;
536
+ showPage();
537
+ continue;
538
+ }
539
+ if (input === "/undo") {
540
+ const removed = picked.pop();
541
+ if (removed)
542
+ pickedIds.delete(removed.id);
543
+ showPage();
544
+ continue;
545
+ }
546
+ if (input === "/done") {
547
+ if (picked.length < 2) {
548
+ showPage("Need at least 2 models (1 chair + 1 member).");
549
+ continue;
550
+ }
551
+ // Confirmation screen
552
+ clearScreen();
553
+ console.log(c.bold("\n ⚖ Confirm Team\n"));
554
+ const chairTier = classifyTier(picked[0]);
555
+ console.log(c.bold(" Chair: ") + c.cyan(picked[0].name) + c.dim(` · ${tierColor(chairTier)(chairTier)} · ${fmtPrice(picked[0].promptPrice).trim()}/${fmtPrice(picked[0].completionPrice).trim()}`));
556
+ console.log(c.bold(" Members:"));
557
+ for (let i = 1; i < picked.length; i++) {
558
+ const m = picked[i];
559
+ const t = classifyTier(m);
560
+ console.log(` • ${c.cyan(m.name)}` + c.dim(` · ${tierColor(t)(t)} · ${fmtPrice(m.promptPrice).trim()}/${fmtPrice(m.completionPrice).trim()}`));
561
+ }
562
+ const confirm = (await ask(c.blue("\n Confirm? [Y/n] "))).trim().toLowerCase();
563
+ if (confirm && confirm !== "y" && confirm !== "yes") {
564
+ showPage();
565
+ continue;
566
+ }
567
+ clearScreen();
568
+ return {
569
+ chair: { id: picked[0].id, label: picked[0].name, role: "chair" },
570
+ members: picked.slice(1).map((m) => ({ id: m.id, label: m.name, role: "member" })),
571
+ };
572
+ }
573
+ if (input.startsWith("/search")) {
574
+ const term = input.slice(7).trim().toLowerCase();
575
+ if (!term) {
576
+ showPage();
577
+ continue;
578
+ }
579
+ const matches = academicModels.filter((m) => m.name.toLowerCase().includes(term) || m.id.toLowerCase().includes(term));
580
+ if (matches.length === 0) {
581
+ showPage(`No models match "${term}".`);
582
+ continue;
583
+ }
584
+ filtered = matches;
585
+ searchTerm = term;
586
+ page = 0;
587
+ showPage();
588
+ continue;
589
+ }
590
+ if (input.startsWith("/")) {
591
+ showPage("Commands: /prev /search <term> /all /undo /done /quit");
592
+ continue;
593
+ }
594
+ // Number input — accumulate picks
595
+ const nums = input.split(/[\s,]+/).map(Number).filter((n) => n > 0 && n <= filtered.length);
596
+ if (nums.length === 0) {
597
+ showPage("Enter model numbers to add. e.g. 80 or 3, 7, 12");
598
+ continue;
599
+ }
600
+ let added = 0;
601
+ for (const n of nums) {
602
+ const m = filtered[n - 1];
603
+ if (!pickedIds.has(m.id)) {
604
+ picked.push(m);
605
+ pickedIds.add(m.id);
606
+ added++;
607
+ }
608
+ }
609
+ showPage(added === 0 ? "Already selected." : undefined);
610
+ continue;
611
+ }
612
+ }
613
+ main().catch((err) => {
614
+ console.error(c.red(err.message));
615
+ process.exit(1);
616
+ });
@@ -0,0 +1,61 @@
1
+ import type { RouterMessage } from "./types.js";
2
+ export interface RemoteModel {
3
+ id: string;
4
+ name: string;
5
+ promptPrice: number;
6
+ completionPrice: number;
7
+ contextLength: number;
8
+ modality: string | null;
9
+ }
10
+ export declare class Router {
11
+ private apiKey;
12
+ private timeoutMs;
13
+ constructor(apiKey: string, timeoutMs?: number);
14
+ private buildHeaders;
15
+ private handleFetchError;
16
+ private buildSignal;
17
+ private checkResponse;
18
+ complete(model: string, messages: RouterMessage[], opts?: {
19
+ temperature?: number;
20
+ maxTokens?: number;
21
+ plugins?: {
22
+ id: string;
23
+ }[];
24
+ signal?: AbortSignal;
25
+ }): Promise<{
26
+ content: string;
27
+ usage: {
28
+ prompt: number;
29
+ completion: number;
30
+ };
31
+ }>;
32
+ /** Streaming completion — calls onChunk for each token as it arrives. */
33
+ completeStream(model: string, messages: RouterMessage[], opts: {
34
+ temperature?: number;
35
+ maxTokens?: number;
36
+ plugins?: {
37
+ id: string;
38
+ }[];
39
+ signal?: AbortSignal;
40
+ }, onChunk: (text: string) => void): Promise<{
41
+ content: string;
42
+ usage: {
43
+ prompt: number;
44
+ completion: number;
45
+ };
46
+ }>;
47
+ /** Fetch available models from OpenRouter. */
48
+ listModels(): Promise<RemoteModel[]>;
49
+ /** Fire completions in parallel, settle all (don't fail-fast). */
50
+ parallel<T>(tasks: (() => Promise<T>)[], staggerMs?: number): Promise<PromiseSettledResult<T>[]>;
51
+ /**
52
+ * Like parallel(), but proceeds early once minResults have fulfilled,
53
+ * giving remaining tasks a short grace period before skipping them.
54
+ */
55
+ parallelRace<T>(tasks: ((signal?: AbortSignal) => Promise<T>)[], opts?: {
56
+ staggerMs?: number;
57
+ minResults?: number;
58
+ graceMs?: number;
59
+ signal?: AbortSignal;
60
+ }): Promise<PromiseSettledResult<T>[]>;
61
+ }