omp-llm-role 1.0.0 → 1.2.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.
Files changed (50) hide show
  1. package/README.md +202 -12
  2. package/agents/designer.md +4 -3
  3. package/docs/dev/README.md +44 -7
  4. package/docs/dev/agent-authoring.md +59 -1
  5. package/docs/dev/architecture.md +106 -28
  6. package/docs/dev/ci.md +163 -0
  7. package/docs/dev/current-state.md +364 -24
  8. package/docs/dev/data-sources.md +231 -38
  9. package/docs/dev/explorer.md +175 -46
  10. package/docs/dev/quirks.md +77 -4
  11. package/docs/dev/releasing.md +130 -17
  12. package/docs/dev/scoring.md +851 -27
  13. package/docs/dev/testing.md +253 -0
  14. package/docs/dev/writes-and-state.md +232 -28
  15. package/docs/llm-role-rankings.md +149 -148
  16. package/package.json +814 -80
  17. package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
  18. package/src/agent-architect.ts +148 -19
  19. package/src/agent-create.ts +800 -40
  20. package/src/agent-file.ts +2 -5
  21. package/src/architect-provenance.ts +16 -0
  22. package/src/availability.ts +191 -15
  23. package/src/benchmark-author.ts +9 -4
  24. package/src/benchmark-sources.ts +283 -18
  25. package/src/cli/create-role.ts +29 -1
  26. package/src/cli/llm-role-rank.ts +40 -1
  27. package/src/config-edit.ts +207 -80
  28. package/src/engine.ts +519 -75
  29. package/src/error-report.ts +562 -0
  30. package/src/explorer/boot.ts +36 -24
  31. package/src/explorer/explain.ts +128 -13
  32. package/src/explorer/scopes.ts +110 -0
  33. package/src/explorer/server.ts +126 -33
  34. package/src/extension.ts +464 -48
  35. package/src/features.ts +105 -0
  36. package/src/project-registry.ts +65 -0
  37. package/src/project-setup.ts +382 -0
  38. package/src/prompts/agent-creation-architect.md +2 -0
  39. package/src/prompts/project-profile-architect.md +33 -0
  40. package/src/prompts/project-profile-user.md +8 -0
  41. package/src/provider-display.ts +67 -0
  42. package/src/role-archetypes.ts +12 -2
  43. package/src/role-settings.ts +22 -0
  44. package/src/settings.ts +211 -24
  45. package/src/state.ts +1 -1
  46. package/src/updater.ts +234 -37
  47. package/web/app.js +434 -12
  48. package/web/index.html +1 -0
  49. package/web/style.css +44 -1
  50. package/docs/dev/spec.md +0 -633
package/src/updater.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Orchestration (SPEC §6–9): rank today's models -> tier gate + catalog
2
+ * Orchestration: rank today's models -> tier gate + catalog
3
3
  * resolution -> per-role hysteresis -> fallback chains -> surgical config patch.
4
4
  *
5
+ * When the account's OpenRouter keyed catalog is active it is the
6
+ * availability source: allowed candidates skip the probe, key-blocked ones are
7
+ * pruned from the walk order, and only candidates in neither catalog are probed.
8
+ *
5
9
  * Every abort path notifies and returns `aborted` without touching config.yml.
6
10
  * A run with zero changes writes nothing (config mtime untouched) but still
