trantor 0.18.91 → 0.18.92

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.91",
3
+ "version": "0.18.92",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
@@ -10,21 +10,26 @@ function routeModel(ctx, provider, candidates, task, difficulty) {
10
10
  ? [script, "route", "--candidates", candidates, "-t", task, "-d", difficulty, "--json"]
11
11
  : [script, "route", "--provider", provider, "-t", task, "-d", difficulty, "--json"];
12
12
  const result = call("python3", args, { env: ctx.env });
13
- if (!result.ok || !result.stdout) return "";
14
- try { return JSON.parse(result.stdout).qualified || ""; }
15
- catch { return ""; }
13
+ try {
14
+ const decision = JSON.parse(result.stdout);
15
+ if (decision.error) throw new Error(`[crew] router refused ${provider}: ${decision.error}`);
16
+ return result.ok ? decision.qualified || "" : "";
17
+ } catch (error) {
18
+ if (error instanceof SyntaxError) return "";
19
+ throw error;
20
+ }
16
21
  }
17
22
 
18
23
  export function resolveModel(ctx, agent, provider, task, difficulty) {
19
24
  const result = call("opencode", ["models", provider], { env: ctx.env });
20
25
  const candidates = result.ok ? result.stdout.split(/\s+/).filter(Boolean).join(" ") : "";
21
26
  let model = routeModel(ctx, provider, candidates, task, difficulty);
22
- if (!model && candidates) {
23
- const first = candidates.split(" ")[0];
24
- model = first.startsWith(`${provider}/`) ? first : `${provider}/${first}`;
25
- console.error(`[crew] router unavailable for ${agent}:${provider} — using the provider's own catalog head (${model})`);
27
+ // An unavailable router must not erase seats from a layout preview. Never launch an unresolved model.
28
+ if (!model && ctx.dry && candidates) {
29
+ model = `${provider}/<unresolved>`;
30
+ console.error(`[crew] dry run only for ${agent}:${provider} — router unavailable; model unresolved, live launch would be refused`);
26
31
  }
27
- if (!model) throw new Error(`[crew] live model selection failed for ${agent}:${provider} — no router and no catalog; refusing opencode global default`);
32
+ if (!model) throw new Error(`[crew] live model selection failed for ${agent}:${provider} — no priced model matching provider preference; refusing opencode global default`);
28
33
  if (model.split("/")[0] !== provider) throw new Error(`[crew] router selected ${model} outside ${provider} — refusing cross-provider fallback`);
29
34
  return model;
30
35
  }
@@ -50,7 +55,7 @@ export function resolveSpec(ctx, spec, task, difficulty, skipped) {
50
55
  const model = resolveModel(ctx, agent, field, task, difficulty);
51
56
  // #7777: the live model is known — attach the catalog's per-difficulty effort parameters here
52
57
  // so the runner applies them where the CLI accepts them (CREW_EFFORT, below).
53
- console.log(` → ${agent}: live model ${model} (${field} · ${task}/${difficulty})`);
58
+ console.log(` → ${agent}: ${model.endsWith("/<unresolved>") ? "preview" : "live"} model ${model} (${field} · ${task}/${difficulty})`);
54
59
  return { agent, model, effort: resolveEffort(agent, model, difficulty) };
55
60
  } catch (error) {
56
61
  console.error(error.message);
package/bin/models.mjs CHANGED
@@ -1,10 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // trantor models — browse the live models behind your crew seats (opencode is the adapter).
3
- //
4
- // trantor models # every opencode-driven seat + how many live models it serves
5
- // trantor models <provider> # list that provider's live models + what the router picks per
6
- // # difficulty (so you can see hard→strong, easy→cheap at a glance)
7
- import { execSync } from "node:child_process";
2
+ // Browse live crew models and preview the same routing used at launch.
3
+ import { execSync, spawnSync } from "node:child_process";
8
4
  import { dirname, join } from "node:path";
9
5
  import { homedir } from "node:os";
10
6
  import { existsSync, statSync } from "node:fs";
@@ -25,16 +21,11 @@ const liveModels = (providerOc) => {
25
21
  catch { return []; }
26
22
  };
27
23
 
28
- const FLASH_TIER = /(flash|turbo|lite|mini|highspeed|small)/i;
29
-
30
24
  function routePick(candList, diff) {
31
- try {
32
- const hard = diff === "hard";
33
- const strong = hard ? candList.filter(id => !FLASH_TIER.test(id)) : candList;
34
- const pool = strong.length > 0 ? strong : candList;
35
- const out = execSync(`python3 ${SCROOGE} route --candidates ${JSON.stringify(pool.join(" "))} -t code -d ${diff} --json 2>/dev/null`, { encoding: "utf8" });
36
- return JSON.parse(out).qualified || "?";
37
- } catch { return "?"; }
25
+ const result = spawnSync("python3", [SCROOGE, "route", "--candidates", candList.join(" "),
26
+ "-t", "code", "-d", diff, "--json"], { encoding: "utf8" });
27
+ try { return JSON.parse(result.stdout); }
28
+ catch { return { error: "router unavailable", unpriced: [] }; }
38
29
  }
39
30
 
40
31
  function listAll() {
@@ -57,12 +48,14 @@ function detail(name) {
57
48
  const models = liveModels(providerOc);
58
49
  if (!models.length) { console.log(`No live models for '${providerOc}' (opencode offline / no key / unknown provider).`); return; }
59
50
  console.log(`${C.gold}${providerOc}${C.off} — ${models.length} live models\n`);
60
- for (const m of models.slice(0, 60)) console.log(` ${m}`);
51
+ const picks = Object.fromEntries(["easy", "medium", "hard"].map(d => [d, routePick(models, d)]));
52
+ const unpriced = new Set(Object.values(picks).flatMap(p => p.unpriced || []));
53
+ for (const m of models.slice(0, 60)) console.log(` ${m}${unpriced.has(m.split("/").at(-1)) ? " (unpriced)" : ""}`);
61
54
  if (models.length > 60) console.log(` ${C.dim}… +${models.length - 60} more${C.off}`);
62
55
  if (existsSync(SCROOGE) || has("scrooge")) {
63
56
  console.log(`\n${C.gold}router picks (code task):${C.off}`);
64
- for (const d of ["easy", "medium", "hard"]) console.log(` ${d.padEnd(7)} → ${routePick(models, d)}`);
65
- const caps = join(H, ".token-scrooge", "capabilities.json");
57
+ for (const d of ["easy", "medium", "hard"]) console.log(` ${d.padEnd(7)} → ${picks[d].qualified || picks[d].error}`);
58
+ const caps = join(process.env.SCROOGE_HOME || join(H, ".token-scrooge"), "capabilities.json");
66
59
  // only nag when the scores are missing or older than the weekly refresh allows for
67
60
  const ageDays = existsSync(caps) ? (Date.now() - statSync(caps).mtimeMs) / 86400e3 : Infinity;
68
61
  if (ageDays > 9) console.log(`${C.dim}(scores ${ageDays === Infinity ? "missing" : `${Math.floor(ageDays)}d old`} — run scrooge-capabilities to (re)score the catalog for accurate difficulty routing)${C.off}`);
@@ -37,7 +37,7 @@ Config: $SCROOGE_HOME (default ~/.token-scrooge) holds registry.json + calls.js
37
37
  Keys: read from the environment; optionally also from $SCROOGE_ENV_FILE (KEY=VALUE)
38
38
  Exit: 0 ok, non-zero on error (message to stderr).
39
39
  """
40
- import sys, os, json, time, argparse, urllib.request, urllib.error, re, shutil, hashlib
40
+ import sys, os, json, time, argparse, urllib.request, urllib.error, re, shutil, hashlib, math
41
41
 
42
42
  HOME = os.path.expanduser("~")
43
43
  SCROOGE_DIR = os.environ.get("SCROOGE_HOME", os.path.join(HOME, ".token-scrooge"))
@@ -171,18 +171,26 @@ def model_quality(caps, mid, metric):
171
171
  v = c.get("intelligence") # fall back to the general index
172
172
  return float(v) if isinstance(v, (int, float)) else 0.0
173
173
 
174
- def blended_cost(reg, caps, mid):
174
+ def model_prices(reg, caps, mid):
175
175
  # The curated registry carries cost for its own models; for CATALOG models (OpenRouter's
176
176
  # hundreds, brought via --candidates) the per-model price rides on the capability entry
177
177
  # instead — so a brought model is ranked on its real price without bloating the registry.
178
- m = reg["models"].get(mid) or (caps.get(mid) if isinstance(caps, dict) else None) or {}
179
- return max(1e-6, 0.3 * m.get("cost_in", 0) + 0.7 * m.get("cost_out", 0))
178
+ for m in (reg["models"].get(mid) or {}, (caps or {}).get(mid) or {}):
179
+ prices = [m.get("cost_in"), m.get("cost_out")]
180
+ if all(type(v) in (int, float) and math.isfinite(v) and v >= 0 for v in prices):
181
+ return prices
182
+ return None
183
+
184
+ def blended_cost(reg, caps, mid):
185
+ prices = model_prices(reg, caps, mid)
186
+ return max(1e-6, 0.3 * prices[0] + 0.7 * prices[1]) if prices is not None else None
180
187
 
181
188
  def weigh_candidates(reg, caps, cand_ids, task, difficulty):
182
189
  """Apply the difficulty floor, then rank survivors by quality^qw / cost^cw.
183
190
  Returns [(model_id, score)] best-first."""
184
191
  metric = task_metric(task)
185
- quals = [(mid, model_quality(caps, mid, metric)) for mid in cand_ids]
192
+ quals = [(mid, model_quality(caps, mid, metric)) for mid in cand_ids
193
+ if blended_cost(reg, caps, mid) is not None]
186
194
  scored_q = sorted(q for _, q in quals if q > 0)
187
195
  pct = DIFF_PCTL.get(difficulty, 0.5)
188
196
  floor = 0.0
@@ -218,6 +226,9 @@ def route_task(reg, caps, task, difficulty, prompt, spread=0):
218
226
  live = [m for m in cands if provider_key(reg, reg["models"][m]["provider"])]
219
227
  if not live:
220
228
  raise SystemExit("No live API key for any model serving task '%s'." % task)
229
+ live = [m for m in live if blended_cost(reg, caps, m) is not None]
230
+ if not live:
231
+ raise SystemExit("No priced candidate models; enrich pricing before routing.")
221
232
  if not caps: # no quality data → legacy cheapest-first
222
233
  return live[0], reg["models"][live[0]]["provider"], {"difficulty": None, "weighed": False}
223
234
  diff = difficulty or infer_difficulty(task, prompt)
@@ -659,7 +670,19 @@ def cmd_route(reg, args):
659
670
  by_bare = {}
660
671
  for r in raw:
661
672
  by_bare.setdefault(r.split("/")[-1], r)
662
- bare_ids = list(by_bare.keys())
673
+ unpriced = [b for b in by_bare if blended_cost(reg, caps, b) is None]
674
+ bare_ids = [b for b in by_bare if b not in unpriced]
675
+ if not provider:
676
+ providers = {r.split("/")[0] for r in raw if "/" in r}
677
+ if len(providers) == 1:
678
+ provider = next(iter(providers))
679
+ if provider == "qwen":
680
+ preferred = "qwen3.8-max" if args.difficulty == "hard" else "qwen3.8-flash"
681
+ bare_ids = [b for b in bare_ids if b == preferred]
682
+ if not bare_ids:
683
+ print(json.dumps({"error": "no priced candidate models matching provider preference",
684
+ "unpriced": unpriced}))
685
+ raise SystemExit(2)
663
686
  task = args.task or "code"
664
687
  difficulty = args.difficulty or "medium"
665
688
  metric = task_metric(task)
@@ -681,11 +704,13 @@ def cmd_route(reg, args):
681
704
  original = by_bare[pick_bare]
682
705
  qualified = original if "/" in original else (("%s/%s" % (provider, pick_bare)) if provider else pick_bare)
683
706
  result = {"provider": provider, "model": pick_bare, "qualified": qualified,
684
- "weighed": weighed, "task": task, "difficulty": difficulty, "why": why, "candidates": cand_out}
707
+ "weighed": weighed, "task": task, "difficulty": difficulty, "why": why, "candidates": cand_out, "unpriced": unpriced}
685
708
  if getattr(args, "json", False):
686
709
  print(json.dumps(result))
687
710
  else:
688
711
  err(DIM("[scrooge route] %s → %s (%s)" % (provider or "?", qualified, "weighed" if weighed else "heuristic")))
712
+ if unpriced:
713
+ err("[scrooge route] unpriced (excluded): " + ", ".join(unpriced))
689
714
  print(qualified)
690
715
  return 0
691
716
 
@@ -704,7 +729,9 @@ def cmd_list(reg, args):
704
729
  q = caps.get(m) or {}
705
730
  fmt = lambda v: ("%2.0f" % v) if isinstance(v, (int, float)) else " -"
706
731
  cap = "%s/%s/%s " % (fmt(q.get("intelligence")), fmt(q.get("coding")), fmt(q.get("reasoning")))
707
- print(" %s %-24s %5.2f/%-5.2f %s%-9s %s" % (live, m, c.get("cost_in",0), c.get("cost_out",0),
732
+ prices = model_prices(reg, caps, m)
733
+ price = "unpriced" if prices is None else "%5.2f/%-5.2f" % tuple(prices)
734
+ print(" %s %-24s %s %s%-9s %s" % (live, m, price,
708
735
  cap, c.get("trust",""), ",".join(c.get("good_for", []))))
709
736
  print("\nTASKS (weighed by capability×cost, gated by difficulty):")
710
737
  for t, ms in reg.get("tasks", {}).items():
@@ -36,8 +36,16 @@ def ok(name, cond):
36
36
  # 1. catalog cost via caps fallback (model absent from registry)
37
37
  ok("blended_cost falls back to the capability entry for catalog models",
38
38
  abs(g["blended_cost"](reg, caps, "frontier") - (0.3 * 5.0 + 0.7 * 22.5)) < 1e-6)
39
- ok("blended_cost is 1e-6 for an entirely unknown model (no crash)",
40
- g["blended_cost"](reg, caps, "does-not-exist") == 1e-6)
39
+ ok("unknown model is unpriced", g["blended_cost"](reg, caps, "does-not-exist") is None)
40
+ for bad in ({}, {"cost_in": 1}, {"cost_in": None, "cost_out": 1},
41
+ {"cost_in": -1, "cost_out": 1}, {"cost_in": float("nan"), "cost_out": 1}):
42
+ ok("invalid or incomplete prices are unpriced", g["blended_cost"](reg, {"bad": bad}, "bad") is None)
43
+ ok("explicit zero prices are free", g["blended_cost"](reg, {"free": {"cost_in": 0, "cost_out": 0}}, "free") == 1e-6)
44
+ for diff in ("easy", "medium", "hard"):
45
+ mixed = {"priced": {"coding": 50, "cost_in": 2, "cost_out": 6}, "unpriced": {"coding": 99}}
46
+ ranked = g["weigh_candidates"](reg, mixed, list(mixed), "code", diff)
47
+ ok("priced beats stronger unpriced at " + diff, [m for m, _ in ranked] == ["priced"])
48
+ ok("all unpriced yields no pick", g["weigh_candidates"](reg, {}, ["unknown"], "code", "easy") == [])
41
49
 
42
50
  def winner(diff):
43
51
  return g["weigh_candidates"](reg, caps, cands, "code", diff)[0][0]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.91",
3
+ "version": "0.18.92",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"