omp-llm-role 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +202 -12
  2. package/agents/designer.md +4 -3
  3. package/docs/dev/README.md +44 -7
  4. package/docs/dev/agent-authoring.md +59 -1
  5. package/docs/dev/architecture.md +106 -28
  6. package/docs/dev/ci.md +163 -0
  7. package/docs/dev/current-state.md +364 -24
  8. package/docs/dev/data-sources.md +231 -38
  9. package/docs/dev/explorer.md +175 -46
  10. package/docs/dev/quirks.md +77 -4
  11. package/docs/dev/releasing.md +130 -17
  12. package/docs/dev/scoring.md +851 -27
  13. package/docs/dev/testing.md +253 -0
  14. package/docs/dev/writes-and-state.md +232 -28
  15. package/docs/llm-role-rankings.md +149 -148
  16. package/package.json +814 -80
  17. package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
  18. package/src/agent-architect.ts +148 -19
  19. package/src/agent-create.ts +800 -40
  20. package/src/agent-file.ts +2 -5
  21. package/src/architect-provenance.ts +16 -0
  22. package/src/availability.ts +191 -15
  23. package/src/benchmark-author.ts +9 -4
  24. package/src/benchmark-sources.ts +283 -18
  25. package/src/cli/create-role.ts +29 -1
  26. package/src/cli/llm-role-rank.ts +40 -1
  27. package/src/config-edit.ts +207 -80
  28. package/src/engine.ts +519 -75
  29. package/src/error-report.ts +562 -0
  30. package/src/explorer/boot.ts +36 -24
  31. package/src/explorer/explain.ts +128 -13
  32. package/src/explorer/scopes.ts +110 -0
  33. package/src/explorer/server.ts +126 -33
  34. package/src/extension.ts +464 -48
  35. package/src/features.ts +105 -0
  36. package/src/project-registry.ts +65 -0
  37. package/src/project-setup.ts +382 -0
  38. package/src/prompts/agent-creation-architect.md +2 -0
  39. package/src/prompts/project-profile-architect.md +33 -0
  40. package/src/prompts/project-profile-user.md +8 -0
  41. package/src/provider-display.ts +67 -0
  42. package/src/role-archetypes.ts +12 -2
  43. package/src/role-settings.ts +22 -0
  44. package/src/settings.ts +211 -24
  45. package/src/state.ts +1 -1
  46. package/src/updater.ts +234 -37
  47. package/web/app.js +434 -12
  48. package/web/index.html +1 -0
  49. package/web/style.css +44 -1
  50. package/docs/dev/spec.md +0 -633