7
11
  * stamps the day gate and appends a history row. Both entry points (the omp
@@ -10,12 +14,13 @@
10
14
  */
11
15
 
12
16
  import { readFileSync, statSync } from "node:fs";
13
- import { join } from "node:path";
14
- import { computeRankings, loadRankData, META_LEVELS, type Ranked, type RankData } from "./engine.ts";
15
- import { currentRankingId, enrichThinkingLevels, fetchKeyMeta, filterCatalog, probeModel, resolveVariant, THINKING_LEVELS, tierGate, type CatalogEntry, type KeyMeta, type ProbeVerdict } from "./availability.ts";
17
+ import { dirname, join } from "node:path";
18
+ import { autoPinApplies, bestRoute, 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, providerAllowed, resolveVariant, THINKING_LEVELS, tierGate, type CatalogEntry, type KeyAvailability, type KeyMeta, type ProbeVerdict } from "./availability.ts";
16
20
  import { ConfigEditError, parseConfig, patchConfig, writeConfigAtomic, type ConfigPatch } from "./config-edit.ts";
17
- import { readPluginSettingsMap, resolveSettings, roleUniverse, type ResolvedSettings } from "./settings.ts";
21
+ import { PLUGIN_SETTINGS_PATH, projectLockPath, readPluginSettingsMap, resolveSettings, roleUniverse, type ResolvedSettings } from "./settings.ts";
18
22
  import { discoverAgentPins } from "./agent-pins.ts";
23
+ import { registerProject } from "./project-registry.ts";
19
24
  import { agentDir, acquireLock, appendHistory, loadState, releaseLock, saveState } from "./state.ts";
20
25
 
21
26
  export type DecisionReason = "adopted" | "switched" | "switched-cost" | "kept-margin" | "kept-eligible" | "no-current";
@@ -30,6 +35,16 @@ export type Decision = {
30
35
  currentValue: number | null;
31
36
  /** Catalog ids probed blocked (provider allowlist) while selecting this role. */
32
37
  blocked: string[];
38
+ /** Ranking ids dropped by the role's endpoint capability filters (no capable
39
+ * standard-tier route) — recorded like a probe-blocked candidate. */
40
+ endpointBlocked: string[];
41
+ /** Ranking ids dropped by the role's provider pin (no route matching the pin) —
42
+ * recorded like a probe-blocked candidate. */
43
+ pinBlocked: string[];
44
+ /** Availability source for this role's selection: the keyed-catalog fast path or the probe walk. */
45
+ availabilitySource: "keyed-catalog" | "probe";
46
+ /** Candidates pruned as key-blocked (present in the public catalog, absent from the keyed one). */
47
+ keyBlockedCount: number;
33
48
  };
34
49
 
35
50
  export type RunResult = {
@@ -38,6 +53,14 @@ export type RunResult = {
38
53
  /** Final selector the run chose for the `default` role, when that role was decided. */
39
54
  defaultSelector?: string;
40
55
  aborted?: string;
56
+ /**
57
+ * Set only on an unexpected exception abort (a bug): the `ConfigEditError` /
58
+ * catch-all path. Unset on every enumerated environment abort (settings
59
+ * invalid, ranking data unavailable, tier `none`, lock, conflict, key-fetch
60
+ * failure) — those are conditions, not defects. The extension reads this to
61
+ * decide whether to file a report.
62
+ */
63
+ defect?: string;
41
64
  };
42
65
 
43
66
  export type Trigger = "session-start" | "manual";
@@ -45,6 +68,11 @@ export type Trigger = "session-start" | "manual";
45
68
  /** A ranked model resolved to a concrete catalog id. */
46
69
  type Candidate = { ranked: Ranked; catalogId: string };
47
70
 
71
+ /** Per managed role: its bare chain key, role suffix, provider pin, the pool it
72
+ * was chosen from, and the inputs the auto-pin chain builder needs (the expanded
73
+ * def for `rankedRoutes`, and whether the pin was auto-derived). */
74
+ type ChainPlan = { key: string; suffix: string | undefined; pin: string; pool: Candidate[]; chosenIdx: number; def: RoleDef; autoPin: boolean };
75
+
48
76
  export type Deps = {
49
77
  getToken(): Promise<string>;
50
78
  getCatalog(): Promise<CatalogEntry[]>;
@@ -55,6 +83,15 @@ export type Deps = {
55
83
  getSettings?(): Promise<Record<string, unknown>>;
56
84
  getKeyMeta?(token: string): Promise<KeyMeta>;
57
85
  probeModel?(token: string, catalogId: string): Promise<ProbeVerdict>;
86
+ /** DI seam like `getKeyMeta`/`probeModel`; production defaults to `fetchKeyAvailability` (never throws). */
87
+ getKeyAvailability?(token: string): Promise<KeyAvailability>;
88
+ /**
89
+ * DI seam like `getKeyAvailability`; production defaults to
90
+ * `fetchAllowedProviders` (never throws). Returns the account's
91
+ * allowed-providers privacy whitelist, or null when there is none (or the
92
+ * harvest failed) — a null result leaves every route pool untouched.
93
+ */
94
+ getAllowedProviders?(token: string, modelId: string): Promise<Set<string> | null>;
58
95
  /**
59
96
  * Host coupling (extension only): apply a concrete selector to the live
60
97
  * session model. Receives the final `default` selector (with thinking
@@ -81,13 +118,15 @@ function decisionLine(d: Decision): string {
81
118
  const from = d.from ?? "(unset)";
82
119
  const scores = `value ${d.value.toFixed(3)}, best ${d.bestValue.toFixed(3)}${d.currentValue == null ? "" : `, was ${d.currentValue.toFixed(3)}`}`;
83
120
  const blocked = d.blocked.length > 0 ? `; blocked: ${d.blocked.join(", ")}` : "";
121
+ const endpoint = d.endpointBlocked.length > 0 ? `; endpoint-blocked: ${d.endpointBlocked.join(", ")}` : "";
122
+ const pin = d.pinBlocked.length > 0 ? `; pin-blocked: ${d.pinBlocked.join(", ")}` : "";
84
123
  return d.from === d.to
85
- ? `@${d.role}: kept ${d.to} (${d.reason}; ${scores}${blocked})`
86
- : `@${d.role}: ${from} -> ${d.to} (${d.reason}; ${scores}${blocked})`;
124
+ ? `@${d.role}: kept ${d.to} (${d.reason}; ${scores}${blocked}${endpoint}${pin})`
125
+ : `@${d.role}: ${from} -> ${d.to} (${d.reason}; ${scores}${blocked}${endpoint}${pin})`;
87
126
  }
88
127
 
89
128
  /**
90
- * Cost-side escape from the hysteresis margin (SPEC §7). `switchMargin` is a
129
+ * Cost-side escape from the hysteresis margin. `switchMargin` is a
91
130
  * flat band on `value`, so it can veto a switch worth up to `switchMargin / λ`
92
131
  * $/M while the incumbent is only marginally better — e.g. a role at λ 0.003
93
132
  * refuses up to $7/M of savings. A challenger inside that band is adopted when
@@ -167,6 +206,19 @@ function writePatch(configPath: string, dir: string, patch: ConfigPatch): { stat
167
206
  }
168
207
  }
169
208
 
209
+ /**
210
+ * The project dir omp reads for the current session (`<cwd>/.omp`), or null when
211
+ * the project carries no `omp-llm-role` settings entry — then the global
212
+ * behavior applies. omp resolves the project dir as `<cwd>/.omp` with no
213
+ * walk-up, so the plugin's project-scope read must use the same dir (not the
214
+ * walk-up anchor `findProjectAnchor` returns).
215
+ */
216
+ function resolveProjectDir(): string | null {
217
+ const projectDir = join(process.cwd(), ".omp");
218
+ const raw = readPluginSettingsMap({ global: join(projectDir, "plugins", "omp-plugins.lock.json"), project: null });
219
+ return Object.keys(raw).length > 0 ? projectDir : null;
220
+ }
221
+
170
222
  export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?: boolean; dryRun?: boolean }): Promise<RunResult> {
171
223
  const notifyAll = trigger !== "session-start" || opts?.dryRun === true;
172
224
  const abort = (lines: string[]): RunResult => {
@@ -174,9 +226,19 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
174
226
  return { decisions: [], wrote: false, aborted: lines[0] };
175
227
  };
176
228
  try {
177
- const state = loadState();
229
+ // Project-aware resolution (issue #27): when the project has its own role
230
+ // config, the whole run — config, state, history, lock — is scoped to
231
+ // `<cwd>/.omp`; otherwise the global agent dir is used exactly as before.
232
+ const projectDir = resolveProjectDir();
233
+ // Register the project in the global registry (issue #28) immediately after
234
+ // resolution and before any early return, so a day-stamped no-op session
235
+ // start still records it. Dry runs register nothing. The registry lives in
236
+ // the global agent dir, never the project's `.omp`.
237
+ if (projectDir !== null && opts?.dryRun !== true) registerProject(dirname(projectDir));
238
+ const dir = projectDir ?? agentDir();
239
+ const state = loadState(dir);
178
240
  const today = deps.nowUtcDay();
179
- const raw = (await deps.getSettings?.()) ?? readPluginSettingsMap();
241
+ const raw = (await deps.getSettings?.()) ?? readPluginSettingsMap(projectDir !== null ? { global: PLUGIN_SETTINGS_PATH, project: projectLockPath() } : undefined);
180
242
  const { settings, errors } = resolveSettings(raw);
181
243
  if (errors.length > 0) return abort([`omp-llm-role settings invalid, no write:`, ...errors.map((e) => ` ${e}`)]);
182
244
 
@@ -189,7 +251,6 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
189
251
  .map(([n]) => n),
190
252
  );
191
253
 
192
- const dir = agentDir();
193
254
  const configPath = join(dir, "config.yml");
194
255
  const agentDisables = agentDisablePatch(raw, settings, state.managedDisabledAgents);
195
256
  const nextManagedDisabled = agentDisables.agentDisableAdds;
@@ -216,8 +277,17 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
216
277
  return abort([`omp-llm-role: ranking data unavailable, no write: ${err instanceof Error ? err.message : err}`]);
217
278
  }
218
279
 
219
- const token = await deps.getToken();
220
- const keyMeta = (await deps.getKeyMeta?.(token)) ?? (await fetchKeyMeta(token));
280
+ // A key-fetch failure (a missing/expired token, an OpenRouter outage) is an
281
+ // enumerated environment abort, not a defect: notify-only, `defect` unset.
282
+ // The message text is unchanged from the catch-all it used to reach.
283
+ let token: string;
284
+ let keyMeta: KeyMeta;
285
+ try {
286
+ token = await deps.getToken();
287
+ keyMeta = (await deps.getKeyMeta?.(token)) ?? (await fetchKeyMeta(token));
288
+ } catch (err) {
289
+ return abort([`omp-llm-role: aborted, no write: ${err instanceof Error ? err.message : String(err)}`]);
290
+ }
221
291
  const tier = tierGate(keyMeta);
222
292
  if (tier === "none") {
223
293
  return abort([
@@ -228,6 +298,10 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
228
298
 
229
299
  const catalog = await deps.getCatalog();
230
300
  const eligible = filterCatalog(catalog, tier);
301
+ // Keyed-catalog availability: fetched once per run and shared
302
+ // across roles. Never throws — an unavailable allowlist degrades to the
303
+ // probe walk with no abort path.
304
+ const availability = await (deps.getKeyAvailability?.(token) ?? fetchKeyAvailability(token));
231
305
  const rowById = new Map(eligible.map((c) => [c.id, c]));
232
306
  enrichThinkingLevels(rank.models, catalog);
233
307
 
@@ -239,15 +313,58 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
239
313
  }
240
314
  const current = parseConfig(configText);
241
315
 
242
- // Per-role selection: rank -> tier/catalog filter -> probe gate ->
243
- // hysteresis -> suffix. The probe walk verifies candidates with one-token
244
- // completions because the key's allowed-providers privacy whitelist is
245
- // invisible to /api/v1/key and the catalog endpoints (SPEC §5 addendum).
316
+ // Per-role selection: rank -> tier/catalog filter -> availability gate ->
317
+ // hysteresis -> suffix. When the keyed catalog is active it classifies
318
+ // candidates without a request (allowed -> clean, key-blocked -> pruned);
319
+ // the probe walk still verifies candidates in neither catalog with
320
+ // one-token completions, because the key's allowed-providers privacy
321
+ // whitelist is otherwise invisible to /api/v1/key and the catalog
322
+ // endpoints.
246
323
  const decisions: Decision[] = [];
247
324
  const notes: string[] = [];
325
+ if (availability.active) {
326
+ notes.push(
327
+ `omp-llm-role: OpenRouter keyed catalog active — ${availability.keyedCount}/${availability.publicCount} models allowed; key-blocked candidates are pruned from the probe walk (up to ${PROBE_BUDGET} probes/role saved)`,
328
+ );
329
+ } else {
330
+ notes.push(
331
+ `omp-llm-role: OpenRouter keyed catalog ${availability.reason === "no-filter" ? "not filtering" : "unavailable"} — enable OpenRouter → Settings → "Filter the model catalog for API keys" to skip probing key-blocked models; the probe walk is still in use`,
332
+ );
333
+ }
334
+
335
+ // Allowed-providers privacy whitelist (spec #65): harvested once per run
336
+ // with a valid catalog model id (the canary 404s, so it bills no tokens).
337
+ // The whitelist is invisible to /api/v1/key and the catalog endpoints, so a
338
+ // route on a provider outside it would be written into config.yml and 404
339
+ // at request time. Pruning every model's route pool to allowed providers
340
+ // before ranking makes the soft basis, the auto-pin and the fallback chains
341
+ // whitelist-aware through the single `m.routes` seam. A null result (no
342
+ // whitelist set / harvest failed) leaves the pools untouched.
343
+ const canaryModelId = eligible[0]?.id;
344
+ const allowed =
345
+ canaryModelId === undefined
346
+ ? null
347
+ : await (deps.getAllowedProviders ? deps.getAllowedProviders(token, canaryModelId) : fetchAllowedProviders(token, canaryModelId));
348
+ if (allowed !== null) {
349
+ let prunedCount = 0;
350
+ const pruned = rank.models.map((m) => {
351
+ const routes = m.routes;
352
+ if (routes === undefined) return m;
353
+ const kept = routes.filter((r) => providerAllowed(allowed, r.providerSlug));
354
+ prunedCount += routes.length - kept.length;
355
+ return { ...m, routes: kept };
356
+ });
357
+ rank = { ...rank, models: pruned };
358
+ notes.push(
359
+ `omp-llm-role: OpenRouter allowed-providers whitelist active — ${allowed.size} providers; ${prunedCount} routes pruned`,
360
+ );
361
+ } else {
362
+ notes.push(`omp-llm-role: OpenRouter allowed-providers whitelist: no whitelist`);
363
+ }
248
364
  const poolByRole: Record<string, Candidate[]> = {};
249
- /** Per managed role: its bare chain key, role suffix, and the pool it was chosen from. */
250
- const chainPlanByRole: Record<string, { key: string; suffix: string | undefined; pool: Candidate[]; chosenIdx: number }> = {};
365
+ const chainPlanByRole: Record<string, ChainPlan> = {};
366
+ /** Roles whose `providerPin` matched no route: left unchanged, their current chain preserved. */
367
+ const pinUnmatched: string[] = [];
251
368
  const probe = deps.probeModel ?? probeModel;
252
369
  const probeVerdicts = new Map<string, ProbeVerdict>();
253
370
  for (const [role, def] of Object.entries(settings.roles)) {
@@ -259,12 +376,41 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
259
376
  const catalogId = resolveVariant(ranked.model.id, eligible, tier);
260
377
  if (catalogId !== null) candidates.push({ ranked, catalogId });
261
378
  }
262
- if (candidates.length === 0) continue; // nothing eligible — role untouched
379
+ if (candidates.length === 0) {
380
+ // A pin that matches no route must never silently unpin: leave the role
381
+ // unchanged (no selector write, no chain write) and say so. The ranking
382
+ // already drops route-less models for a pinned role, so an empty pool
383
+ // here means the pin matched nothing.
384
+ if (def.providerPin !== undefined) {
385
+ notes.push(`@${role}: providerPin "${def.providerPin}" matches no route — role untouched`);
386
+ pinUnmatched.push(role);
387
+ }
388
+ continue; // nothing eligible — role untouched
389
+ }
263
390
 
264
391
  const currentSelector = current.modelRoles[role] ?? null;
265
392
  const currentId = currentSelector === null ? null : currentRankingId(currentSelector);
266
393
  const currentIdx = currentId === null ? -1 : candidates.findIndex((c) => c.ranked.model.id === currentId);
267
394
 
395
+ // Keyed-catalog fast path: when the account's OpenRouter
396
+ // filter is on, membership in the keyed catalog is authoritative — an
397
+ // allowed candidate is clean without a probe, and a key-blocked one is
398
+ // pruned from the walk order entirely. Pruning (not pre-seeding a
399
+ // "blocked" verdict) is load-bearing: the budget counts candidates
400
+ // *examined*, so a pre-seeded blocked candidate would still consume it
401
+ // and reproduce the all-blocked-pool failure this path exists to fix.
402
+ const keyBlocked = (c: Candidate) => availability.active && availability.blocked.has(c.ranked.model.id);
403
+ if (availability.active) {
404
+ for (const c of candidates) {
405
+ if (availability.allowed.has(c.ranked.model.id)) probeVerdicts.set(c.catalogId, "ok");
406
+ }
407
+ }
408
+ const keyBlockedCount = candidates.filter(keyBlocked).length;
409
+ // A key-blocked current candidate is not the walk's current: it will
410
+ // never be kept, so the stop condition must not demand extra clean
411
+ // candidates beyond it.
412
+ const currentWalkIdx = currentIdx >= 0 && !keyBlocked(candidates[currentIdx]) ? currentIdx : -1;
413
+
268
414
  // Probe walk: current candidate first (hysteresis must see it), then rank
269
415
  // order. It stops only when 1 + chain depth clean candidates exist AND —
270
416
  // when the current candidate is clean — `fallbackChainDepth` clean
@@ -273,9 +419,9 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
273
419
  // the graceful degradation); verdicts cached across roles.
274
420
  const target = 1 + settings.fallbackChainDepth;
275
421
  const order: number[] = [];
276
- if (currentIdx >= 0) order.push(currentIdx);
422
+ if (currentWalkIdx >= 0) order.push(currentWalkIdx);
277
423
  for (let i = 0; i < candidates.length; i++) {
278
- if (i !== currentIdx) order.push(i);
424
+ if (i !== currentWalkIdx && !keyBlocked(candidates[i])) order.push(i);
279
425
  }
280
426
  const probed: number[] = [];
281
427
  const blockedForRole: string[] = [];
@@ -294,9 +440,9 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
294
440
  blockedForRole.push(id);
295
441
  } else {
296
442
  cleanCount++;
297
- if (currentIdx >= 0 && idx > currentIdx) cleanAfterCurrent++;
443
+ if (currentWalkIdx >= 0 && idx > currentWalkIdx) cleanAfterCurrent++;
298
444
  }
299
- const currentClean = currentIdx >= 0 && probeVerdicts.get(candidates[currentIdx].catalogId) !== "blocked";
445
+ const currentClean = currentWalkIdx >= 0 && probeVerdicts.get(candidates[currentWalkIdx].catalogId) !== "blocked";
300
446
  if (cleanCount >= target && (!currentClean || cleanAfterCurrent >= settings.fallbackChainDepth)) break;
301
447
  }
302
448
  const pool = probed
@@ -304,7 +450,8 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
304
450
  .sort((a, b) => a - b)
305
451
  .map((idx) => candidates[idx]);
306
452
  if (pool.length === 0) {
307
- notes.push(`@${role}: no probe-clean candidate among ${probed.length} probed (blocked: ${blockedForRole.join(", ")}) — role untouched`);
453
+ const keyed = availability.active ? `key-blocked: ${keyBlockedCount}, ` : "";
454
+ notes.push(`@${role}: no probe-clean candidate among ${probed.length} probed (${keyed}blocked: ${blockedForRole.join(", ")}) — role untouched`);
308
455
  continue;
309
456
  }
310
457
  poolByRole[role] = pool;
@@ -326,12 +473,26 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
326
473
  }
327
474
 
328
475
  const suffix = def.thinking;
476
+ // A provider pin rides as the selector's `@<slug>` suffix, before the
477
+ // thinking level (`openrouter/<id>@<slug>:<level>`) — omp's
478
+ // `splitUpstreamRouting` parses the trailing `@<slug>` and applies
479
+ // `compat.openRouterRouting = { only: [slug] }`.
480
+ //
481
+ // Auto-pin (spec #58): with the soft `preferOwnProvider` capability on and
482
+ // no manual pin, the chosen model's best route by the role's own value
483
+ // rides as that suffix, so the price the ranking assumed is the price the
484
+ // request pays. A manual `providerPin` is a hard gate and always wins; a
485
+ // model with no candidate route keeps default routing (bare selector).
486
+ const manualPin = def.providerPin;
487
+ const autoRoute = autoPinApplies(def) ? bestRoute(chosen.ranked.model, def) : null;
488
+ const pinSlug = manualPin ?? autoRoute?.providerSlug;
489
+ const pin = pinSlug !== undefined ? `@${pinSlug}` : "";
329
490
  const chosenRow = rowById.get(chosen.catalogId);
330
491
  // Append only a level the model's catalog thinking[] actually supports —
331
492
  // omp clamps unsupported levels, so an unsupported pin would run at a
332
493
  // different effort than the role intends (and than the ranking priced).
333
494
  const appendSuffix = suffix !== undefined && chosenRow !== undefined && (META_LEVELS[suffix] === true || chosenRow.thinking.includes(suffix));
334
- const finalSelector = `openrouter/${chosen.catalogId}${appendSuffix ? `:${suffix}` : ""}`;
495
+ const finalSelector = `openrouter/${chosen.catalogId}${pin}${appendSuffix ? `:${suffix}` : ""}`;
335
496
  decisions.push({
336
497
  role,
337
498
  from: currentSelector,
@@ -341,6 +502,10 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
341
502
  bestValue: best.ranked.value,
342
503
  currentValue: currentEntry?.ranked.value ?? null,
343
504
  blocked: blockedForRole,
505
+ endpointBlocked: endpointFilterDrops(def, rank.models),
506
+ pinBlocked: providerPinDrops(def, rank.models),
507
+ availabilitySource: availability.active ? "keyed-catalog" : "probe",
508
+ keyBlockedCount,
344
509
  });
345
510
 
346
511
  // Fallback chain inputs for every managed role: the bare key (a chain key
@@ -348,7 +513,7 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
348
513
  // came from. Values are built after the loop, once every key claim is known.
349
514
  if (settings.writeFallbackChains) {
350
515
  const key = suffix !== undefined && finalSelector.endsWith(`:${suffix}`) ? finalSelector.slice(0, -(suffix.length + 1)) : finalSelector;
351
- chainPlanByRole[role] = { key, suffix, pool, chosenIdx: pool.indexOf(chosen) };
516
+ chainPlanByRole[role] = { key, suffix, pin, pool, chosenIdx: pool.indexOf(chosen), def, autoPin: autoRoute !== null };
352
517
  }
353
518
  }
