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/LICENSE +674 -0
- package/README.md +182 -2
- package/package.json +55 -4
- package/src/config.ts +197 -0
- package/src/core/catalog.ts +189 -0
- package/src/core/hash.ts +12 -0
- package/src/core/pins.ts +158 -0
- package/src/core/rank.ts +132 -0
- package/src/core/types.ts +50 -0
- package/src/index.ts +478 -0
- package/src/opencode/aliases.ts +58 -0
- package/src/opencode/commands.ts +251 -0
- package/src/opencode/guard.ts +44 -0
- package/src/opencode/refresh.ts +188 -0
- package/src/opencode/resolution.ts +69 -0
- package/src/opencode/storage.ts +97 -0
- package/src/opencode/usebest.ts +121 -0
- package/src/rpc.ts +78 -0
- package/src/sources/index.ts +115 -0
- package/src/sources/local.ts +175 -0
- package/src/sources/remote.ts +155 -0
- package/src/tui.tsx +772 -0
- package/vendor/UPSTREAM.json +6 -0
- package/vendor/model_compare.py +1950 -0
package/README.md
CHANGED
|
@@ -1,3 +1,183 @@
|
|
|
1
|
-
#
|
|
1
|
+
# model-compare-opencode-plugin
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/core/hash.ts
ADDED
|
@@ -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
|
+
}
|