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.
- package/README.md +202 -12
- package/agents/designer.md +4 -3
- package/docs/dev/README.md +44 -7
- package/docs/dev/agent-authoring.md +59 -1
- package/docs/dev/architecture.md +106 -28
- package/docs/dev/ci.md +163 -0
- package/docs/dev/current-state.md +364 -24
- package/docs/dev/data-sources.md +231 -38
- package/docs/dev/explorer.md +175 -46
- package/docs/dev/quirks.md +77 -4
- package/docs/dev/releasing.md +130 -17
- package/docs/dev/scoring.md +851 -27
- package/docs/dev/testing.md +253 -0
- package/docs/dev/writes-and-state.md +232 -28
- package/docs/llm-role-rankings.md +149 -148
- package/package.json +814 -80
- package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
- package/src/agent-architect.ts +148 -19
- package/src/agent-create.ts +800 -40
- package/src/agent-file.ts +2 -5
- package/src/architect-provenance.ts +16 -0
- package/src/availability.ts +191 -15
- package/src/benchmark-author.ts +9 -4
- package/src/benchmark-sources.ts +283 -18
- package/src/cli/create-role.ts +29 -1
- package/src/cli/llm-role-rank.ts +40 -1
- package/src/config-edit.ts +207 -80
- package/src/engine.ts +519 -75
- package/src/error-report.ts +562 -0
- package/src/explorer/boot.ts +36 -24
- package/src/explorer/explain.ts +128 -13
- package/src/explorer/scopes.ts +110 -0
- package/src/explorer/server.ts +126 -33
- package/src/extension.ts +464 -48
- package/src/features.ts +105 -0
- package/src/project-registry.ts +65 -0
- package/src/project-setup.ts +382 -0
- package/src/prompts/agent-creation-architect.md +2 -0
- package/src/prompts/project-profile-architect.md +33 -0
- package/src/prompts/project-profile-user.md +8 -0
- package/src/provider-display.ts +67 -0
- package/src/role-archetypes.ts +12 -2
- package/src/role-settings.ts +22 -0
- package/src/settings.ts +211 -24
- package/src/state.ts +1 -1
- package/src/updater.ts +234 -37
- package/web/app.js +434 -12
- package/web/index.html +1 -0
- package/web/style.css +44 -1
- 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?: {
|
|
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
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// :
|
|
191
|
-
//
|
|
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
|
|
234
|
-
* stable standard-tier billed routes — the expected values under OpenRouter's
|
|
235
|
-
* price-based load balancing
|
|
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) =>
|
|
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
|
|
292
|
-
if (eligible.length > 0 &&
|
|
293
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
//
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1314
|
-
//
|
|
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;
|