354
519
 
@@ -377,19 +542,49 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
377
542
  const cheaper = after.filter((p) => p.ranked.priceEff <= chosenPrice);
378
543
  const pricier = after.filter((p) => p.ranked.priceEff > chosenPrice);
379
544
  const chainPool = [...cheaper, ...pricier].slice(0, settings.fallbackChainDepth);
380
- chainUpserts[plan.key] = [
381
- ...new Set(chainPool.map((p) => {
382
- const row = rowById.get(p.catalogId);
383
- const level = suffix !== undefined && row !== undefined && (META_LEVELS[suffix] === true || row.thinking.includes(suffix)) ? `:${suffix}` : "";
384
- return `openrouter/${p.catalogId}${level}`;
385
- })),
386
- ].filter((v) => v !== plan.key);
545
+ // A chain value's thinking level is appended only when the target's
546
+ // catalog entry advertises support (or the level is a meta level).
547
+ const levelFor = (catalogId: string): string => {
548
+ const row = rowById.get(catalogId);
549
+ return suffix !== undefined && row !== undefined && (META_LEVELS[suffix] === true || row.thinking.includes(suffix)) ? `:${suffix}` : "";
550
+ };
551
+ const values: string[] = [];
552
+ if (plan.autoPin) {
553
+ // Auto-pin chain (spec #58, expanded by #69): every gate-passing route
554
+ // of the primary model (all but the pinned best), then every
555
+ // gate-passing route of each fallback model, in the role's
556
+ // value-for-money score order (`rankedRoutes`, spec #71) — every entry
557
+ // bound to its route's provider, so a down primary
558
+ // provider falls back to the same model on another provider rather
559
+ // than a different model. `rankedRoutes` already applies every gate
560
+ // (non-degraded, endpoint filters, usable billed price, the
561
+ // maxPriceUsdPerM cap, the allowed-providers whitelist), so the chain
562
+ // carries exactly the routes the role can use. The dedup below
563
+ // collapses two routes that share a provider slug (same chain value),
564
+ // so the chain length is (R_primary − 1) + Σ R_fallback over distinct
565
+ // gate-passing provider slugs.
566
+ const chosenCandidate = plan.pool[plan.chosenIdx];
567
+ for (const r of rankedRoutes(chosenCandidate.ranked.model, plan.def).slice(1)) {
568
+ values.push(`openrouter/${chosenCandidate.catalogId}@${r.providerSlug}${levelFor(chosenCandidate.catalogId)}`);
569
+ }
570
+ for (const p of chainPool) {
571
+ for (const r of rankedRoutes(p.ranked.model, plan.def)) {
572
+ values.push(`openrouter/${p.catalogId}@${r.providerSlug}${levelFor(p.catalogId)}`);
573
+ }
574
+ }
575
+ } else {
576
+ // Manual pin (every value carries it) or no pin (bare values).
577
+ for (const p of chainPool) values.push(`openrouter/${p.catalogId}${plan.pin}${levelFor(p.catalogId)}`);
578
+ }
579
+ chainUpserts[plan.key] = [...new Set(values)].filter((v) => v !== plan.key);
387
580
  referenced.add(plan.key);
