auto-model-router 0.36.0 → 0.38.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 (40) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +117 -15
  3. package/omp-extension/remote-logic.ts +15 -0
  4. package/package.json +1 -1
  5. package/src/catalog/benchmark-feeds.ts +179 -41
  6. package/src/catalog/openrouter-catalog.ts +10 -2
  7. package/src/cli/connect.ts +89 -17
  8. package/src/cli/context-token.ts +133 -0
  9. package/src/cli/credential-store.ts +65 -20
  10. package/src/cli/refresh.ts +26 -1
  11. package/src/config/schema.ts +22 -0
  12. package/src/config/types.ts +22 -0
  13. package/src/cost/ledger-sql.ts +3 -2
  14. package/src/cost/ledger.ts +4 -0
  15. package/src/cost/types.ts +12 -0
  16. package/src/cost/views.ts +12 -0
  17. package/src/lib.ts +12 -0
  18. package/src/router/escalate.ts +11 -0
  19. package/src/router/types.ts +2 -1
  20. package/src/server/compaction-digest.ts +3 -0
  21. package/src/server/digest.ts +11 -0
  22. package/src/server/http.ts +46 -4
  23. package/src/server/turn.ts +6 -1
  24. package/src/util/requestid.ts +77 -0
  25. package/src/util/schema.ts +42 -3
  26. package/src/util/sqlite.ts +20 -1
  27. package/src/wire/openai/request.ts +8 -0
  28. package/src/wire/types.ts +12 -0
  29. package/test/benchmark-feeds.test.ts +194 -0
  30. package/test/config.test.ts +26 -0
  31. package/test/context-token.test.ts +301 -0
  32. package/test/escalate.test.ts +25 -0
  33. package/test/ledger-sql.test.ts +1 -1
  34. package/test/mcp-entry.test.ts +14 -5
  35. package/test/migrations.test.ts +11 -4
  36. package/test/reconfigure.test.ts +55 -0
  37. package/test/request-id.test.ts +424 -0
  38. package/test/schema.test.ts +2 -2
  39. package/test/trust-attribution.test.ts +2 -2
  40. package/test/turn.test.ts +55 -0
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.36.0",
10
+ "version": "0.38.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.36.0",
17
+ "version": "0.38.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -753,8 +753,9 @@ modelRoles:
753
753
 
754
754
  The router decides the concrete OpenRouter model **per turn**; omp only sees the
755
755
  virtual profile it picked. Every routed response carries
