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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/crew/models.mjs +14 -9
- package/bin/models.mjs +11 -18
- package/engine/bin/scrooge +35 -8
- package/engine/test-routing.py +10 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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": {
|
package/bin/crew/models.mjs
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
model =
|
|
25
|
-
console.error(`[crew]
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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)} → ${
|
|
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}`);
|
package/engine/bin/scrooge
CHANGED
|
@@ -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
|
|
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
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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():
|
package/engine/test-routing.py
CHANGED
|
@@ -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("
|
|
40
|
-
|
|
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]
|