model-compare-opencode-plugin 0.0.0-stage → 0.1.1
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 +186 -2
- package/dist/tui.js +741 -0
- package/package.json +60 -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/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "model-compare-opencode-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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": "./dist/tui.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"dist/tui.js",
|
|
20
|
+
"vendor/model_compare.py",
|
|
21
|
+
"vendor/UPSTREAM.json",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"license": "GPL-3.0-only",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/canonical/model-compare-opencode-plugin.git"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/canonical/model-compare-opencode-plugin/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/canonical/model-compare-opencode-plugin#readme",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"overrides": {
|
|
38
|
+
"seroval": "^1.6.9"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "node scripts/build-tui.mjs",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm test && node scripts/pack-smoke.mjs"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@opencode/plugin": "2.0.16"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@opentui/core": "^0.5.8",
|
|
51
|
+
"@opentui/solid": "^0.5.8",
|
|
52
|
+
"solid-js": "^1.9.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@opentui/core": "0.5.17",
|
|
56
|
+
"@opentui/solid": "0.5.17",
|
|
57
|
+
"@types/node": "^22.18.13",
|
|
58
|
+
"solid-js": "1.9.12",
|
|
59
|
+
"typescript": "^7.0.2",
|
|
60
|
+
"vitest": "^4.1.11"
|
|
61
|
+
}
|
|
62
|
+
}
|
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
|
+
}
|
package/src/core/pins.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// src/core/pins.ts
|
|
2
|
+
// Pin/last-known-good state and pick resolution (design §7, §9). Pure
|
|
3
|
+
// decision logic over a validated Catalog — no I/O, no OpenCode imports; the
|
|
4
|
+
// caller supplies runnability, the stored pin, and the clock.
|
|
5
|
+
|
|
6
|
+
import type { Catalog, Priority, Weights } from "./types"
|
|
7
|
+
import { fnv1aHex } from "./hash"
|
|
8
|
+
import { effectiveWeights, overallScore, rankModels } from "./rank"
|
|
9
|
+
|
|
10
|
+
// One record per priority and effective-weights hash: both the pin and the
|
|
11
|
+
// last-known-good pick (§7.2).
|
|
12
|
+
export type Pin = { v: 1; id: string; since: number }
|
|
13
|
+
|
|
14
|
+
export type ResolveInput = {
|
|
15
|
+
catalog: Catalog | null
|
|
16
|
+
p: Priority
|
|
17
|
+
override?: Partial<Weights>
|
|
18
|
+
// True when OpenCode lists `openrouter/<id>` as an enabled model (§7.1).
|
|
19
|
+
// Receives bare catalog ids.
|
|
20
|
+
runnable: (id: string) => boolean
|
|
21
|
+
// Config value like "openrouter/<id>", or null (§3).
|
|
22
|
+
fallback: string | null
|
|
23
|
+
stored: Pin | null
|
|
24
|
+
now: number
|
|
25
|
+
pinDays: number
|
|
26
|
+
minLead: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ResolveResult = {
|
|
30
|
+
targetId: string | null
|
|
31
|
+
source: "pick" | "pin" | "fallback" | "none"
|
|
32
|
+
// The pin state after this resolution: a freshly adopted/kept record, or
|
|
33
|
+
// null only when nothing was adopted and nothing is stored (§7.8 keeps the
|
|
34
|
+
// fallback out of the pin; §9 skips, never deletes, an unrunnable record).
|
|
35
|
+
pin: Pin | null
|
|
36
|
+
notices: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PROVIDER_PREFIX = "openrouter/"
|
|
40
|
+
const DAY_MS = 86_400_000
|
|
41
|
+
|
|
42
|
+
// Config values look like "openrouter/<id>"; catalog ids are bare. Strip
|
|
43
|
+
// exactly one leading prefix — a bare id may itself contain slashes
|
|
44
|
+
// (provider/model), so nothing beyond the prefix is touched.
|
|
45
|
+
function bareModelId(configValue: string): string {
|
|
46
|
+
return configValue.startsWith(PROVIDER_PREFIX)
|
|
47
|
+
? configValue.slice(PROVIDER_PREFIX.length)
|
|
48
|
+
: configValue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { fnv1aHex }
|
|
52
|
+
|
|
53
|
+
export function pinKey(p: Priority, w: Weights): string {
|
|
54
|
+
return `pin/${p}/${fnv1aHex(JSON.stringify(w))}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// §9's chain when there is no runnable pick from ranking: configured fallback
|
|
58
|
+
// (checked by bare id) → none. `stored` rides along as the pin state: the
|
|
59
|
+
// fallback itself is never stored as a pin (§7.8).
|
|
60
|
+
function fallbackOrNone(
|
|
61
|
+
fallback: string | null,
|
|
62
|
+
runnable: (id: string) => boolean,
|
|
63
|
+
stored: Pin | null,
|
|
64
|
+
notices: string[],
|
|
65
|
+
): ResolveResult {
|
|
66
|
+
if (fallback !== null) {
|
|
67
|
+
const bare = bareModelId(fallback)
|
|
68
|
+
if (runnable(bare)) {
|
|
69
|
+
notices.push(`fallback used: ${bare}`)
|
|
70
|
+
return { targetId: bare, source: "fallback", pin: stored, notices }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { targetId: null, source: "none", pin: stored, notices }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolve(input: ResolveInput): ResolveResult {
|
|
77
|
+
const { catalog, p, override, runnable, fallback, stored, now, pinDays, minLead } = input
|
|
78
|
+
const notices: string[] = []
|
|
79
|
+
|
|
80
|
+
// Without a fresh catalog (§9): stored pin (if runnable) → fallback (if
|
|
81
|
+
// runnable) → none. No rank-related notices — there is no ranking here.
|
|
82
|
+
if (catalog === null) {
|
|
83
|
+
if (stored && runnable(stored.id)) {
|
|
84
|
+
return { targetId: stored.id, source: "pin", pin: stored, notices }
|
|
85
|
+
}
|
|
86
|
+
if (stored) notices.push(`pin skipped: ${stored.id} is not runnable`)
|
|
87
|
+
return fallbackOrNone(fallback, runnable, stored, notices)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Fresh catalog (§7.4): rank under the current weights, then walk the order
|
|
91
|
+
// for the first runnable candidate.
|
|
92
|
+
const eff = effectiveWeights(catalog, p, override)
|
|
93
|
+
// "Override ignored" notices: whatever the weight merge chose to ignore.
|
|
94
|
+
notices.push(...eff.warnings)
|
|
95
|
+
const ranked = rankModels(catalog, p, override).ids
|
|
96
|
+
const byId = new Map(catalog.models.map((m) => [m.id, m]))
|
|
97
|
+
const best = ranked.find((id) => runnable(id)) ?? null
|
|
98
|
+
|
|
99
|
+
// §9 when the fresh rules yield no runnable candidate: same chain as the
|
|
100
|
+
// no-catalog path.
|
|
101
|
+
if (best === null) {
|
|
102
|
+
if (stored && runnable(stored.id)) {
|
|
103
|
+
return { targetId: stored.id, source: "pin", pin: stored, notices }
|
|
104
|
+
}
|
|
105
|
+
if (stored) notices.push(`pin skipped: ${stored.id} is not runnable`)
|
|
106
|
+
return fallbackOrNone(fallback, runnable, stored, notices)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// (Re)adopt the best candidate; the target staying the stored pin id (only
|
|
110
|
+
// possible on the expiry re-adoption below) still counts as "pin".
|
|
111
|
+
const adopt = (id: string): ResolveResult => ({
|
|
112
|
+
targetId: id,
|
|
113
|
+
source: stored !== null && id === stored.id ? "pin" : "pick",
|
|
114
|
+
pin: { v: 1, id, since: now },
|
|
115
|
+
notices,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
if (stored === null) return adopt(best)
|
|
119
|
+
|
|
120
|
+
const pinEntry = byId.get(stored.id) ?? null
|
|
121
|
+
|
|
122
|
+
// (a) The pinned model is not runnable, or has left the pool — §7.4 says
|
|
123
|
+
// switch to the best candidate now. (A ranking entry outside the pool could
|
|
124
|
+
// still name the pin id; that degenerates into a same-id re-adoption.)
|
|
125
|
+
if (!runnable(stored.id) || pinEntry === null) {
|
|
126
|
+
if (best !== stored.id) notices.push(`pin switched: ${stored.id} → ${best}`)
|
|
127
|
+
return adopt(best)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// (b) The pin is older than pinDays: re-adopt the best candidate — which
|
|
131
|
+
// may be the pinned model itself — and reset `since` (§7.4 "which may be
|
|
132
|
+
// the same model"; §7.6 "since resets every time a pin is adopted or
|
|
133
|
+
// re-adopted").
|
|
134
|
+
if (now - stored.since > pinDays * DAY_MS) {
|
|
135
|
+
if (best !== stored.id) notices.push(`pin switched: ${stored.id} → ${best}`)
|
|
136
|
+
return adopt(best)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Nothing changed: keep the pin exactly as stored, clock untouched.
|
|
140
|
+
if (best === stored.id) {
|
|
141
|
+
return { targetId: stored.id, source: "pin", pin: stored, notices }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// (c) Switch only when the challenger leads by ≥ minLead; both scores come
|
|
145
|
+
// from the current catalog under the current effective weights (§7.4). A
|
|
146
|
+
// challenger missing from the pool can't be scored, so it can't lead.
|
|
147
|
+
const bestEntry = byId.get(best) ?? null
|
|
148
|
+
const lead =
|
|
149
|
+
bestEntry === null
|
|
150
|
+
? Number.NEGATIVE_INFINITY
|
|
151
|
+
: overallScore(bestEntry, eff.weights) - overallScore(pinEntry, eff.weights)
|
|
152
|
+
if (lead >= minLead) {
|
|
153
|
+
notices.push(`pin switched: ${stored.id} → ${best}`)
|
|
154
|
+
return adopt(best)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { targetId: stored.id, source: "pin", pin: stored, notices }
|
|
158
|
+
}
|