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.
- package/README.md +75 -13
- package/docs/dev/architecture.md +35 -11
- package/docs/dev/ci.md +21 -1
- package/docs/dev/current-state.md +99 -27
- package/docs/dev/data-sources.md +33 -4
- package/docs/dev/explorer.md +126 -21
- package/docs/dev/quirks.md +32 -0
- package/docs/dev/releasing.md +63 -32
- package/docs/dev/scoring.md +153 -39
- package/docs/dev/testing.md +112 -24
- package/docs/dev/writes-and-state.md +86 -0
- package/docs/llm-role-rankings.md +126 -126
- package/package.json +92 -22
- package/src/agent-create.ts +4 -2
- package/src/availability.ts +87 -9
- package/src/cli/create-role.ts +1 -1
- package/src/engine.ts +190 -78
- package/src/explorer/boot.ts +43 -1
- package/src/explorer/explain.ts +113 -10
- package/src/explorer/levers.ts +100 -0
- package/src/explorer/pool.ts +46 -0
- package/src/explorer/server.ts +107 -23
- package/src/extension.ts +189 -16
- package/src/features.ts +9 -7
- package/src/provider-display.ts +67 -0
- package/src/settings.ts +39 -2
- package/src/updater.ts +97 -14
- package/src/usage-ledger.ts +127 -0
- package/web/app.js +156 -1
- package/web/style.css +3 -0
package/docs/dev/scoring.md
CHANGED
|
@@ -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
|
-
- `
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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-
|
|
168
|
-
cache, `default` role,
|
|
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*):
|
|
173
|
-
eligible, leader unchanged (`deepseek-v4.1-flash`, 0.
|
|
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*):
|
|
177
|
-
`priceEff` 0.
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
(unlike the hard `providerPin`, which drops a
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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:
|
|
204
|
-
7.8 and p95
|
|
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
|
|
256
|
-
|
|
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 (★)
|
package/docs/dev/testing.md
CHANGED
|
@@ -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. **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
174
|
-
|
|
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.
|