388
581
  }
389
582
  // Locked roles keep their current chain: the plugin neither rewrites nor
390
583
  // prunes it, so its key must count as referenced. A chain key is the
391
584
  // current selector minus a trailing `:level` (chain keys are level-free).
392
- for (const role of lockedRoles) {
585
+ // A role whose pin matched no route is likewise left unchanged, so its
586
+ // current chain must survive the prune.
587
+ for (const role of [...lockedRoles, ...pinUnmatched]) {
393
588
  const sel = current.modelRoles[role];
394
589
  if (sel === undefined) continue;
395
590
  const colon = sel.lastIndexOf(":");
@@ -414,7 +609,7 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
414
609
  const previousRoles = res.status === "written" && res.before !== null ? parseConfig(res.before).modelRoles : null;
415
610
 
416
611
  state.lastRunDay = today;
417
- state.managedRoles = [...decisions.map((d) => d.role), ...lockedRoles];
612
+ state.managedRoles = [...decisions.map((d) => d.role), ...lockedRoles, ...pinUnmatched];
418
613
  state.managedDisabledAgents = nextManagedDisabled;
419
614
  for (const d of decisions) state.roleLastSelector[d.role] = d.to;
420
615
  if (settings.writeFallbackChains) state.pluginWrittenChainKeys = [...referenced];
@@ -466,7 +661,9 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
466
661
  } catch (err) {
467
662
  const message = err instanceof ConfigEditError ? `config edit refused: ${err.message}` : err instanceof Error ? err.message : String(err);
468
663
  deps.notify([`omp-llm-role: aborted, no write: ${message}`]);
469
- return { decisions: [], wrote: false, aborted: message };
664
+ // An exception escaping the run is a defect (a bug), unlike the enumerated
665
+ // environment aborts above: mark it so the extension can report it.
666
+ return { decisions: [], wrote: false, aborted: message, defect: message };
470
667
  }
471
668
  }
472
669