package/src/engine.ts CHANGED
@@ -87,6 +87,10 @@ export type LlmStatsRow = {
87
87
  index_vision: number | null;
88
88
  index_tool_calling: number | null;
89
89
  index_long_context: number | null;
90
+ index_communication: number | null;
91
+ index_finance: number | null;
92
+ index_healthcare: number | null;
93
+ index_legal: number | null;
90
94
  // Benchmarks (0-1)
91
95
  gpqa_score: number | null;
92
96
  aime_2025_score: number | null;
@@ -95,6 +99,21 @@ export type LlmStatsRow = {
95
99
  mrcr_v2_score: number | null;
96
100
  terminal_bench_score: number | null;
97
101
  tau_bench_retail_score: number | null;
102
+ simpleqa_score: number | null;
103
+ hle_score: number | null;
104
+ mmmu_score: number | null;
105
+ mmmu_pro_score: number | null;
106
+ mmmlu_score: number | null;
107
+ browsecomp_score: number | null;
108
+ swe_bench_pro_score: number | null;
109
+ mcp_atlas_score: number | null;
110
+ apex_agents_score: number | null;
111
+ osworld_score: number | null;
112
+ scicode_score: number | null;
113
+ screenspot_pro_score: number | null;
114
+ charxiv_r_score: number | null;
115
+ frontiermath_score: number | null;
116
+ toolathlon_score: number | null;
98
117
  };
99
118
 
100
119
  export type Model = {
@@ -103,6 +122,9 @@ export type Model = {
103
122
  org: string;
104
123
  orgId: string;
105
124
  context: number | null;
125
+ /** llm-stats `release_date` (ISO date), carried for the focus-metric freshness
126
+ * gate; null when the row has no date. */
127
+ releaseDate: string | null;
106
128
  multimodal: boolean;
107
129
  /** supports reasoning (OpenRouter `supports_reasoning`) — the fallback gate
108
130
  * for the role's thinking price factor and the selector suffix at write
@@ -131,6 +153,10 @@ export type Model = {
131
153
  * is then capability-filled, see `applyWritingScores`. */
132
154
  writingBench: number | null;
133
155
  metrics: Record<string, number | null>;
156
+ /** Eligible standard-tier OpenRouter routes (the 1/price² blend pool) with their
157
+ * per-endpoint capability ceilings; set by `applyOpenRouterData`. Undefined when
158
+ * the model has no OpenRouter page data. The role endpoint filters gate on this. */
159
+ routes?: OpenRouterEndpointRecord[];
134
160
  };
135
161
 
136
162
  export type RoleDef = {
@@ -145,13 +171,47 @@ export type RoleDef = {
145
171
  /** metrics the model must have to be ranked at all for this role */
146
172
  required: string[];
147
173
  /** schema-capability filters applied before ranking eligibility */
148
- filters?: { image?: boolean };
174
+ filters?: {
175
+ image?: boolean;
176
+ /** require every kept route's endpoint to support tools (`supported_parameters`) */
177
+ tools?: boolean;
178
+ /** drop routes whose endpoint `context_length` is below this (tokens); 0 = off */
179
+ minContextTokens?: number;
180
+ /** drop routes whose endpoint `max_completion_tokens` is below this (tokens); 0 = off */
181
+ minOutputTokens?: number;
182
+ /** drop the model when its role-priced blend exceeds this ($/M); 0 = off */
183
+ maxPriceUsdPerM?: number;
184
+ /** soft throughput floor (tok/s); 0/absent = off. A route at or above the
185
+ * floor sorts before a route below it, but is never dropped. */
186
+ preferredMinThroughput?: number;
187
+ };
149
188
  /** explicit λ override ($ per quality point); default derives from the price weight */
150
189
  lambda?: number;
151
190
  /** thinking level appended to the role's selector (`:level`) when the chosen
152
191
  * catalog entry supports thinking; absent = bare (session default level).
153
192
  * Also scales the price axis for ranking (thinkingPriceFactor). */
154
193
  thinking?: SuffixLevel;
194
+ /** Provider slug the role's requests are pinned to (OpenRouter `@<slug>`
195
+ * routing); may be tiered like `deepinfra/fp8`. When set, the role is priced
196
+ * by that route — its billed price and p50 throughput — not the 1/price²
197
+ * blend, and a model with no matching route is ineligible. Absent = OpenRouter
198
+ * default routing (the blend, unchanged). */
199
+ providerPin?: string;
200
+ /** Assumed cache-hit rate (0–1) for the role's input tokens; 0/absent = off. When
201
+ * set, the role's effective input price blends the endpoint's cache-read price and
202
+ * its full input price (`hitRate·cacheRead + (1−hitRate)·input`) before the 3:1
203
+ * blend and the thinking factor — the ranking then reflects a cache-heavy agent
204
+ * loop's invoice, not the sticker price. A route with no cache-read price keeps its
205
+ * full input price. The weight basis stays the listed input price (the router's
206
+ * sort key), so a cheap cache on a lightly-weighted route does not dominate. */
207
+ cacheHitRate?: number;
208
+ /** Opt-in capability flags (`roles.<role>.features.<id>`); expanded by
209
+ * `expandFeatures` before ranking. `true`/`false` overrides the global flag. */
210
+ features?: Record<string, boolean>;
211
+ /** Soft provider preference (issue #40, re-based by #58): price a model on its
212
+ * best route by the role's own value when the role's `providerPinning` feature
213
+ * is on, else the 1/price² blend. Unlike `providerPin` this NEVER drops a model. */
214
+ preferOwnProvider?: boolean;
155
215
  /** Locked role: the plugin still ranks it (so it stays in the universe and its
156
216
  * sources are fetched) but never rewrites its `modelRoles` selector or fallback
157
217
  * chain, and never removes it. Enable/disable still applies. */
@@ -183,12 +243,18 @@ export type RankData = {
183
243
  // OpenRouter's default routing is price-based load balancing: a request goes to
184
244
  // ONE provider, picked among the stable standard-tier routes with probability
185
245
  // proportional to 1/price² (docs: "select one weighted by inverse square of the
186
- // price"). The enrichment is therefore the expected value over that distribution:
187
- // price = Σ(1/p²)·p / Σ(1/p²), throughput = Σ(1/p²)·t / Σ(1/p²) over the routes
188
- // that have throughput data (renormalized — a provider without recent traffic
189
- // can't contribute). flex/priority service tiers are excluded (only the :floor /
190
- // :nitro variants make them eligible), as are degraded routes (status ≠ 0 — they
191
- // are fallbacks), :batch variants and $0 :free tiers (1/p² blows up at 0).
246
+ // price"). The router sorts on the INPUT (prompt) price alone — verified against
247
+ // Tarun Chitra's "Caching Cheaters on OpenRouter" (64/64 splits, a 1,648-choice
248
+ // fit r=1.968); OpenRouter's docs are silent on the basis. The enrichment is
249
+ // therefore the expected value over that distribution, weighted by the input
250
+ // price: reported price = Σ(1/p_in²)·p_billed / Σ(1/p_in²) (the billed 3:1 blend
251
+ // is what a caller pays, so it stays the reported price), throughput =
252
+ // Σ(1/p_in²)·t / Σ(1/p_in²) over the routes that have throughput data
253
+ // (renormalized — a provider without recent traffic can't contribute).
254
+ // flex/priority service tiers are excluded (only the :floor / :nitro variants
255
+ // make them eligible), as are degraded routes (status ≠ 0 — they are fallbacks),
256
+ // :batch variants, $0 :free tiers and routes with no input price (1/p² blows up
257
+ // at 0, and the router cannot score a route without an input price).
192
258
  // Without page data the pool is the find route(s) alone (a :free variant never
193
259
  // contributes there either — it is a separate slug default routing never requests);
194
260
  // the pre-page max-p50/min-price path remains only for pools where no eligible
@@ -205,6 +271,13 @@ type OpenRouterFindData = {
205
271
  endpoint_perf: Record<string, OpenRouterPerf>;
206
272
  /** endpoint id -> blended $/M (3:1 in:out); only endpoints with usable prompt+completion pricing */
207
273
  endpoint_price: Record<string, number>;
274
+ /** endpoint id -> input-only $/M (prompt price) — the router's sort key and the 1/price² weight basis;
275
+ * only endpoints with usable prompt pricing */
276
+ endpoint_weight_price: Record<string, number>;
277
+ /** endpoint id -> cache-read input-only $/M (the endpoint's `pricing.input_cache_read`);
278
+ * only endpoints with usable cache-read pricing. A role's cache-hit rate blends this with
279
+ * `endpoint_weight_price` before the 3:1 blend (see `blendRoutePool`). */
280
+ endpoint_cache_read_price: Record<string, number>;
208
281
  };
209
282
 
210
283
  /** One provider route from a model page, narrowed: pricing blended to $/M (3:1
@@ -217,22 +290,81 @@ export type OpenRouterEndpointRecord = {
217
290
  status: number;
218
291
  free: boolean;
219
292
  variant: string;
293
+ /** billed $/M, 3:1 in:out blend — the reported price */
220
294
  price: number | null;
295
+ /** input-only $/M (prompt price) — the router's sort key, the 1/price² weight basis */
296
+ weightPrice: number | null;
297
+ /** cache-read input-only $/M (the endpoint's `pricing.input_cache_read`); null when the
298
+ * endpoint reports none — a role's cache-hit rate then uses the full input price
299
+ * (`weightPrice`), so missing data is not a silent discount. */
300
+ cacheReadPrice: number | null;
221
301
  tput: number | null;
222
302
  latency: number | null;
303
+ /** endpoint `context_length` (tokens); null when the field is absent */
304
+ contextLength: number | null;
305
+ /** endpoint `max_completion_tokens` (tokens); null when the field is absent */
306
+ maxCompletionTokens: number | null;
307
+ /** endpoint `supported_parameters` contains `tools`; null when the field is absent */
308
+ supportsTools: boolean | null;
223
309
  };
224
310
 
225
311
  /** OR slug -> its provider routes, from the model pages. */
226
312
  export type OpenRouterEndpointPages = Record<string, OpenRouterEndpointRecord[]>;
227
313
 
228
- export type OrEnrichment = { tput: number; latency: number | null; price: number | null; thinking: boolean };
314
+ export type OrEnrichment = { tput: number; latency: number | null; price: number | null; thinking: boolean; routes: OpenRouterEndpointRecord[] };
315
+
316
+ /** The effective billed $/M of one route under an assumed cache-hit rate. The route's
317
+ * billed 3:1 price is `price = (3·input + output)/4` on the listed input price; a hit
318
+ * rate `h` blends the cache-read input into the input term,
319
+ * `effInput = h·cacheRead + (1−h)·input`, so `billed = price + 3·(effInput − input)/4`
320
+ * (the output term is unchanged). With `h = 0`, no cache-read price, or no input price
321
+ * this is the route's listed `price` — missing data keeps the full input price, never a
322
+ * silent discount. Shared by the blend (`blendRoutePool`), the pin path (`rankRole`) and
323
+ * `roleMetricValue`. */
324
+ export function routeEffectivePrice(route: OpenRouterEndpointRecord, cacheHitRate = 0): number | null {
325
+ if (route.price == null) return null;
326
+ if (cacheHitRate <= 0 || route.cacheReadPrice == null || route.weightPrice == null) return route.price;
327
+ const input = route.weightPrice;
328
+ const effInput = cacheHitRate * route.cacheReadPrice + (1 - cacheHitRate) * input;
329
+ return route.price + (3 * (effInput - input)) / 4;
330
+ }
331
+
332
+ /** Expected price/throughput/latency of one request over a route pool under the
333
+ * router's default price-based load balancing: P(route i) ∝ 1/input_price_i² — the
334
+ * router sorts on the input (prompt) price alone, so the weight basis is
335
+ * `weightPrice`, not the reported 3:1 billed blend (and the cache-hit rate does not
336
+ * change routing: a cheap cache on a lightly-weighted route must not dominate). The
337
+ * billed per-route price is `routeEffectivePrice(route, cacheHitRate)`, so a role's
338
+ * assumed cache-hit rate blends the cache-read price in before the weighting;
339
+ * `cacheHitRate = 0` makes this byte-identical to the uncached blend. `price` is the
340
+ * w-weighted mean of those billed prices; throughput and latency renormalize over the
341
+ * routes that carry p50 data (a provider without recent traffic cannot contribute).
342
+ * Only routes with a usable billed and input price can be scored; an unscorable pool
343
+ * returns all-null. This is the model-level blend AND the pool a role with endpoint
344
+ * filters is priced on (issue #18). */
345
+ export function blendRoutePool(routes: OpenRouterEndpointRecord[], cacheHitRate = 0): { price: number | null; tput: number | null; latency: number | null } {
346
+ const usable = routes.filter((r) => r.price !== null && r.price > 0 && r.weightPrice !== null && r.weightPrice > 0);
347
+ if (usable.length === 0) return { price: null, tput: null, latency: null };
348
+ const weighted = usable.map((r) => ({ r, w: 1 / ((r.weightPrice as number) * (r.weightPrice as number)) }));
349
+ const wSum = weighted.reduce((a, x) => a + x.w, 0);
350
+ const price = weighted.reduce((a, x) => a + x.w * (routeEffectivePrice(x.r, cacheHitRate) as number), 0) / wSum;
351
+ const tputKnown = weighted.filter((x) => x.r.tput !== null);
352
+ const tputW = tputKnown.reduce((a, x) => a + x.w, 0);
353
+ const tput = tputW > 0 ? tputKnown.reduce((a, x) => a + x.w * (x.r.tput as number), 0) / tputW : null;
354
+ const latKnown = tputKnown.filter((x) => x.r.latency !== null);
355
+ const latW = latKnown.reduce((a, x) => a + x.w, 0);
356
+ const latency = latW > 0 ? latKnown.reduce((a, x) => a + x.w * (x.r.latency as number), 0) / latW : null;
357
+ return { price, tput, latency };
358
+ }
229
359
 
230
360
  /**
231
361
  * Build llm-stats model_id -> OpenRouter enrichment (throughput, latency, blended
232
362
  * price, thinking support). :batch variants are skipped (no perf data, half-price
233
- * async tier). Price and throughput are the 1/price²-weighted means over the pool's
234
- * stable standard-tier billed routes — the expected values under OpenRouter's default
235
- * price-based load balancing. The pool is the page's per-provider routes plus any
363
+ * async tier). Price and throughput are the 1/input-price²-weighted means over the
364
+ * pool's stable standard-tier billed routes — the expected values under OpenRouter's
365
+ * default price-based load balancing, whose sort key is the input (prompt) price
366
+ * alone. The reported price is the w-weighted mean of the BILLED 3:1 blend (what a
367
+ * caller pays); the weight basis is the input price. The pool is the page's per-provider routes plus any
236
368
  * find-row endpoint the page doesn't list; without page data it is the find route(s)
237
369
  * alone, so a :free variant never contributes there either (it is a separate slug
238
370
  * default routing never requests). Only when no eligible route carries throughput
@@ -242,7 +374,7 @@ export type OrEnrichment = { tput: number; latency: number | null; price: number
242
374
  * variant row advertising `supports_reasoning`.
243
375
  */
244
376
  export function buildOpenRouterEnrichment(data: OpenRouterFindData, pages?: OpenRouterEndpointPages): Record<string, OrEnrichment> {
245
- type Cand = { slug: string; id: string; free: boolean; think: boolean; status: number | null; variant: string; perf: OpenRouterPerf | null; price: number | null };
377
+ type Cand = { slug: string; id: string; free: boolean; think: boolean; status: number | null; variant: string; perf: OpenRouterPerf | null; price: number | null; weightPrice: number | null; cacheReadPrice: number | null };
246
378
  const bySuffix: Record<string, Cand[]> = {};
247
379
  for (const m of data.models) {
248
380
  const ep = m.endpoint;
@@ -259,6 +391,8 @@ export function buildOpenRouterEnrichment(data: OpenRouterFindData, pages?: Open
259
391
  variant,
260
392
  perf: data.endpoint_perf[ep.id] ?? null,
261
393
  price: data.endpoint_price[ep.id] ?? null,
394
+ weightPrice: data.endpoint_weight_price?.[ep.id] ?? null,
395
+ cacheReadPrice: data.endpoint_cache_read_price?.[ep.id] ?? null,
262
396
  });
263
397
  }
264
398
  const out: Record<string, OrEnrichment> = {};
@@ -281,27 +415,30 @@ export function buildOpenRouterEnrichment(data: OpenRouterFindData, pages?: Open
281
415
  free: c.free,
282
416
  variant: c.variant,
283
417
  price: c.price,
418
+ weightPrice: c.weightPrice,
419
+ cacheReadPrice: c.cacheReadPrice,
284
420
  tput: c.perf?.p50_throughput ?? null,
285
421
  latency: c.perf?.p50_latency ?? null,
422
+ // The find payload carries no capability ceilings — unstated (null), kept by the gate.
423
+ contextLength: null,
424
+ maxCompletionTokens: null,
425
+ supportsTools: null,
286
426
  };
287
427
  }
288
428
  const eligible = Object.values(pool).filter(
289
- (r) => r.serviceTier === null && r.status === 0 && !r.free && r.price !== null && r.price > 0 && !r.variant.endsWith(":batch"),
429
+ (r) =>
430
+ r.serviceTier === null &&
431
+ r.status === 0 &&
432
+ !r.free &&
433
+ r.price !== null &&
434
+ r.price > 0 &&
435
+ r.weightPrice !== null &&
436
+ r.weightPrice > 0 &&
437
+ !r.variant.endsWith(":batch"),
290
438
  );
291
- const withTput = eligible.filter((r) => r.tput !== null);
292
- if (eligible.length > 0 && withTput.length > 0) {
293
- // Expected price/throughput of one request under price-based load balancing:
294
- // P(route i) ∝ 1/price_i². Throughput renormalizes over the routes with data.
295
- const weighted = eligible.map((r) => ({ r, w: 1 / (r.price * r.price) }));
296
- const wSum = weighted.reduce((a, x) => a + x.w, 0);
297
- const price = weighted.reduce((a, x) => a + x.w * x.r.price, 0) / wSum;
298
- const tputKnown = weighted.filter((x) => x.r.tput !== null);
299
- const tputW = tputKnown.reduce((a, x) => a + x.w, 0);
300
- const tput = tputKnown.reduce((a, x) => a + x.w * (x.r.tput ?? 0), 0) / tputW;
301
- const latKnown = tputKnown.filter((x) => x.r.latency !== null);
302
- const latW = latKnown.reduce((a, x) => a + x.w, 0);
303
- const latency = latW > 0 ? latKnown.reduce((a, x) => a + x.w * (x.r.latency ?? 0), 0) / latW : null;
304
- out[suffix] = { tput, latency, price, thinking: cands.some((c) => c.think) };
439
+ const blend = blendRoutePool(eligible);
440
+ if (eligible.length > 0 && blend.tput !== null && blend.price !== null) {
441
+ out[suffix] = { tput: blend.tput, latency: blend.latency, price: blend.price, thinking: cands.some((c) => c.think), routes: eligible };
305
442
  continue;
306
443
  }
307
444
  // Fallback: the find route alone. Throughput is the highest-p50 variant, :free
@@ -320,7 +457,7 @@ export function buildOpenRouterEnrichment(data: OpenRouterFindData, pages?: Open
320
457
  const billed: number[] = [];
321
458
  for (const c of cands) if (!c.free && c.price != null && c.price > 0) billed.push(c.price);
322
459
  const price = billed.length === 0 ? null : Math.min(...billed);
323
- out[suffix] = { tput, latency, price, thinking: cands.some((c) => c.think) };
460
+ out[suffix] = { tput, latency, price, thinking: cands.some((c) => c.think), routes: eligible };
324
461
  }
325
462
  return out;
326
463
  }
@@ -336,6 +473,7 @@ export function applyOpenRouterData(models: Model[], orData: Record<string, OrEn
336
473
  m.thinking = or.thinking;
337
474
  m.throughput = or.tput;
338
475
  m.metrics.throughput = or.tput;
476
+ m.routes = or.routes;
339
477
  if (or.price != null) {
340
478
  priced++;
341
479
  m.price = or.price;
@@ -351,7 +489,7 @@ export function applyOpenRouterData(models: Model[], orData: Record<string, OrEn
351
489
  // ---------------------------------------------------------------------------
352
490
 
353
491
  /** Narrow one page endpoint record; null when the row isn't the expected shape. */
354
- function narrowEndpointRecord(row: unknown): OpenRouterEndpointRecord | null {
492
+ export function narrowEndpointRecord(row: unknown): OpenRouterEndpointRecord | null {
355
493
  if (typeof row !== "object" || row === null) return null;
356
494
  if (!("id" in row) || typeof row.id !== "string") return null;
357
495
  if (!("provider_slug" in row) || typeof row.provider_slug !== "string") return null;
@@ -360,9 +498,14 @@ function narrowEndpointRecord(row: unknown): OpenRouterEndpointRecord | null {
360
498
  const free = "is_free" in row && row.is_free === true;
361
499
  const variant = "model_variant_permaslug" in row && typeof row.model_variant_permaslug === "string" ? row.model_variant_permaslug : "";
362
500
  let price: number | null = null;
501
+ let weightPrice: number | null = null;
502
+ let cacheReadPrice: number | null = null;
363
503
  if ("pricing" in row && typeof row.pricing === "object" && row.pricing !== null) {
364
504
  const prompt = "prompt" in row.pricing ? Number(row.pricing.prompt) : Number.NaN;
365
505
  const completion = "completion" in row.pricing ? Number(row.pricing.completion) : Number.NaN;
506
+ const cacheRead = "input_cache_read" in row.pricing ? Number(row.pricing.input_cache_read) : Number.NaN;
507
+ if (Number.isFinite(prompt) && prompt >= 0) weightPrice = prompt * 1e6; // input-only $/M: the router's sort key
508
+ if (Number.isFinite(cacheRead) && cacheRead >= 0) cacheReadPrice = cacheRead * 1e6; // cache-read input-only $/M
366
509
  if (Number.isFinite(prompt) && Number.isFinite(completion) && prompt >= 0 && completion >= 0) {
367
510
  price = ((3 * prompt + completion) / 4) * 1e6; // USD/token -> blended $/M, 3:1 in:out
368
511
  }
@@ -373,7 +516,13 @@ function narrowEndpointRecord(row: unknown): OpenRouterEndpointRecord | null {
373
516
  if ("p50_throughput" in row.stats && typeof row.stats.p50_throughput === "number") tput = row.stats.p50_throughput;
374
517
  if ("p50_latency" in row.stats && typeof row.stats.p50_latency === "number") latency = row.stats.p50_latency;
375
518
  }
376
- return { id: row.id, providerSlug: row.provider_slug, serviceTier: tier, status, free, variant, price, tput, latency };
519
+ // Per-endpoint capability ceilings. A missing field yields null (kept by the
520
+ // role gate — missing data is not a capability failure).
521
+ const contextLength = "context_length" in row && typeof row.context_length === "number" ? row.context_length : null;
522
+ const maxCompletionTokens = "max_completion_tokens" in row && typeof row.max_completion_tokens === "number" ? row.max_completion_tokens : null;
523
+ const supportsTools =
524
+ "supported_parameters" in row && Array.isArray(row.supported_parameters) ? row.supported_parameters.includes("tools") : null;
525
+ return { id: row.id, providerSlug: row.provider_slug, serviceTier: tier, status, free, variant, price, weightPrice, cacheReadPrice, tput, latency, contextLength, maxCompletionTokens, supportsTools };
377
526
  }
378
527
 
379
528
  /** Extract the per-provider endpoint records from a model page's RSC flight. The
@@ -493,7 +642,7 @@ async function loadEndpointPages(slugs: string[], refresh: boolean): Promise<Ope
493
642
  * covered median overstates an unmeasured model; the uncovered cohort's own
494
643
  * mean general index sits at the 0.195 percentile of the covered field. A
495
644
  * below-median covered model therefore still scores below the fill — that
496
- * residual inversion is documented in README Known quirks. (A regression fill
645
+ * residual inversion is documented in `docs/dev/quirks.md`. (A regression fill
497
646
  * was rejected: a least-squares fit of percentile on the general index
498
647
  * saturates at 0 for ~19% of the uncovered eligible pool, and a
499
648
  * nearest-neighbour fill is discontinuous (0.49 jumps between models 0.06
@@ -567,6 +716,8 @@ export function parseFindData(v: unknown): ParsedFindData {
567
716
  perf[id] = { p50_latency: latency, p50_throughput: tput };
568
717
  }
569
718
  const price: Record<string, number> = {};
719
+ const weightPrice: Record<string, number> = {};
720
+ const cacheReadPrice: Record<string, number> = {};
570
721
  for (const row of d.models) {
571
722
  if (typeof row !== "object" || row === null || !("endpoint" in row)) continue;
572
723
  const ep: unknown = row.endpoint;
@@ -576,10 +727,13 @@ export function parseFindData(v: unknown): ParsedFindData {
576
727
  if (typeof id !== "string" || typeof pr !== "object" || pr === null) continue;
577
728
  const prompt = "prompt" in pr ? Number(pr.prompt) : Number.NaN;
578
729
  const completion = "completion" in pr ? Number(pr.completion) : Number.NaN;
730
+ const cacheRead = "input_cache_read" in pr ? Number(pr.input_cache_read) : Number.NaN;
731
+ if (Number.isFinite(prompt) && prompt >= 0) weightPrice[id] = prompt * 1e6; // input-only $/M: the router's sort key
732
+ if (Number.isFinite(cacheRead) && cacheRead >= 0) cacheReadPrice[id] = cacheRead * 1e6; // cache-read input-only $/M
579
733
  if (!Number.isFinite(prompt) || !Number.isFinite(completion) || prompt < 0 || completion < 0) continue;
580
734
  price[id] = ((3 * prompt + completion) / 4) * 1e6; // USD/token -> blended $/M, 3:1 in:out
581
735
  }
582
- return { find: { models: d.models, endpoint_perf: perf, endpoint_price: price }, data: d, designElo: extractDesignElo(d.models, "benchmarks" in d ? d.benchmarks : null) };
736
+ return { find: { models: d.models, endpoint_perf: perf, endpoint_price: price, endpoint_weight_price: weightPrice, endpoint_cache_read_price: cacheReadPrice }, data: d, designElo: extractDesignElo(d.models, "benchmarks" in d ? d.benchmarks : null) };
583
737
  }
584
738
 
585
739
  /**
@@ -697,11 +851,15 @@ const INDEX_METRICS: Record<string, true> = {
697
851
  vision: true,
698
852
  tool_calling: true,
699
853
  long_context: true,
854
+ index_communication: true,
855
+ index_finance: true,
856
+ index_healthcare: true,
857
+ index_legal: true,
700
858
  };
701
859
 
702
860
  /** Chance-level pass rates for benchmark metrics (guessing baseline = true zero
703
861
  * of skill). Benchmarks absent here guess ≈ 0 and use the raw pass rate. */
704
- const BENCHMARK_CHANCE: Record<string, number> = {
862
+ export const BENCHMARK_CHANCE: Record<string, number> = {
705
863
  gpqa: 0.25, // 4-way multiple choice
706
864
  };
707
865
 
@@ -789,6 +947,7 @@ export function buildModels(rows: LlmStatsRow[]): Model[] {
789
947
  org: r.organization,
790
948
  orgId: r.organization_id,
791
949
  context: r.context,
950
+ releaseDate: r.release_date,
792
951
  multimodal: r.multimodal === true,
793
952
  thinking: false,
794
953
  price: null,
@@ -806,6 +965,10 @@ export function buildModels(rows: LlmStatsRow[]): Model[] {
806
965
  vision: r.index_vision,
807
966
  tool_calling: r.index_tool_calling,
808
967
  long_context: r.index_long_context,
968
+ index_communication: r.index_communication,
969
+ index_finance: r.index_finance,
970
+ index_healthcare: r.index_healthcare,
971
+ index_legal: r.index_legal,
809
972
  gpqa: r.gpqa_score,
810
973
  aime: r.aime_2025_score,
811
974
  swe_bench: r.swe_bench_verified_score,
@@ -813,6 +976,21 @@ export function buildModels(rows: LlmStatsRow[]): Model[] {
813
976
  mrcr: r.mrcr_v2_score,
814
977
  terminal_bench: r.terminal_bench_score,
815
978
  tau_bench: r.tau_bench_retail_score,
979
+ simpleqa_score: r.simpleqa_score,
980
+ hle_score: r.hle_score,
981
+ mmmu_score: r.mmmu_score,
982
+ mmmu_pro_score: r.mmmu_pro_score,
983
+ mmmlu_score: r.mmmlu_score,
984
+ browsecomp_score: r.browsecomp_score,
985
+ swe_bench_pro_score: r.swe_bench_pro_score,
986
+ mcp_atlas_score: r.mcp_atlas_score,
987
+ apex_agents_score: r.apex_agents_score,
988
+ osworld_score: r.osworld_score,
989
+ scicode_score: r.scicode_score,
990
+ screenspot_pro_score: r.screenspot_pro_score,
991
+ charxiv_r_score: r.charxiv_r_score,
992
+ frontiermath_score: r.frontiermath_score,
993
+ toolathlon_score: r.toolathlon_score,
816
994
  price: null, // set by OpenRouter enrichment; the λ·$ penalty axis, never blended
817
995
  throughput: null,
818
996
  website: null, // set by applyDesignPercentiles (percentile + capability-consistent fill)
@@ -821,49 +999,291 @@ export function buildModels(rows: LlmStatsRow[]): Model[] {
821
999
  }));
822
1000
  }
823
1001
 
1002
+ /** True when the role declares any endpoint capability filter (tools/context/output). */
1003
+ export function hasEndpointFilters(filters: RoleDef["filters"]): boolean {
1004
+ if (!filters) return false;
1005
+ return filters.tools === true || (filters.minContextTokens ?? 0) > 0 || (filters.minOutputTokens ?? 0) > 0;
1006
+ }
1007
+
1008
+ /** True when a route satisfies every declared endpoint filter. A null capability
1009
+ * field is kept — missing data is not a capability failure. */
1010
+ export function routePassesEndpointFilters(route: OpenRouterEndpointRecord, filters: RoleDef["filters"]): boolean {
1011
+ if (!filters) return true;
1012
+ if (filters.tools === true && route.supportsTools === false) return false;
1013
+ if ((filters.minContextTokens ?? 0) > 0 && route.contextLength !== null && route.contextLength < (filters.minContextTokens as number)) return false;
1014
+ if ((filters.minOutputTokens ?? 0) > 0 && route.maxCompletionTokens !== null && route.maxCompletionTokens < (filters.minOutputTokens as number)) return false;
1015
+ return true;
1016
+ }
1017
+
1018
+ /** True when the model has at least one standard-tier route satisfying the role's
1019
+ * endpoint filters. A model with no route data has no capable route (fails). */
1020
+ export function modelPassesEndpointFilters(model: Model, filters: RoleDef["filters"]): boolean {
1021
+ if (!hasEndpointFilters(filters)) return true;
1022
+ return (model.routes ?? []).some((r) => routePassesEndpointFilters(r, filters));
1023
+ }
1024
+
1025
+ /** The model as a role with endpoint filters and/or a cache-hit rate sees it: the
1026
+ * route pool is narrowed to the standard-tier routes that satisfy every declared
1027
+ * filter, and `price`/`throughput` become the 1/price² blend over that pool — the
1028
+ * routes the router would actually choose from — with the role's cache-hit rate
1029
+ * blended into each route's billed price (`blendRoutePool`). Returns null when no
1030
+ * route survives (the model is ineligible for the role). A role with no endpoint
1031
+ * filter and no cache-hit rate gets the model unchanged. */
1032
+ export function endpointFilteredModel(m: Model, filters: RoleDef["filters"], cacheHitRate = 0): Model | null {
1033
+ if (!hasEndpointFilters(filters) && cacheHitRate <= 0) return m;
1034
+ const pool = (m.routes ?? []).filter((r) => routePassesEndpointFilters(r, filters));
1035
+ if (pool.length === 0) return null;
1036
+ const { price, tput } = blendRoutePool(pool, cacheHitRate);
1037
+ const effPrice = price ?? m.price;
1038
+ const effTput = tput ?? m.throughput;
1039
+ return { ...m, price: effPrice, throughput: effTput, metrics: { ...m.metrics, price: effPrice, throughput: effTput } };
1040
+ }
1041
+
1042
+ /** Ranking ids dropped by the role's endpoint filters: they pass every other
1043
+ * eligibility gate (required metrics, image, billed price) but have no capable
1044
+ * standard-tier route. For the decision log; empty when the role declares no
1045
+ * endpoint filter. */
1046
+ export function endpointFilterDrops(def: RoleDef, models: Model[]): string[] {
1047
+ if (!hasEndpointFilters(def.filters)) return [];
1048
+ return models
1049
+ .filter(
1050
+ (m) =>
1051
+ def.required.every((k) => m.metrics[k] != null) &&
1052
+ !(def.filters?.image && !m.multimodal) &&
1053
+ m.price != null &&
1054
+ !modelPassesEndpointFilters(m, def.filters),
1055
+ )
1056
+ .map((m) => m.id);
1057
+ }
1058
+
1059
+ /** The model's route matching the role's provider pin (exact, tiered verbatim);
1060
+ * null when the role is unpinned or the model has no such route. */
1061
+ export function pinnedRoute(model: Model, providerPin: string | undefined): OpenRouterEndpointRecord | null {
1062
+ if (providerPin === undefined) return null;
1063
+ return (model.routes ?? []).find((r) => r.providerSlug === providerPin) ?? null;
1064
+ }
1065
+
1066
+ /** The role's thinking-adjusted billed price for a model at `basePrice`: the
1067
+ * thinking factor applies only when the model actually runs the role's level
1068
+ * (catalog `thinkingLevels` wins; without it the `supports_reasoning` flag
1069
+ * gates). Mirrors the factor `rankRole` applies, so the route basis and the
1070
+ * ranking can never disagree. */
1071
+ export function thinkingAdjustedPrice(model: Model, def: RoleDef, basePrice: number): number {
1072
+ const levelFactor = def.thinking === undefined ? 1 : thinkingPriceFactor(def.thinking);
1073
+ if (levelFactor === 1) return basePrice;
1074
+ const levels = model.thinkingLevels;
1075
+ const runsAtLevel = levels
1076
+ ? levels.length > 0 && (def.thinking === undefined || META_LEVELS[def.thinking] === true || levels.includes(def.thinking))
1077
+ : model.thinking;
1078
+ return runsAtLevel ? basePrice * levelFactor : basePrice;
1079
+ }
1080
+
1081
+ /** The role's value of a model priced on `route` (null = the 1/price² blend):
1082
+ * the price-free quality composite q over the model's metrics with the route's
1083
+ * p50 throughput and cache-adjusted billed price, minus λ·priceEff. Returns null
1084
+ * when the model has no usable billed price. */
1085
+ function roleValueOnRoute(
1086
+ em: Model,
1087
+ def: RoleDef,
1088
+ route: OpenRouterEndpointRecord | null,
1089
+ cacheHitRate: number,
1090
+ lambda: number,
1091
+ levelFactor: number,
1092
+ qW: number,
1093
+ ): { q: number; priceEff: number; value: number; parts: Record<string, number> } | null {
1094
+ const basePrice = route !== null ? routeEffectivePrice(route, cacheHitRate) : em.price;
1095
+ if (basePrice == null) return null;
1096
+ const priceEff = levelFactor === 1 ? basePrice : thinkingAdjustedPrice(em, def, basePrice);
1097
+ let q = 0;
1098
+ const parts: Record<string, number> = {};
1099
+ for (const [metric, w] of Object.entries(def.weights)) {
1100
+ if (metric === "price") continue; // cost enters as the λ·$ penalty, not the blend
1101
+ const raw = roleMetricValue(em, metric, route, cacheHitRate);
1102
+ if (raw == null) {
1103
+ // Sparse capability metrics are capability-filled (below-median, not 0)
1104
+ // so absence is not a coverage penalty; every other metric contributes
1105
+ // nothing when missing.
1106
+ const fill = CAPABILITY_FILL[metric];
1107
+ if (fill === undefined) continue;
1108
+ const contrib = (w / qW) * fill;
1109
+ parts[metric] = contrib;
1110
+ q += contrib;
1111
+ continue;
1112
+ }
1113
+ const contrib = (w / qW) * cardinalMetric(metric, raw);
1114
+ parts[metric] = contrib;
1115
+ q += contrib;
1116
+ }
1117
+ return { q, priceEff, value: q - lambda * priceEff, parts };
1118
+ }
1119
+
1120
+ /** The model's routes that are candidates for the role's soft best-route basis,
1121
+ * sorted by the value-for-money score desc (ties: providerSlug asc, id asc). A
1122
+ * candidate is non-degraded (status === 0), passes
1123
+ * `routePassesEndpointFilters(route, def.filters)`, has a usable billed price,
1124
+ * and (when `filters.maxPriceUsdPerM > 0`) a thinking-adjusted price at or below
1125
+ * the cap.
1126
+ *
1127
+ * The score is `vfm = wT·tNorm − wP·pNorm`, where `tNorm`/`pNorm` are the
1128
+ * route's p50 throughput and cache-adjusted billed price min/max-normalized over
1129
+ * the candidate set, and `wT = wTput/(wPrice+wTput)`, `wP = wPrice/(wPrice+wTput)`.
1130
+ * A route with no p50 uses the model's blended throughput (an absent p50 is
1131
+ * unknown, not slow). `filters.preferredMinThroughput` is a SOFT floor: routes at
1132
+ * or above it sort before routes below it, but are never dropped. */
1133
+ export function rankedRoutes(model: Model, def: RoleDef): OpenRouterEndpointRecord[] {
1134
+ const cacheHitRate = def.cacheHitRate ?? 0;
1135
+ const cap = def.filters?.maxPriceUsdPerM ?? 0;
1136
+ const floor = def.filters?.preferredMinThroughput ?? 0;
1137
+ const out = (model.routes ?? []).filter((r) => {
1138
+ if (r.status !== 0) return false;
1139
+ if (!routePassesEndpointFilters(r, def.filters)) return false;
1140
+ const p = routeEffectivePrice(r, cacheHitRate);
1141
+ if (p === null || p <= 0) return false;
1142
+ if (cap > 0 && thinkingAdjustedPrice(model, def, p) > cap) return false;
1143
+ return true;
1144
+ });
1145
+ if (out.length === 0) return [];
1146
+ // A route with no p50 falls back to the model's blended throughput over the
1147
+ // endpoint-filtered pool — the same basis `roleMetricValue` used before.
1148
+ const em = endpointFilteredModel(model, def.filters, cacheHitRate) ?? model;
1149
+ const wTput = def.weights.throughput ?? 0;
1150
+ const wPrice = def.weights.price ?? 0;
1151
+ const wSum = wTput + wPrice;
1152
+ const wT = wSum > 0 ? wTput / wSum : 0;
1153
+ const wP = wSum > 0 ? wPrice / wSum : 0;
1154
+ // Decorate-sort-undecorate: the throughput/price lookups are not free, so
1155
+ // compute each route's terms once.
1156
+ const decorated = out.map((route) => ({
1157
+ route,
1158
+ tput: roleMetricValue(em, "throughput", route, cacheHitRate) ?? 0,
1159
+ priceEff: routeEffectivePrice(route, cacheHitRate) as number,
1160
+ }));
1161
+ const tMin = Math.min(...decorated.map((d) => d.tput));
1162
+ const tMax = Math.max(...decorated.map((d) => d.tput));
1163
+ const pMin = Math.min(...decorated.map((d) => d.priceEff));
1164
+ const pMax = Math.max(...decorated.map((d) => d.priceEff));
1165
+ const tSpan = tMax - tMin;
1166
+ const pSpan = pMax - pMin;
1167
+ return decorated
1168
+ .map((d) => {
1169
+ const tNorm = tSpan > 0 ? (d.tput - tMin) / tSpan : 0;
1170
+ const pNorm = pSpan > 0 ? (d.priceEff - pMin) / pSpan : 0;
1171
+ return { route: d.route, score: wT * tNorm - wP * pNorm, aboveFloor: floor <= 0 || d.tput >= floor };
1172
+ })
1173
+ .sort((a, b) => {
1174
+ if (a.aboveFloor !== b.aboveFloor) return a.aboveFloor ? -1 : 1;
1175
+ if (a.score !== b.score) return b.score - a.score;
1176
+ if (a.route.providerSlug !== b.route.providerSlug) return a.route.providerSlug < b.route.providerSlug ? -1 : 1;
1177
+ return a.route.id < b.route.id ? -1 : a.route.id > b.route.id ? 1 : 0;
1178
+ })
1179
+ .map((d) => d.route);
1180
+ }
1181
+
1182
+ /** `rankedRoutes(model, def)[0]` or null. The auto-pin slug and the soft basis. */
1183
+ export function bestRoute(model: Model, def: RoleDef): OpenRouterEndpointRecord | null {
1184
+ return rankedRoutes(model, def)[0] ?? null;
1185
+ }
1186
+
1187
+ /** True when the role's soft best-route basis applies: the `preferOwnProvider`
1188
+ * capability is on and no manual `providerPin` overrides it. The soft basis the
1189
+ * engine prices on and the auto-pin the updater writes share this predicate. */
1190
+ export function autoPinApplies(def: RoleDef): boolean {
1191
+ return def.providerPin === undefined && def.preferOwnProvider === true;
1192
+ }
1193
+
1194
+ /** The route a role prices a model on: the hard pin's route when the role is
1195
+ * pinned, else the model's best route by the role's own value when the soft
1196
+ * `preferOwnProvider` preference is on, else null (the 1/price² blend). A
1197
+ * preference never drops a model — a model with no candidate route keeps the
1198
+ * blend. */
1199
+ export function roleRoute(model: Model, def: RoleDef): OpenRouterEndpointRecord | null {
1200
+ const pin = pinnedRoute(model, def.providerPin);
1201
+ if (pin !== null) return pin;
1202
+ if (!autoPinApplies(def)) return null;
1203
+ return bestRoute(model, def);
1204
+ }
1205
+
1206
+ /** The metric value a role sees for a model. A pinned role's throughput is its
1207
+ * route's p50, falling back to the model's blended throughput when the route has
1208
+ * no p50 (a sparse route must not zero the metric), and its price is the route's
1209
+ * cache-adjusted billed price (`routeEffectivePrice`); every other metric is the
1210
+ * model's own. */
1211
+ export function roleMetricValue(m: Model, metric: string, route: OpenRouterEndpointRecord | null, cacheHitRate = 0): number | null {
1212
+ if (route !== null) {
1213
+ if (metric === "throughput") return route.tput ?? m.throughput;
1214
+ if (metric === "price") return routeEffectivePrice(route, cacheHitRate);
1215
+ }
1216
+ return m.metrics[metric];
1217
+ }
1218
+
1219
+ /** Ranking ids dropped by the role's provider pin: they pass every other
1220
+ * eligibility gate (required metrics, image, billed price, endpoint filters) but
1221
+ * have no route matching the pin, so the request cannot be routed to it. For the
1222
+ * decision log; empty when the role is unpinned. */
1223
+ export function providerPinDrops(def: RoleDef, models: Model[]): string[] {
1224
+ if (def.providerPin === undefined) return [];
1225
+ return models
1226
+ .filter(
1227
+ (m) =>
1228
+ def.required.every((k) => m.metrics[k] != null) &&
1229
+ !(def.filters?.image && !m.multimodal) &&
1230
+ m.price != null &&
1231
+ modelPassesEndpointFilters(m, def.filters) &&
1232
+ pinnedRoute(m, def.providerPin) === null,
1233
+ )
1234
+ .map((m) => m.id);
1235
+ }
1236
+
824
1237
  export function rankRole(def: RoleDef, models: Model[]): Ranked[] {
825
1238
  const wPrice = def.weights.price ?? 0;
826
1239
  const qW = 1 - wPrice;
827
1240
  const lambda = roleLambda(def);
828
1241
  const levelFactor = def.thinking === undefined ? 1 : thinkingPriceFactor(def.thinking);
1242
+ // Assumed cache-hit rate (0 = off): blends each route's cache-read price into
1243
+ // its billed price. 0 makes every cache path byte-identical to the uncached one.
1244
+ const cacheHitRate = def.cacheHitRate ?? 0;
829
1245
  const ranked: Ranked[] = [];
830
1246
  for (const m of models) {
831
- if (def.required.some((k) => m.metrics[k] == null)) continue;
832
1247
  if (def.filters?.image && !m.multimodal) continue;
833
- if (m.price == null) continue; // value needs a billed price
834
-
835
- // The factor assumes the model runs at the role's level. omp clamps
836
- // unsupported levels, so a model whose catalog thinking[] excludes the
837
- // level is priced bare — matching the updater, which appends no suffix in
838
- // that case. Catalog data wins when present; without it (standalone
839
- // ranking), the OR supports_reasoning flag gates.
840
- const levels = m.thinkingLevels;
841
- const runsAtLevel = levels
842
- ? levels.length > 0 && (def.thinking === undefined || META_LEVELS[def.thinking] === true || levels.includes(def.thinking))
843
- : m.thinking;
844
- const priceEff = levelFactor === 1 || !runsAtLevel ? m.price : m.price * levelFactor;
845
-
846
- let q = 0;
847
- const parts: Record<string, number> = {};
848
- for (const [metric, w] of Object.entries(def.weights)) {
849
- if (metric === "price") continue; // cost enters as the λ·$ penalty, not the blend
850
- const raw = m.metrics[metric];
851
- if (raw == null) {
852
- // Sparse capability metrics are capability-filled (below-median, not 0)
853
- // so absence is not a coverage penalty; every other metric contributes
854
- // nothing when missing.
855
- const fill = CAPABILITY_FILL[metric];
856
- if (fill === undefined) continue;
857
- const contrib = (w / qW) * fill;
858
- parts[metric] = contrib;
859
- q += contrib;
860
- continue;
861
- }
862
- const contrib = (w / qW) * cardinalMetric(metric, raw);
863
- parts[metric] = contrib;
864
- q += contrib;
1248
+ // Provider pin: a pinned role prices and gates on the model's matching route,
1249
+ // not the 1/price² blend. A model with no matching route cannot be routed to
1250
+ // the pin and is ineligible (recorded by `providerPinDrops`).
1251
+ const pinRoute = pinnedRoute(m, def.providerPin);
1252
+ if (def.providerPin !== undefined && pinRoute === null) continue;
1253
+ // Soft best-route preference (issue #40, re-based by #58): with no hard pin,
1254
+ // a role that prefers a bound route is priced on the model's best route by
1255
+ // the role's own value, else on the 1/price² blend. This is a preference,
1256
+ // never a gate: a model with no candidate route keeps the blend, so the
1257
+ // eligible count is unchanged. `providerPinDrops`/`pinUnmatched` key on
1258
+ // `def.providerPin` only, so the preference never drops a model.
1259
+ const route = roleRoute(m, def);
1260
+ // Endpoint capability gate: a role that declares endpoint filters is priced on
1261
+ // the 1/price² blend over the routes that survive them — the pool the router
1262
+ // would actually choose from. A model whose every route fails (or that has no
1263
+ // route data) has no such pool and is ineligible; the find-route fallback does
1264
+ // not resurrect it. No endpoint filter leaves the model's own blend unchanged.
1265
+ // A cache-hit rate recomputes the blend over the same pool with the cache-read
1266
+ // price blended per route.
1267
+ const em = endpointFilteredModel(m, def.filters, cacheHitRate);
1268
+ if (em === null) continue;
1269
+ // The required-metric gate keys on the hard pin only (null when unpinned), so
1270
+ // the soft best-route preference can never change eligibility — it only
1271
+ // re-prices a model that already passes every gate.
1272
+ if (def.required.some((k) => roleMetricValue(em, k, pinRoute, cacheHitRate) == null)) continue;
1273
+ // The role's value on its route basis (the pin's route, the soft best route,
1274
+ // or the 1/price² blend). Null when the model has no usable billed price.
1275
+ const rv = roleValueOnRoute(em, def, route, cacheHitRate, lambda, levelFactor, qW);
1276
+ if (rv === null) continue;
1277
+ // maxPriceUsdPerM caps the model's own price basis, never the soft best-route
1278
+ // basis: with no hard pin the gate uses the model's blend price (thinking-
1279
+ // adjusted), so enabling `preferOwnProvider` cannot change eligibility. A hard
1280
+ // pin keeps gating its route price — the price the request actually pays. 0 = off.
1281
+ const cap = def.filters?.maxPriceUsdPerM ?? 0;
1282
+ if (cap > 0) {
1283
+ const capBase = pinRoute !== null ? routeEffectivePrice(pinRoute, cacheHitRate) : em.price;
1284
+ if (capBase != null && thinkingAdjustedPrice(em, def, capBase) > cap) continue;
865
1285
  }
866
- ranked.push({ model: m, value: q - lambda * priceEff, q, priceEff, parts });
1286
+ ranked.push({ model: em, value: rv.value, q: rv.q, priceEff: rv.priceEff, parts: rv.parts });
867
1287
  }
868
1288
  ranked.sort((a, b) => b.value - a.value);
869
1289
  return ranked;
@@ -973,8 +1393,9 @@ export function writeOrCache(path: string, fetchedAt: string, data: object, mode
973
1393
 
974
1394
  type EndpointsCacheFile = { fetchedAt: string; source: string; slugCount: number; slugs: OpenRouterEndpointPages };
975
1395
 
976
- /** Daily cache: current when fetchedAt is the current UTC day; requireFresh=false accepts stale. */
977
- function readEndpointsCache(path: string, requireFresh: boolean): EndpointsCacheFile | null {
1396
+ /** Daily cache: current when fetchedAt is the current UTC day; requireFresh=false accepts stale.
1397
+ * Exported for the shape-guard test (a cache predating a narrowed field must be rejected). */
1398
+ export function readEndpointsCache(path: string, requireFresh: boolean): EndpointsCacheFile | null {
978
1399
  let parsed: EndpointsCacheFile;
979
1400
  try {
980
1401
  parsed = JSON.parse(readFileSync(path, "utf8")) as EndpointsCacheFile;
@@ -982,6 +1403,23 @@ function readEndpointsCache(path: string, requireFresh: boolean): EndpointsCache
982
1403
  return null;
983
1404
  }
984
1405
  if (typeof parsed?.slugs !== "object" || parsed.slugs === null || Object.keys(parsed.slugs).length === 0) return null;
1406
+ // Records narrowed before the input-price weight basis lack `weightPrice`; refetch rather than
1407
+ // silently dropping every route from the blend (the old cache cannot supply the router's sort key).
1408
+ // Records narrowed before the endpoint capability ceilings lack `contextLength`; refetch so the
1409
+ // role gate reads real values instead of treating every route's ceiling as unstated (null).
1410
+ // Records narrowed before the cache-read price lack `cacheReadPrice`; refetch so a role's
1411
+ // `cacheHitRate` reads real values instead of treating every route's cache price as unstated
1412
+ // (null → full input price, which would leave the knob silently inert for the rest of the day).
1413
+ const sample = Object.values(parsed.slugs).find((recs) => Array.isArray(recs) && recs.length > 0)?.[0];
1414
+ if (
1415
+ typeof sample !== "object" ||
1416
+ sample === null ||
1417
+ !("weightPrice" in sample) ||
1418
+ !("contextLength" in sample) ||
1419
+ !("cacheReadPrice" in sample)
1420
+ ) {
1421
+ return null;
1422
+ }
985
1423
  if (requireFresh && parsed.fetchedAt?.slice(0, 10) !== new Date().toISOString().slice(0, 10)) return null;
986
1424
  return parsed;
987
1425
  }
@@ -1157,18 +1595,21 @@ async function loadDesignArenaBoards(refresh: boolean): Promise<{ website: Desig
1157
1595
  * falls back to a stale cache (non-fatal — affected models just go unranked).
1158
1596
  * Throws only when llm-stats has neither fresh cache nor a usable fetch (§9 abort).
1159
1597
  */
1160
- export async function loadRankData(opts?: { refresh?: boolean; url?: string; roles?: Record<string, RoleDef> }): Promise<RankData> {
1598
+ export async function loadRankData(opts?: { refresh?: boolean; url?: string; roles?: Record<string, RoleDef>; extraMetrics?: readonly string[] }): Promise<RankData> {
1161
1599
  const refresh = opts?.refresh ?? false;
1162
1600
  const url = opts?.url ?? DEFAULT_URL;
1601
+ const extraMetrics = new Set(opts?.extraMetrics ?? []);
1163
1602
  // Design Arena is the only source a role can depend on exclusively (`website`).
1164
1603
  // When the caller names the roles it will rank and none weights `website`, skip
1165
1604
  // the endpoint fetch entirely — the OpenRouter mirror (part of the find payload
1166
1605
  // fetched anyway) still populates `designElo`. Undefined roles = fetch (the
1167
- // standalone default, e.g. a caller that ranks every shipped role).
1168
- const needsDesignArena = opts?.roles === undefined || Object.values(opts.roles).some((r) => r.weights.website !== undefined);
1606
+ // standalone default, e.g. a caller that ranks every shipped role). An
1607
+ // `extraMetrics` entry forces the fetch too: the explorer's UI can weight any
1608
+ // shipped metric, so it passes the shipped keys even when no role weights them.
1609
+ const needsDesignArena = opts?.roles === undefined || extraMetrics.has("website") || Object.values(opts.roles).some((r) => r.weights.website !== undefined);
1169
1610
  // The writing leaderboard is the only source a role can depend on exclusively
1170
1611
  // (`writing`); skip its fetch when no ranked role weights it.
1171
- const needsWriting = opts?.roles === undefined || Object.values(opts.roles).some((r) => r.weights.writing !== undefined);
1612
+ const needsWriting = opts?.roles === undefined || extraMetrics.has("writing") || Object.values(opts.roles).some((r) => r.weights.writing !== undefined);
1172
1613
 
1173
1614
  const cached = refresh ? null : readCache(CACHE_PATH);
1174
1615
  let rows: LlmStatsRow[];
@@ -1310,8 +1751,10 @@ export async function loadRankData(opts?: { refresh?: boolean; url?: string; rol
1310
1751
  // does not ship resolves through the benchmark-source registry (a declared
1311
1752
  // source, or the generic llm-stats benchmark). Fetched only when a ranked role
1312
1753
  // weights the metric; `opts.roles === undefined` fetches every declared source.
1313
- // Non-fatal: a source that fails leaves its metric unfilled and the role ranks
1314
- // on the rest of its weights.
1754
+ // `extraMetrics` adds metrics no role weights (the explorer's UI can weight any
1755
+ // shipped key, so it passes them to keep its dataset a superset of any def it
1756
+ // can rank). Non-fatal: a source that fails leaves its metric unfilled and the
1757
+ // role ranks on the rest of its weights.
1315
1758
  const declared = loadDeclaredSources();
1316
1759
  const externalMetrics = new Set<string>();
1317
1760
  if (opts?.roles === undefined) {
@@ -1321,6 +1764,7 @@ export async function loadRankData(opts?: { refresh?: boolean; url?: string; rol
1321
1764
  for (const metric of Object.keys(def.weights)) externalMetrics.add(metric);
1322
1765
  }
1323
1766
  }
1767
+ for (const metric of extraMetrics) externalMetrics.add(metric);
1324
1768
  for (const metric of externalMetrics) {
1325
1769
  const source = sourceForMetric(metric, declared);
1326
1770
  if (source === null || source.external !== true || source.fetch === undefined) continue;