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
|
@@ -134,41 +134,139 @@ gating — that this plugin never reads (zero occurrences in `src/`, `web/`,
|
|
|
134
134
|
`tests/`). The plugin's `features.<id>` keys are plugin settings, not omp
|
|
135
135
|
feature flags; do not merge the two.
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
**`tokenProfile` (spec #81).** A global setting (`tokenProfile.inputTokens` /
|
|
138
|
+
`tokenProfile.outputTokens`, flat dotted keys like the rest) — the per-task
|
|
139
|
+
token profile the explorer's `$/task` column falls back to when a role has no
|
|
140
|
+
recorded usage. `null` (the default) = unset, and the column reads `—`. It is
|
|
141
|
+
validated to `null` or a record whose two fields are finite numbers ≥ 0
|
|
142
|
+
(`tokenProfile: must be null or an object with inputTokens/outputTokens` /
|
|
143
|
+
`tokenProfile.<key>: must be a number >= 0`). It is **display-only**: it never
|
|
144
|
+
feeds the ranking, the reported `$/M` price, or any write. The recorded usage
|
|
145
|
+
takes precedence over it (`resolveTokenProfile`), so once a role has ledger rows
|
|
146
|
+
the setting is ignored for that role.
|
|
147
|
+
|
|
148
|
+
## The provider-pin auto-configuration (spec #58, routability-verified by #90)
|
|
138
149
|
|
|
139
150
|
When a role's **expanded** def sets `preferOwnProvider: true` (the
|
|
140
151
|
`providerPinning` capability's recommended bundle) and carries **no** manual
|
|
141
|
-
`providerPin`, the updater binds the role's requests to
|
|
142
|
-
|
|
152
|
+
`providerPin`, the updater binds the role's requests to one provider route by
|
|
153
|
+
writing that route's slug as the selector's `@<slug>` suffix:
|
|
143
154
|
|
|
144
155
|
```
|
|
145
156
|
openrouter/<id>@<slug>:<level>
|
|
146
157
|
```
|
|
147
158
|
|
|
148
159
|
omp's `splitUpstreamRouting` parses the trailing `@<slug>` and applies
|
|
149
|
-
`compat.openRouterRouting = { only: [slug] }
|
|
150
|
-
is the
|
|
151
|
-
|
|
152
|
-
*
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
`compat.openRouterRouting = { only: [slug] }`. The pin is the best route **the
|
|
161
|
+
probe accepts**, which is the route the role was priced on — the route the walk
|
|
162
|
+
tries first — so the price the ranking assumed is the price the request pays
|
|
163
|
+
*only while the walk does not skip that candidate*; a skipped candidate means the
|
|
164
|
+
route that replaced it pays, at its own price. The pin is re-derived on every
|
|
165
|
+
run, so it follows the ranking when the chosen model changes.
|
|
166
|
+
|
|
167
|
+
**The walk — the pin is probed before it is written.** The slug is **not** the
|
|
168
|
+
score-max route unconditionally: the updater walks the role's route order
|
|
169
|
+
(`rankedRoutes(chosenModel, def)` — the `vfm` order the chain also uses; see
|
|
170
|
+
scoring.md, *Route ordering*) and probes each candidate with `probeRoute`: a
|
|
171
|
+
one-token completion carrying `provider: { only: [slug] }`, the same request
|
|
172
|
+
skeleton and the same narrow no-allowed-providers pattern as `probeModel`. The
|
|
173
|
+
verdict vocabulary is `probeModel`'s:
|
|
174
|
+
|
|
175
|
+
- `blocked` — the narrow no-allowed-providers 404 — skips the route and the walk
|
|
176
|
+
continues to the next candidate;
|
|
177
|
+
- `ok` and **`unknown` are both usable**. A non-404 status (a 429, a 5xx), a 404
|
|
178
|
+
whose body does not carry the pattern, and a transport throw (a timeout, a
|
|
179
|
+
connection reset) are all `unknown`: a transient blip must never silently unpin
|
|
180
|
+
a role, and a rate limit is omp's runtime-fallback domain, not this plugin's.
|
|
181
|
+
|
|
182
|
+
Verdicts are cached per run by `catalogId@slug`, so a route shared by several
|
|
183
|
+
roles is probed once, and the walk is capped per role at **`ROUTE_PROBE_BUDGET =
|
|
184
|
+
12`** *new* probes (cached verdicts are free) — a pathological pool must not turn
|
|
185
|
+
the daily update into a long probe walk.
|
|
186
|
+
|
|
187
|
+
**Why a real request.** OpenRouter's endpoints API and its routing table
|
|
188
|
+
disagree: an endpoint can list `status: 0` with a full `supported_parameters`
|
|
189
|
+
list and 100 % uptime, pass the whitelist gate and carry a usable billed price,
|
|
190
|
+
and still 404 for `provider.only` — and the exclusion is dynamic (the same
|
|
191
|
+
request can succeed minutes later). The routing table is visible only in the 404
|
|
192
|
+
body of a request that failed, so a request is the only reliable signal. This is
|
|
193
|
+
exactly the failure the endpoint gates cannot see (spec #90: the live config held
|
|
194
|
+
`deepseek/deepseek-v4.1-flash@relace`, which 404'd on every revert).
|
|
195
|
+
|
|
196
|
+
**No routable candidate → no pin.** If every candidate is `blocked` (or the
|
|
197
|
+
budget runs out with no clean candidate), no pin is written: the selector stays
|
|
198
|
+
**bare** and OpenRouter routes the request itself, instead of omp failing on an
|
|
199
|
+
unroutable `@<slug>`. `Decision.pinDropped` is true in that case (see *State,
|
|
200
|
+
history, lock*), and the run notes it.
|
|
201
|
+
|
|
202
|
+
A manual `roles.<role>.providerPin` **wins** over the automatic choice and stays
|
|
203
|
+
a **hard** gate (a model with no matching route is ineligible). It is **never
|
|
204
|
+
probed and never replaced** — the probe applies only to the route the capability
|
|
205
|
+
itself chooses; clearing the manual pin resumes the automatic choice.
|
|
156
206
|
|
|
157
207
|
**Chain shape.** The primary's chain key is the selector minus the trailing
|
|
158
|
-
`:level` — so it **includes** the pin (`openrouter/<id>@<slug
|
|
159
|
-
order:
|
|
208
|
+
`:level` — so it **includes** the pin (`openrouter/<id>@<slug>`, or the bare
|
|
209
|
+
`openrouter/<id>` when the pin was dropped). Its values, in order:
|
|
160
210
|
|
|
161
|
-
1. the same model on **every** gate-passing route except the pinned
|
|
211
|
+
1. the same model on **every** gate-passing route except the pinned route, in
|
|
162
212
|
score order (the `vfm` route score desc — see scoring.md, *Route ordering*),
|
|
163
213
|
2. then, for each of the `fallbackChainDepth` fallback models, that model on
|
|
164
214
|
**every** gate-passing route, in score order.
|
|
165
215
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
216
|
+
**Which models are chained (spec #95).** The `fallbackChainDepth` fallback
|
|
217
|
+
**models** come from the role's whole **ranked candidate list** — the
|
|
218
|
+
tier-eligible ranking rows, in value order — not from the probe window (which is
|
|
219
|
+
`1 + fallbackChainDepth` models, the same size as the chain, so a reorder of it
|
|
220
|
+
can never reach a cheaper model at rank 4 or deeper). Two classes of candidate
|
|
221
|
+
are excluded, with the same predicates the probe walk uses: **key-blocked**
|
|
222
|
+
models (the account's key cannot use them) and models whose probe verdict is
|
|
223
|
+
`blocked`. An **unprobed** candidate stays — the chain's deeper entries are
|
|
224
|
+
never probe-verified (they are not a new class: chain entries are already
|
|
225
|
+
route-level unprobed, and omp fails over on an unroutable entry). `after` is
|
|
226
|
+
that list **below the chosen model** — the leader on a fresh pick, the kept
|
|
227
|
+
current candidate on a kept role (`kept-eligible` / `kept-margin`) — so the
|
|
228
|
+
`after` set and the floor both key off the model the selector actually names —
|
|
229
|
+
the previous `pool.slice(chosenIdx + 1)` keyed off the same index in the
|
|
230
|
+
probe-clean pool — and candidates ranked above the chosen are never chained. The
|
|
231
|
+
set is then split:
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
cheaper = after where priceEff <= chosen.priceEff AND q >= chosen.q · fallbackQualityFloor
|
|
235
|
+
pricier = after where priceEff > chosen.priceEff (never floor-filtered)
|
|
236
|
+
chain = [...cheaper, ...pricier].slice(0, fallbackChainDepth)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
so a cheaper-or-equal fallback keeps the role's quality (the floor is a fraction
|
|
240
|
+
of the chosen model's **own** `q`, so it reads the same on every role's quality
|
|
241
|
+
scale) and a cheap-but-weak candidate set cannot starve the chain (the pricier
|
|
242
|
+
bucket always fills any remaining depth with the next-best by value). Both
|
|
243
|
+
buckets keep value order, so a cheaper candidate deep in the ranking is reached
|
|
244
|
+
only when fewer than `fallbackChainDepth` cheaper-or-equal candidates clear the
|
|
245
|
+
floor — a bounded search, not a cheapest-first sort. A candidate priced exactly
|
|
246
|
+
at the chosen model's price whose `q` is below the floor is in **neither** bucket
|
|
247
|
+
and drops out. `fallbackQualityFloor` (a number in `[0, 1]`, default `0.9`; `0`
|
|
248
|
+
disables the floor, `1` requires an exact quality match — validated, never
|
|
249
|
+
silently clamped) is documented in the README's settings table. The floor gates
|
|
250
|
+
**chain membership only**: the primary choice, the hysteresis decision, the
|
|
251
|
+
ranking, the probe walk's stop condition and budget, the chain key, the route
|
|
252
|
+
expansion, the thinking levels, the dedup and the prune-on-pin-change are all
|
|
253
|
+
unchanged.
|
|
254
|
+
|
|
255
|
+
The exclusion is **by pinned slug, not by position**: the probe can pin a route
|
|
256
|
+
other than the score leader (so the leader rides as a fallback entry while the
|
|
257
|
+
pinned route does not), and with a **dropped pin** (the auto-pin applied, but no
|
|
258
|
+
candidate was routable) **no** route is excluded — the primary contributes every
|
|
259
|
+
gate-passing route. Length = `(R_primary − 1) + Σ R_fallback`, or `R_primary + Σ
|
|
260
|
+
R_fallback` with a dropped pin, where `R` is the number of distinct gate-passing
|
|
261
|
+
provider slugs for that model — data-driven, not a fixed cap. Entries dedup by
|
|
262
|
+
chain value, so two routes sharing a provider slug collapse to one. Every entry
|
|
263
|
+
carries the same `@<slug>` suffix (and the role's thinking level where the target
|
|
264
|
+
advertises it).
|
|
265
|
+
**Chain entries are not probed** — only the primary pin is: the chain carries
|
|
266
|
+
many routes and one unroutable entry is survivable (omp fails over), so probing
|
|
267
|
+
it would multiply the request cost for no benefit (out of scope, spec #90). A
|
|
268
|
+
model with no gate-passing route contributes nothing. The gates are the engine's
|
|
269
|
+
`rankedRoutes` set: non-degraded
|
|
172
270
|
(`status === 0`), the endpoint capability filters (`tools` / `minContextTokens` /
|
|
173
271
|
`minOutputTokens`), a usable billed price, and the `maxPriceUsdPerM` cap
|
|
174
272
|
(thinking-adjusted). The soft `filters.preferredMinThroughput` floor is a
|
|
@@ -179,16 +277,28 @@ pool to allowed providers before ranking, which is what makes `rankedRoutes`
|
|
|
179
277
|
whitelist-aware. The probe walk target is unchanged (`1 + fallbackChainDepth` —
|
|
180
278
|
it counts models, not routes).
|
|
181
279
|
|
|
280
|
+
**Outside the auto-pin.** The chosen model contributes nothing when the auto-pin
|
|
281
|
+
does not apply: with a manual `providerPin` (`autoPinApplies` is false, so the
|
|
282
|
+
route probe does not run either) or with the capability off, the chain carries
|
|
283
|
+
**one entry per `fallbackChainDepth` fallback model** —
|
|
284
|
+
`openrouter/<fallbackId>@<manualSlug>` with a manual pin, the bare
|
|
285
|
+
`openrouter/<fallbackId>` with none — each with the role's thinking level where
|
|
286
|
+
the target advertises it, and no route of the chosen model at all.
|
|
287
|
+
|
|
182
288
|
**Prune on a pin change.** Because the chain key includes the pin, a pin change
|
|
183
289
|
changes the key; the updater's chain prune (`state.pluginWrittenChainKeys`) drops
|
|
184
290
|
the old key so a stale pinned chain does not linger.
|
|
185
291
|
|
|
186
|
-
**No-ops.** The auto-pin writes nothing
|
|
187
|
-
(`preferOwnProvider` not `true`); a
|
|
188
|
-
|
|
189
|
-
route data; no route passes the role's
|
|
190
|
-
|
|
191
|
-
to
|
|
292
|
+
**No-ops.** The auto-pin writes nothing — and **the route probe is never
|
|
293
|
+
consulted** — when: the capability is off (`preferOwnProvider` not `true`); a
|
|
294
|
+
manual `providerPin` is set; the role is locked or disabled; the selector is not
|
|
295
|
+
an OpenRouter selector; the model has no route data; no route passes the role's
|
|
296
|
+
endpoint filters; or no candidate has a usable billed price (the last three leave
|
|
297
|
+
`rankedRoutes` empty, so there is no candidate to probe, and `pinDropped` stays
|
|
298
|
+
false — the pin is simply absent). The case where the model *has* candidates but
|
|
299
|
+
none is routable is different: that is the **dropped pin** above — still no pin,
|
|
300
|
+
but recorded (`pinDropped`). With the capability off the written config is
|
|
301
|
+
byte-identical to before.
|
|
192
302
|
|
|
193
303
|
## State, history, lock
|
|
194
304
|
|
|
@@ -209,6 +319,7 @@ settings entry the global behavior is unchanged.
|
|
|
209
319
|
|---|---|---|
|
|
210
320
|
| `llm-role-state.json` | `{ lastRunDay, managedRoles, roleLastSelector, pluginWrittenChainKeys, previousModelRoles, managedDisabledAgents }` | day gate, managed-role set, last selectors, plugin-owned chain keys, pre-write `modelRoles` snapshot, **managed `task.disabledAgents` names** |
|
|
211
321
|
| `llm-role-history.jsonl` | one JSON row per completed run: `{ ts, trigger, keyMeta{isFreeTier, limitRemaining, creditsRemaining}, decisions[] }` | append-only decision log |
|
|
322
|
+
| `llm-role-usage.jsonl` | one JSON row per assistant turn: `{ ts, role, model, provider, input, output, cacheRead, cacheWrite, cost }` | the per-role usage ledger (spec #81) the explorer's per-task price resolves its token profile from |
|
|
212
323
|
| `llm-role-projects.json` | `{ projects: [{ root, lastUsed }] }` | the plugin's memory of the projects where `/project-roles` was used (the explorer's scope list) |
|
|
213
324
|
| `.llm-role-refresh.lock` | `O_EXCL` create, holds `"<pid> <iso>"` | serializes concurrent session starts; stale (> 60 s) locks are unlinked and retried once |
|
|
214
325
|
| `llm-role-error-reports.json` | `{ reports: [{ fingerprint, label, title, firstSeen, lastSeen, createdDay?, offeredDay?, count, action, issueNumber?, issueUrl? }], dayCount: { day, count } }` | what the opt-in `errorReporting` path filed, offered or suppressed |
|
|
@@ -241,6 +352,24 @@ settings entry the global behavior is unchanged.
|
|
|
241
352
|
that feeds the 3-creations-per-day cap. Nothing is written when
|
|
242
353
|
`errorReporting` is `off`.
|
|
243
354
|
|
|
355
|
+
- `llm-role-usage.jsonl` is the per-role usage ledger (spec #81,
|
|
356
|
+
`src/usage-ledger.ts`). Unlike the two ledgers above it is **not** global: it
|
|
357
|
+
follows the plugin's state location — `<cwd>/.omp` in project mode,
|
|
358
|
+
`agentDir()` otherwise — so a project's usage stays separate from the
|
|
359
|
+
user-level profile. The extension records one row per assistant turn on
|
|
360
|
+
`turn_end` and `session_shutdown` (the backstop for a headless one-turn run),
|
|
361
|
+
read from `sessionManager.getBranch()` — the same entries the session jsonl
|
|
362
|
+
persists — into `resolveProjectDir() ?? agentDir()`, deduped by `ts|model` so
|
|
363
|
+
the two hooks cannot double-record. The file is **append-only** (a crash never
|
|
364
|
+
corrupts it) and **bounded**: `appendUsageRow` compacts to the newest
|
|
365
|
+
`USAGE_LEDGER_CAP = 2000` rows via a temp file + atomic rename, so a crash
|
|
366
|
+
mid-compaction leaves the original intact. Recording is **best-effort** — a
|
|
367
|
+
failure never breaks a session, and `appendUsageRow` never throws. Reading is
|
|
368
|
+
tolerant: a missing, unreadable or malformed file degrades to `[]`, and an
|
|
369
|
+
unparseable or wrongly-shaped line is skipped. The explorer reads the active
|
|
370
|
+
scope's ledger (a project scope its own `<root>/.omp`), so the per-task column
|
|
371
|
+
follows the scope.
|
|
372
|
+
|
|
244
373
|
- `previousModelRoles` is the full snapshot of the last `modelRoles` block
|
|
245
374
|
**before** the plugin changed it — a manual rollback aid; there is no rollback
|
|
246
375
|
command.
|
|
@@ -264,7 +393,13 @@ settings entry the global behavior is unchanged.
|
|
|
264
393
|
ids the role's endpoint filters dropped (no capable standard-tier route), rendered
|
|
265
394
|
`; endpoint-blocked: …` on the decision line; `pinBlocked[]` records the ranking ids
|
|
266
395
|
the role's provider pin dropped (no route matching the pin), rendered
|
|
267
|
-
`; pin-blocked: …`.
|
|
396
|
+
`; pin-blocked: …`. `routeBlockedSlugs[]` records the provider slugs the auto-pin's
|
|
397
|
+
route probe rejected on the chosen model, in walk order and deduped by slug
|
|
398
|
+
(two routes of one model can share a slug), rendered `; route-blocked: …`;
|
|
399
|
+
`pinDropped` is `true` when the auto-pin applies and the model has gate-passing
|
|
400
|
+
routes but no candidate survived the probe (or the `ROUTE_PROBE_BUDGET` ran
|
|
401
|
+
out), rendered `; pin dropped (no routable route)` — the selector is bare in
|
|
402
|
+
that case. Both are empty/false when no probe ran.
|
|
268
403
|
- Role removal (`/remove-agent`) goes through `removeRoleSettings` in
|
|
269
404
|
`src/role-settings.ts` — the same validate/backup/atomic-write path as
|
|
270
405
|
`writeRoleSettings`, deleting the role's flat dotted keys and any nested entry.
|