omp-llm-role 1.1.0 → 1.3.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.
@@ -101,10 +101,13 @@ cache reads; the knob prices that, opt-in, without touching any shipped role.
101
101
  - `endpointFilteredModel(model, filters, cacheHitRate)` recomputes the blend when
102
102
  the role declares endpoint filters **or** a non-zero hit rate; the hit rate
103
103
  composes with the filters (filter the pool first, then cache-price it);
104
- - `roleEffectivePrice`/`routeEffectivePrice` is the single per-route accessor the
105
- blend, the pinned-route path and `roleMetricValue` share; a pinned role prices
106
- its route's cache-adjusted billed price, and `maxPriceUsdPerM` caps the
107
- thinking-adjusted `priceEff` (the same cap as the blend path);
104
+ - the route-price accessors — `routeInputPrice` (the cache-adjusted input term),
105
+ `routeEffectivePrice` (the billed 3:1 blend, which reuses the input accessor),
106
+ `routeOutputPrice`/`blendOutputPrice` (the inverse blend) — are what the
107
+ blend, the pinned-route path, `roleMetricValue` and the explorer's per-task
108
+ price share; a pinned role prices its route's cache-adjusted billed price, and
109
+ `maxPriceUsdPerM` caps the thinking-adjusted `priceEff` (the same cap as the
110
+ blend path);
108
111
  - `explainModel` mirrors all of it, carries `role.cacheHitRate`, and reports the
109
112
  cache-adjusted `cost.billedPrice`; the SPA cost line appends `· cache <rate>`
110
113
  when the rate is set;
@@ -137,12 +140,108 @@ route instead of the `1/price²` blend:
137
140
  - a model with **no matching route** is ineligible for that role
138
141
  (`providerPinDrops` → `Decision.pinBlocked`, rendered `; pin-blocked: …`);
139
142
  `explainModel` reports "no route matches the role's provider pin".
140
- - an **unpinned** role ignores `routes` entirely — the blend path is unchanged.
143
+ - an **unpinned** role ignores `routes` entirely — the blend path is unchanged —
144
+ **unless** it sets the soft `preferOwnProvider` policy (the `providerPinning`
145
+ capability), which prices it on its **best route** (the max-value-for-money
146
+ candidate route; see *Route ordering* and *Capability presets*).
141
147
 
142
148
  The pin is a cost-and-throughput posture, not a quality edit: it moves the price
143
149
  axis to the route's billed price and the throughput axis to the route's p50,
144
150
  while every other weighted metric stays the model's own.
145
151
 
