omp-llm-role 1.2.0 → 1.3.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/README.md +45 -5
- package/docs/dev/architecture.md +11 -8
- package/docs/dev/ci.md +9 -0
- package/docs/dev/current-state.md +47 -12
- package/docs/dev/explorer.md +106 -19
- package/docs/dev/releasing.md +33 -3
- package/docs/dev/scoring.md +55 -4
- package/docs/dev/testing.md +43 -2
- package/docs/dev/writes-and-state.md +30 -0
- package/docs/llm-role-rankings.md +115 -115
- package/package.json +11 -1
- package/src/agent-create.ts +4 -2
- package/src/engine.ts +33 -2
- package/src/explorer/boot.ts +43 -1
- package/src/explorer/explain.ts +90 -5
- package/src/explorer/levers.ts +100 -0
- package/src/explorer/pool.ts +46 -0
- package/src/explorer/server.ts +92 -21
- package/src/extension.ts +132 -2
- package/src/settings.ts +31 -0
- package/src/updater.ts +6 -1
- package/src/usage-ledger.ts +127 -0
- package/web/app.js +132 -0
- package/web/style.css +3 -0
package/src/extension.ts
CHANGED
|
@@ -20,7 +20,8 @@ import { generateAgentSpec, generateProjectProfile } from "./agent-architect.ts"
|
|
|
20
20
|
import { authorBenchmarkSource } from "./benchmark-author.ts";
|
|
21
21
|
import { applyBenchmarkScores, BENCHMARK_ENTRY_CAP, cachedSourceInfo, declarationToSource, declaredSourceForLink, dryRunDeclaration, fetchJson, loadBenchmarkCatalog, loadBenchmarkScores, loadDeclaredSources, resolveBenchmarkSource, saveDeclaredSource, sourceForMetric, validateDeclaration, type BenchmarkCatalogEntry, type BenchmarkPayloadMeta, type BenchmarkSource, type SourceDeclaration } from "./benchmark-sources.ts";
|
|
22
22
|
import { judgeBenchmarkRelevance } from "./benchmark-discovery.ts";
|
|
23
|
-
import { catalogFromOmpModelsJson, fetchKeyAvailability, splitThinkingSuffix, type KeyAvailability } from "./availability.ts";
|
|
23
|
+
import { catalogFromOmpModelsJson, currentRankingId, fetchAllowedProviders, fetchKeyAvailability, fetchKeyMeta, filterCatalog, splitThinkingSuffix, tierGate, type KeyAvailability, type Tier } from "./availability.ts";
|
|
24
|
+
import { parseConfig } from "./config-edit.ts";
|
|
24
25
|
import { loadRankData, rankRole, type Model } from "./engine.ts";
|
|
25
26
|
import { defaultErrorReportDeps, reportUnexpectedError, type ErrorReportInput } from "./error-report.ts";
|
|
26
27
|
import { formatFeatures } from "./features.ts";
|
|
@@ -30,7 +31,9 @@ import { isRecord } from "./guards.ts";
|
|
|
30
31
|
import { applyProviderDisplay, providerPinOf, providerPinOfSelector } from "./provider-display.ts";
|
|
31
32
|
import { applyProfileOverrides, formatProjectRolesReport, parseProjectRolesArgs, PROJECT_ROLES_USAGE, setupProject, type ProjectProfile } from "./project-setup.ts";
|
|
32
33
|
import { DEFAULT_ROLES, findProjectAnchor, KNOWN_METRICS, PLUGIN_SETTINGS_PATH, projectLockPath, readPluginSettingsMap, resolveSettings } from "./settings.ts";
|
|
33
|
-
import {
|
|
34
|
+
import { agentDir } from "./state.ts";
|
|
35
|
+
import { resolveProjectDir, runUpdater, type Deps } from "./updater.ts";
|
|
36
|
+
import { appendUsageRow, readUsageRows, type UsageRow } from "./usage-ledger.ts";
|
|
34
37
|
|
|
35
38
|
/** SPA directory shipped beside this extension (repo `web/`). */
|
|
36
39
|
const WEB_DIR = fileURLToPath(new URL("../web", import.meta.url));
|
|
@@ -109,6 +112,93 @@ function applyProviderDisplayFor(ctx: ExtContext, selector?: string): void {
|
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Append one usage-ledger row per assistant turn in the session's branch
|
|
117
|
+
* (spec #81). The branch is the session's own record — the same entries the
|
|
118
|
+
* session jsonl persists — so each row carries the model, the provider, and the
|
|
119
|
+
* usage block omp billed. Rows already in the ledger (same timestamp + model)
|
|
120
|
+
* are skipped, so firing this on several events cannot double-record a turn.
|
|
121
|
+
*
|
|
122
|
+
* The ledger follows the plugin's state location: a project session records into
|
|
123
|
+
* `<cwd>/.omp`, so a project's usage stays separate from the user-level profile
|
|
124
|
+
* (the same dir the updater writes config/state/history to, and the same dir the
|
|
125
|
+
* role map is read from).
|
|
126
|
+
*
|
|
127
|
+
* Best-effort: a recording failure never breaks a session (and `appendUsageRow`
|
|
128
|
+
* never throws either).
|
|
129
|
+
*/
|
|
130
|
+
function recordUsage(ctx: ExtContext): void {
|
|
131
|
+
try {
|
|
132
|
+
const branch = ctx.sessionManager?.getBranch();
|
|
133
|
+
if (branch === undefined) return;
|
|
134
|
+
const dir = resolveProjectDir() ?? agentDir();
|
|
135
|
+
const roleByModel = configuredRoles(dir);
|
|
136
|
+
const seen = new Set(readUsageRows(dir).map((row) => `${row.ts}|${row.model}`));
|
|
137
|
+
for (const entry of branch) {
|
|
138
|
+
const row = usageRowOf(entry, roleByModel);
|
|
139
|
+
if (row === null) continue;
|
|
140
|
+
const key = `${row.ts}|${row.model}`;
|
|
141
|
+
if (seen.has(key)) continue;
|
|
142
|
+
seen.add(key);
|
|
143
|
+
appendUsageRow(dir, row);
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// best-effort: the ledger never owns a session's fate
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The config's role→selector map inverted to model slug → role: a turn's
|
|
152
|
+
* `message.model` names the selector's ranking id, and `currentRankingId`
|
|
153
|
+
* strips the provider prefix, the `@pin`, and the `:level` from the configured
|
|
154
|
+
* selector, so the two join. The first role wins when two share a model; an
|
|
155
|
+
* unreadable config maps nothing, so every row's role is "".
|
|
156
|
+
*/
|
|
157
|
+
function configuredRoles(dir: string): Map<string, string> {
|
|
158
|
+
const byModel = new Map<string, string>();
|
|
159
|
+
let text: string;
|
|
160
|
+
try {
|
|
161
|
+
text = readFileSync(join(dir, "config.yml"), "utf8");
|
|
162
|
+
} catch {
|
|
163
|
+
return byModel;
|
|
164
|
+
}
|
|
165
|
+
for (const [role, selector] of Object.entries(parseConfig(text).modelRoles)) {
|
|
166
|
+
const id = currentRankingId(selector);
|
|
167
|
+
if (id !== "" && !byModel.has(id)) byModel.set(id, role);
|
|
168
|
+
}
|
|
169
|
+
return byModel;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The ledger row for one session branch entry: an assistant message carrying a
|
|
174
|
+
* usage block. Null for every other entry (user turns, tool results, custom
|
|
175
|
+
* entries) and for a message with no usage to record.
|
|
176
|
+
*/
|
|
177
|
+
function usageRowOf(entry: unknown, roleByModel: ReadonlyMap<string, string>): UsageRow | null {
|
|
178
|
+
if (!isRecord(entry) || entry.type !== "message") return null;
|
|
179
|
+
const message = entry.message;
|
|
180
|
+
if (!isRecord(message) || message.role !== "assistant" || typeof message.model !== "string") return null;
|
|
181
|
+
const usage = message.usage;
|
|
182
|
+
if (!isRecord(usage)) return null;
|
|
183
|
+
const cost = isRecord(usage.cost) ? usage.cost.total : 0;
|
|
184
|
+
return {
|
|
185
|
+
ts: typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString(),
|
|
186
|
+
role: roleByModel.get(currentRankingId(message.model)) ?? "",
|
|
187
|
+
model: message.model,
|
|
188
|
+
provider: typeof message.provider === "string" ? message.provider : "",
|
|
189
|
+
input: usageNumber(usage.input),
|
|
190
|
+
output: usageNumber(usage.output),
|
|
191
|
+
cacheRead: usageNumber(usage.cacheRead),
|
|
192
|
+
cacheWrite: usageNumber(usage.cacheWrite),
|
|
193
|
+
cost: usageNumber(cost),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A usage field as a finite number; an absent field records 0. */
|
|
198
|
+
function usageNumber(value: unknown): number {
|
|
199
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
112
202
|
/**
|
|
113
203
|
* Apply a concrete selector (with thinking suffix) to the live session model
|
|
114
204
|
* via the ExtensionAPI surface (`pi.setModel`/`pi.setThinkingLevel` — probed
|
|
@@ -461,6 +551,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
461
551
|
applyProviderDisplayFor(ctx);
|
|
462
552
|
});
|
|
463
553
|
|
|
554
|
+
// Usage ledger (spec #81): one row per assistant turn, read from the session
|
|
555
|
+
// branch. turn_end records the turn that just finished; session_shutdown is
|
|
556
|
+
// the backstop for a headless one-turn run (a short-lived `omp -p` may exit
|
|
557
|
+
// before another turn_end). The file-side dedupe makes the second hook
|
|
558
|
+
// harmless.
|
|
559
|
+
pi.on("turn_end", async (_event, ctx: ExtContext) => recordUsage(ctx));
|
|
560
|
+
pi.on("session_shutdown", async (_event, ctx: ExtContext) => recordUsage(ctx));
|
|
561
|
+
|
|
464
562
|
pi.registerCommand("refresh-roles", {
|
|
465
563
|
description: "Refresh model roles from today's llm-stats/OpenRouter rankings",
|
|
466
564
|
handler: async (_args, ctx: ExtContext) => {
|
|
@@ -508,6 +606,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
508
606
|
? fetchKeyAvailability(token)
|
|
509
607
|
: { active: false, reason: "unavailable", allowed: new Set(), blocked: new Set(), publicCount: 0, keyedCount: 0, fetchedAt: new Date().toISOString() };
|
|
510
608
|
};
|
|
609
|
+
// Best-effort account-scoped inputs for the as-written pool: the key's
|
|
610
|
+
// tier gates the catalog, and the allowed-providers whitelist prunes the
|
|
611
|
+
// routes the account cannot call. The canary must be a CATALOG slug
|
|
612
|
+
// (`eligible[0].id`) — a ranking id 400s before the whitelist body, so
|
|
613
|
+
// the harvest would silently return nothing. Any failure (no key, an
|
|
614
|
+
// unreachable endpoint, no whitelist) degrades the pool to the raw
|
|
615
|
+
// dataset instead of blocking Explorer boot.
|
|
616
|
+
const loadPoolInputs = async (): Promise<{ tier: Tier; allowed: ReadonlySet<string> | null }> => {
|
|
617
|
+
const token = await ctx.modelRegistry.getApiKeyForProvider("openrouter").catch(() => undefined);
|
|
618
|
+
if (!token) return { tier: "billed", allowed: null };
|
|
619
|
+
let tier: Tier = "billed";
|
|
620
|
+
try {
|
|
621
|
+
tier = tierGate(await fetchKeyMeta(token));
|
|
622
|
+
} catch {
|
|
623
|
+
// an unreachable key endpoint leaves the ordinary account's tier
|
|
624
|
+
}
|
|
625
|
+
const canary = filterCatalog(catalog, tier)[0]?.id;
|
|
626
|
+
if (canary === undefined) return { tier, allowed: null };
|
|
627
|
+
try {
|
|
628
|
+
return { tier, allowed: await fetchAllowedProviders(token, canary) };
|
|
629
|
+
} catch {
|
|
630
|
+
return { tier, allowed: null };
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
const pool = await loadPoolInputs();
|
|
511
634
|
explorer = await startExplorer({
|
|
512
635
|
webDir: WEB_DIR,
|
|
513
636
|
cwd: ctx.cwd,
|
|
@@ -515,6 +638,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
515
638
|
catalog,
|
|
516
639
|
availability: await loadAvailability(),
|
|
517
640
|
reloadAvailability: loadAvailability,
|
|
641
|
+
tier: pool.tier,
|
|
642
|
+
allowedProviders: pool.allowed,
|
|
643
|
+
reloadAllowedProviders: async () => (await loadPoolInputs()).allowed,
|
|
644
|
+
tokenProfile: resolveSettings(readPluginSettingsMap({ global: PLUGIN_SETTINGS_PATH, project: projectLockPath(ctx.cwd) })).settings.tokenProfile,
|
|
645
|
+
// The user-level ledger (the project scopes read their own
|
|
646
|
+
// `<root>/.omp` ledger, so the per-task column follows the scope).
|
|
647
|
+
usageDir: agentDir(),
|
|
518
648
|
reload: (refresh) => loadRankData({ refresh, roles: explorerRoles(), extraMetrics: Object.keys(KNOWN_METRICS) }),
|
|
519
649
|
port,
|
|
520
650
|
open,
|
package/src/settings.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { dirname, join } from "node:path";
|
|
|
16
16
|
import { SUFFIX_LEVELS, type RoleDef } from "./engine.ts";
|
|
17
17
|
import { FEATURES, expandFeatures, featureById } from "./features.ts";
|
|
18
18
|
import { isRecord } from "./guards.ts";
|
|
19
|
+
import type { TokenProfile } from "./usage-ledger.ts";
|
|
19
20
|
|
|
20
21
|
/** User-level plugin settings lock file (the explorer's read/export target and
|
|
21
22
|
* the default source for `readPluginSettingsMap`). */
|
|
@@ -244,6 +245,9 @@ export type ResolvedSettings = {
|
|
|
244
245
|
/** Global capability flags (`features.<id>`); a role's `features.<id>` overrides
|
|
245
246
|
* the global in either direction. Expanded per role by `resolveSettings`. */
|
|
246
247
|
features: Record<string, boolean>;
|
|
248
|
+
/** Per-task token mix for the per-task price, used when the role has no
|
|
249
|
+
* recorded usage. `null` = no profile (the per-task price is absent). */
|
|
250
|
+
tokenProfile: TokenProfile | null;
|
|
247
251
|
};
|
|
248
252
|
|
|
249
253
|
export const DEFAULT_SETTINGS: ResolvedSettings = {
|
|
@@ -255,6 +259,7 @@ export const DEFAULT_SETTINGS: ResolvedSettings = {
|
|
|
255
259
|
activateDefaultOnEmptySession: true,
|
|
256
260
|
errorReporting: "off",
|
|
257
261
|
features: {},
|
|
262
|
+
tokenProfile: null,
|
|
258
263
|
};
|
|
259
264
|
|
|
260
265
|
/** One row of the plugin's settings schema (omp's `PluginSettingSchema`): the
|
|
@@ -312,6 +317,16 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
|
|
|
312
317
|
default: DEFAULT_SETTINGS.errorReporting,
|
|
313
318
|
values: ["off", "ask", "auto"],
|
|
314
319
|
},
|
|
320
|
+
"tokenProfile.inputTokens": {
|
|
321
|
+
type: "number",
|
|
322
|
+
description: "Input tokens per task for the per-task price (used when the role has no recorded usage)",
|
|
323
|
+
min: 0,
|
|
324
|
+
},
|
|
325
|
+
"tokenProfile.outputTokens": {
|
|
326
|
+
type: "number",
|
|
327
|
+
description: "Output tokens per task for the per-task price (used when the role has no recorded usage)",
|
|
328
|
+
min: 0,
|
|
329
|
+
},
|
|
315
330
|
};
|
|
316
331
|
for (const row of FEATURES) {
|
|
317
332
|
schema[`features.${row.id}`] = {
|
|
@@ -573,6 +588,22 @@ export function resolveSettings(raw: Record<string, unknown>): { settings: Resol
|
|
|
573
588
|
errors.push(`errorReporting: must be one of off, ask, auto, got ${JSON.stringify(merged.errorReporting)}`);
|
|
574
589
|
}
|
|
575
590
|
|
|
591
|
+
// The per-task token profile: null (no profile), or a record whose two counts
|
|
592
|
+
// are finite numbers >= 0. One error string per violation.
|
|
593
|
+
const tokenProfile: unknown = merged.tokenProfile;
|
|
594
|
+
if (tokenProfile !== null) {
|
|
595
|
+
if (!isRecord(tokenProfile)) {
|
|
596
|
+
errors.push(`tokenProfile: must be null or an object with inputTokens/outputTokens, got ${JSON.stringify(tokenProfile)}`);
|
|
597
|
+
} else {
|
|
598
|
+
for (const key of ["inputTokens", "outputTokens"] as const) {
|
|
599
|
+
const v = tokenProfile[key];
|
|
600
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
|
|
601
|
+
errors.push(`tokenProfile.${key}: must be a number >= 0, got ${JSON.stringify(v)}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
576
607
|
// Global capability flags (`features.<id>`): an unknown id or a non-boolean
|
|
577
608
|
// value aborts the run (a typo must never silently do nothing). A non-record
|
|
578
609
|
// `features` is reset so the per-role expansion below always gets a map.
|
package/src/updater.ts
CHANGED
|
@@ -212,8 +212,13 @@ function writePatch(configPath: string, dir: string, patch: ConfigPatch): { stat
|
|
|
212
212
|
* behavior applies. omp resolves the project dir as `<cwd>/.omp` with no
|
|
213
213
|
* walk-up, so the plugin's project-scope read must use the same dir (not the
|
|
214
214
|
* walk-up anchor `findProjectAnchor` returns).
|
|
215
|
+
*
|
|
216
|
+
* Exported as the one home for "where this session's plugin state lives": the
|
|
217
|
+
* updater scopes config/state/history/lock to it, and the extension records the
|
|
218
|
+
* usage ledger there too, so a project's usage is separate from the user-level
|
|
219
|
+
* profile.
|
|
215
220
|
*/
|
|
216
|
-
function resolveProjectDir(): string | null {
|
|
221
|
+
export function resolveProjectDir(): string | null {
|
|
217
222
|
const projectDir = join(process.cwd(), ".omp");
|
|
218
223
|
const raw = readPluginSettingsMap({ global: join(projectDir, "plugins", "omp-plugins.lock.json"), project: null });
|
|
219
224
|
return Object.keys(raw).length > 0 ? projectDir : null;
|
|
@@ -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).",
|
|
@@ -267,6 +297,20 @@ function scopeSelect() {
|
|
|
267
297
|
return sel;
|
|
268
298
|
}
|
|
269
299
|
|
|
300
|
+
/** The pool selector: the raw dataset (account-agnostic) or the as-written pool
|
|
301
|
+
* (the catalog/tier gate + allowed-providers prune the updater ranks on). The
|
|
302
|
+
* value is the server's mode, so the header always names the active pool. */
|
|
303
|
+
function poolSelect() {
|
|
304
|
+
const sel = el("select", { id: "pool", "data-tip": TIPS.pool });
|
|
305
|
+
sel.append(
|
|
306
|
+
el("option", { value: "raw", text: "raw pool" }),
|
|
307
|
+
el("option", { value: "as-written", text: "as-written pool" }),
|
|
308
|
+
);
|
|
309
|
+
sel.value = state.poolMode;
|
|
310
|
+
sel.addEventListener("change", onPoolChange);
|
|
311
|
+
return sel;
|
|
312
|
+
}
|
|
313
|
+
|
|
270
314
|
function renderMeta(data) {
|
|
271
315
|
const meta = document.getElementById("meta");
|
|
272
316
|
meta.textContent = "";
|
|
@@ -275,6 +319,8 @@ function renderMeta(data) {
|
|
|
275
319
|
el("span", { class: "sep", text: "·" }),
|
|
276
320
|
scopeSelect(),
|
|
277
321
|
el("span", { class: "sep", text: "·" }),
|
|
322
|
+
poolSelect(),
|
|
323
|
+
el("span", { class: "sep", text: "·" }),
|
|
278
324
|
el("span", { "data-tip": TIPS.models, text: data.modelCount + " models" }),
|
|
279
325
|
el("span", { class: "sep", text: "·" }),
|
|
280
326
|
el("span", { "data-tip": TIPS.fetched, text: "fetched " + data.fetchedAt.slice(0, 10) }),
|
|
@@ -426,6 +472,7 @@ function renderTable() {
|
|
|
426
472
|
el("th", { class: "num", "data-tip": TIPS.value, text: "value" }),
|
|
427
473
|
el("th", { class: "num", "data-tip": TIPS.q, text: "q" }),
|
|
428
474
|
el("th", { class: "num", "data-tip": TIPS.price, text: "$/M" }),
|
|
475
|
+
el("th", { class: "num", "data-tip": TIPS.perTask, text: "$/task" }),
|
|
429
476
|
el("th", { class: "num", "data-tip": TIPS.throughput, text: "tok/s" }),
|
|
430
477
|
el("th", { class: "num", "data-tip": TIPS.context, text: "ctx" }),
|
|
431
478
|
])),
|
|
@@ -450,6 +497,7 @@ function renderTable() {
|
|
|
450
497
|
el("td", { class: "num", text: fmt(r.value, 3) }),
|
|
451
498
|
el("td", { class: "num", text: fmt(r.q, 3) }),
|
|
452
499
|
el("td", { class: "num", text: fmt(r.priceEff, 2) }),
|
|
500
|
+
el("td", { class: "num", text: fmtUsd(r.perTask) }),
|
|
453
501
|
el("td", { class: "num", text: r.throughput === null ? "—" : fmt(r.throughput, 0) }),
|
|
454
502
|
el("td", { class: "num", text: r.context === null ? "—" : fmtCtx(r.context) }),
|
|
455
503
|
]),
|
|
@@ -562,6 +610,23 @@ function renderExplain() {
|
|
|
562
610
|
const ownNote = ex.ownProvider ? " · best route" : "";
|
|
563
611
|
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
612
|
|
|
613
|
+
// The per-task price breakdown: display-only, so it sits apart from the
|
|
614
|
+
// ranking's cost line. A muted note when no token profile resolved (never a
|
|
615
|
+
// silent zero).
|
|
616
|
+
panel.append(el("h3", { "data-tip": TIPS.perTask, text: "Per-task price" }));
|
|
617
|
+
if (ex.perTask) {
|
|
618
|
+
const p = ex.perTask;
|
|
619
|
+
panel.append(
|
|
620
|
+
el("p", {
|
|
621
|
+
class: "cost",
|
|
622
|
+
"data-tip": TIPS.perTask,
|
|
623
|
+
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),
|
|
624
|
+
}),
|
|
625
|
+
);
|
|
626
|
+
} else {
|
|
627
|
+
panel.append(el("p", { class: "cost muted", "data-tip": TIPS.perTask, text: "per task: — (no token profile — record usage or set tokenProfile)" }));
|
|
628
|
+
}
|
|
629
|
+
|
|
565
630
|
panel.append(el("h3", { "data-tip": TIPS.whyNotHigher, text: "Why not higher" }));
|
|
566
631
|
if (ex.gapAbove === null) {
|
|
567
632
|
panel.append(el("p", { class: "muted", text: "Already #1 for this role." }));
|
|
@@ -593,6 +658,53 @@ function renderExplain() {
|
|
|
593
658
|
for (const d of ex.dominators) ul.append(el("li", { text: d.name + " — $" + fmt(d.priceEff, 2) + "/M, q " + fmt(d.q, 3) }));
|
|
594
659
|
panel.append(ul);
|
|
595
660
|
}
|
|
661
|
+
|
|
662
|
+
// The two price-lever readouts are role-level (computed on the pool the
|
|
663
|
+
// ranking above uses), so they close the panel. Each is absent when the
|
|
664
|
+
// server sends null: no cheaper challenger, or a bare role.
|
|
665
|
+
const flip = ex.leaderFlip;
|
|
666
|
+
const levels = ex.thinkingLevels;
|
|
667
|
+
if (flip || levels) {
|
|
668
|
+
panel.append(el("h3", { "data-tip": TIPS.levers, text: "Price levers" }));
|
|
669
|
+
if (flip) {
|
|
670
|
+
const ratio = flip.wPrice > 0 ? (flip.flipW / flip.wPrice).toFixed(2) + "×" : "—";
|
|
671
|
+
panel.append(
|
|
672
|
+
el("p", {
|
|
673
|
+
class: "cost",
|
|
674
|
+
"data-tip": TIPS.leaderFlip,
|
|
675
|
+
text: "leader flip: w* = " + flip.flipW.toFixed(3) + " · now " + flip.wPrice.toFixed(3) + " · distance " + (flip.distance >= 0 ? "+" : "−") + Math.abs(flip.distance).toFixed(3) + " (" + ratio + ")",
|
|
676
|
+
}),
|
|
677
|
+
);
|
|
678
|
+
panel.append(
|
|
679
|
+
el("p", {
|
|
680
|
+
class: "cost",
|
|
681
|
+
text: "challenger: " + flip.challenger.name + " — $" + fmt(flip.challenger.priceEff, 2) + "/M, value " + fmt(flip.challenger.value, 3),
|
|
682
|
+
}),
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
if (levels) {
|
|
686
|
+
const t = el("table", { class: "comp" });
|
|
687
|
+
t.append(
|
|
688
|
+
el("thead", {}, el("tr", {}, [
|
|
689
|
+
el("th", { text: "level" }),
|
|
690
|
+
el("th", { class: "num", "data-tip": TIPS.thinking, text: "×price" }),
|
|
691
|
+
el("th", { "data-tip": TIPS.thinkingLevels, text: "leader" }),
|
|
692
|
+
])),
|
|
693
|
+
);
|
|
694
|
+
const tb = el("tbody");
|
|
695
|
+
for (const row of levels) {
|
|
696
|
+
tb.append(
|
|
697
|
+
el("tr", { class: row.level === ex.role.thinking ? "active-level" : "" }, [
|
|
698
|
+
el("td", { text: row.level }),
|
|
699
|
+
el("td", { class: "num", text: "×" + row.factor.toFixed(3) }),
|
|
700
|
+
el("td", { text: row.leader ? row.leader.name + " (" + fmt(row.leader.value, 3) + ")" : "—" }),
|
|
701
|
+
]),
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
t.append(tb);
|
|
705
|
+
panel.append(t);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
596
708
|
}
|
|
597
709
|
|
|
598
710
|
// ---------------------------------------------------------------------------
|
|
@@ -1185,6 +1297,24 @@ async function onScopeChange(event) {
|
|
|
1185
1297
|
}
|
|
1186
1298
|
}
|
|
1187
1299
|
|
|
1300
|
+
/** Switch the ranked pool (raw ↔ as-written). Unsaved edits are confirmed first
|
|
1301
|
+
* (mirroring the scope switcher); the returned payload rebuilds every panel, so
|
|
1302
|
+
* the header's model count and the table describe the new pool. */
|
|
1303
|
+
async function onPoolChange(event) {
|
|
1304
|
+
const mode = event.target.value;
|
|
1305
|
+
if (mode === state.poolMode) return;
|
|
1306
|
+
if (Object.keys(dirtyRoles()).length > 0 && !confirm("Discard unsaved role edits and switch pool?")) {
|
|
1307
|
+
event.target.value = state.poolMode;
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
applyBootstrap(await api("/api/pool", { mode }));
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
alert("Pool switch failed: " + err.message);
|
|
1314
|
+
event.target.value = state.poolMode;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1188
1318
|
// ---------------------------------------------------------------------------
|
|
1189
1319
|
// Boot
|
|
1190
1320
|
// ---------------------------------------------------------------------------
|
|
@@ -1196,6 +1326,8 @@ function applyBootstrap(data) {
|
|
|
1196
1326
|
const previousRole = state.role;
|
|
1197
1327
|
state.scopes = data.scopes || [];
|
|
1198
1328
|
state.activeScope = data.activeScope;
|
|
1329
|
+
state.poolMode = data.poolMode || "raw";
|
|
1330
|
+
state.tokenProfile = data.tokenProfile ?? null;
|
|
1199
1331
|
state.universe = data.universe;
|
|
1200
1332
|
state.effective = {};
|
|
1201
1333
|
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). */
|