756
- `x-auto-model-router-model`, `x-auto-model-router-tier`, `x-auto-model-router-cost-usd`, and
757
- `x-auto-model-router-attempts`.
756
+ `x-auto-model-router-model`, `x-auto-model-router-tier`, `x-auto-model-router-cost-usd`,
757
+ `x-auto-model-router-attempts`, and `x-request-id` — the id the turn's ledger rows
758
+ are filed under (see [Request ids](#request-ids)).
758
759
 
759
760
  ---
760
761
 
@@ -764,8 +765,10 @@ The ledger records every dispatch: model decided and served, tier, provider,
764
765
  tokens (including cached), reported cost, time to first token, total latency,
765
766
  escalation signal, error, the agentdox context scope the turn carried (the
766
767
  project it belongs to; NULL for a turn that carried none, and for every row
767
- written before v0.19.0) and how many strings redaction removed from the request
768
- (NULL when redaction was off, and for every row written before v0.21.0). Three views aggregate it, all from the same
768
+ written before v0.19.0), how many strings redaction removed from the request
769
+ (NULL when redaction was off, and for every row written before v0.21.0) and the
770
+ `request_id` the turn answered (indexed; NULL for every row written before
771
+ v0.37.0 — see [Request ids](#request-ids)). Three views aggregate it, all from the same
769
772
  `buildUsageReport` in `src/cost/report.ts`:
770
773
 
771
774
  - `/router report` in omp — a fullscreen hub with the `/models` look: views
@@ -786,9 +789,10 @@ written before v0.19.0) and how many strings redaction removed from the request
786
789
  lists verdicts by model and the recent ones with the harness that gave them, and
787
790
  `GET /v1/router/decisions?harness=&days=|since=&slug=&tier=&limit=` is the decision trail
788
791
  itself, newest first, each turn with its reasons, the classifier's view, forecast against
789
- bill, escalation signal and verdicts (`?session=` narrows to one omp session, as `/router
790
- why` does). These are what a front door such as the team edition reads instead of the
791
- ledger file.
792
+ bill, escalation signal, verdicts and its `requestId` (`?session=` narrows to one omp
793
+ session, as `/router why` does; `?requestId=` narrows to the one request a customer
794
+ quoted, every attempt of it included). These are what a front door such as the team
795
+ edition reads instead of the ledger file.
792
796
  - `GET /v1/router/catalog[?policy=<X-Omp-Policy JSON>]` — the model catalog as data: every
793
797
  model the router knows, sorted by slug, with provider (`openrouter`, `ollama`, a named
794
798
  upstream's id), vendor, context, capabilities, prices in USD per million tokens and quality
@@ -830,6 +834,35 @@ to save money (after a burst, the next 15 minutes ran 84–1,577 successes per
830
834
  escalation already cover the retry. Use a spike as the cue to `/router pin`
831
835
  or deny a model for the session.
832
836
 
837
+ ### Request ids
838
+
839
+ Every turn is recorded under the id of the HTTP request it answered, so an id a
840
+ customer quotes names **that** turn instead of "whichever turn of theirs was
841
+ closest in time".
842
+
843
+ - **Read.** `X-Request-Id` on `/v1/chat/completions`, `/v1/responses` and
844
+ `/v1/messages`. It is opaque, and it comes from outside, so it is accepted
845
+ only when it is at most 128 characters of letters, digits and `. _ - : + =`
846
+ (every id in circulation: a UUID, a ULID, a `traceparent`, a proxy's own).
847
+ Anything else — too long, a control character, a newline, whitespace — is
848
+ refused **whole** rather than truncated or stripped: a repaired id looks
849
+ valid and matches no row.
850
+ - **Minted when absent.** A caller that sends no usable header gets one minted,
851
+ prefixed `amr-`, so every turn is addressable and a direct user of the router
852
+ has what a front door's customers have. The prefix is also refused *inbound*:
853
+ no caller can pass an id off as one the router minted.
854
+ - **Returned.** The turn's response carries `x-request-id` (the caller's value
855
+ unchanged, or the minted one), and every ledger row of the turn — each
856
+ escalation attempt, and a digest taken during it — carries it as `request_id`,
857
+ reported as `requestId` on `GET /v1/router/decisions`. `?requestId=` there
858
+ filters to exactly those rows; an id nothing was recorded under answers with
859
+ no entries, and a mangled one is a 400 rather than a silently ignored filter.
860
+ - **Both directions.** A caller that sends no header behaves exactly as before.
861
+ A row written before v0.37.0 reads as having no id (`null`), never as an
862
+ error, and is still found by every other filter. `/health` lists `request-id`
863
+ in `features`, so a front door can tell whether the router it is talking to
864
+ records ids before it relies on them.
865
+
833
866
  Spend follows the ledger's rule — the provider's reported cost when it gave
834
867
  one, else the usage-priced figure the router computed, else the forecast.
835
868
  Speed uses only clean streamed rows (TTFT recorded, no error); tokens/s is
@@ -1038,11 +1071,19 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
1038
1071
 
1039
1072
  The model that produced the rejected output never serves the retry, at this
1040
1073
  tier or the next. Signals that indict the *provider* rather than the tier —
1041
- `empty_completion`, `refusal`, and an error finish — first try a different
1042
- model in the **same** tier (bounded, like a 5xx failover) and only then step
1043
- up; structural signals (`malformed_tool_args`, `repeat_tool_call`, a truncated
1044
- tool call) escalate a tier directly. A client that hangs up after the finish
1045
- event has already arrived is treated as a completed turn, not an error.
1074
+ `empty_completion`, `refusal`, `content_filter`, and an error finish — first
1075
+ try a different model in the **same** tier (bounded, like a 5xx failover) and
1076
+ only then step up; structural signals (`malformed_tool_args`,
1077
+ `repeat_tool_call`, a truncated tool call) escalate a tier directly. A client
1078
+ that hangs up after the finish event has already arrived is treated as a
1079
+ completed turn, not an error.
1080
+
1081
+ A `content_filter` finish (the provider's filter stopping the completion,
1082
+ including Anthropic's `refusal` stop reason, which maps to it) rides the
1083
+ `refusal` trigger: the same failure class — the provider declining the work —
1084
+ so a config that already opts into refusal escalation gets it without a
1085
+ change. A filter finish that produced NO text is an `empty_completion`
1086
+ instead: nothing was filtered, so a plain retry is the fix.
1046
1087
 
1047
1088
  ### `hysteresis` — cache-aware model stickiness
1048
1089
 
@@ -1648,9 +1689,30 @@ left alone with a note. A remote without skills answers 404 and nothing happens.
1648
1689
 
1649
1690
  A remote whose `/setup/info` says `mcp: true` (a team edition serving shared context) also
1650
1691
  gets a `team-context` MCP server written for omp (`~/.omp/agent/mcp.json`) and Claude Code
1651
- (`~/.claude.json`): `type: http`, the remote's `/mcp`, the member key in the Authorization
1652
- header. Every refresh rewrites it with the current key, like models.yml; other servers in
1653
- those files are untouched, and a remote that stops serving MCP has the entry removed.
1692
+ (`~/.claude.json`): `type: http`, the remote's `/mcp`, and a bearer token in the
1693
+ Authorization header. Other servers in those files are untouched, and a remote that stops
1694
+ serving MCP has the entry removed.
1695
+
1696
+ **Which bearer** is what `/setup/info`'s `mcpAuth` decides. An MCP client substitutes its
1697
+ configuration once, at startup, while a team edition rotates the member access key roughly
1698
+ every 72 hours - so an entry carrying the access key 401s mid-session every few days and
1699
+ reads to the member as "re-authorise". A team that answers `mcpAuth: "context-token"` mints
1700
+ a second credential instead (`amrctx_...`, a year long) that can do nothing but read and
1701
+ write that member's shared project context: it is refused on turns, on the admin API, on
1702
+ the portal and on SCIM. `connect` takes it from `/setup/exchange` when a setup token was
1703
+ used, and mints one at `POST /me/context-tokens` otherwise, so `--key` and `--setup-token`
1704
+ both end up with one; the team binds it to the calling device, so revoking the machine
1705
+ revokes the token with it.
1706
+
1707
+ That token is a long-lived secret, so it goes where the refresh token goes - the OS
1708
+ credential store, in its own slot (`<refresh account>#context`, falling back to
1709
+ `<router home>/context.token`, owner-readable only). `remote.json` records only which store
1710
+ holds it, its expiry and its id. **A refresh does not rewrite the MCP entry**: it re-writes
1711
+ models.yml and every other harness config with the new access key and leaves `team-context`
1712
+ holding the token it already has, renewing it only when there is none or it is within 30
1713
+ days of expiring. A team edition that answers `mcpAuth: "member-key"`, or says nothing at
1714
+ all, keeps the old behaviour exactly - the entry carries the access key and every refresh
1715
+ rewrites it.
1654
1716
 
1655
1717
  ## Direct upstreams: OpenAI, Azure OpenAI, Anthropic, vLLM
1656
1718
 
@@ -2024,6 +2086,46 @@ out and rebuilds the catalog immediately instead of leaving the change inert unt
2024
2086
  tomorrow. Every fetch stays best-effort — one that fails or returns nothing leaves
2025
2087
  the scores already serving in place.
2026
2088
 
2089
+ **A front door can supply the rest** (`benchmarks.extraScores`, empty by default).
2090
+ The feeds cover a lot and still leave holes, and a *partial* hole is the expensive
2091
+ kind: a model carrying intelligence and neither coding nor agentic clears no tier
2092
+ floor above the cheapest on those axes, so it is never picked no matter how good
2093
+ or how cheap it is. Whatever curates models in front of the router — a team
2094
+ dashboard, a config file you maintain — can hand over the numbers it has:
2095
+
2096
+ ```yaml
2097
+ benchmarks:
2098
+ extraScores:
2099
+ - key: deepseek/deepseek-v4.1-flash # the OpenRouter slug is fine; it is normalised
2100
+ creator: deepseek # optional, and only ever a tie-break
2101
+ coding: 55.2
2102
+ agentic: 31.8
2103
+ source: vendor # or `neutral`
2104
+ ```
2105
+
2106
+ `source` is the provenance and only two values are accepted: **`neutral`**, a
2107
+ benchmark's own leaderboard, and **`vendor`**, a self-reported model-card number
2108
+ that the supplier is expected to have discounted already — the router applies no
2109
+ discount of its own, because a discount applied twice is its own distortion.
2110
+ Nothing else may be claimed: an entry calling itself `artificial_analysis` would
2111
+ outrank BenchLM on the strength of a label, and one calling itself `local` would
2112
+ write into the lane `useLocalScores` gates.
2113
+
2114
+ These obey exactly the rules the feeds do, and sit exactly where the name says in
2115
+ the order: **Artificial Analysis → BenchLM → `neutral` → `vendor` → `local`**, and
2116
+ none of them touches an axis that already has a value. So a supplied score can
2117
+ only ever fill a hole; it can never move a number a published source measured.
2118
+ Which axis came from where is recorded per model under `benchmarks.fill_sources`.
2119
+
2120
+ Rows arriving over a live config change (a dashboard save) are sanitised on use:
2121
+ a malformed one is dropped with a warning and the rest still apply, because the
2122
+ same patch carries unrelated settings and one bad row must not take an operator's
2123
+ key save down with it. An unreadable score leaves its axis **absent**, never zero
2124
+ — a zero would satisfy `trivial` and bid for every turn. Rows written into
2125
+ `config.yml` are validated strictly instead, like every other key in the file. A
2126
+ changed table ages the feed cache out and rebuilds the catalog straight away, so
2127
+ it applies on the next refresh rather than waiting out the day.
2128
+
2027
2129
  ---
2028
2130
 
2029
2131
  ## Adaptive tier floors
@@ -32,6 +32,17 @@ export interface RemoteRouter {
32
32
  refreshTokenStore?: "dpapi" | "keychain" | "secret-service" | "file";
33
33
  /** The account the store files it under (`<userId>@<remote host>`). */
34
34
  refreshAccount?: string;
35
+ /**
36
+ * The team edition's context token — the credential the shared-context MCP
37
+ * server holds. It is long-lived (a year) and powerless outside that member's
38
+ * project context, so it never sits in remote.json: the store named here holds
39
+ * it, under `contextAccount` (`<refresh account>#context`). The expiry and the
40
+ * id are recorded so a refresh knows when to renew it and which one it is.
41
+ */
42
+ contextTokenStore?: "dpapi" | "keychain" | "secret-service" | "file";
43
+ contextAccount?: string;
44
+ contextTokenExpiresAtMs?: number;
45
+ contextTokenId?: string;
35
46
  keyExpiresAtMs?: number;
36
47
  refreshExpiresAtMs?: number;
37
48
  /** What the remote calls this machine. */
@@ -62,6 +73,10 @@ export function parseRemoteRouter(text: string): RemoteRouter | null {
62
73
  ...(typeof raw.refreshToken === "string" && raw.refreshToken !== "" ? { refreshToken: raw.refreshToken } : {}),
63
74
  ...(raw.refreshTokenStore === "dpapi" || raw.refreshTokenStore === "keychain" || raw.refreshTokenStore === "secret-service" || raw.refreshTokenStore === "file" ? { refreshTokenStore: raw.refreshTokenStore } : {}),
64
75
  ...(typeof raw.refreshAccount === "string" && raw.refreshAccount !== "" ? { refreshAccount: raw.refreshAccount } : {}),
76
+ ...(raw.contextTokenStore === "dpapi" || raw.contextTokenStore === "keychain" || raw.contextTokenStore === "secret-service" || raw.contextTokenStore === "file" ? { contextTokenStore: raw.contextTokenStore } : {}),
77
+ ...(typeof raw.contextAccount === "string" && raw.contextAccount !== "" ? { contextAccount: raw.contextAccount } : {}),
78
+ ...(typeof raw.contextTokenExpiresAtMs === "number" ? { contextTokenExpiresAtMs: raw.contextTokenExpiresAtMs } : {}),
79
+ ...(typeof raw.contextTokenId === "string" && raw.contextTokenId !== "" ? { contextTokenId: raw.contextTokenId } : {}),
65
80
  ...(typeof raw.keyExpiresAtMs === "number" ? { keyExpiresAtMs: raw.keyExpiresAtMs } : {}),
66
81
  ...(typeof raw.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: raw.refreshExpiresAtMs } : {}),
67
82
  ...(typeof raw.device === "string" && raw.device !== "" ? { device: raw.device } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -11,14 +11,23 @@
11
11
  * baseline, but only when a key is configured.
12
12
  * - BenchLM (`/api/data/leaderboard`, keyless): covers the models AA omits.
13
13
  *
14
+ * Both still leave holes, and the expensive kind is a PARTIAL one: a model with
15
+ * intelligence and neither coding nor agentic clears no tier floor above the
16
+ * cheapest on those axes and is simply never picked. `benchmarks.extraScores`
17
+ * is the seam for that — curated rows a front door supplies (`suppliedScores`),
18
+ * under exactly the rules below.
19
+ *
14
20
  * Three rules, all load-bearing:
15
21
  *
16
22
  * - FILL, NEVER OVERWRITE. A score OpenRouter already published wins; the feeds
17
23
  * only supply axes that are absent. Two suites measure the same idea on
18
24
  * different tests, so letting one overwrite the other would make a model's
19
25
  * score jump with whichever feed refreshed last.
20
- * - PER AXIS, AA BEFORE BENCHLM. AA is the stronger source and fills first;
21
- * BenchLM fills whatever axis AA still left empty.
26
+ * - PER AXIS, STRONGEST SOURCE FIRST (`FILL_ORDER`). AA fills first, BenchLM
27
+ * fills whatever axis AA left empty, then anything the front door supplied
28
+ * through `benchmarks.extraScores` (a neutral leaderboard ahead of a
29
+ * self-reported vendor number), then our own eval. Every one of them only
30
+ * ever fills a hole the ones above it left.
22
31
  * - MATCH EXACTLY OR NOT AT ALL. Matching is on a normalized model-name key,
23
32
  * with the creator used only to break a tie between two rows that share a
24
33
  * key. A fuzzy match would let a 7B inherit a 72B's score and then be handed
@@ -36,7 +45,40 @@ import type { QualityAxis } from "../config/types.ts";
36
45
  export const AA_MODELS_URL = "https://artificialanalysis.ai/api/v2/data/llms/models";
37
46
  export const BENCHLM_URL = "https://benchlm.ai/api/data/leaderboard";
38
47
 
39
- export type FeedSource = "artificial_analysis" | "benchlm" | "local";
48
+ /**
49
+ * Where one score came from, and — through `FILL_ORDER` — what it may outrank.
50
+ *
51
+ * - `artificial_analysis`, `benchlm`: the fetched feeds described above.
52
+ * - `neutral`, `vendor`: supplied through `benchmarks.extraScores` by whatever
53
+ * sits in front of the router. `neutral` is a benchmark's own leaderboard;
54
+ * `vendor` is a self-reported model-card number. They stay two members rather
55
+ * than collapsing into one `supplied` because that distinction is the whole
56
+ * reason the supplier curated the table, and collapsing would discard it at
57
+ * the boundary. The router rescales neither: a `vendor` number is expected to
58
+ * arrive already discounted, and a discount applied twice is its own lie.
59
+ * - `local`: our own eval harness, gated by `benchmarks.useLocalScores`.
60
+ */
61
+ export type FeedSource = "artificial_analysis" | "benchlm" | "neutral" | "vendor" | "local";
62
+
63
+ /**
64
+ * Fill priority, strongest first — one list rather than a chain of ifs, so the
65
+ * ordering rule is a thing a test can point at. A published score is not in it
66
+ * at all: an axis that already has a value is skipped before this is consulted.
67
+ */
68
+ export const FILL_ORDER: readonly FeedSource[] = ["artificial_analysis", "benchlm", "neutral", "vendor", "local"];
69
+
70
+ /** The sources the fetched-feed cache may hold — exactly what fetches write it. */
71
+ const FETCHED_SOURCES: readonly FeedSource[] = ["artificial_analysis", "benchlm"];
72
+
73
+ /** The sources `benchmarks.extraScores` may claim. Anything else is dropped. */
74
+ export const SUPPLIED_SOURCES: readonly FeedSource[] = ["neutral", "vendor"];
75
+
76
+ /**
77
+ * Cap on `benchmarks.extraScores`. The curated table is a few hundred rows; this
78
+ * sits far above it and exists only so one config patch cannot hand the fill
79
+ * path an unbounded list. The excess is dropped; the rest still applies.
80
+ */
81
+ export const MAX_EXTRA_SCORES = 2_000;
40
82
 
41
83
  /** One model's scores from one feed, on the router's three axes (0-100). */
42
84
  export interface FeedScore {
@@ -61,6 +103,13 @@ export interface FillResult {
61
103
 
62
104
  const AXES: readonly QualityAxis[] = ["coding", "intelligence", "agentic"];
63
105
 
106
+ /** A zeroed per-source counter, derived from `FILL_ORDER` so it cannot drift from it. */
107
+ function zeroSources(): Record<FeedSource, number> {
108
+ const out = {} as Record<FeedSource, number>;
109
+ for (const source of FILL_ORDER) out[source] = 0;
110
+ return out;
111
+ }
112
+
64
113
  function asRecord(value: unknown): Record<string, unknown> | null {
65
114
  return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
66
115
  }
@@ -231,7 +280,7 @@ export function applyFeedScores(rawModels: unknown[], feeds: FeedScore[]): FillR
231
280
  const result: FillResult = {
232
281
  modelsFilled: 0,
233
282
  axes: { coding: 0, intelligence: 0, agentic: 0 },
234
- sources: { artificial_analysis: 0, benchlm: 0, local: 0 },
283
+ sources: zeroSources(),
235
284
  };
236
285
  if (feeds.length === 0) return result;
237
286
 
@@ -258,22 +307,21 @@ export function applyFeedScores(rawModels: unknown[], feeds: FeedScore[]): FillR
258
307
 
259
308
  for (const axis of AXES) {
260
309
  if (score100(aa[`${axis}_index`]) !== null) continue; // published; never overwrite
261
- const aaHit = pick(candidates, "artificial_analysis", author);
262
- let value = aaHit === null ? undefined : axisValue(aaHit, axis);
263
- let source: FeedSource = "artificial_analysis";
264
- if (value === undefined) {
265
- const blHit = pick(candidates, "benchlm", author);
266
- value = blHit === null ? undefined : axisValue(blHit, axis);
267
- source = "benchlm";
268
- }
269
- if (value === undefined) {
270
- // Our own calibrated eval is the weakest source: only where neither
271
- // published nor third-party feeds have anything.
272
- const localHit = pick(candidates, "local", author);
273
- value = localHit === null ? undefined : axisValue(localHit, axis);
274
- source = "local";
310
+ // Walk the sources strongest-first and take the first that has this axis:
311
+ // the measured feeds, then whatever the front door supplied (a neutral
312
+ // leaderboard ahead of a self-reported vendor number), then our own
313
+ // calibrated eval, which only ever fills what nothing else does.
314
+ let value: number | undefined;
315
+ let source: FeedSource | undefined;
316
+ for (const candidate of FILL_ORDER) {
317
+ const hit = pick(candidates, candidate, author);
318
+ const found = hit === null ? undefined : axisValue(hit, axis);
319
+ if (found === undefined) continue;
320
+ value = found;
321
+ source = candidate;
322
+ break;
275
323
  }
276
- if (value === undefined) continue;
324
+ if (value === undefined || source === undefined) continue;
277
325
  aa[`${axis}_index`] = value;
278
326
  fillSources[axis] = source;
279
327
  result.axes[axis] += 1;
@@ -317,7 +365,7 @@ export async function refreshFeedScores(cfg: RouterConfig, db: Database, opts: R
317
365
  const bm = cfg.benchmarks;
318
366
 
319
367
  const row = db.query("SELECT payload, fetched_at_ms FROM benchmark_cache WHERE id = 1").get() as CacheRow | null;
320
- const cached: FeedScore[] | null = row === null ? null : parseFeedScores(row.payload);
368
+ const cached: FeedScore[] | null = row === null ? null : parseFeedScores(row.payload, FETCHED_SOURCES);
321
369
  // A zero timestamp is `invalidateFeedCache`'s marker, not a real fetch time:
322
370
  // the payload stays readable as a fallback but never counts as fresh again.
323
371
  const fresh = row !== null && row.fetched_at_ms > 0 && now - row.fetched_at_ms < bm.refreshMs;
@@ -365,8 +413,76 @@ export function invalidateFeedCache(db: Database): void {
365
413
  db.query("UPDATE benchmark_cache SET fetched_at_ms = 0 WHERE id = 1").run();
366
414
  }
367
415
 
368
- /** Validate a persisted `FeedScore[]` blob, skipping any entry that drifted. */
369
- function parseFeedScores(payload: string): FeedScore[] | null {
416
+ interface SanitizeOpts {
417
+ /** Which `source` values are acceptable here. */
418
+ allow: readonly FeedSource[];
419
+ /** Re-run `normalizeModelKey`/`normalizeCreator` on the way in. */
420
+ normalize?: boolean;
421
+ }
422
+
423
+ interface SanitizeResult {
424
+ scores: FeedScore[];
425
+ /** Entries thrown away whole: not an object, no key, or a source not allowed. */
426
+ dropped: number;
427
+ /** Axes thrown away from an otherwise usable entry: not a finite number in [0, 100]. */
428
+ droppedAxes: number;
429
+ }
430
+
431
+ /**
432
+ * Turn an arbitrary array into `FeedScore[]`, keeping only what is usable.
433
+ *
434
+ * The one invariant everything downstream leans on: an axis this cannot read is
435
+ * OMITTED, never defaulted. `applyFeedScores` writes only defined values, so a
436
+ * `"61"` or a `-3` or a `NaN` leaves the axis exactly as unscored as it was —
437
+ * a bad entry can no more zero a score than it can raise one.
438
+ */
439
+ function sanitizeFeedScores(value: unknown, opts: SanitizeOpts): SanitizeResult {
440
+ const result: SanitizeResult = { scores: [], dropped: 0, droppedAxes: 0 };
441
+ if (!Array.isArray(value)) return result;
442
+ for (const item of value) {
443
+ const rec = asRecord(item);
444
+ if (rec === null || typeof rec.key !== "string") {
445
+ result.dropped += 1;
446
+ continue;
447
+ }
448
+ const source = rec.source;
449
+ if (typeof source !== "string" || !opts.allow.includes(source as FeedSource)) {
450
+ result.dropped += 1;
451
+ continue;
452
+ }
453
+ const rawCreator = typeof rec.creator === "string" ? rec.creator : "";
454
+ const key = opts.normalize === true ? normalizeModelKey(rec.key) : rec.key;
455
+ if (key === "") {
456
+ result.dropped += 1;
457
+ continue;
458
+ }
459
+ const entry: FeedScore = {
460
+ key,
461
+ creator: opts.normalize === true ? normalizeCreator(rawCreator) : rawCreator,
462
+ source: source as FeedSource,
463
+ };
464
+ for (const axis of AXES) {
465
+ const present = rec[axis];
466
+ if (present === undefined || present === null) continue;
467
+ const parsed = score100(present);
468
+ if (parsed === null) {
469
+ result.droppedAxes += 1;
470
+ continue;
471
+ }
472
+ entry[axis] = parsed;
473
+ }
474
+ result.scores.push(entry);
475
+ }
476
+ return result;
477
+ }
478
+
479
+ /**
480
+ * Validate a persisted `FeedScore[]` blob, skipping any entry that drifted.
481
+ * `allow` is what WROTE this particular blob, so a row can never reach a rank by
482
+ * sitting in a table that does not produce that source — the keys are already
483
+ * normalized here, having been normalized when they were parsed out of a feed.
484
+ */
485
+ function parseFeedScores(payload: string, allow: readonly FeedSource[]): FeedScore[] | null {
370
486
  let parsed: unknown;
371
487
  try {
372
488
  parsed = JSON.parse(payload);
@@ -374,25 +490,45 @@ function parseFeedScores(payload: string): FeedScore[] | null {
374
490
  return null;
375
491
  }
376
492
  if (!Array.isArray(parsed)) return null;
377
- const out: FeedScore[] = [];
378
- for (const item of parsed) {
379
- const rec = asRecord(item);
380
- if (rec === null || typeof rec.key !== "string") continue;
381
- if (rec.source !== "artificial_analysis" && rec.source !== "benchlm" && rec.source !== "local") continue;
382
- const entry: FeedScore = {
383
- key: rec.key,
384
- creator: typeof rec.creator === "string" ? rec.creator : "",
385
- source: rec.source,
386
- };
387
- const coding = score100(rec.coding);
388
- if (coding !== null) entry.coding = coding;
389
- const intelligence = score100(rec.intelligence);
390
- if (intelligence !== null) entry.intelligence = intelligence;
391
- const agentic = score100(rec.agentic);
392
- if (agentic !== null) entry.agentic = agentic;
393
- out.push(entry);
493
+ return sanitizeFeedScores(parsed, { allow }).scores;
494
+ }
495
+
496
+ /**
497
+ * `benchmarks.extraScores`, sanitised. The front door curates a table of
498
+ * vendor-published and neutral-leaderboard numbers for axes the feeds leave
499
+ * empty — the case this exists for is a model carrying intelligence and nothing
500
+ * else, which no tier floor above the cheapest can admit.
501
+ *
502
+ * Three decisions live here, all about the fact that this arrives over a config
503
+ * patch from ANOTHER PROCESS rather than out of a file the operator wrote:
504
+ *
505
+ * - A malformed entry is DROPPED, LOUDLY — never a refused patch. The patch
506
+ * carries unrelated settings (the Artificial Analysis key rides in the same
507
+ * `benchmarks` block), and one bad row out of six hundred must not take an
508
+ * operator's key save down with it. Silence was the other option and is worse:
509
+ * a table that quietly stopped applying looks exactly like one that worked.
510
+ * - Only `neutral` and `vendor` are accepted. Config claiming
511
+ * `artificial_analysis` would outrank BenchLM on the strength of a label, and
512
+ * config claiming `local` would write into the lane `useLocalScores` gates —
513
+ * which is precisely the switch this design refuses to make into a lie.
514
+ * - Keys are normalised here, so the supplier may send the OpenRouter slug
515
+ * (`deepseek/deepseek-v4.1-flash`) and need not reimplement the match key.
516
+ */
517
+ export function suppliedScores(cfg: RouterConfig, log?: Logger): FeedScore[] {
518
+ const supplied = cfg.benchmarks.extraScores;
519
+ if (supplied === undefined || supplied.length === 0) return [];
520
+ const capped = supplied.length > MAX_EXTRA_SCORES ? supplied.slice(0, MAX_EXTRA_SCORES) : supplied;
521
+ const { scores, dropped, droppedAxes } = sanitizeFeedScores(capped, { allow: SUPPLIED_SOURCES, normalize: true });
522
+ const over = supplied.length - capped.length;
523
+ if (dropped > 0 || droppedAxes > 0 || over > 0) {
524
+ (log ?? createLogger(cfg.logLevel)).warn("supplied benchmark scores partly unusable; the rest still apply", {
525
+ kept: scores.length,
526
+ droppedEntries: dropped + over,
527
+ droppedAxes,
528
+ ...(over > 0 ? { overCap: MAX_EXTRA_SCORES } : {}),
529
+ });
394
530
  }
395
- return out;
531
+ return scores;
396
532
  }
397
533
 
398
534
  /**
@@ -403,7 +539,9 @@ function parseFeedScores(payload: string): FeedScore[] | null {
403
539
  export function loadLocalScores(db: Database): FeedScore[] {
404
540
  const row = db.query("SELECT payload FROM local_scores WHERE id = 1").get() as { payload: string } | null;
405
541
  if (row === null) return [];
406
- return parseFeedScores(row.payload) ?? [];
542
+ // Only `local` may come out of the local lane: the table `useLocalScores`
543
+ // gates must not be a way to claim a rank it does not have.
544
+ return parseFeedScores(row.payload, ["local"]) ?? [];
407
545
  }
408
546
 
409
547
  /** Persist local eval scores (source `local`) for `doRefresh` to pick up when enabled. */
@@ -31,7 +31,7 @@ import type {
31
31
  */
32
32
  const SHRINK_KEEP_RATIO = 0.5;
33
33
  const SHRINK_MIN_PREVIOUS = 20;
34
- import { applyFeedScores, loadLocalScores, refreshFeedScores } from "./benchmark-feeds.ts";
34
+ import { applyFeedScores, loadLocalScores, refreshFeedScores, suppliedScores } from "./benchmark-feeds.ts";
35
35
 
36
36
  function asRecord(value: unknown): Record<string, unknown> | null {
37
37
  return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
@@ -394,10 +394,16 @@ export function createCatalog(cfg: RouterConfig, upstream: UpstreamClient, db: D
394
394
  if (cfg.benchmarks.enabled) {
395
395
  try {
396
396
  const feeds = await refreshFeedScores(cfg, db, { log });
397
+ // Scores the front door supplied for axes the feeds leave empty. Read
398
+ // from the live config on every refresh, never cached — a set that just
399
+ // arrived over a patch applies to the very next catalog build.
400
+ const supplied = suppliedScores(cfg, log);
397
401
  // Local eval scores are last-resort and gated: they change routing, so
398
402
  // they apply only when the operator opts in.
399
403
  const local = cfg.benchmarks.useLocalScores ? loadLocalScores(db) : [];
400
- const filled = applyFeedScores(raw, [...feeds, ...local]);
404
+ // Order within this array is irrelevant: `applyFeedScores` ranks by
405
+ // `source` through FILL_ORDER, not by position.
406
+ const filled = applyFeedScores(raw, [...feeds, ...supplied, ...local]);
401
407
  if (filled.modelsFilled > 0) {
402
408
  log.debug("backfilled missing benchmarks from external feeds", {
403
409
  models: filled.modelsFilled,
@@ -406,6 +412,8 @@ export function createCatalog(cfg: RouterConfig, upstream: UpstreamClient, db: D
406
412
  agentic: filled.axes.agentic,
407
413
  aa: filled.sources.artificial_analysis,
408
414
  benchlm: filled.sources.benchlm,
415
+ neutral: filled.sources.neutral,
416
+ vendor: filled.sources.vendor,
409
417
  local: filled.sources.local,
410
418
  });
411
419
  }