152
+ ### Route ordering (the value-for-money score)
153
+
154
+ `rankedRoutes(model, def)` (`src/engine.ts`) is the one function that orders a
155
+ model's candidate routes; `bestRoute` is `rankedRoutes[0]`, and `roleRoute` (the
156
+ route `rankRole` prices the model on) and the updater's auto-pin both read it, so
157
+ the priced route, the written pin and the fallback chain move together.
158
+
159
+ - **Candidates.** A route is a candidate when it is non-degraded (`status === 0`),
160
+ passes `routePassesEndpointFilters(route, def.filters)`, carries a usable billed
161
+ price, and (when `filters.maxPriceUsdPerM > 0`) a thinking-adjusted price at or
162
+ below the cap. The allowed-providers whitelist is applied upstream by pruning
163
+ `m.routes` (see *Whitelist-aware route selection*).
164
+ - **The score.** Each candidate is scored
165
+ `vfm = wT·tNorm − wP·pNorm`, where `tNorm`/`pNorm` are the route's p50
166
+ throughput and cache-adjusted billed price **min/max-normalized over the
167
+ candidate set** (`tNorm = (tput − tMin)/(tMax − tMin)`, `pNorm = (priceEff −
168
+ pMin)/(pMax − pMin)`; a zero span normalizes to 0), and `wT =
169
+ wTput/(wPrice+wTput)`, `wP = wPrice/(wPrice+wTput)`. Sort desc; ties break by
170
+ `providerSlug` then `id`, so a re-run is deterministic. A route with no p50 uses
171
+ the model's blended throughput (an absent p50 is unknown, not slow).
172
+ - **The soft floor.** `filters.preferredMinThroughput` (tok/s; `0`/absent = off)
173
+ is a **preference, not a gate**: routes at or above the floor sort before
174
+ routes below it, and the score orders within each band. A below-floor route is
175
+ **never dropped** — it stays a last-resort candidate.
176
+ - **`routeValue` is deleted.** The old `q(route p50) − λ·route price` route sort
177
+ key is gone; the score above is the only route basis. (The model-level
178
+ `value = q − λ·priceEff` is unchanged — see *`λ` and `value`*.)
179
+
180
+ ### Whitelist-aware route selection
181
+
182
+ The updater narrows every model's route pool to the account's
183
+ **allowed-providers privacy whitelist** before ranking (data-sources.md,
184
+ *OpenRouter — allowed-providers whitelist*; architecture.md, *Data flow*). The
185
+ prune is the single change point — `roleRoute`, `bestRoute`, `rankedRoutes` and
186
+ `endpointFilteredModel` all read `m.routes` — so every route consumer above is
187
+ whitelist-aware: a hard `providerPin` on a blocked provider matches no route and
188
+ drops the model (`providerPinDrops`), the soft `preferOwnProvider` basis prices
189
+ each model on its best **allowed** route, the endpoint-filtered blend is computed
190
+ over the allowed pool, and the fallback chains carry only allowed routes. A
191
+ `null` whitelist (no whitelist set, harvest failed) leaves every pool untouched,
192
+ so the ranking is byte-identical to today. The whitelist is a route-selection
193
+ filter, not a re-pricing: the role-independent `1/price²` blend (`m.price`/
194
+ `m.throughput`) is left as the account-agnostic expected value, and a model with
195
+ no allowed route keeps that blend (the soft basis never drops a model).
196
+
197
+ ## Price levers (the explorer's readouts)
198
+
199
+ Two readouts make the price posture visible (spec #81). Both are pure
200
+ (`src/explorer/levers.ts`), computed on the pool the explorer is viewing, and
201
+ reuse the engine's own `roleLambda`/`rankRole`/`SUFFIX_LEVELS`/
202
+ `thinkingPriceFactor` — never a re-derivation.
203
+
204
+ ### The leader-flip threshold
205
+
206
+ The price weight at which a **cheaper** challenger overtakes the value leader.
207
+ Let `L = rankRole(def, models)[0]` (the value leader). For every challenger `X`
208
+ with `pEff_X < pEff_L` (strictly cheaper), the flip λ is
209
+ `(q_L − q_X)/(pEff_L − pEff_X)`; the threshold is the minimum over those
210
+ challengers:
211
+
212
+ ```
213
+ λ* = min over cheaper challengers X of (q_L − q_X)/(pEff_L − pEff_X)
214
+ w* = 20·λ* / (1 + 20·λ*)
215
+ ```
216
+
217
+ `w*` is the inverse of `roleLambda`'s `λ = w/(1−w)/20` (`P_REF_USD = 20`). The
218
+ readout reports the role's current `wPrice`, the flip `w*`, the distance
219
+ `w* − wPrice`, and the challenger that would take over. **No cheaper challenger
220
+ ⇒ `null`** — the readout is absent, never invented. The degenerate case (the
221
+ value leader is also the quality leader, where the naive
222
+ `(q_QL − q_VL)/(p_QL − p_VL)` is `0/0`) is handled by this same min formula: it
223
+ is *undefined*, not zero.
224
+
225
+ **Exactness.** The threshold is exact when the role's price basis is
226
+ weight-independent — the `1/price²` blend, or a hard `providerPin`. Under the
227
+ soft `preferOwnProvider` basis the route choice itself moves with the price
228
+ weight, so the threshold is a **first-order estimate**: measured on `smol`,
229
+ moving `w` from 0.45 to 0.9586 moves the leader's route `coreweave/fp8 →
230
+ decart/fp4`, so the flip is approximate there.
231
+
232
+ ### The thinking level's effect
233
+
234
+ `thinkingLevelEffect(def, models)` re-ranks the role at each thinking level and
235
+ reports the leader per level, with the level's price factor
236
+ `thinkingPriceFactor(level) = (1 + 3·0.25 + THINKING_TOKEN_OVERHEAD[level]) /
237
+ (1 + 3·0.25)`. It is **inert for a bare role** (no thinking level) — the readout
238
+ is `null`. The factor is a **price-sensitivity amplifier, not a hidden cost**: it
239
+ is multiplicative on `priceEff`, so it penalizes expensive models more in
240
+ absolute λ-units — which is why `auto` favours the cheap model. On the user's
241
+ live `default` def: `off` → Gemini 4 Argon $2.99, `auto` → Gemini 4 Argon $5.55,
242
+ `high` → DeepSeek-V4.1-Flash $0.17. The readout shows this effect; it never
243
+ frames the level as a cost to remove.
244
+
146
245
  ## Capability presets (feature flags)
147
246
 
148
247
  One named flag per opt-in capability (`features.<id>` global,
@@ -164,44 +263,55 @@ no flags the resolved roles are byte-identical to `DEFAULT_ROLES`. The effective
164
263
  def is what `rankRole`/`explainModel` see; the authored def (flags + explicit
165
264
  keys) is what the explorer edits and exports.
166
265
 
167
- What each recommendation does to the ranking math (grounded on the 2026-10-07
168
- cache, `default` role, 142 eligible):
266
+ What each recommendation does to the ranking math (grounded on the 2026-10-09
267
+ cache, `default` role, 146 eligible; the `providerPinning` bullet is re-measured
268
+ on the 2026-10-11 cache):
169
269
 
170
270
  - **`endpointCeilings`** (`filters.tools: true`, `filters.minOutputTokens:
171
271
  16384`) drops tool-incapable routes and routes whose output ceiling is below
172
- 16 384 tokens from the priced pool (see *Endpoint filters*): 142 → 132
173
- eligible, leader unchanged (`deepseek-v4.1-flash`, 0.579 $/M). 16 384 is the
272
+ 16 384 tokens from the priced pool (see *Endpoint filters*): 146 → 135
273
+ eligible, leader unchanged (`deepseek-v4.1-flash`, 0.37 $/M). 16 384 is the
174
274
  video's own threshold (19 endpoints cap output at or below it).
175
275
  - **`cachePricing`** (`cacheHitRate: 0.5`) reprices the role on a cache-heavy
176
- loop's invoice (see *Cache-read pricing*): 142 eligible, leader unchanged, its
177
- `priceEff` 0.579 → 0.508 (−12 %). 0.5 is the low end of the measured
276
+ loop's invoice (see *Cache-read pricing*): 146 eligible, leader unchanged, its
277
+ `priceEff` 0.3732 → 0.3522 (−5.6 %). 0.5 is the low end of the measured
178
278
  third-party range (51–69 %), so the preset never over-credits a cache.
179
- - **`providerPinning`** (`preferOwnProvider: true`) prices a model on its own
180
- lab's route where one exists, falling back to the default `1/price²` blend
181
- otherwise. It is a **per-model policy, not a scalar** — the sensible pin
182
- differs per model, so the bundle carries a policy field evaluated against the
183
- model's own routes at rank time, not a slug baked into the lock file. The lab
184
- match is a **slug prefix on the model's `orgId`** (exact, or `orgId` + `/`),
185
- with the display `org` as a second try; there is no alias map in v1. Measured
186
- coverage: 51 of the 149 routed models resolve to one of their own lab's routes
187
- (33 on an exact slug match, 17 on an `orgId` prefix, 1 on the display-`org`
188
- fallback), and 10 of 25 orgs match for no model (`zai-org` —
189
- its lab route slug is `z-ai` — `google`, `qwen`, `inclusionai`, `nvidia`,
190
- `microsoft`, `bytedance`, `thinking-machines`, `ibm`,
191
- `gryphe`). An unmatched model keeps the blend, so the capability
192
- **under-delivers rather than mis-pins**, and it **never changes eligibility**
193
- (unlike the hard `providerPin`, which drops a model with no matching route).
194
- The resolved route is the same basis a hard pin uses, so the preference moves
195
- the **throughput** axis as well as the billed price — a role that routes to the
196
- lab's endpoint gets that endpoint's p50 tok/s, not the blend's. Measured on the
197
- 2026-10-07 cache, the `default` leader's `priceEff` rises 0.579 → 0.975 while
198
- its `value` *rises* 0.8132 → 0.8172, because its own lab route's p50 throughput
199
- is higher than the blend's, so `q` moves too. That is deliberate (the pin path
200
- has always priced and measured on the pinned route); a role that wants the
201
- blend's throughput should leave the flag off.
279
+ - **`providerPinning`** (`{ preferOwnProvider: true, filters: {
280
+ preferredMinThroughput: 50 } }`) prices a model on its **best route** — the
281
+ max-value-for-money candidate route (see *Route ordering*: the `vfm` score
282
+ desc, with the soft 50 tok/s floor sorting below-floor routes last) among the
283
+ routes that pass the role's endpoint filters and the account's
284
+ allowed-providers whitelist (see *Whitelist-aware route selection*) — falling
285
+ back to
286
+ the default `1/price²` blend only when the model has no candidate route. It is
287
+ a **per-model policy, not a scalar** — the sensible pin differs per model, so
288
+ the bundle carries a policy field evaluated against the model's own routes at
289
+ rank time, not a slug baked into the lock file. `bestRoute` (`src/engine.ts`)
290
+ is `rankedRoutes(model, def)[0]` (see *Route ordering* for the candidate
291
+ criteria and the score). The basis is **soft**: a model with no
292
+ candidate route keeps the blend and is never dropped, so the capability
293
+ **never changes eligibility** (unlike the hard `providerPin`, which drops a
294
+ model with no matching route). The `maxPriceUsdPerM` gate is **basis-independent**:
295
+ with no hard pin it caps the model's own blend price, not the soft route's, so
296
+ the flag can neither add nor drop a model; the candidate-route cap filter only
297
+ keeps the written pin at or below the cap. Measured coverage on the 2026-10-11 cache: all
298
+ 144 routed models have a candidate route (the old own-lab slug-prefix rule
299
+ matched only 51 of 149), so the capability covers the whole routed field. The
300
+ resolved route is the same basis a hard pin uses, so the preference moves the
301
+ **throughput** axis as well as the billed price — a role that routes to the
302
+ best endpoint gets that endpoint's p50 tok/s, not the blend's. Measured on the
303
+ 2026-10-11 cache (`rankRole` over the full model set), the `default` leader is
304
+ `deepseek-v4.1-flash` either way: the blend prices it at `priceEff` 0.2317 /
305
+ `value` 0.8056 / `q` 0.8069, while the soft best-route basis prices it at
306
+ `priceEff` 0.1187 / `value` 0.8092 / `q` 0.8099 on its best route `decart/fp4`
307
+ ($0.0639 billed, 72 tok/s). The soft route is cheaper than the blend, so
308
+ `priceEff` falls and `value` rises; `q` moves too because the route's p50
309
+ throughput replaces the blend's. That is deliberate (the pin path has always
310
+ priced and measured on the pinned route); a role that wants the blend's
311
+ throughput should leave the flag off.
202
312
  - **`costCap`** (`filters.maxPriceUsdPerM: 10`) drops the priciest tail of the
203
- eligible pool: 142 → 135 (the top ≈5 %), leader unchanged. The pool's p90 is
204
- 7.8 and p95 8.9 $/M, and every role leader sits at 0.58–0.85 $/M, so the
313
+ eligible pool: 146 → 137 (the top ≈6 %), leader unchanged. The pool's p90 is
314
+ 7.8 and p95 11.1 $/M, and every role leader sits at 0.20–3.71 $/M, so the
205
315
  preset never excludes today's leader.
206
316
 
207
317
  **Interaction with the switch hysteresis.** `switchMargin`/`priceSwitchFraction`
@@ -242,7 +352,10 @@ A model ranks for a role only when:
242
352
  - `minContextTokens` filters the **endpoint** `context_length`, not the
243
353
  model-level `context`;
244
354
  - `maxPriceUsdPerM` caps the thinking-adjusted `priceEff` (the price the role
245
- actually pays), not the bare billed blend;
355
+ actually pays), not the bare billed blend. For a **pinned** role that is the
356
+ pin route's price; for a soft `preferOwnProvider` role it is the model's own
357
+ blend price (basis-independent, so the flag cannot change eligibility), while
358
+ the candidate-route filter keeps the auto-pin at or below the cap;
246
359
  - a role that declares `tools`/`minContextTokens`/`minOutputTokens` is **priced**
247
360
  on the surviving pool: `endpointFilteredModel` recomputes `price`/`throughput`
248
361
  as the `1/price²` blend over the routes that clear the filters
@@ -252,8 +365,9 @@ A model ranks for a role only when:
252
365
 
253
366
  `endpointFilterDrops(def, models)` lists the ranking ids the gate removed (they
254
367
  pass every other eligibility gate but have no capable route) for the decision
255
- log. `resolveSettings` validates all four; no shipped role sets an endpoint
256
- filter. Filters apply before ranking eligibility so percentile norms stay on the
368
+ log. `resolveSettings` validates the four endpoint-filter keys plus the soft
369
+ `filters.preferredMinThroughput`; no shipped role sets an endpoint filter.
370
+ Filters apply before ranking eligibility so percentile norms stay on the
257
371
  unfiltered pool.
258
372
 
259
373
  ## Pareto frontier (★)
@@ -52,28 +52,45 @@ passing.
52
52
  equal sets → no-filter; empty → unavailable; a keyed id absent from public →
53
53
  no-filter; aliases collapse; two parallel GETs, one authenticated; never
54
54
  throws.
55
- 11. **Endpoint ceilings** (`endpoint-ceilings.test.ts`) — the narrower reads
55
+ 11. **Allowed-providers whitelist** (`allowed-providers.test.ts`,
56
+ `whitelist.test.ts`) — `fetchAllowedProviders` parses the 404 body's
57
+ "permits only:" list into a set and returns `null` for a 200, a 404 without
58
+ the clause, or a transport throw, with the canary request shape (POST,
59
+ bearer, `provider.only`, `max_tokens: 1`); the updater prunes every model's
60
+ route pool to allowed providers before ranking, so a blocked best route
61
+ falls to the next-best allowed route as the auto-pin, the chain carries only
62
+ allowed routes, a bare entry matches a tiered slug by prefix, the notify line
63
+ reports the active whitelist and the pruned count (or "no whitelist"), and a
64
+ `null` whitelist reproduces today's output byte-for-byte.
65
+ 12. **Endpoint ceilings** (`endpoint-ceilings.test.ts`) — the narrower reads
56
66
  context/output/tools, a missing field is null; a failing route drops, a null
57
67
  field is kept; a model with no capable route is ineligible; a filtered role
58
68
  is priced on the surviving pool; `maxPriceUsdPerM` caps the thinking-adjusted
59
69
  price.
60
- 12. **Route pricing (provider pin)** (`route-pricing.test.ts`) — a pinned role
70
+ 13. **Route pricing (provider pin)** (`route-pricing.test.ts`) — a pinned role
61
71
  prices the matching route, not the blend; a tiered pin matches verbatim;
62
72
  `maxPriceUsdPerM` caps the route price; throughput is the route p50 with a
63
73
  blend fallback; no matching route → ineligible; the `@<slug>` selector and
64
- chain carry the pin; a pin matching no route leaves the role unchanged.
65
- 13. **Thinking price** (`thinking-price.test.ts`) — bare/off unadjusted; a level
74
+ chain carry the pin; a pin matching no route leaves the role unchanged. The
75
+ route-ordering score (spec #71): `rankedRoutes` orders by the value-for-money
76
+ score `vfm = wT·tNorm − wP·pNorm` (a throughput-heavy role picks a faster
77
+ route than a price-heavy role); the soft `filters.preferredMinThroughput`
78
+ floor sorts a below-floor route last **without dropping it**; a route with no
79
+ p50 uses the model's blended throughput (for both the score and the floor
80
+ check); equal scores break by `providerSlug` then `id`; `explainModel`'s
81
+ `autoPin` is the score-max route.
82
+ 14. **Thinking price** (`thinking-price.test.ts`) — bare/off unadjusted; a level
66
83
  scales only thinking-capable models; the frontier uses the effective price;
67
84
  `resolveSettings` validates thinking and rejects the legacy `suffixes` map;
68
85
  catalog `thinking[]` wins over the OR flag; meta levels never gate.
69
- 14. **OpenRouter blend** (`openrouter-blend.test.ts`) — price/throughput are the
86
+ 15. **OpenRouter blend** (`openrouter-blend.test.ts`) — price/throughput are the
70
87
  `1/price²`-weighted means over the page routes; flex/priority/degraded/free/
71
88
  `:batch` stay out; throughput renormalizes; the find-row fallback; the weight
72
89
  follows the input price.
73
- 15. **Writing metric** (`writing.test.ts`) — `parseWritingEvidence` keys finite
90
+ 16. **Writing metric** (`writing.test.ts`) — `parseWritingEvidence` keys finite
74
91
  scores and rejects junk; `applyWritingScores` fills uncovered models; a
75
92
  covered model ranks above an uncovered one.
76
- 16. **Benchmark sources** (`benchmark-sources.test.ts`) — `resolveBenchmarkSource`
93
+ 17. **Benchmark sources** (`benchmark-sources.test.ts`) — `resolveBenchmarkSource`
77
94
  maps an llm-stats benchmark page, a bare id, the writing leaderboard and a
78
95
  Design Arena link, `null` for an unknown host; `normalizeMetricKey` is
79
96
  dot-free; `parseBenchmarkPayload` reads the llm-stats `entries[]` and writing
@@ -91,7 +108,7 @@ passing.
91
108
  fields, `scoreMax`, join) and a metric colliding with a shipped key is
92
109
  rejected; `dryRunDeclaration` reports coverage/leader and rejects a zero-join;
93
110
  `extractBenchmarkLinks` pulls URLs from prose.
94
- 17. **Agent creation** (`create-agent.test.ts`) — a purpose fits the expected
111
+ 18. **Agent creation** (`create-agent.test.ts`) — a purpose fits the expected
95
112
  archetype; a `--weights` set violating Σ(non-price) = 1 − w_price is refused;
96
113
  an existing agent file without `--force` is refused and writes no role;
97
114
  `--dry-run` writes neither file; an architect `spec` replaces the
@@ -99,46 +116,58 @@ passing.
99
116
  share, is a no-op for the archetype's own specialist set, and keeps both
100
117
  invariants; the nine-axis gate (the maintenance, provenance and cross-source
101
118
  axes are annotations, never a drop).
102
- 18. **Agent removal** (`remove-agent.test.ts`) — the role's lock-file keys and the
119
+ 19. **Agent removal** (`remove-agent.test.ts`) — the role's lock-file keys and the
103
120
  agent file are deleted (backup written); a shipped default role, a reserved
104
121
  name and an invalid name are refused; nothing-to-remove errors; `--dry-run`
105
122
  writes nothing; the updater drops `modelRoles.<n>` and the plugin-managed
106
123
  `task.disabledAgents` entry.
107
- 19. **Agent pins / disable** (`agent-pins.test.ts`, `agent-disable.test.ts`) —
124
+ 20. **Agent pins / disable** (`agent-pins.test.ts`, `agent-disable.test.ts`) —
108
125
  `parseAgentPin` takes the first `@role`; `discoverAgentPins` scans the three
109
126
  scopes, most specific wins; the sync is not day-gated; an unrelated entry
110
127
  keeps its position; an unknown role is never added.
111
- 20. **Role enable / lock** (`role-enable.test.ts`, `role-lock.test.ts`) —
128
+ 21. **Role enable / lock** (`role-enable.test.ts`, `role-lock.test.ts`) —
112
129
  disabling drops the role from the resolved set and its `modelRoles` line; a
113
130
  locked role is not switched and its chain survives the prune; unlocking
114
131
  restores the switch.
115
- 21. **Settings** (`settings.test.ts`, `settings-schema.test.ts`) — opt-in roles;
132
+ 22. **Settings** (`settings.test.ts`, `settings-schema.test.ts`) — opt-in roles;
116
133
  `priceSwitchFraction` range; flat/nested merge; `required` as string or
117
134
  array; an external metric key is weightable, a dotted one rejected;
118
- `package.json` `omp.settings` deep-equals `deriveSettingsSchema()`.
119
- 22. **Explorer** (`explorer.test.ts`, `explorer-scopes.test.ts`) — rank deltas;
135
+ `package.json` `omp.settings` deep-equals `deriveSettingsSchema()`. The
136
+ `providerPinning` preset sets `preferOwnProvider: true` +
137
+ `filters.preferredMinThroughput: 50` (the registry row and the expanded def
138
+ agree), an explicit `filters.preferredMinThroughput` beats the preset, and
139
+ the schema carries the `roles.<role>.filters.preferredMinThroughput` row
140
+ (number, min 0, default 0). The `tokenProfile` setting (spec #81) defaults to
141
+ `null`, accepts a record with finite `inputTokens`/`outputTokens` ≥ 0, and
142
+ rejects a non-record or a negative/non-finite field; the schema carries the
143
+ `tokenProfile.inputTokens`/`tokenProfile.outputTokens` rows (number, min 0).
144
+ 23. **Explorer** (`explorer.test.ts`, `explorer-scopes.test.ts`) — rank deltas;
120
145
  contributions sum to `q`; `inverseCardinal` round-trips; Export preserves
121
146
  siblings and normalizes nested roles; the scope switcher; the availability
122
- overlay.
123
- 23. **Project scope** (`project-updater.test.ts`, `project-setup.test.ts`,
147
+ overlay. The per-task price (spec #81): `rankRows`/`explainModel` fill
148
+ `perTask` from a supplied profile and leave it `null` without one; the
149
+ `POST /api/pool` toggle returns the gated/pruned dataset in `as-written` mode
150
+ and the full one in `raw`; the bootstrap payload carries `poolMode` and
151
+ `tokenProfile`; the explain response carries `leaderFlip`/`thinkingLevels`.
152
+ 24. **Project scope** (`project-updater.test.ts`, `project-setup.test.ts`,
124
153
  `project-roles-command.test.ts`) — project mode scopes config/state/history/
125
154
  lock to `<cwd>/.omp`; the day gate is per scope; the registry write;
126
155
  `setupProject` add/drop/keep, `--dry-run`, `--force`; the command is
127
156
  registered.
128
- 24. **Session model** (`session-model.test.ts`) — the hook fires only after a real
157
+ 25. **Session model** (`session-model.test.ts`) — the hook fires only after a real
129
158
  write on a real `default` change; a dry run and a kept default skip it; a
130
159
  throwing hook is contained.
131
- 25. **Docs** (`docs.test.ts`) — every relative markdown link resolves; no
160
+ 26. **Docs** (`docs.test.ts`) — every relative markdown link resolves; no
132
161
  reference to a moved doc; the CHANGELOG shape; `package.json` `files` entries
133
162
  exist; the README has no dev-only headings.
134
- 26. **Architect provenance** (`architect-provenance.test.ts`) — the vendored
163
+ 27. **Architect provenance** (`architect-provenance.test.ts`) — the vendored
135
164
  prompt header matches `ARCHITECT_PROMPT_VERSION` and the version named in
136
165
  [`agent-authoring.md`](agent-authoring.md).
137
- 27. **Architect retry** (`agent-architect.test.ts`) — a malformed architect
166
+ 28. **Architect retry** (`agent-architect.test.ts`) — a malformed architect
138
167
  response is retried once and the parsed spec returned; an always-malformed
139
168
  run throws carrying the raw output; a `run()` failure propagates with no
140
169
  retry.
141
- 28. **Error reporting** (`error-report.test.ts`) — `reportUnexpectedError`
170
+ 29. **Error reporting** (`error-report.test.ts`) — `reportUnexpectedError`
142
171
  through its one entry point with fully injected deps: `off` → no fetch, no
143
172
  ledger write, `action: "off"`; `auto` + token → exactly one create whose body
144
173
  carries the fingerprint marker, plugin version, runtime, platform, label and
@@ -166,17 +195,64 @@ passing.
166
195
  aborts under one label differ; the in-process guard; and a ledger that
167
196
  tolerates junk and truncates a pathological stack — including a multi-byte
168
197
  one — to the 64 KB **byte** budget.
169
- 29. **CI/CD** (`ci.test.ts`) — the workflow parses and declares both jobs with
198
+ 30. **CI/CD** (`ci.test.ts`) — the workflow parses and declares both jobs with
170
199
  `release` needing `test`; the `test` job's command is identical to the
171
200
  pre-commit hook's; the tag trigger covers every CHANGELOG tag link and the
172
201
  package version; `.node-version` is the file the workflow reads; the release
173
- job declares `id-token: write` and references no npm token; the four
174
- lockstep version fields agree; no tracked file lives under `cache/`; and
202
+ job declares `id-token: write` and references no npm token; the release job
203
+ runs the `notes` guard before `npm publish` and both steps name the same
204
+ notes file; the four lockstep version fields agree; no tracked file lives
205
+ under `cache/`; and
175
206
  `scripts/release.ts` against fixture repos — `due`'s exit codes, `notes`'s
176
207
  five refusals and its exact section body, `cut --dry-run` writing nothing,
177
208
  and `cut` bumping the four fields, cutting the changelog, committing and
178
209
  tagging while refusing a dirty tree, an existing tag, an empty
179
210
  `## [Unreleased]` and a HEAD that is not `origin/main`.
211
+ 31. **Auto-pin** (`auto-pin.test.ts`) — the selector gains the best route's
212
+ `@<slug>`; the chain carries every gate-passing route of the primary (all but
213
+ the pinned best) then of each fallback model, in score order (the `vfm` route
214
+ score desc, spec #71), deduped by
215
+ provider slug; a degraded / over-`maxPriceUsdPerM` / endpoint-filter-failing /
216
+ whitelist-blocked route is excluded; the manual pin wins and the no-pin path
217
+ is bare; a re-run is idempotent and a pin change prunes the old key.
218
+ 32. **Provider display** (`provider-display.test.ts`,
219
+ `provider-display-wiring.test.ts`) — `providerPinOf` reads the single
220
+ `openRouterRouting.only[0]` (else `vercelGatewayRouting`), preferring
221
+ OpenRouter, and returns null for a missing/empty/multi-entry block or a
222
+ non-string entry; `providerPinOfSelector` parses the trailing `@<slug>` with
223
+ and without a thinking suffix and returns null without one;
224
+ `withProviderSuffix` appends, strips a previous suffix, is idempotent, and
225
+ returns the base name for a null pin; `applyProviderDisplay` mutates a
226
+ record's `name` and no-ops on a non-record or a non-string name. The wiring
227
+ (a fake `pi`/`ctx` recording handlers) suffixes the live model on
228
+ `session_start`, applies the pin parsed from `event.to` on
229
+ `retry_fallback_applied` (falling back to the live model's routing block),
230
+ applies the pin from `event.model` on `retry_fallback_succeeded`, re-derives
231
+ from the live model on `turn_start` (the silent-restore case), strips when
232
+ neither source carries a pin, never calls `pi.setModel`, and contains a
233
+ throwing live-model read.
234
+ 33. **Usage ledger** (`usage-ledger.test.ts`, `usage-ledger-wiring.test.ts`) —
235
+ `appendUsageRow` writes one row with the expected fields; `readUsageRows`
236
+ degrades to `[]` on a missing/malformed file and skips an unparseable or
237
+ wrongly-shaped line; the cap holds (the newest `USAGE_LEDGER_CAP` rows
238
+ survive a compaction); `roleTokenProfile` is the role's median per-turn
239
+ counts (or `null` with no rows) and `resolveTokenProfile` prefers the
240
+ recorded usage over the setting. The wiring (a fake `pi`/`ctx` with a
241
+ `sessionManager.getBranch()`) records one row per assistant turn on
242
+ `turn_end` and `session_shutdown`, dedupes by `ts|model` so the two hooks
243
+ cannot double-record, writes into `resolveProjectDir() ?? agentDir()`, and
244
+ contains a throwing branch read.
245
+ 34. **Explorer price levers** (`explorer-levers.test.ts`) — `leaderFlip` returns
246
+ the min-λ threshold over the strictly cheaper challengers, the current
247
+ `wPrice`, the distance and the challenger; `null` when no cheaper challenger
248
+ exists; the degenerate case (value leader = quality leader) is handled by the
249
+ same min formula, not `0/0`. `thinkingLevelEffect` returns one row per level
250
+ with the engine's own factor and the leader at that level, and `null` for a
251
+ bare role.
252
+ 35. **As-written pool** (`explorer-pool.test.ts`) — `asWrittenPool` applies the
253
+ catalog/tier gate (`resolveVariant`) and the allowed-providers prune
254
+ (`providerAllowed`), leaves the routes untouched on a `null` whitelist,
255
+ yields an empty pool on `tier: "none"`, and never mutates its input.
180
256
 
181
257
  ## Live checks
182
258
 
@@ -197,6 +273,18 @@ passing.
197
273
  derived metadata.
198
274
  - **Agent removal** — `/remove-agent --name <n>` in a session deletes both
199
275
  artifacts and the updater pass removes the config entries.
276
+ - **Explorer pool / per-task / levers** — `/explore-roles` in a session with a
277
+ recorded usage ledger: confirm the header's pool toggle switches the table
278
+ between the raw and as-written pools (the model count and the leader change),
279
+ the `$/task` column renders a value (or `—` with no profile), and the explain
280
+ panel shows the per-task breakdown and the **Price levers** section (the
281
+ leader-flip line and the per-level leader table). Verify in headless Chromium
282
+ (the SPA is not unit-tested).
283
+ - **Usage ledger** — run a real omp turn, then confirm `llm-role-usage.jsonl`
284
+ gained one row per assistant turn (the model, the role, the provider, the
285
+ token counts, the cost) in the plugin's state dir (`<project>/.omp` in project
286
+ mode, `agentDir()` otherwise), and that the explorer's `$/task` column then
287
+ fills for that role. A session with no completed turn writes no rows.
200
288
  - **CI/CD** — push a branch and open a PR (or push to `main`) and `gh run watch`
201
289
  the `test` job green; push a tag that disagrees with the package version and
202
290
  confirm the `release` job fails in the `notes` guard *before* any publish;
@@ -134,6 +134,73 @@ 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
+ **`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)
149
+
150
+ When a role's **expanded** def sets `preferOwnProvider: true` (the
151
+ `providerPinning` capability's recommended bundle) and carries **no** manual
152
+ `providerPin`, the updater binds the role's requests to the chosen model's best
153
+ route by writing that route's provider slug as the selector's `@<slug>` suffix:
154
+
155
+ ```
156
+ openrouter/<id>@<slug>:<level>
157
+ ```
158
+
159
+ omp's `splitUpstreamRouting` parses the trailing `@<slug>` and applies
160
+ `compat.openRouterRouting = { only: [slug] }`, so the price the ranking assumed
161
+ is the price the request pays. The slug is `bestRoute(chosenModel, def)`'s
162
+ `providerSlug` — the max-value-for-money candidate route (see scoring.md,
163
+ *Route ordering*). The pin is re-derived on every run, so it follows the ranking when the
164
+ chosen model changes. A manual `roles.<role>.providerPin` **wins** over the
165
+ automatic choice and stays a **hard** gate (a model with no matching route is
166
+ ineligible); clearing it resumes the automatic choice.
167
+
168
+ **Chain shape.** The primary's chain key is the selector minus the trailing
169
+ `:level` — so it **includes** the pin (`openrouter/<id>@<slug>`). Its values, in
170
+ order:
171
+
172
+ 1. the same model on **every** gate-passing route except the pinned best, in
173
+ score order (the `vfm` route score desc — see scoring.md, *Route ordering*),
174
+ 2. then, for each of the `fallbackChainDepth` fallback models, that model on
175
+ **every** gate-passing route, in score order.
176
+
177
+ Length = `(R_primary − 1) + Σ R_fallback`, where `R` is the number of distinct
178
+ gate-passing provider slugs for that model — data-driven, not a fixed cap.
179
+ Entries dedup by chain value, so two routes sharing a provider slug collapse to
180
+ one. Every entry carries the same `@<slug>` suffix (and the role's thinking level
181
+ where the target advertises it). A model with no gate-passing route contributes
182
+ nothing. The gates are the engine's `rankedRoutes` set: non-degraded
183
+ (`status === 0`), the endpoint capability filters (`tools` / `minContextTokens` /
184
+ `minOutputTokens`), a usable billed price, and the `maxPriceUsdPerM` cap
185
+ (thinking-adjusted). The soft `filters.preferredMinThroughput` floor is a
186
+ **preference, not a gate** — it sorts below-floor routes last but never drops
187
+ them, so a below-floor route still appears in the chain. The allowed-providers
188
+ whitelist is not a `rankedRoutes` filter: the updater prunes each model's route
189
+ pool to allowed providers before ranking, which is what makes `rankedRoutes`
190
+ whitelist-aware. The probe walk target is unchanged (`1 + fallbackChainDepth` —
191
+ it counts models, not routes).
192
+
193
+ **Prune on a pin change.** Because the chain key includes the pin, a pin change
194
+ changes the key; the updater's chain prune (`state.pluginWrittenChainKeys`) drops
195
+ the old key so a stale pinned chain does not linger.
196
+
197
+ **No-ops.** The auto-pin writes nothing when: the capability is off
198
+ (`preferOwnProvider` not `true`); a manual `providerPin` is set; the role is
199
+ locked or disabled; the selector is not an OpenRouter selector; the model has no
200
+ route data; no route passes the role's endpoint filters; or the best route has no
201
+ usable billed price. With the capability off the written config is byte-identical
202
+ to before.
203
+
137
204
  ## State, history, lock
138
205
 
139
206
  All under the agent dir (`agentDir()` in `src/state.ts`), next to the config
@@ -153,6 +220,7 @@ settings entry the global behavior is unchanged.
153
220
  |---|---|---|
154
221
  | `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** |
155
222
  | `llm-role-history.jsonl` | one JSON row per completed run: `{ ts, trigger, keyMeta{isFreeTier, limitRemaining, creditsRemaining}, decisions[] }` | append-only decision log |
223
+ | `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 |
156
224
  | `llm-role-projects.json` | `{ projects: [{ root, lastUsed }] }` | the plugin's memory of the projects where `/project-roles` was used (the explorer's scope list) |
157
225
  | `.llm-role-refresh.lock` | `O_EXCL` create, holds `"<pid> <iso>"` | serializes concurrent session starts; stale (> 60 s) locks are unlinked and retried once |
158
226
  | `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 |
@@ -185,6 +253,24 @@ settings entry the global behavior is unchanged.
185
253
  that feeds the 3-creations-per-day cap. Nothing is written when
186
254
  `errorReporting` is `off`.
187
255
 
256
+ - `llm-role-usage.jsonl` is the per-role usage ledger (spec #81,
257
+ `src/usage-ledger.ts`). Unlike the two ledgers above it is **not** global: it
258
+ follows the plugin's state location — `<cwd>/.omp` in project mode,
259
+ `agentDir()` otherwise — so a project's usage stays separate from the
260
+ user-level profile. The extension records one row per assistant turn on
261
+ `turn_end` and `session_shutdown` (the backstop for a headless one-turn run),
262
+ read from `sessionManager.getBranch()` — the same entries the session jsonl
263
+ persists — into `resolveProjectDir() ?? agentDir()`, deduped by `ts|model` so
264
+ the two hooks cannot double-record. The file is **append-only** (a crash never
265
+ corrupts it) and **bounded**: `appendUsageRow` compacts to the newest
266
+ `USAGE_LEDGER_CAP = 2000` rows via a temp file + atomic rename, so a crash
267
+ mid-compaction leaves the original intact. Recording is **best-effort** — a
268
+ failure never breaks a session, and `appendUsageRow` never throws. Reading is
269
+ tolerant: a missing, unreadable or malformed file degrades to `[]`, and an
270
+ unparseable or wrongly-shaped line is skipped. The explorer reads the active
271
+ scope's ledger (a project scope its own `<root>/.omp`), so the per-task column
272
+ follows the scope.
273
+
188
274
  - `previousModelRoles` is the full snapshot of the last `modelRoles` block
189
275
  **before** the plugin changed it — a manual rollback aid; there is no rollback
190
276
  command.