omp-llm-role 1.2.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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The per-role usage ledger — an append-only jsonl file beside the plugin's
3
+ * state, one row per recorded assistant turn, and the token profile derived
4
+ * from it.
5
+ *
6
+ * - `llm-role-usage.jsonl` — one row per turn (role, model, provider, token
7
+ * counts, cost). Bounded to `USAGE_LEDGER_CAP` rows: the normal path is a
8
+ * single append; an over-cap file is compacted to its newest rows via a temp
9
+ * file + atomic rename, so a crash mid-compaction leaves the original intact.
10
+ * - Recording is best-effort: a failure never breaks a session.
11
+ *
12
+ * The per-task price resolves its token profile from the recorded usage first
13
+ * (the role's median per-turn counts), then the user-set `tokenProfile` setting.
14
+ */
15
+
16
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { quantile } from "./agent-create.ts";
19
+ import { isRecord } from "./guards.ts";
20
+
21
+ export type UsageRow = {
22
+ ts: string;
23
+ role: string;
24
+ model: string;
25
+ provider: string;
26
+ input: number;
27
+ output: number;
28
+ cacheRead: number;
29
+ cacheWrite: number;
30
+ cost: number;
31
+ };
32
+
33
+ export type TokenProfile = { inputTokens: number; outputTokens: number };
34
+
35
+ export const USAGE_LEDGER_FILE = "llm-role-usage.jsonl";
36
+ export const USAGE_LEDGER_CAP = 2000;
37
+
38
+ const STRING_FIELDS = ["ts", "role", "model", "provider"] as const;
39
+ const NUMBER_FIELDS = ["input", "output", "cacheRead", "cacheWrite", "cost"] as const;
40
+
41
+ /** A parsed line is a row only when every field is present with the right type;
42
+ * anything else is a malformed line the reader skips. */
43
+ function toUsageRow(v: unknown): UsageRow | null {
44
+ if (!isRecord(v)) return null;
45
+ for (const k of STRING_FIELDS) if (typeof v[k] !== "string") return null;
46
+ for (const k of NUMBER_FIELDS) if (typeof v[k] !== "number" || !Number.isFinite(v[k])) return null;
47
+ return {
48
+ ts: v.ts as string,
49
+ role: v.role as string,
50
+ model: v.model as string,
51
+ provider: v.provider as string,
52
+ input: v.input as number,
53
+ output: v.output as number,
54
+ cacheRead: v.cacheRead as number,
55
+ cacheWrite: v.cacheWrite as number,
56
+ cost: v.cost as number,
57
+ };
58
+ }
59
+
60
+ /** Keep the newest `USAGE_LEDGER_CAP` rows: temp file + atomic rename, so a
61
+ * crash mid-compaction leaves the original file intact. */
62
+ function compactIfOverCap(dir: string, path: string): void {
63
+ const lines = readFileSync(path, "utf8")
64
+ .split("\n")
65
+ .filter((l) => l.length > 0);
66
+ if (lines.length <= USAGE_LEDGER_CAP) return;
67
+ const tmp = join(dir, `${USAGE_LEDGER_FILE}.tmp`);
68
+ writeFileSync(tmp, `${lines.slice(-USAGE_LEDGER_CAP).join("\n")}\n`);
69
+ renameSync(tmp, path);
70
+ }
71
+
72
+ /** Append one row; best-effort — a recording failure never throws. */
73
+ export function appendUsageRow(dir: string, row: UsageRow): void {
74
+ try {
75
+ mkdirSync(dir, { recursive: true });
76
+ const path = join(dir, USAGE_LEDGER_FILE);
77
+ appendFileSync(path, `${JSON.stringify(row)}\n`);
78
+ compactIfOverCap(dir, path);
79
+ } catch {
80
+ // best-effort: never break a session over a ledger write
81
+ }
82
+ }
83
+
84
+ /** Tolerant read: a missing, unreadable, or malformed file degrades to []; an
85
+ * unparseable or wrongly-shaped line is skipped. */
86
+ export function readUsageRows(dir: string): UsageRow[] {
87
+ let text: string;
88
+ try {
89
+ text = readFileSync(join(dir, USAGE_LEDGER_FILE), "utf8");
90
+ } catch {
91
+ return [];
92
+ }
93
+ const rows: UsageRow[] = [];
94
+ for (const line of text.split("\n")) {
95
+ if (line.length === 0) continue;
96
+ let parsed: unknown;
97
+ try {
98
+ parsed = JSON.parse(line);
99
+ } catch {
100
+ continue;
101
+ }
102
+ const row = toUsageRow(parsed);
103
+ if (row) rows.push(row);
104
+ }
105
+ return rows;
106
+ }
107
+
108
+ /** The role's median per-turn input/output counts, or null when it has no rows.
109
+ * The median is `agent-create.ts`'s `quantile(values, 0.5)` — the repo's one
110
+ * quantile convention (linear interpolation), not a second implementation. */
111
+ export function roleTokenProfile(rows: readonly UsageRow[], role: string): TokenProfile | null {
112
+ const mine = rows.filter((r) => r.role === role);
113
+ if (mine.length === 0) return null;
114
+ return {
115
+ inputTokens: quantile(mine.map((r) => r.input), 0.5),
116
+ outputTokens: quantile(mine.map((r) => r.output), 0.5),
117
+ };
118
+ }
119
+
120
+ /** Precedence: the recorded usage, then the user-set setting, then null. */
121
+ export function resolveTokenProfile(
122
+ role: string,
123
+ rows: readonly UsageRow[],
124
+ setting: TokenProfile | null,
125
+ ): TokenProfile | null {
126
+ return roleTokenProfile(rows, role) ?? setting;
127
+ }
package/web/app.js CHANGED
@@ -23,6 +23,11 @@ const state = {
23
23
  lockPath: "",
24
24
  scopes: [],
25
25
  activeScope: "",
26
+ // The ranked pool the server is serving (raw | as-written) and the user-set
27
+ // token profile the per-task price falls back to (null = unset). Both come
28
+ // from the bootstrap payload, so a refresh keeps them.
29
+ poolMode: "raw",
30
+ tokenProfile: null,
26
31
  features: {},
27
32
  featureRegistry: [],
28
33
  filter: "",
@@ -82,6 +87,26 @@ function fmtCtx(value) {
82
87
  return value >= 1000 ? Math.round(value / 1000) + "K" : String(value);
83
88
  }
84
89
 
90
+ /** Dollars per task, at a precision that keeps the small values readable: a
91
+ * per-task price spans roughly $0.0001 (a cheap model, a short task) to $1+. */
92
+ function fmtUsd(value) {
93
+ if (value === null || value === undefined) return "—";
94
+ const abs = Math.abs(value);
95
+ if (abs >= 1) return "$" + value.toFixed(2);
96
+ if (abs >= 0.01) return "$" + value.toFixed(3);
97
+ if (abs > 0) return "$" + value.toFixed(4);
98
+ return "$0";
99
+ }
100
+
101
+ /** Token counts, compact: 20000 → "20K", 1500 → "1.5K", 800 → "800". */
102
+ function fmtTokens(value) {
103
+ if (value >= 1000) {
104
+ const k = value / 1000;
105
+ return (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + "K";
106
+ }
107
+ return String(value);
108
+ }
109
+
85
110
  function deltaText(delta) {
86
111
  if (delta === null || delta === undefined || delta === 0) return "·";
87
112
  return delta > 0 ? "+" + delta : "−" + Math.abs(delta);
@@ -121,6 +146,7 @@ const TIPS = {
121
146
  hideBlocked: "Drop rows your OpenRouter key cannot run (key = blocked). Applied after the text filter and the top-n slice.",
122
147
  lock: "Plugin settings lock file that Export writes to (the active scope's lock file).",
123
148
  scope: "Role-config source: the user-level scope, or a project where /project-roles was used. Switching re-reads that scope's roles and universe from disk and points Export at its lock file. A project whose lock file is gone is unavailable.",
149
+ pool: "Ranked pool. raw = the full cached dataset (account-agnostic). as-written = the same catalog/tier gate and allowed-providers whitelist prune the updater applies before writing, so the leader you see is the model you will get. Best-effort: a failed whitelist fetch degrades to the raw pool. Export is unaffected — it always writes the authored role.",
124
150
  refresh: "Refetch llm-stats and OpenRouter data (hits the network).",
125
151
  rank: "Rank among this role's eligible models, by value, descending.",
126
152
  delta: "Rank change against the role's saved (effective) definition: + means this definition ranks the model higher than the plugin does today.",
@@ -128,6 +154,7 @@ const TIPS = {
128
154
  value: "Sort key: value = q − λ·$/M on the thinking-adjusted effective price. λ is the price of one quality point, in $/M.",
129
155
  q: "Quality q = Σ (wᵢ/(1−w_price))·tᵢ over the weighted quality metrics. Price is not blended in — it is the penalty axis.",
130
156
  price: "Effective price in $/M: OpenRouter standard-route blend (3:1 in:out) scaled by the role's thinking factor when the model supports thinking; unadjusted for bare/off roles.",
157
+ perTask: "Dollars per task: your own token mix (the role's recorded usage, else the tokenProfile setting) × the role's priced route, thinking-adjusted. Display-only — the ranking still sorts on $/M. — when no token profile is available (no recorded usage and no tokenProfile set).",
131
158
  throughput: "Output throughput in tok/s (OpenRouter p50 when matched, else llm-stats).",
132
159
  context: "Context window in tokens.",
133
160
  explainSub: "value = q − λ·$/M (effective price), where q is the weighted quality sum and λ the price of one quality point.",
@@ -141,6 +168,9 @@ const TIPS = {
141
168
  whyNotHigher: "The value gap to the model ranked above, and the per-metric raw target that would close it (inverse of the cardinal transform).",
142
169
  dominated: "Models that are both cheaper and at least as good on q.",
143
170
  lambda: "λ = price of one quality point, in $/M. Derived as " + LAMBDA_DERIVATION + " unless the role overrides lambda.",
171
+ leaderFlip: "The price weight w* at which a cheaper challenger overtakes the value leader: λ* = min over cheaper challengers X of (q_L − q_X)/(pEff_L − pEff_X), then w* = 20λ*/(1+20λ*). Absent when no cheaper challenger exists. Exact for the 1/price² blend or a hard provider pin; a first-order estimate under the soft best-route basis (the route choice itself moves with the price weight).",
172
+ levers: "Two price-lever readouts, computed on the pool you are viewing: the leader-flip threshold (the price weight at which a cheaper challenger overtakes the leader) and the thinking level's effect on the leader. Each is absent when the server sends null — no cheaper challenger, or a bare role.",
173
+ thinkingLevels: "The leader at each thinking level, with the level's price factor (3ρ+1+T)/(3ρ+1). The factor multiplies the price axis, so it penalizes expensive models more in absolute λ-units — that is why auto favours the cheap model. Absent for a bare role (no thinking level).",
144
174
  sum: "Weights must sum to 1.0 (±0.01) or the plugin rejects the role.",
145
175
  required: "Eligibility gate, not a weight: a model missing this metric is not ranked at all for the role.",
146
176
  focus: "Nine-axis audit of each weighted benchmark/percentile metric, over the same pool the updater ranks on. A below-bar axis is warned; unknown means the metric's scores were not loaded (never a silent ok).",
@@ -155,6 +185,7 @@ const TIPS = {
155
185
  focusCrossSource: "Cross-source: the zeroeval per-entry price/context compared against the plugin's own price/context for the models carrying the metric (the median relative divergence, within a 0.5 tolerance). The speed figure is informational only (rps vs tok/s). An annotation, never a gate — it never feeds the 1/price² blend; unknown means no model joined or the payload carried none of the four fields.",
156
186
  imageFilter: "Require image input (filters.image) — the gate that shrinks the vision role's eligible set.",
157
187
  providerPin: "Hard provider pin: the OpenRouter provider slug this role's requests are routed to (a tiered slug like deepinfra/fp8 is accepted verbatim). Setting it opts the role out of the automatic best-route pin; empty = default OpenRouter routing.",
188
+ autoPin: "Automatic provider pin (candidate): this model's best route by the role's value-for-money score — the first route the updater's daily run tries. The updater probes that route with a one-token request bound to it (provider: { only: [slug] }) and skips to the next route when OpenRouter does not serve it, dropping the pin entirely (bare selector, OpenRouter's own routing) when none is routable — so the pin that gets written can be a later route, or absent.",
158
189
  features: "Opt-in capability presets (issue #40). Each flag applies the plugin's recommended settings for that capability to this role; an explicit knob value always wins. Export writes the flag, not the expanded values, so a future change to the recommendation keeps reaching the role.",
159
190
  featureState: "inherit = use the global flag (features.<id> in the lock file); on/off = a per-role override (roles.<role>.features.<id>) that beats the global in either direction.",
160
191
  featureKnob: "An individual knob the capability's recommended bundle fills. Leave it at inherit to take the recommendation; set a value to override it (an explicit value always wins over the flag).",
@@ -267,6 +298,20 @@ function scopeSelect() {
267
298
  return sel;
268
299
  }
269
300
 
301
+ /** The pool selector: the raw dataset (account-agnostic) or the as-written pool
302
+ * (the catalog/tier gate + allowed-providers prune the updater ranks on). The
303
+ * value is the server's mode, so the header always names the active pool. */
304
+ function poolSelect() {
305
+ const sel = el("select", { id: "pool", "data-tip": TIPS.pool });
306
+ sel.append(
307
+ el("option", { value: "raw", text: "raw pool" }),
308
+ el("option", { value: "as-written", text: "as-written pool" }),
309
+ );
310
+ sel.value = state.poolMode;
311
+ sel.addEventListener("change", onPoolChange);
312
+ return sel;
313
+ }
314
+
270
315
  function renderMeta(data) {
271
316
  const meta = document.getElementById("meta");
272
317
  meta.textContent = "";
@@ -275,6 +320,8 @@ function renderMeta(data) {
275
320
  el("span", { class: "sep", text: "·" }),
276
321
  scopeSelect(),
277
322
  el("span", { class: "sep", text: "·" }),
323
+ poolSelect(),
324
+ el("span", { class: "sep", text: "·" }),
278
325
  el("span", { "data-tip": TIPS.models, text: data.modelCount + " models" }),
279
326
  el("span", { class: "sep", text: "·" }),
280
327
  el("span", { "data-tip": TIPS.fetched, text: "fetched " + data.fetchedAt.slice(0, 10) }),
@@ -426,6 +473,7 @@ function renderTable() {
426
473
  el("th", { class: "num", "data-tip": TIPS.value, text: "value" }),
427
474
  el("th", { class: "num", "data-tip": TIPS.q, text: "q" }),
428
475
  el("th", { class: "num", "data-tip": TIPS.price, text: "$/M" }),
476
+ el("th", { class: "num", "data-tip": TIPS.perTask, text: "$/task" }),
429
477
  el("th", { class: "num", "data-tip": TIPS.throughput, text: "tok/s" }),
430
478
  el("th", { class: "num", "data-tip": TIPS.context, text: "ctx" }),
431
479
  ])),
@@ -450,6 +498,7 @@ function renderTable() {
450
498
  el("td", { class: "num", text: fmt(r.value, 3) }),
451
499
  el("td", { class: "num", text: fmt(r.q, 3) }),
452
500
  el("td", { class: "num", text: fmt(r.priceEff, 2) }),
501
+ el("td", { class: "num", text: fmtUsd(r.perTask) }),
453
502
  el("td", { class: "num", text: r.throughput === null ? "—" : fmt(r.throughput, 0) }),
454
503
  el("td", { class: "num", text: r.context === null ? "—" : fmtCtx(r.context) }),
455
504
  ]),
@@ -514,7 +563,12 @@ function renderExplain() {
514
563
  if (manualPin) {
515
564
  panel.append(el("p", { class: "sub pin-line", "data-tip": TIPS.providerPin, text: "pin: " + manualPin + " (manual)" }));
516
565
  } else if (ex.autoPin) {
517
- panel.append(el("p", { class: "sub pin-line", "data-tip": TIPS.providerPin, text: "auto pin: " + ex.autoPin }));
566
+ // The candidate route — the first route the updater's value-ordered walk
567
+ // tries — not a promise of the written pin: the updater probes it and skips
568
+ // to the next route when OpenRouter does not serve it, or drops the pin
569
+ // entirely when none is routable (spec #90). Read-only, offline: the
570
+ // explorer cannot probe, so it names the candidate and says so in the tip.
571
+ panel.append(el("p", { class: "sub pin-line", "data-tip": TIPS.autoPin, text: "auto pin (candidate): " + ex.autoPin }));
518
572
  }
519
573
 
520
574
  panel.append(el("h3", { "data-tip": TIPS.composition, text: "Value composition" }));
@@ -562,6 +616,23 @@ function renderExplain() {
562
616
  const ownNote = ex.ownProvider ? " · best route" : "";
563
617
  panel.append(el("p", { class: "cost", "data-tip": TIPS.costPenalty, text: "eff price $" + fmt(ex.cost.priceEff, 2) + "/M" + (ex.cost.priceEff !== ex.cost.billedPrice ? " (billed $" + fmt(ex.cost.billedPrice, 2) + " × " + thinkNote + ")" : " (" + thinkNote + ")") + cacheNote + ownNote + " · penalty = λ·price = " + fmt(ex.cost.penalty, 4) + " · value = q − penalty = " + fmt(ex.cost.value, 4) }));
564
618
 
619
+ // The per-task price breakdown: display-only, so it sits apart from the
620
+ // ranking's cost line. A muted note when no token profile resolved (never a
621
+ // silent zero).
622
+ panel.append(el("h3", { "data-tip": TIPS.perTask, text: "Per-task price" }));
623
+ if (ex.perTask) {
624
+ const p = ex.perTask;
625
+ panel.append(
626
+ el("p", {
627
+ class: "cost",
628
+ "data-tip": TIPS.perTask,
629
+ text: "per task: " + fmtTokens(p.inputTokens) + " in × $" + fmt(p.inputPrice, 3) + "/M + " + fmtTokens(p.outputTokens) + " out × $" + fmt(p.outputPrice, 3) + "/M × " + p.thinkingFactor.toFixed(3) + " = " + fmtUsd(p.total),
630
+ }),
631
+ );
632
+ } else {
633
+ panel.append(el("p", { class: "cost muted", "data-tip": TIPS.perTask, text: "per task: — (no token profile — record usage or set tokenProfile)" }));
634
+ }
635
+
565
636
  panel.append(el("h3", { "data-tip": TIPS.whyNotHigher, text: "Why not higher" }));
566
637
  if (ex.gapAbove === null) {
567
638
  panel.append(el("p", { class: "muted", text: "Already #1 for this role." }));
@@ -593,6 +664,53 @@ function renderExplain() {
593
664
  for (const d of ex.dominators) ul.append(el("li", { text: d.name + " — $" + fmt(d.priceEff, 2) + "/M, q " + fmt(d.q, 3) }));
594
665
  panel.append(ul);
595
666
  }
667
+
668
+ // The two price-lever readouts are role-level (computed on the pool the
669
+ // ranking above uses), so they close the panel. Each is absent when the
670
+ // server sends null: no cheaper challenger, or a bare role.
671
+ const flip = ex.leaderFlip;
672
+ const levels = ex.thinkingLevels;
673
+ if (flip || levels) {
674
+ panel.append(el("h3", { "data-tip": TIPS.levers, text: "Price levers" }));
675
+ if (flip) {
676
+ const ratio = flip.wPrice > 0 ? (flip.flipW / flip.wPrice).toFixed(2) + "×" : "—";
677
+ panel.append(
678
+ el("p", {
679
+ class: "cost",
680
+ "data-tip": TIPS.leaderFlip,
681
+ text: "leader flip: w* = " + flip.flipW.toFixed(3) + " · now " + flip.wPrice.toFixed(3) + " · distance " + (flip.distance >= 0 ? "+" : "−") + Math.abs(flip.distance).toFixed(3) + " (" + ratio + ")",
682
+ }),
683
+ );
684
+ panel.append(
685
+ el("p", {
686
+ class: "cost",
687
+ text: "challenger: " + flip.challenger.name + " — $" + fmt(flip.challenger.priceEff, 2) + "/M, value " + fmt(flip.challenger.value, 3),
688
+ }),
689
+ );
690
+ }
691
+ if (levels) {
692
+ const t = el("table", { class: "comp" });
693
+ t.append(
694
+ el("thead", {}, el("tr", {}, [
695
+ el("th", { text: "level" }),
696
+ el("th", { class: "num", "data-tip": TIPS.thinking, text: "×price" }),
697
+ el("th", { "data-tip": TIPS.thinkingLevels, text: "leader" }),
698
+ ])),
699
+ );
700
+ const tb = el("tbody");
701
+ for (const row of levels) {
702
+ tb.append(
703
+ el("tr", { class: row.level === ex.role.thinking ? "active-level" : "" }, [
704
+ el("td", { text: row.level }),
705
+ el("td", { class: "num", text: "×" + row.factor.toFixed(3) }),
706
+ el("td", { text: row.leader ? row.leader.name + " (" + fmt(row.leader.value, 3) + ")" : "—" }),
707
+ ]),
708
+ );
709
+ }
710
+ t.append(tb);
711
+ panel.append(t);
712
+ }
713
+ }
596
714
  }
597
715
 
598
716
  // ---------------------------------------------------------------------------
@@ -1185,6 +1303,24 @@ async function onScopeChange(event) {
1185
1303
  }
1186
1304
  }
1187
1305
 
1306
+ /** Switch the ranked pool (raw ↔ as-written). Unsaved edits are confirmed first
1307
+ * (mirroring the scope switcher); the returned payload rebuilds every panel, so
1308
+ * the header's model count and the table describe the new pool. */
1309
+ async function onPoolChange(event) {
1310
+ const mode = event.target.value;
1311
+ if (mode === state.poolMode) return;
1312
+ if (Object.keys(dirtyRoles()).length > 0 && !confirm("Discard unsaved role edits and switch pool?")) {
1313
+ event.target.value = state.poolMode;
1314
+ return;
1315
+ }
1316
+ try {
1317
+ applyBootstrap(await api("/api/pool", { mode }));
1318
+ } catch (err) {
1319
+ alert("Pool switch failed: " + err.message);
1320
+ event.target.value = state.poolMode;
1321
+ }
1322
+ }
1323
+
1188
1324
  // ---------------------------------------------------------------------------
1189
1325
  // Boot
1190
1326
  // ---------------------------------------------------------------------------
@@ -1196,6 +1332,8 @@ function applyBootstrap(data) {
1196
1332
  const previousRole = state.role;
1197
1333
  state.scopes = data.scopes || [];
1198
1334
  state.activeScope = data.activeScope;
1335
+ state.poolMode = data.poolMode || "raw";
1336
+ state.tokenProfile = data.tokenProfile ?? null;
1199
1337
  state.universe = data.universe;
1200
1338
  state.effective = {};
1201
1339
  for (const [name, entry] of Object.entries(state.universe)) state.effective[name] = entry.def;
package/web/style.css CHANGED
@@ -39,6 +39,7 @@ body {
39
39
  #meta .sep { color: var(--line); }
40
40
  #meta .lock { color: var(--muted); font-family: var(--mono); font-size: 11px; }
41
41
  #meta #scope { max-width: 340px; }
42
+ #meta #pool { max-width: 200px; }
42
43
  #meta button { margin-left: auto; }
43
44
 
44
45
  #roles {
@@ -189,6 +190,8 @@ table.comp th { text-align: left; color: var(--muted); font-weight: 500; font-si
189
190
  table.comp td.num, table.comp th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
190
191
  table.comp tr.missing-row td { color: var(--muted); }
191
192
  table.comp tr.note-row td { border-bottom: none; font-size: 11px; padding-top: 0; }
193
+ /* The thinking-level readout: the role's current level is the row that matters. */
194
+ table.comp tr.active-level td { color: var(--accent); }
192
195
 
193
196
  /* Focus-metric audit: one row per weighted benchmark/percentile metric, four
194
197
  signal cells. ok = up, below-bar = warn + glyph, unknown = muted (never ok). */