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.
@@ -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
- ## The provider-pin auto-configuration (spec #58)
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 the chosen model's best
142
- route by writing that route's provider slug as the selector's `@<slug>` suffix:
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] }`, so the price the ranking assumed
150
- is the price the request pays. The slug is `bestRoute(chosenModel, def)`'s
151
- `providerSlug` — the max-value-for-money candidate route (see scoring.md,
152
- *Route ordering*). The pin is re-derived on every run, so it follows the ranking when the
153
- chosen model changes. A manual `roles.<role>.providerPin` **wins** over the
154
- automatic choice and stays a **hard** gate (a model with no matching route is
155
- ineligible); clearing it resumes the automatic choice.
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>`). Its values, in
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 best, in
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
- Length = `(R_primary − 1) + Σ R_fallback`, where `R` is the number of distinct
167
- gate-passing provider slugs for that model — data-driven, not a fixed cap.
168
- Entries dedup by chain value, so two routes sharing a provider slug collapse to
169
- one. Every entry carries the same `@<slug>` suffix (and the role's thinking level
170
- where the target advertises it). A model with no gate-passing route contributes
171
- nothing. The gates are the engine's `rankedRoutes` set: non-degraded
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 when: the capability is off
187
- (`preferOwnProvider` not `true`); a manual `providerPin` is set; the role is
188
- locked or disabled; the selector is not an OpenRouter selector; the model has no
189
- route data; no route passes the role's endpoint filters; or the best route has no
190
- usable billed price. With the capability off the written config is byte-identical
191
- to before.
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.