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.
- package/README.md +47 -6
- package/docs/dev/architecture.md +42 -19
- package/docs/dev/ci.md +9 -0
- package/docs/dev/current-state.md +126 -20
- package/docs/dev/explorer.md +126 -23
- package/docs/dev/releasing.md +66 -3
- package/docs/dev/scoring.md +76 -8
- package/docs/dev/testing.md +105 -14
- package/docs/dev/writes-and-state.md +161 -26
- package/docs/llm-role-rankings.md +115 -115
- package/package.json +19 -1
- package/src/agent-create.ts +4 -2
- package/src/availability.ts +40 -7
- package/src/engine.ts +33 -2
- package/src/explorer/boot.ts +43 -1
- package/src/explorer/explain.ts +101 -11
- 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 +50 -0
- package/src/updater.ts +132 -33
- package/src/usage-ledger.ts +127 -0
- package/web/app.js +139 -1
- 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`). */
|
|
@@ -234,6 +235,13 @@ export type ResolvedSettings = {
|
|
|
234
235
|
priceSwitchFraction: number;
|
|
235
236
|
writeFallbackChains: boolean;
|
|
236
237
|
fallbackChainDepth: number;
|
|
238
|
+
/**
|
|
239
|
+
* Quality floor for the fallback chain's cheaper-or-equal candidates: a
|
|
240
|
+
* candidate priced at or below the chosen model qualifies only if it retains
|
|
241
|
+
* at least this fraction of the chosen model's `q`. 0 disables the floor (any
|
|
242
|
+
* cheaper-or-equal candidate qualifies); 1 requires an exact quality match.
|
|
243
|
+
*/
|
|
244
|
+
fallbackQualityFloor: number;
|
|
237
245
|
roles: Record<string, RoleDef>;
|
|
238
246
|
activateDefaultOnEmptySession: boolean;
|
|
239
247
|
/** Opt-in reporting of unexpected plugin errors as GitHub issues on the
|
|
@@ -244,6 +252,9 @@ export type ResolvedSettings = {
|
|
|
244
252
|
/** Global capability flags (`features.<id>`); a role's `features.<id>` overrides
|
|
245
253
|
* the global in either direction. Expanded per role by `resolveSettings`. */
|
|
246
254
|
features: Record<string, boolean>;
|
|
255
|
+
/** Per-task token mix for the per-task price, used when the role has no
|
|
256
|
+
* recorded usage. `null` = no profile (the per-task price is absent). */
|
|
257
|
+
tokenProfile: TokenProfile | null;
|
|
247
258
|
};
|
|
248
259
|
|
|
249
260
|
export const DEFAULT_SETTINGS: ResolvedSettings = {
|
|
@@ -251,10 +262,12 @@ export const DEFAULT_SETTINGS: ResolvedSettings = {
|
|
|
251
262
|
priceSwitchFraction: 0.5,
|
|
252
263
|
writeFallbackChains: true,
|
|
253
264
|
fallbackChainDepth: 2,
|
|
265
|
+
fallbackQualityFloor: 0.9,
|
|
254
266
|
roles: DEFAULT_ROLES,
|
|
255
267
|
activateDefaultOnEmptySession: true,
|
|
256
268
|
errorReporting: "off",
|
|
257
269
|
features: {},
|
|
270
|
+
tokenProfile: null,
|
|
258
271
|
};
|
|
259
272
|
|
|
260
273
|
/** One row of the plugin's settings schema (omp's `PluginSettingSchema`): the
|
|
@@ -301,6 +314,14 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
|
|
|
301
314
|
min: 0,
|
|
302
315
|
step: 1,
|
|
303
316
|
},
|
|
317
|
+
fallbackQualityFloor: {
|
|
318
|
+
type: "number",
|
|
319
|
+
description: "Quality floor for a cheaper-or-equal fallback: the fraction of the chosen model's quality it must retain (0 disables)",
|
|
320
|
+
default: DEFAULT_SETTINGS.fallbackQualityFloor,
|
|
321
|
+
min: 0,
|
|
322
|
+
max: 1,
|
|
323
|
+
step: 0.001,
|
|
324
|
+
},
|
|
304
325
|
activateDefaultOnEmptySession: {
|
|
305
326
|
type: "boolean",
|
|
306
327
|
description: "Reapply the new default selector to an empty session's active model",
|
|
@@ -312,6 +333,16 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
|
|
|
312
333
|
default: DEFAULT_SETTINGS.errorReporting,
|
|
313
334
|
values: ["off", "ask", "auto"],
|
|
314
335
|
},
|
|
336
|
+
"tokenProfile.inputTokens": {
|
|
337
|
+
type: "number",
|
|
338
|
+
description: "Input tokens per task for the per-task price (used when the role has no recorded usage)",
|
|
339
|
+
min: 0,
|
|
340
|
+
},
|
|
341
|
+
"tokenProfile.outputTokens": {
|
|
342
|
+
type: "number",
|
|
343
|
+
description: "Output tokens per task for the per-task price (used when the role has no recorded usage)",
|
|
344
|
+
min: 0,
|
|
345
|
+
},
|
|
315
346
|
};
|
|
316
347
|
for (const row of FEATURES) {
|
|
317
348
|
schema[`features.${row.id}`] = {
|
|
@@ -564,6 +595,9 @@ export function resolveSettings(raw: Record<string, unknown>): { settings: Resol
|
|
|
564
595
|
if (!Number.isInteger(merged.fallbackChainDepth) || merged.fallbackChainDepth < 0) {
|
|
565
596
|
errors.push(`fallbackChainDepth: must be an integer >= 0, got ${JSON.stringify(merged.fallbackChainDepth)}`);
|
|
566
597
|
}
|
|
598
|
+
if (typeof merged.fallbackQualityFloor !== "number" || !Number.isFinite(merged.fallbackQualityFloor) || merged.fallbackQualityFloor < 0 || merged.fallbackQualityFloor > 1) {
|
|
599
|
+
errors.push(`fallbackQualityFloor: must be a number in [0, 1], got ${JSON.stringify(merged.fallbackQualityFloor)}`);
|
|
600
|
+
}
|
|
567
601
|
if (typeof merged.activateDefaultOnEmptySession !== "boolean") {
|
|
568
602
|
errors.push(
|
|
569
603
|
`${ACTIVATE_DEFAULT_KEY}: must be a boolean, got ${JSON.stringify(merged.activateDefaultOnEmptySession)}`,
|
|
@@ -573,6 +607,22 @@ export function resolveSettings(raw: Record<string, unknown>): { settings: Resol
|
|
|
573
607
|
errors.push(`errorReporting: must be one of off, ask, auto, got ${JSON.stringify(merged.errorReporting)}`);
|
|
574
608
|
}
|
|
575
609
|
|
|
610
|
+
// The per-task token profile: null (no profile), or a record whose two counts
|
|
611
|
+
// are finite numbers >= 0. One error string per violation.
|
|
612
|
+
const tokenProfile: unknown = merged.tokenProfile;
|
|
613
|
+
if (tokenProfile !== null) {
|
|
614
|
+
if (!isRecord(tokenProfile)) {
|
|
615
|
+
errors.push(`tokenProfile: must be null or an object with inputTokens/outputTokens, got ${JSON.stringify(tokenProfile)}`);
|
|
616
|
+
} else {
|
|
617
|
+
for (const key of ["inputTokens", "outputTokens"] as const) {
|
|
618
|
+
const v = tokenProfile[key];
|
|
619
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
|
|
620
|
+
errors.push(`tokenProfile.${key}: must be a number >= 0, got ${JSON.stringify(v)}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
576
626
|
// Global capability flags (`features.<id>`): an unknown id or a non-boolean
|
|
577
627
|
// value aborts the run (a typo must never silently do nothing). A non-record
|
|
578
628
|
// `features` is reset so the per-role expansion below always gets a map.
|
package/src/updater.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import { readFileSync, statSync } from "node:fs";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
|
-
import { autoPinApplies,
|
|
19
|
-
import { currentRankingId, enrichThinkingLevels, fetchAllowedProviders, fetchKeyAvailability, fetchKeyMeta, filterCatalog, probeModel, providerAllowed, resolveVariant, THINKING_LEVELS, tierGate, type CatalogEntry, type KeyAvailability, type KeyMeta, type ProbeVerdict } from "./availability.ts";
|
|
18
|
+
import { autoPinApplies, computeRankings, endpointFilterDrops, loadRankData, META_LEVELS, providerPinDrops, rankedRoutes, type Ranked, type RankData, type RoleDef } from "./engine.ts";
|
|
19
|
+
import { currentRankingId, enrichThinkingLevels, fetchAllowedProviders, fetchKeyAvailability, fetchKeyMeta, filterCatalog, probeModel, probeRoute, providerAllowed, resolveVariant, THINKING_LEVELS, tierGate, type CatalogEntry, type KeyAvailability, type KeyMeta, type ProbeVerdict } from "./availability.ts";
|
|
20
20
|
import { ConfigEditError, parseConfig, patchConfig, writeConfigAtomic, type ConfigPatch } from "./config-edit.ts";
|
|
21
21
|
import { PLUGIN_SETTINGS_PATH, projectLockPath, readPluginSettingsMap, resolveSettings, roleUniverse, type ResolvedSettings } from "./settings.ts";
|
|
22
22
|
import { discoverAgentPins } from "./agent-pins.ts";
|
|
@@ -41,6 +41,15 @@ export type Decision = {
|
|
|
41
41
|
/** Ranking ids dropped by the role's provider pin (no route matching the pin) —
|
|
42
42
|
* recorded like a probe-blocked candidate. */
|
|
43
43
|
pinBlocked: string[];
|
|
44
|
+
/** Provider slugs the auto-pin's route probe rejected on the chosen model, in
|
|
45
|
+
* walk order — rendered `; route-blocked: …`. Slugs, not ids: the sibling
|
|
46
|
+
* `blocked` holds catalog ids and `pinBlocked` ranking ids. Empty when no
|
|
47
|
+
* probe ran. */
|
|
48
|
+
routeBlockedSlugs: string[];
|
|
49
|
+
/** True when the auto-pin applies and the model has gate-passing routes but
|
|
50
|
+
* every candidate was rejected (or the route-probe budget ran out): the
|
|
51
|
+
* selector stays bare and OpenRouter routes the request itself. */
|
|
52
|
+
pinDropped: boolean;
|
|
44
53
|
/** Availability source for this role's selection: the keyed-catalog fast path or the probe walk. */
|
|
45
54
|
availabilitySource: "keyed-catalog" | "probe";
|
|
46
55
|
/** Candidates pruned as key-blocked (present in the public catalog, absent from the keyed one). */
|
|
@@ -68,10 +77,18 @@ export type Trigger = "session-start" | "manual";
|
|
|
68
77
|
/** A ranked model resolved to a concrete catalog id. */
|
|
69
78
|
type Candidate = { ranked: Ranked; catalogId: string };
|
|
70
79
|
|
|
71
|
-
/** Per managed role: its bare chain key, role suffix, provider pin, the
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
80
|
+
/** Per managed role: its bare chain key, role suffix, provider pin, the
|
|
81
|
+
* candidates its chain's fallback models are drawn from, the chosen model's
|
|
82
|
+
* index in that list, and the inputs the auto-pin chain builder needs (the
|
|
83
|
+
* expanded def for `rankedRoutes`, and whether the pin was auto-derived).
|
|
84
|
+
* `candidates` is the role's ranked candidate list minus the candidates no
|
|
85
|
+
* chain entry may name (key-blocked, probe-blocked) — not the probe-clean pool
|
|
86
|
+
* — so the chain can reach a cheaper-or-equal model ranked below the probe
|
|
87
|
+
* window. `pinnedSlug` is the route the auto-pin wrote — undefined for a manual
|
|
88
|
+
* pin, for a dropped pin and with the capability off — so the chain builder can
|
|
89
|
+
* exclude the pinned route by slug rather than by its position in the route
|
|
90
|
+
* order. */
|
|
91
|
+
type ChainPlan = { key: string; suffix: string | undefined; pin: string; pinnedSlug: string | undefined; candidates: Candidate[]; chosenIdx: number; def: RoleDef; autoPin: boolean };
|
|
75
92
|
|
|
76
93
|
export type Deps = {
|
|
77
94
|
getToken(): Promise<string>;
|
|
@@ -83,6 +100,13 @@ export type Deps = {
|
|
|
83
100
|
getSettings?(): Promise<Record<string, unknown>>;
|
|
84
101
|
getKeyMeta?(token: string): Promise<KeyMeta>;
|
|
85
102
|
probeModel?(token: string, catalogId: string): Promise<ProbeVerdict>;
|
|
103
|
+
/**
|
|
104
|
+
* DI seam like `probeModel`; production defaults to the real `probeRoute`.
|
|
105
|
+
* One-token completion bound to a single provider route (`provider.only`), the
|
|
106
|
+
* auto-pin's routability gate: OpenRouter's routing table can exclude a route
|
|
107
|
+
* its endpoints API still lists as healthy.
|
|
108
|
+
*/
|
|
109
|
+
probeRoute?(token: string, catalogId: string, providerSlug: string): Promise<ProbeVerdict>;
|
|
86
110
|
/** DI seam like `getKeyMeta`/`probeModel`; production defaults to `fetchKeyAvailability` (never throws). */
|
|
87
111
|
getKeyAvailability?(token: string): Promise<KeyAvailability>;
|
|
88
112
|
/**
|
|
@@ -106,6 +130,12 @@ const CONFLICT_RETRIES = 3;
|
|
|
106
130
|
/** Per-role cap on allowlist probe requests (budget against pathological all-blocked pools). */
|
|
107
131
|
const PROBE_BUDGET = 12;
|
|
108
132
|
|
|
133
|
+
/** Per-role cap on route-probe requests for the auto-pin walk (budget against a
|
|
134
|
+
* pathological route pool: a fully blocked model with dozens of routes must not
|
|
135
|
+
* turn the daily update into a long probe walk). Hitting the cap with no clean
|
|
136
|
+
* candidate drops the pin, exactly as an all-blocked pool does. */
|
|
137
|
+
export const ROUTE_PROBE_BUDGET = 12;
|
|
138
|
+
|
|
109
139
|
function mtimeOf(path: string): number {
|
|
110
140
|
try {
|
|
111
141
|
return statSync(path).mtimeMs;
|
|
@@ -120,9 +150,11 @@ function decisionLine(d: Decision): string {
|
|
|
120
150
|
const blocked = d.blocked.length > 0 ? `; blocked: ${d.blocked.join(", ")}` : "";
|
|
121
151
|
const endpoint = d.endpointBlocked.length > 0 ? `; endpoint-blocked: ${d.endpointBlocked.join(", ")}` : "";
|
|
122
152
|
const pin = d.pinBlocked.length > 0 ? `; pin-blocked: ${d.pinBlocked.join(", ")}` : "";
|
|
153
|
+
const routes = d.routeBlockedSlugs.length > 0 ? `; route-blocked: ${d.routeBlockedSlugs.join(", ")}` : "";
|
|
154
|
+
const dropped = d.pinDropped ? "; pin dropped (no routable route)" : "";
|
|
123
155
|
return d.from === d.to
|
|
124
|
-
? `@${d.role}: kept ${d.to} (${d.reason}; ${scores}${blocked}${endpoint}${pin})`
|
|
125
|
-
: `@${d.role}: ${from} -> ${d.to} (${d.reason}; ${scores}${blocked}${endpoint}${pin})`;
|
|
156
|
+
? `@${d.role}: kept ${d.to} (${d.reason}; ${scores}${blocked}${endpoint}${pin}${routes}${dropped})`
|
|
157
|
+
: `@${d.role}: ${from} -> ${d.to} (${d.reason}; ${scores}${blocked}${endpoint}${pin}${routes}${dropped})`;
|
|
126
158
|
}
|
|
127
159
|
|
|
128
160
|
/**
|
|
@@ -212,8 +244,13 @@ function writePatch(configPath: string, dir: string, patch: ConfigPatch): { stat
|
|
|
212
244
|
* behavior applies. omp resolves the project dir as `<cwd>/.omp` with no
|
|
213
245
|
* walk-up, so the plugin's project-scope read must use the same dir (not the
|
|
214
246
|
* walk-up anchor `findProjectAnchor` returns).
|
|
247
|
+
*
|
|
248
|
+
* Exported as the one home for "where this session's plugin state lives": the
|
|
249
|
+
* updater scopes config/state/history/lock to it, and the extension records the
|
|
250
|
+
* usage ledger there too, so a project's usage is separate from the user-level
|
|
251
|
+
* profile.
|
|
215
252
|
*/
|
|
216
|
-
function resolveProjectDir(): string | null {
|
|
253
|
+
export function resolveProjectDir(): string | null {
|
|
217
254
|
const projectDir = join(process.cwd(), ".omp");
|
|
218
255
|
const raw = readPluginSettingsMap({ global: join(projectDir, "plugins", "omp-plugins.lock.json"), project: null });
|
|
219
256
|
return Object.keys(raw).length > 0 ? projectDir : null;
|
|
@@ -367,6 +404,10 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
|
|
|
367
404
|
const pinUnmatched: string[] = [];
|
|
368
405
|
const probe = deps.probeModel ?? probeModel;
|
|
369
406
|
const probeVerdicts = new Map<string, ProbeVerdict>();
|
|
407
|
+
const routeProbe = deps.probeRoute ?? probeRoute;
|
|
408
|
+
/** Route verdicts, cached by `catalogId@slug` across roles: a route shared by
|
|
409
|
+
* several roles is probed once per run. */
|
|
410
|
+
const routeVerdicts = new Map<string, ProbeVerdict>();
|
|
370
411
|
for (const [role, def] of Object.entries(settings.roles)) {
|
|
371
412
|
// Locked: no ranking, no probe, no decision, no selector/chain write.
|
|
372
413
|
if (lockedRoles.has(role)) continue;
|
|
@@ -478,14 +519,50 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
|
|
|
478
519
|
// `splitUpstreamRouting` parses the trailing `@<slug>` and applies
|
|
479
520
|
// `compat.openRouterRouting = { only: [slug] }`.
|
|
480
521
|
//
|
|
481
|
-
// Auto-pin (spec #58): with the soft
|
|
482
|
-
// no manual pin, the
|
|
483
|
-
// rides as that suffix
|
|
484
|
-
//
|
|
485
|
-
//
|
|
522
|
+
// Auto-pin (spec #58, routability-verified by spec #90): with the soft
|
|
523
|
+
// `preferOwnProvider` capability on and no manual pin, the best route the
|
|
524
|
+
// probe accepts rides as that suffix. That candidate is the route the role
|
|
525
|
+
// was priced on, so the price the ranking assumed is the price the request
|
|
526
|
+
// pays — but only while the walk does not skip the candidate; a skipped
|
|
527
|
+
// candidate means the route that replaced it pays, at its own price. A
|
|
528
|
+
// manual `providerPin` is a hard gate and always wins; a model with no
|
|
529
|
+
// gate-passing route keeps default routing (bare selector).
|
|
530
|
+
//
|
|
531
|
+
// The route order is the one the fallback chain already uses
|
|
532
|
+
// (`rankedRoutes`). Every candidate is probed with `provider: { only:
|
|
533
|
+
// [slug] }`, because OpenRouter's endpoints API can list a route as
|
|
534
|
+
// healthy while its routing table excludes it — the failure the endpoints
|
|
535
|
+
// data cannot see. A `blocked` route is skipped, `unknown` counts as
|
|
536
|
+
// usable (a transient blip must never unpin a role), and a pool with no
|
|
537
|
+
// clean candidate drops the pin: the selector stays bare and OpenRouter
|
|
538
|
+
// routes the request itself instead of 404ing on an unroutable `@<slug>`.
|
|
486
539
|
const manualPin = def.providerPin;
|
|
487
|
-
const
|
|
488
|
-
|
|
540
|
+
const autoRoutes = autoPinApplies(def) ? rankedRoutes(chosen.ranked.model, def) : [];
|
|
541
|
+
// No gate-passing route means nothing to pin and nothing to drop: the bare
|
|
542
|
+
// selector is default routing, not a dropped pin.
|
|
543
|
+
const hasAutoRoutes = autoRoutes.length > 0;
|
|
544
|
+
const routeBlockedSlugs: string[] = [];
|
|
545
|
+
let autoSlug: string | undefined;
|
|
546
|
+
let routeProbesSpent = 0;
|
|
547
|
+
for (const r of autoRoutes) {
|
|
548
|
+
const key = `${chosen.catalogId}@${r.providerSlug}`;
|
|
549
|
+
let verdict = routeVerdicts.get(key);
|
|
550
|
+
if (verdict === undefined) {
|
|
551
|
+
if (routeProbesSpent >= ROUTE_PROBE_BUDGET) break;
|
|
552
|
+
verdict = await routeProbe(token, chosen.catalogId, r.providerSlug);
|
|
553
|
+
routeVerdicts.set(key, verdict);
|
|
554
|
+
routeProbesSpent++;
|
|
555
|
+
}
|
|
556
|
+
if (verdict === "blocked") {
|
|
557
|
+
// Deduped: two routes can share a provider slug (the chain collapses
|
|
558
|
+
// them too), and the decision line should not list one slug twice.
|
|
559
|
+
if (!routeBlockedSlugs.includes(r.providerSlug)) routeBlockedSlugs.push(r.providerSlug);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
autoSlug = r.providerSlug;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
const pinSlug = manualPin ?? autoSlug;
|
|
489
566
|
const pin = pinSlug !== undefined ? `@${pinSlug}` : "";
|
|
490
567
|
const chosenRow = rowById.get(chosen.catalogId);
|
|
491
568
|
// Append only a level the model's catalog thinking[] actually supports —
|
|
@@ -504,16 +581,28 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
|
|
|
504
581
|
blocked: blockedForRole,
|
|
505
582
|
endpointBlocked: endpointFilterDrops(def, rank.models),
|
|
506
583
|
pinBlocked: providerPinDrops(def, rank.models),
|
|
584
|
+
routeBlockedSlugs,
|
|
585
|
+
pinDropped: hasAutoRoutes && autoSlug === undefined,
|
|
507
586
|
availabilitySource: availability.active ? "keyed-catalog" : "probe",
|
|
508
587
|
keyBlockedCount,
|
|
509
588
|
});
|
|
510
589
|
|
|
511
590
|
// Fallback chain inputs for every managed role: the bare key (a chain key
|
|
512
|
-
// matches the active model id, never a level) plus the
|
|
513
|
-
//
|
|
591
|
+
// matches the active model id, never a level) plus the candidate list the
|
|
592
|
+
// chain's fallback models are drawn from. Values are built after the loop,
|
|
593
|
+
// once every key claim is known.
|
|
514
594
|
if (settings.writeFallbackChains) {
|
|
515
595
|
const key = suffix !== undefined && finalSelector.endsWith(`:${suffix}`) ? finalSelector.slice(0, -(suffix.length + 1)) : finalSelector;
|
|
516
|
-
|
|
596
|
+
// The chain's fallback models come from the role's whole ranked
|
|
597
|
+
// candidate list, so a cheaper-or-equal model ranked below the probe
|
|
598
|
+
// window can be chained. Only the candidates the probe walk would never
|
|
599
|
+
// accept are dropped — key-blocked (the account's key cannot use them)
|
|
600
|
+
// and probe-verdict-blocked (present in the public catalog but not for
|
|
601
|
+
// this key). An unprobed candidate stays: the chain's deeper entries are
|
|
602
|
+
// no longer required to be probe-verified. The chosen model passed both
|
|
603
|
+
// predicates, so it is always in the list and `chosenIdx` is never -1.
|
|
604
|
+
const chainCandidates = candidates.filter((c) => !keyBlocked(c) && probeVerdicts.get(c.catalogId) !== "blocked");
|
|
605
|
+
chainPlanByRole[role] = { key, suffix, pin, pinnedSlug: autoSlug, candidates: chainCandidates, chosenIdx: chainCandidates.indexOf(chosen), def, autoPin: hasAutoRoutes };
|
|
517
606
|
}
|
|
518
607
|
}
|
|
519
608
|
|
|
@@ -534,13 +623,18 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
|
|
|
534
623
|
for (const d of decisions) {
|
|
535
624
|
const plan = chainPlanByRole[d.role];
|
|
536
625
|
const suffix = claims[plan.key] === 1 ? plan.suffix : undefined;
|
|
537
|
-
// Cost-aware ordering: prefer fallbacks priced at or
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
626
|
+
// Cost-aware ordering with a quality floor: prefer fallbacks priced at or
|
|
627
|
+
// below the chosen model that still retain `fallbackQualityFloor` of its
|
|
628
|
+
// quality (a failover must not silently drop the role onto a much weaker
|
|
629
|
+
// model), then fill any remaining depth with the next-best by value. The
|
|
630
|
+
// floor and the `after` set both key off the chosen model — the leader on
|
|
631
|
+
// a fresh pick, the kept current candidate on a kept role — and the floor
|
|
632
|
+
// never filters the pricier set, so a cheap-but-weak candidate set cannot
|
|
633
|
+
// starve the chain.
|
|
634
|
+
const after = plan.candidates.slice(plan.chosenIdx + 1);
|
|
635
|
+
const chosen = plan.candidates[plan.chosenIdx];
|
|
636
|
+
const cheaper = after.filter((p) => p.ranked.priceEff <= chosen.ranked.priceEff && p.ranked.q >= chosen.ranked.q * settings.fallbackQualityFloor);
|
|
637
|
+
const pricier = after.filter((p) => p.ranked.priceEff > chosen.ranked.priceEff);
|
|
544
638
|
const chainPool = [...cheaper, ...pricier].slice(0, settings.fallbackChainDepth);
|
|
545
639
|
// A chain value's thinking level is appended only when the target's
|
|
546
640
|
// catalog entry advertises support (or the level is a meta level).
|
|
@@ -551,7 +645,7 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
|
|
|
551
645
|
const values: string[] = [];
|
|
552
646
|
if (plan.autoPin) {
|
|
553
647
|
// Auto-pin chain (spec #58, expanded by #69): every gate-passing route
|
|
554
|
-
// of the primary model
|
|
648
|
+
// of the primary model except the pinned route, then every
|
|
555
649
|
// gate-passing route of each fallback model, in the role's
|
|
556
650
|
// value-for-money score order (`rankedRoutes`, spec #71) — every entry
|
|
557
651
|
// bound to its route's provider, so a down primary
|
|
@@ -559,12 +653,17 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
|
|
|
559
653
|
// than a different model. `rankedRoutes` already applies every gate
|
|
560
654
|
// (non-degraded, endpoint filters, usable billed price, the
|
|
561
655
|
// maxPriceUsdPerM cap, the allowed-providers whitelist), so the chain
|
|
562
|
-
// carries exactly the routes the role can use. The
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
656
|
+
// carries exactly the routes the role can use. The pin is excluded by
|
|
657
|
+
// slug, not by position: the route probe can pin a route other than
|
|
658
|
+
// the score leader, and with a dropped pin no route is excluded at
|
|
659
|
+
// all. The dedup below collapses two routes that share a provider slug
|
|
660
|
+
// (same chain value), so the chain length is (R_primary − 1) + Σ
|
|
661
|
+
// R_fallback over distinct gate-passing provider slugs (R_primary with
|
|
662
|
+
// no pin). Only the pinned route is probe-verified: one unroutable
|
|
663
|
+
// chain entry is survivable, omp fails over.
|
|
664
|
+
const chosenCandidate = plan.candidates[plan.chosenIdx];
|
|
665
|
+
for (const r of rankedRoutes(chosenCandidate.ranked.model, plan.def)) {
|
|
666
|
+
if (r.providerSlug === plan.pinnedSlug) continue;
|
|
568
667
|
values.push(`openrouter/${chosenCandidate.catalogId}@${r.providerSlug}${levelFor(chosenCandidate.catalogId)}`);
|
|
569
668
|
}
|
|
570
669
|
for (const p of chainPool) {
|