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.
- package/README.md +202 -12
- package/agents/designer.md +4 -3
- package/docs/dev/README.md +44 -7
- package/docs/dev/agent-authoring.md +59 -1
- package/docs/dev/architecture.md +106 -28
- package/docs/dev/ci.md +163 -0
- package/docs/dev/current-state.md +364 -24
- package/docs/dev/data-sources.md +231 -38
- package/docs/dev/explorer.md +175 -46
- package/docs/dev/quirks.md +77 -4
- package/docs/dev/releasing.md +130 -17
- package/docs/dev/scoring.md +851 -27
- package/docs/dev/testing.md +253 -0
- package/docs/dev/writes-and-state.md +232 -28
- package/docs/llm-role-rankings.md +149 -148
- package/package.json +814 -80
- package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
- package/src/agent-architect.ts +148 -19
- package/src/agent-create.ts +800 -40
- package/src/agent-file.ts +2 -5
- package/src/architect-provenance.ts +16 -0
- package/src/availability.ts +191 -15
- package/src/benchmark-author.ts +9 -4
- package/src/benchmark-sources.ts +283 -18
- package/src/cli/create-role.ts +29 -1
- package/src/cli/llm-role-rank.ts +40 -1
- package/src/config-edit.ts +207 -80
- package/src/engine.ts +519 -75
- package/src/error-report.ts +562 -0
- package/src/explorer/boot.ts +36 -24
- package/src/explorer/explain.ts +128 -13
- package/src/explorer/scopes.ts +110 -0
- package/src/explorer/server.ts +126 -33
- package/src/extension.ts +464 -48
- package/src/features.ts +105 -0
- package/src/project-registry.ts +65 -0
- package/src/project-setup.ts +382 -0
- package/src/prompts/agent-creation-architect.md +2 -0
- package/src/prompts/project-profile-architect.md +33 -0
- package/src/prompts/project-profile-user.md +8 -0
- package/src/provider-display.ts +67 -0
- package/src/role-archetypes.ts +12 -2
- package/src/role-settings.ts +22 -0
- package/src/settings.ts +211 -24
- package/src/state.ts +1 -1
- package/src/updater.ts +234 -37
- package/web/app.js +434 -12
- package/web/index.html +1 -0
- package/web/style.css +44 -1
- package/docs/dev/spec.md +0 -633
package/src/updater.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Orchestration
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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 ->
|
|
243
|
-
// hysteresis -> suffix.
|
|
244
|
-
//
|
|
245
|
-
//
|
|
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
|
-
|
|
250
|
-
|
|
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)
|
|
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 (
|
|
422
|
+
if (currentWalkIdx >= 0) order.push(currentWalkIdx);
|
|
277
423
|
for (let i = 0; i < candidates.length; i++) {
|
|
278
|
-
if (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 (
|
|
443
|
+
if (currentWalkIdx >= 0 && idx > currentWalkIdx) cleanAfterCurrent++;
|
|
298
444
|
}
|
|
299
|
-
const currentClean =
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|