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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +117 -15
- package/omp-extension/remote-logic.ts +15 -0
- package/package.json +1 -1
- package/src/catalog/benchmark-feeds.ts +179 -41
- package/src/catalog/openrouter-catalog.ts +10 -2
- package/src/cli/connect.ts +89 -17
- package/src/cli/context-token.ts +133 -0
- package/src/cli/credential-store.ts +65 -20
- package/src/cli/refresh.ts +26 -1
- package/src/config/schema.ts +22 -0
- package/src/config/types.ts +22 -0
- package/src/cost/ledger-sql.ts +3 -2
- package/src/cost/ledger.ts +4 -0
- package/src/cost/types.ts +12 -0
- package/src/cost/views.ts +12 -0
- package/src/lib.ts +12 -0
- package/src/router/escalate.ts +11 -0
- package/src/router/types.ts +2 -1
- package/src/server/compaction-digest.ts +3 -0
- package/src/server/digest.ts +11 -0
- package/src/server/http.ts +46 -4
- package/src/server/turn.ts +6 -1
- package/src/util/requestid.ts +77 -0
- package/src/util/schema.ts +42 -3
- package/src/util/sqlite.ts +20 -1
- package/src/wire/openai/request.ts +8 -0
- package/src/wire/types.ts +12 -0
- package/test/benchmark-feeds.test.ts +194 -0
- package/test/config.test.ts +26 -0
- package/test/context-token.test.ts +301 -0
- package/test/escalate.test.ts +25 -0
- package/test/ledger-sql.test.ts +1 -1
- package/test/mcp-entry.test.ts +14 -5
- package/test/migrations.test.ts +11 -4
- package/test/reconfigure.test.ts +55 -0
- package/test/request-id.test.ts +424 -0
- package/test/schema.test.ts +2 -2
- package/test/trust-attribution.test.ts +2 -2
- 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.
|
|
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.
|
|
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`,
|
|
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)
|
|
768
|
-
(NULL when redaction was off, and for every row written before v0.21.0)
|
|
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
|
|
790
|
-
why` does
|
|
791
|
-
|
|
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
|
|
1042
|
-
model in the **same** tier (bounded, like a 5xx failover) and
|
|
1043
|
-
up; structural signals (`malformed_tool_args`,
|
|
1044
|
-
tool call) escalate a tier directly. A client
|
|
1045
|
-
event has already arrived is treated as a
|
|
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`,
|
|
1652
|
-
header.
|
|
1653
|
-
|
|
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
|
@@ -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,
|
|
21
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|