model-compare-opencode-plugin 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,183 @@
1
- # Temporary Holding Version
1
+ # model-compare-opencode-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [OpenCode](https://opencode.ai) v2 plugin that picks the best value-for-money OpenRouter model from [canonical/model-compare](https://github.com/canonical/model-compare)'s ranking, for three priorities: `balanced`, `price`, `quality`. Ranking data comes from a vendored copy of upstream's `model_compare.py` (default) or the published `catalog.json`.
4
+
5
+ Each priority is a live alias inside the existing OpenRouter provider — no new provider, no new credentials:
6
+
7
+ | Alias | Resolves to |
8
+ |---|---|
9
+ | `openrouter/model-compare/balanced` | best all-round value pick |
10
+ | `openrouter/model-compare/price` | best price pick |
11
+ | `openrouter/model-compare/quality` | best quality pick |
12
+
13
+ The alias re-points at the current pick while OpenCode runs (refresh every 30 minutes, data cache 6 hours). OpenCode's model picker shows it as `Model Compare: <priority> → <pick>`, with `(fallback)` or `(unavailable)` when the fallback is serving or the alias is disabled.
14
+
15
+
16
+ ## Install
17
+
18
+ Add a `plugins` entry to `opencode.jsonc` (project or global):
19
+
20
+ ```json
21
+ {
22
+ "$schema": "https://opencode.ai/config.json",
23
+ "plugins": [
24
+ {
25
+ "package": "model-compare-opencode-plugin",
26
+ "options": {
27
+ "source": "auto",
28
+ "defaultPriority": "balanced",
29
+ "fallback": "openrouter/z-ai/glm-5.3-flash"
30
+ }
31
+ }
32
+ ]
33
+ }
34
+ ```
35
+
36
+ All options are optional — see [Configuration](#configuration). The package is on npm; OpenCode resolves and loads it itself. To run a checkout of this repo instead, see [Development](#development).
37
+
38
+
39
+ ## Usage
40
+
41
+ Point sessions at an alias — as the root default in `opencode.jsonc`:
42
+
43
+ ```json
44
+ {
45
+ "$schema": "https://opencode.ai/config.json",
46
+ "model": "openrouter/model-compare/balanced"
47
+ }
48
+ ```
49
+
50
+ Per agent (in the `.md` frontmatter):
51
+
52
+ ```md
53
+ ---
54
+ description: Reviews changes for correctness
55
+ mode: subagent
56
+ model: openrouter/model-compare/quality
57
+ ---
58
+ ```
59
+
60
+ Or via `--model`:
61
+
62
+ ```
63
+ opencode run --model "msg"
64
+ ```
65
+
66
+
67
+ **Where the alias binds.** An agent's `model` applies to subagent launches everywhere, and to primary agents only when you select the agent in the TUI. Headless runs (`opencode run --agent …`) and server-side agent switches do **not** apply it — the root default (or `--model`, or `/use-best`) governs.
68
+
69
+ Commands:
70
+
71
+ - `/use-best [priority]` — switch the session to `openrouter/model-compare/<p>` (defaults to `defaultPriority`). Refuses a disabled alias.
72
+ - `/model-compare` — top 5 per priority with scores and data age; priorities whose `weights` override actually takes effect are labelled `(custom weights)` with recomputed scores, and each alias's current pick is marked `← current pick`. The output is a synthetic message (`resume: false`): it costs no model turn and, in a headless session, surfaces in the transcript at the next turn. In the TUI it opens the report dialog instead.
73
+
74
+
75
+ ### Keyboard shortcuts
76
+
77
+ | Key | Action |
78
+ |---|---|
79
+ | `alt+m` | picks dialog — all three priorities with their current picks |
80
+ | `alt+shift+m` | report dialog — the top-5 table; `return` switches the session to the highlighted model |
81
+ | `up` / `down` | move the dialog cursor |
82
+ | `return` | select the highlighted row (picks dialog: switch to that priority) |
83
+ | `esc` | close the dialog |
84
+
85
+ Both dialogs need an open session — with none, a toast explains and nothing else happens. The TUI has no `/use-best` keybinding: the slash command already covers switching.
86
+
87
+ Status: the footer shows `MC: balanced → <pick>` (balanced only — `disabled`/`unavailable`/`(fallback)` as applicable; all three priorities live in the picks dialog). Toasts announce `/use-best` outcomes and pick changes — never for the first observation after load. Headless, all feedback is synthetic transcript messages at the next turn.
88
+
89
+
90
+ ## Configuration
91
+
92
+ Options live under `plugins[].options`:
93
+
94
+ | Option | Default | Meaning |
95
+ |---|---|---|
96
+ | `source` | `auto` | `auto`, `local`, or `remote`. `auto` uses the vendored script when `python3` 3.10+ is available, otherwise the remote catalog |
97
+ | `defaultPriority` | `balanced` | Priority used by `/use-best` with no argument |
98
+ | `fallback` | unset | `openrouter/<id>` used when no pick is available. Only OpenRouter models work: the alias's `modelID` lives inside the `openrouter` provider. Runnability is checked at each resolution |
99
+ | `weights` | upstream | Partial overrides per named priority (`quality`, `price`, `context`, `age`), merged with upstream's weights and renormalized to sum 1 |
100
+ | `pinDays` | `14` | Age in days after which a pick is re-evaluated |
101
+ | `minLead` | `0.03` | Score lead needed to replace a pinned pick |
102
+ | `catalogURL` | published catalog | Remote source URL (`https://canonical.github.io/model-compare/catalog.json`). Only point it at a source you trust: it decides which models can be picked |
103
+ | `scriptPath` | vendored copy | Development override for a local `model_compare.py` |
104
+
105
+ A present-but-invalid value logs a warning and applies the default (`weights` drop invalid entries individually; the valid remainder applies). Absent options apply defaults silently. Setup never throws; the aliases stay functional.
106
+
107
+
108
+ ## Pinning and fallback
109
+
110
+ Per priority the plugin keeps one pin/last-known-good record in plugin storage, keyed by the effective weights so different weight configurations don't collide. With a fresh catalog:
111
+
112
+ - No pin: adopt the best candidate.
113
+ - Pinned model gone or not runnable: switch to the best candidate now.
114
+ - Pin older than `pinDays`: re-evaluate (which may re-adopt the same model; `since` resets on every adoption).
115
+ - Otherwise: switch only when the best candidate leads the pinned pick by at least `minLead` (upstream's 0–1 score scale).
116
+
117
+ Resolution order: pin (if runnable) → best runnable candidate → `fallback` (if runnable) → the alias is disabled. The fallback is never stored as a pin. Pins are advisory: concurrent OpenCode processes refresh independently, last writer wins.
118
+
119
+
120
+ ## Data sources
121
+
122
+ - `auto` — vendored `vendor/model_compare.py` run with `python3` (no shell, 60 s timeout, minimal environment); degrades to remote when python3 is missing or the run fails.
123
+ - `local` — the vendored script only; failures are reported and the pin/last-known-good pick is kept.
124
+ - `remote` — HTTPS fetch of `catalogURL` with a 6-hour cache (upstream republishes every 6 hours).
125
+
126
+ The last validated catalog is persisted, so ranking and `/model-compare` work offline after a restart. The cache is keyed by source (URL or script path), so changing the source never serves the previous source's data. Script exit code 2 means "no candidates" *or* an argparse usage error — never read it as "no candidates" alone.
127
+
128
+ **Vendored pin:** `vendor/model_compare.py` is byte-identical to upstream `v0.3.1` (`vendor/UPSTREAM.json` records repo, tag, commit, sha256). Since upstream 0.3.0 the catalog is schema v2: `pricing` scalars hold effective-tier values — the tier in effect at 1M-token prompts — so models with OpenRouter price overrides are ranked at what a long session actually pays.
129
+
130
+ **Upgrading the pin** — reviewed PRs only; nothing merges automatically:
131
+
132
+ 1. `node scripts/sync-vendor.mjs <tag>` — fetches the script at that commit, reports the sha256 change, re-pins `UPSTREAM.json`, prints the matching CHANGELOG section (`--check` verifies offline).
133
+ 2. Conformance tests check `--version`, schema v2, required fields, recomputed `overall` scores, and the standard-library-only import allowlist.
134
+ 3. The upgrade PR body lists script lines touching imports, URLs, environment variables, subprocess calls and file writes, for reviewer attention.
135
+ 4. Release with a changelog line naming the upstream tag; users run `opencode plugin update` (OpenCode checks but doesn't apply).
136
+
137
+ A scheduled workflow opens a draft upgrade PR when upstream publishes a newer release (never a downgrade); a nightly canary exercises the script live; a weekly drift check compares the vendored sha256 against upstream's pinned commit and the published rankings against the published scores.
138
+
139
+
140
+ ## Troubleshooting
141
+
142
+ - **`model-compare: no ranking data for openrouter/model-compare/<p>`** — an agent whose `model` is that alias made a primary request while the alias was disabled. The guard aborts the request — even when the root-governed effective model is healthy — to protect the configuration's intent. Set `options.fallback` or fix the data source. Primary requests only; title/compaction/generation are untouched.
143
+ - **Primary run answered with a model I didn't configure** — the root default (and `/use-best`-switched sessions) fall back silently to OpenCode's choice when the alias is disabled. Agent `model` doesn't apply on headless primary runs anyway — see the binding note above.
144
+ - **Where are the plugin's warnings?** Nowhere, currently: `opencode serve` prints nothing to stdio and plugin `console.warn` reaches no log. The visible diagnosis surface is `/model-compare` — its data-age and no-data lines.
145
+ - **A pick change takes a minute or two to land, and runs started meanwhile wait.** When a pick changes (at most once per 30-minute refresh), the plugin replays OpenCode's provider/model/agent registries; the harness measured ~90–105 s, with runs started during the replay blocking. Nothing is wrong.
146
+ - **`Model unavailable: openrouter/model-compare/<p>`** — a subagent configured with a disabled alias; OpenCode fails the launch before any request, so the guard is not involved.
147
+ - **Data age in `/model-compare`** — the plugin's own acquisition time of the document, not upstream's `generated_at` (set at build time even when serving inputs from cache).
148
+ - **No aliases at all** — the aliases live inside the `openrouter` provider; if it is not connected in the project, they cannot be created. The plugin warns at setup and on every refresh; primary runs fall back silently.
149
+
150
+
151
+ ## Development
152
+
153
+ Run a checkout instead of the npm package:
154
+
155
+ ```json
156
+ {
157
+ "plugins": [
158
+ {
159
+ "package": "./src",
160
+ "options": {
161
+ "source": "auto"
162
+ }
163
+ }
164
+ ]
165
+ }
166
+ ```
167
+
168
+ OpenCode's local loader resolves a directory target strictly as `<dir>/index.*` — `exports` are ignored for local paths, and file targets are rejected. `src/` has the physical entry files (`src/index.ts`, `src/tui.tsx`); the plugin's imports resolve from the repo's `node_modules` (`npm ci` once at the repo root). The repo ships this setup as [`.test/`](.test/README.md). Two caveats: the watcher only watches package-style entries, so source edits need an OpenCode restart; and a bare package name is always npm-installed by OpenCode — it is not read from `node_modules` — so local testing must use the directory form.
169
+
170
+ ```sh
171
+ npm test # unit + vendored-conformance suites (hermetic)
172
+ MC_INTEGRATION=1 npx vitest run tests/integration/ # live-server suite (opt-in)
173
+ ```
174
+
175
+ - The integration files skip unless `MC_INTEGRATION=1`, so plain `npm test` stays hermetic. The suite drives a real `opencode serve` on 127.0.0.1:14721 and needs the `opencode` CLI and `python3` (skips cleanly when either is missing); one case needs network access and the `sqlite3` CLI.
176
+ - `node scripts/pack-smoke.mjs` (also in CI) packs the plugin and checks the tarball against the packaging list (files, `exports`, relative imports). It does **not** load the package in OpenCode — install the tarball into a scratch project and confirm OpenCode loads it before publishing.
177
+ - `fixtures/catalog-v0.3.0.json` is a recorded schema-v2 catalog; `fixtures/catalog-no-quality.json` is generated from it by `scripts/make-no-quality-fixture.py` (the vendored script's own quality-blind weights, so its scores are internally consistent). `node scripts/sync-vendor.mjs --check` verifies the pin's sha256 offline.
178
+ - Repo layout: `src/core/` (pure ranking/pinning), `src/sources/` (local + remote acquisition), `src/opencode/` (aliases, guard, commands, storage, refresh), `vendor/` (pinned upstream script), `tests/{unit,conformance,integration}`.
179
+
180
+
181
+ ## License
182
+
183
+ GPL-3.0 — see [LICENSE](LICENSE). The vendored `vendor/model_compare.py` comes from [canonical/model-compare](https://github.com/canonical/model-compare) (GPL-3.0).
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "model-compare-opencode-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin that switches OpenRouter model aliases to the best value-for-money pick from canonical/model-compare's ranking (balanced, price, quality).",
5
+ "keywords": [
6
+ "opencode",
7
+ "openrouter",
8
+ "model-compare",
9
+ "plugin",
10
+ "llm"
11
+ ],
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./tui": "./src/tui.tsx"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "vendor/model_compare.py",
20
+ "vendor/UPSTREAM.json",
21
+ "LICENSE",
22
+ "README.md"
23
+ ],
24
+ "license": "GPL-3.0-only",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/canonical/model-compare-opencode-plugin.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/canonical/model-compare-opencode-plugin/issues"
31
+ },
32
+ "homepage": "https://github.com/canonical/model-compare-opencode-plugin#readme",
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "scripts": {
37
+ "test": "vitest run",
38
+ "typecheck": "tsc --noEmit",
39
+ "prepublishOnly": "npm run typecheck && npm test && node scripts/pack-smoke.mjs"
40
+ },
41
+ "dependencies": {
42
+ "@opencode/plugin": "2.0.16"
43
+ },
44
+ "peerDependencies": {
45
+ "@opentui/core": "^0.5.8",
46
+ "@opentui/solid": "^0.5.8",
47
+ "solid-js": "^1.9.0"
48
+ },
49
+ "devDependencies": {
50
+ "@opentui/core": "0.5.17",
51
+ "@opentui/solid": "0.5.17",
52
+ "@types/node": "^22.18.13",
53
+ "solid-js": "1.9.12",
54
+ "typescript": "^7.0.2",
55
+ "vitest": "^4.1.11"
56
+ }
57
+ }
package/src/config.ts ADDED
@@ -0,0 +1,197 @@
1
+ // src/config.ts
2
+ // Plugin option parsing (design §3): warn-and-default validation over the
3
+ // raw `options` object OpenCode hands the plugin. Any violation logs a
4
+ // warning naming the option and applies the default (or drops the offending
5
+ // piece); absent options apply their default silently. Never throws —
6
+ // whatever shape arrives collapses to a usable Config.
7
+ //
8
+ // Absent-vs-invalid: an option OpenCode didn't set (`undefined`) simply takes
9
+ // its default without a warning — nothing was misconfigured, and a bare
10
+ // `plugins` entry without `options` must stay quiet. A value that IS present
11
+ // but invalid warns. `fallback` and `scriptPath` additionally accept explicit
12
+ // `null` as a valid "unset" (their §3 rows list `null` as legal).
13
+
14
+ import type { Priority, Weights } from "./core/types"
15
+
16
+ export type Config = {
17
+ source: "auto" | "local" | "remote"
18
+ defaultPriority: Priority
19
+ // "openrouter/<id>" (§3) or null.
20
+ fallback: string | null
21
+ weights: Partial<Record<Priority, Partial<Weights>>>
22
+ pinDays: number // default 14
23
+ minLead: number // default 0.03
24
+ catalogURL: string // default "https://canonical.github.io/model-compare/catalog.json"
25
+ scriptPath: string | null // null → vendored copy
26
+ }
27
+
28
+ const SOURCES = ["auto", "local", "remote"] as const
29
+ const PRIORITIES = ["balanced", "price", "quality"] as const
30
+ const WEIGHT_KEYS = ["quality", "price", "context", "age"] as const
31
+
32
+ const DEFAULT_CATALOG_URL = "https://canonical.github.io/model-compare/catalog.json"
33
+ const FALLBACK_PREFIX = "openrouter/"
34
+
35
+ // True for plain config objects. Arrays are excluded: their index keys are
36
+ // never meaningful option names, so they warn like any other garbage.
37
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
38
+ return typeof v === "object" && v !== null && !Array.isArray(v)
39
+ }
40
+
41
+ // A safe rendering of an offending value for warning messages. Deliberately
42
+ // avoids JSON.stringify, which throws on circular structures.
43
+ function shown(v: unknown): string {
44
+ if (typeof v === "string") return `"${v}"`
45
+ if (typeof v === "object" && v !== null) return Array.isArray(v) ? "an array" : "an object"
46
+ if (typeof v === "function") return "a function"
47
+ return String(v)
48
+ }
49
+
50
+ function isEnumValue(v: unknown, names: readonly string[]): v is string {
51
+ return typeof v === "string" && (names as readonly string[]).includes(v)
52
+ }
53
+
54
+ export function parseConfig(raw: unknown): { config: Config; warnings: string[] } {
55
+ const warnings: string[] = []
56
+ const o: Record<string, unknown> = isPlainObject(raw) ? raw : {}
57
+
58
+ // source — one of the three names (§3).
59
+ let source: Config["source"] = "auto"
60
+ if (o.source !== undefined) {
61
+ if (isEnumValue(o.source, SOURCES)) {
62
+ source = o.source as Config["source"]
63
+ } else {
64
+ warnings.push(
65
+ `invalid source: ${shown(o.source)} is not one of "auto", "local", "remote"; using "auto"`,
66
+ )
67
+ }
68
+ }
69
+
70
+ // defaultPriority — one of the three Priority names.
71
+ let defaultPriority: Priority = "balanced"
72
+ if (o.defaultPriority !== undefined) {
73
+ if (isEnumValue(o.defaultPriority, PRIORITIES)) {
74
+ defaultPriority = o.defaultPriority as Priority
75
+ } else {
76
+ warnings.push(
77
+ `invalid defaultPriority: ${shown(o.defaultPriority)} is not one of` +
78
+ ` "balanced", "price", "quality"; using "balanced"`,
79
+ )
80
+ }
81
+ }
82
+
83
+ // fallback — "openrouter/<id>" with a non-empty, "#"-free remainder (§3),
84
+ // or unset. Explicit null counts as unset.
85
+ let fallback: string | null = null
86
+ const rawFallback = o.fallback
87
+ if (rawFallback !== undefined && rawFallback !== null) {
88
+ if (
89
+ typeof rawFallback === "string" &&
90
+ rawFallback.startsWith(FALLBACK_PREFIX) &&
91
+ rawFallback.length > FALLBACK_PREFIX.length &&
92
+ !rawFallback.includes("#")
93
+ ) {
94
+ fallback = rawFallback
95
+ } else {
96
+ warnings.push(
97
+ `invalid fallback: ${shown(rawFallback)} is not "openrouter/<model-id>"; treating it as unset`,
98
+ )
99
+ }
100
+ }
101
+
102
+ // weights — partial overrides per named priority. Unknown priority names,
103
+ // unknown weight keys, and values that aren't finite numbers ≥ 0 are
104
+ // dropped individually; the valid remainder is kept. An override whose
105
+ // keys all drop leaves no entry behind: an empty override would change
106
+ // downstream ranking semantics (§6) despite overriding nothing.
107
+ const weights: Config["weights"] = {}
108
+ const rawWeights = o.weights
109
+ if (rawWeights !== undefined) {
110
+ if (!isPlainObject(rawWeights)) {
111
+ warnings.push(
112
+ `invalid weights: ${shown(rawWeights)} is not an object; ignoring all weight overrides`,
113
+ )
114
+ } else {
115
+ for (const [priority, override] of Object.entries(rawWeights)) {
116
+ if (!isEnumValue(priority, PRIORITIES)) {
117
+ warnings.push(`invalid weights: unknown priority "${priority}"; dropping its override`)
118
+ continue
119
+ }
120
+ if (!isPlainObject(override)) {
121
+ warnings.push(
122
+ `invalid weights.${priority}: ${shown(override)} is not an object; dropping the override`,
123
+ )
124
+ continue
125
+ }
126
+ const kept: Partial<Weights> = {}
127
+ for (const [key, value] of Object.entries(override)) {
128
+ if (!isEnumValue(key, WEIGHT_KEYS)) {
129
+ warnings.push(`invalid weights.${priority}: unknown weight key "${key}"; dropping it`)
130
+ continue
131
+ }
132
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
133
+ warnings.push(
134
+ `invalid weights.${priority}.${key}: ${shown(value)} is not a number ≥ 0; dropping it`,
135
+ )
136
+ continue
137
+ }
138
+ kept[key as keyof Weights] = value
139
+ }
140
+ if (Object.keys(kept).length > 0) weights[priority as Priority] = kept
141
+ }
142
+ }
143
+ }
144
+
145
+ // pinDays — a finite number > 0; fractional days are fine.
146
+ let pinDays = 14
147
+ if (o.pinDays !== undefined) {
148
+ const v = o.pinDays
149
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) {
150
+ pinDays = v
151
+ } else {
152
+ warnings.push(`invalid pinDays: ${shown(v)} is not a positive number; using 14`)
153
+ }
154
+ }
155
+
156
+ // minLead — a finite number in [0, 1].
157
+ let minLead = 0.03
158
+ if (o.minLead !== undefined) {
159
+ const v = o.minLead
160
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1) {
161
+ minLead = v
162
+ } else {
163
+ warnings.push(`invalid minLead: ${shown(v)} is not a number between 0 and 1; using 0.03`)
164
+ }
165
+ }
166
+
167
+ // catalogURL — an "https://" URL; anything else gets the published catalog.
168
+ let catalogURL = DEFAULT_CATALOG_URL
169
+ if (o.catalogURL !== undefined) {
170
+ const v = o.catalogURL
171
+ if (typeof v === "string" && v.startsWith("https://")) {
172
+ catalogURL = v
173
+ } else {
174
+ warnings.push(
175
+ `invalid catalogURL: ${shown(v)} is not an "https://" URL; using the published catalog`,
176
+ )
177
+ }
178
+ }
179
+
180
+ // scriptPath — a non-empty string, or unset/null for the vendored copy.
181
+ let scriptPath: string | null = null
182
+ const rawScriptPath = o.scriptPath
183
+ if (rawScriptPath !== undefined && rawScriptPath !== null) {
184
+ if (typeof rawScriptPath === "string" && rawScriptPath.length > 0) {
185
+ scriptPath = rawScriptPath
186
+ } else {
187
+ warnings.push(
188
+ `invalid scriptPath: ${shown(rawScriptPath)} is not a non-empty string; using the vendored copy`,
189
+ )
190
+ }
191
+ }
192
+
193
+ return {
194
+ config: { source, defaultPriority, fallback, weights, pinDays, minLead, catalogURL, scriptPath },
195
+ warnings,
196
+ }
197
+ }
@@ -0,0 +1,189 @@
1
+ // src/core/catalog.ts
2
+ // Catalog document validation (design §5). Accepts schema v2 documents (only) from
3
+ // either source (vendored script or remote catalog) and projects them onto
4
+ // the plugin's Catalog type. Validation only — ranking lives elsewhere.
5
+
6
+ import type { Catalog, ModelEntry, Priority, Weights } from "./types"
7
+
8
+ const PRIORITIES: readonly Priority[] = ["balanced", "price", "quality"]
9
+ const SCORE_KEYS = ["quality", "price", "context", "age"] as const
10
+
11
+ function isObject(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value)
13
+ }
14
+
15
+ export function validateCatalog(
16
+ raw: unknown,
17
+ ): { ok: true; catalog: Catalog } | { ok: false; reason: string } {
18
+ if (!isObject(raw)) return { ok: false, reason: "catalog must be a JSON object" }
19
+
20
+ // Supported schema: v2 only — upstream's current contract (0.3.0: pricing
21
+ // scalars are effective-tier values; pricing.base/tiers/schedule and a
22
+ // top-level schedules index were added — ignored here, see the roadmap
23
+ // for consumer ideas). The plugin has never been released, so no stored
24
+ // v1 documents exist anywhere; the published catalog has served v2 since
25
+ // 2026-10-10.
26
+ if (raw.schema_version !== 2) {
27
+ return { ok: false, reason: `schema_version must be 2, got ${JSON.stringify(raw.schema_version)}` }
28
+ }
29
+ if (typeof raw.generated_at !== "string") {
30
+ return { ok: false, reason: "generated_at must be a string" }
31
+ }
32
+
33
+ // parameters.weights: optional; kept as published (possibly partial, keys
34
+ // absent upstream stay absent), except that every value must survive a
35
+ // sanity check: only the four WEIGHT_KEYS with a finite, non-negative
36
+ // number are kept (a string, NaN/±Infinity or negative weight is dropped
37
+ // like an unknown key — a hostile catalogURL serves arbitrary JSON here).
38
+ // An all-dropped map behaves as absent: with every priority map dropped,
39
+ // weights stays {} and consumers fall back to DEFAULT_WEIGHTS (design §5);
40
+ // when at least one map survives, a dropped priority reads as a missing
41
+ // one — weight 0 per the documented no-data semantics.
42
+ const weights: Partial<Record<Priority, Partial<Weights>>> = {}
43
+ const weightsRaw = isObject(raw.parameters) ? raw.parameters.weights : undefined
44
+ if (weightsRaw !== undefined) {
45
+ if (!isObject(weightsRaw)) return { ok: false, reason: "parameters.weights must be an object" }
46
+ for (const priority of PRIORITIES) {
47
+ const entry = weightsRaw[priority]
48
+ if (entry === undefined) continue
49
+ if (!isObject(entry)) {
50
+ return { ok: false, reason: `parameters.weights.${priority} must be an object` }
51
+ }
52
+ // The weight keys are the same four names as the score components.
53
+ const filtered: Partial<Weights> = {}
54
+ for (const key of SCORE_KEYS) {
55
+ const value = entry[key]
56
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) filtered[key] = value
57
+ }
58
+ if (Object.keys(filtered).length === 0) continue
59
+ weights[priority] = filtered
60
+ }
61
+ }
62
+
63
+ // models: required. Each entry needs an id and full scores (components and
64
+ // overall), plus the fields the ModelEntry type promises: a string name, an
65
+ // object pricing with a numeric blended_per_1m (the price tie-break reads
66
+ // it), and a numeric context — matching upstream, where models without
67
+ // parseable pricing land in filtered, never in models (design §5). Every
68
+ // numeric must be a FINITE number: JSON.parse("1e999") and hand-edited
69
+ // storage records can carry Infinity/NaN, and either poisons the ranking
70
+ // comparators (Infinity - Infinity is NaN) — a non-finite value rejects the
71
+ // document in the same reason style as a non-number. The optional pricing
72
+ // split (input_per_1m/output_per_1m) survives only as finite numbers — the
73
+ // values fmtPrice reads — while every other listed field is optional and
74
+ // passes through as published.
75
+ if (!Array.isArray(raw.models)) return { ok: false, reason: "models must be an array" }
76
+ const models: ModelEntry[] = []
77
+ for (let i = 0; i < raw.models.length; i++) {
78
+ const entry = raw.models[i]
79
+ if (!isObject(entry)) return { ok: false, reason: `models[${i}] must be an object` }
80
+ if (typeof entry.id !== "string") return { ok: false, reason: `models[${i}].id must be a string` }
81
+ if (!isObject(entry.scores)) return { ok: false, reason: `models[${i}].scores must be an object` }
82
+
83
+ const components = {} as Record<(typeof SCORE_KEYS)[number], number>
84
+ for (const key of SCORE_KEYS) {
85
+ const value = entry.scores[key]
86
+ if (typeof value !== "number" || !Number.isFinite(value)) {
87
+ return { ok: false, reason: `models[${i}].scores.${key} must be a number` }
88
+ }
89
+ components[key] = value
90
+ }
91
+ if (!isObject(entry.scores.overall)) {
92
+ return { ok: false, reason: `models[${i}].scores.overall must be an object` }
93
+ }
94
+ const overall = {} as Record<Priority, number>
95
+ for (const priority of PRIORITIES) {
96
+ const value = entry.scores.overall[priority]
97
+ if (typeof value !== "number" || !Number.isFinite(value)) {
98
+ return { ok: false, reason: `models[${i}].scores.overall.${priority} must be a number` }
99
+ }
100
+ overall[priority] = value
101
+ }
102
+
103
+ if (typeof entry.name !== "string") {
104
+ return { ok: false, reason: `models[${i}].name must be a string` }
105
+ }
106
+ const pricing = entry.pricing
107
+ if (!isObject(pricing)) {
108
+ return { ok: false, reason: `models[${i}].pricing must be an object` }
109
+ }
110
+ const blended = pricing.blended_per_1m
111
+ if (typeof blended !== "number" || !Number.isFinite(blended)) {
112
+ return { ok: false, reason: `models[${i}].pricing.blended_per_1m must be a number` }
113
+ }
114
+ if (typeof entry.context !== "number" || !Number.isFinite(entry.context)) {
115
+ return { ok: false, reason: `models[${i}].context must be a number` }
116
+ }
117
+
118
+ // quality is optional (number | null): a non-number is coerced to null as
119
+ // before, but a NUMBER must be finite — NaN/Infinity would poison the
120
+ // quality tie-break in byRankingKey.
121
+ if (typeof entry.quality === "number" && !Number.isFinite(entry.quality)) {
122
+ return { ok: false, reason: `models[${i}].quality must be a finite number` }
123
+ }
124
+ const quality = typeof entry.quality === "number" ? entry.quality : null
125
+ // The optional pricing split: kept only when finite numbers. A hostile
126
+ // catalogURL can serve any JSON here and fmtPrice reads these as numbers
127
+ // — a non-numeric or non-finite value is dropped to undefined (absent
128
+ // stays absent, present-but-invalid behaves as absent).
129
+ const input = pricing.input_per_1m
130
+ const output = pricing.output_per_1m
131
+ models.push({
132
+ ...entry,
133
+ id: entry.id,
134
+ name: entry.name,
135
+ pricing: {
136
+ ...pricing,
137
+ blended_per_1m: blended,
138
+ input_per_1m: typeof input === "number" && Number.isFinite(input) ? input : undefined,
139
+ output_per_1m: typeof output === "number" && Number.isFinite(output) ? output : undefined,
140
+ },
141
+ context: entry.context,
142
+ scores: { ...components, overall },
143
+ quality,
144
+ })
145
+ }
146
+
147
+ // rankings: optional; when present, each known priority must map to an
148
+ // ordered list of model ids. Unknown priority keys are ignored.
149
+ const rankings: Partial<Record<Priority, string[]>> = {}
150
+ if (raw.rankings !== undefined) {
151
+ if (!isObject(raw.rankings)) return { ok: false, reason: "rankings must be an object" }
152
+ for (const priority of PRIORITIES) {
153
+ const list = raw.rankings[priority]
154
+ if (list === undefined) continue
155
+ if (!Array.isArray(list) || !list.every((id) => typeof id === "string")) {
156
+ return { ok: false, reason: `rankings.${priority} must be an array of strings` }
157
+ }
158
+ rankings[priority] = [...(list as string[])]
159
+ }
160
+ }
161
+
162
+ return {
163
+ ok: true,
164
+ catalog: {
165
+ generatedAt: raw.generated_at,
166
+ weights,
167
+ rankings,
168
+ models,
169
+ },
170
+ }
171
+ }
172
+
173
+ // Re-validates a catalog read back from plugin storage (the projected
174
+ // `Catalog`, not the raw upstream document) by rebuilding the raw shape and
175
+ // running it through validateCatalog: a corrupt or hand-edited record is
176
+ // rejected instead of reaching ranking code. The round trip is lossless for a
177
+ // catalog validateCatalog produced.
178
+ export function validateStoredCatalog(
179
+ stored: unknown,
180
+ ): { ok: true; catalog: Catalog } | { ok: false; reason: string } {
181
+ if (!isObject(stored)) return { ok: false, reason: "stored catalog must be an object" }
182
+ return validateCatalog({
183
+ schema_version: 2,
184
+ generated_at: stored.generatedAt,
185
+ parameters: { weights: stored.weights },
186
+ models: stored.models,
187
+ rankings: stored.rankings,
188
+ })
189
+ }
@@ -0,0 +1,12 @@
1
+ // src/core/hash.ts
2
+ // 32-bit FNV-1a as lowercase hex. Used to derive storage keys (pin records by
3
+ // effective weights §7.2, the document cache by source identity) — not a
4
+ // security primitive.
5
+ export function fnv1aHex(input: string): string {
6
+ let hash = 0x811c9dc5
7
+ for (let i = 0; i < input.length; i++) {
8
+ hash ^= input.charCodeAt(i)
9
+ hash = Math.imul(hash, 0x01000193)
10
+ }
11
+ return (hash >>> 0).toString(16).padStart(8, "0")
12
+ }