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.
@@ -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
+ }
@@ -0,0 +1,132 @@
1
+ // src/core/rank.ts
2
+ // Weight merging, scoring, and ranking (design §5, §6). Pure functions over a
3
+ // validated Catalog — no I/O, no OpenCode imports.
4
+
5
+ import type { Catalog, ModelEntry, Priority, Weights } from "./types"
6
+ import { DEFAULT_WEIGHTS } from "./types"
7
+
8
+ const WEIGHT_KEYS = ["quality", "price", "context", "age"] as const
9
+
10
+ export type EffectiveWeights = {
11
+ weights: Weights
12
+ usedOverride: boolean
13
+ // Whether at least one override key actually landed in the merged map (an
14
+ // empty override, or one whose every key the published map's no-data signal
15
+ // rejected, applies nothing). `applied && !zeroSum` is the honest answer to
16
+ // "did the override take effect" — the report's "(custom weights)" label
17
+ // keys off exactly that (src/opencode/commands.ts).
18
+ applied: boolean
19
+ zeroSum: boolean
20
+ warnings: string[]
21
+ }
22
+
23
+ function sumOf(w: Weights): number {
24
+ return w.quality + w.price + w.context + w.age
25
+ }
26
+
27
+ export function effectiveWeights(
28
+ catalog: Catalog,
29
+ p: Priority,
30
+ override?: Partial<Weights>,
31
+ ): EffectiveWeights {
32
+ const warnings: string[] = []
33
+
34
+ // `parameters.weights` missing entirely leaves `catalog.weights` empty
35
+ // (validateCatalog): the pinned-tag defaults apply and overrides replace
36
+ // keys of those defaults freely. Otherwise the published map for this
37
+ // priority is the base, and a key it lacks means weight 0 — upstream
38
+ // removes a key entirely when no candidate has that data (design §5).
39
+ const hasParametersWeights = Object.keys(catalog.weights).length > 0
40
+ const published = hasParametersWeights ? (catalog.weights[p] ?? {}) : undefined
41
+ const base: Weights = published
42
+ ? {
43
+ quality: published.quality ?? 0,
44
+ price: published.price ?? 0,
45
+ context: published.context ?? 0,
46
+ age: published.age ?? 0,
47
+ }
48
+ : { ...DEFAULT_WEIGHTS[p] }
49
+
50
+ if (!override) {
51
+ return { weights: base, usedOverride: false, applied: false, zeroSum: sumOf(base) === 0, warnings }
52
+ }
53
+
54
+ // Replace the keys the user set. When a published map exists for this
55
+ // priority, a key it lacks (the no-data signal) is ignored with a
56
+ // warning; the other keys apply. With no published map at all (no
57
+ // `parameters.weights`), every override key applies to the DEFAULT base.
58
+ const merged: Weights = { ...base }
59
+ let applied = false
60
+ for (const key of WEIGHT_KEYS) {
61
+ const value = override[key]
62
+ if (value === undefined) continue
63
+ if (published && published[key] === undefined) {
64
+ warnings.push(
65
+ `ignoring ${key} weight override: the catalog's ${p} weights have no ${key} key (no data for it)`,
66
+ )
67
+ continue
68
+ }
69
+ merged[key] = value
70
+ applied = true
71
+ }
72
+
73
+ const total = sumOf(merged)
74
+ if (total === 0) {
75
+ // Runtime zero-sum (design §6): the merged map is a config error the
76
+ // schema can't catch; use the published (or DEFAULT) weights instead.
77
+ return { weights: base, usedOverride: true, applied, zeroSum: true, warnings }
78
+ }
79
+ const weights = {
80
+ quality: merged.quality / total,
81
+ price: merged.price / total,
82
+ context: merged.context / total,
83
+ age: merged.age / total,
84
+ }
85
+ return { weights, usedOverride: true, applied, zeroSum: false, warnings }
86
+ }
87
+
88
+ export function overallScore(entry: ModelEntry, w: Weights): number {
89
+ // Operand order matches upstream's scoring (design §6).
90
+ return (
91
+ w.quality * entry.scores.quality +
92
+ w.price * entry.scores.price +
93
+ w.context * entry.scores.context +
94
+ w.age * entry.scores.age
95
+ )
96
+ }
97
+
98
+ // Every plugin-side ordering sorts by (-score, -(quality ?? 0),
99
+ // pricing.blended_per_1m ascending, id ascending), mirroring upstream's
100
+ // ranking_key (design §6).
101
+ function byRankingKey(score: (m: ModelEntry) => number): (a: ModelEntry, b: ModelEntry) => number {
102
+ return (a, b) => {
103
+ const byScore = score(b) - score(a)
104
+ if (byScore !== 0) return byScore
105
+ const byQuality = (b.quality ?? 0) - (a.quality ?? 0)
106
+ if (byQuality !== 0) return byQuality
107
+ const byPrice = a.pricing.blended_per_1m - b.pricing.blended_per_1m
108
+ if (byPrice !== 0) return byPrice
109
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
110
+ }
111
+ }
112
+
113
+ function rankBy(models: ModelEntry[], score: (m: ModelEntry) => number): string[] {
114
+ return [...models].sort(byRankingKey(score)).map((m) => m.id)
115
+ }
116
+
117
+ export function rankModels(
118
+ catalog: Catalog,
119
+ p: Priority,
120
+ override?: Partial<Weights>,
121
+ ): { ids: string[]; usedFallbackWeights: boolean } {
122
+ if (!override) {
123
+ const published = catalog.rankings[p]
124
+ if (published) return { ids: [...published], usedFallbackWeights: false }
125
+ // No published ranking: sort by the published overall score (design §6).
126
+ return { ids: rankBy(catalog.models, (m) => m.scores.overall[p]), usedFallbackWeights: false }
127
+ }
128
+ const eff = effectiveWeights(catalog, p, override)
129
+ // A zero-sum merged map falls back to the published (or DEFAULT) weights,
130
+ // which `effectiveWeights` returns in `weights` on that path.
131
+ return { ids: rankBy(catalog.models, (m) => overallScore(m, eff.weights)), usedFallbackWeights: eff.zeroSum }
132
+ }
@@ -0,0 +1,50 @@
1
+ // src/core/types.ts
2
+ // Shared vocabulary for the plugin's core (design §4): pure types plus the
3
+ // pinned-tag weight defaults. No OpenCode imports, no I/O.
4
+
5
+ export type Priority = "balanced" | "price" | "quality"
6
+
7
+ export type Weights = { quality: number; price: number; context: number; age: number }
8
+
9
+ export type ModelEntry = {
10
+ id: string
11
+ name: string
12
+ // The raw catalog carries the effective-tier input/output split alongside
13
+ // the blended value; validation copies the split through only when it is a
14
+ // finite number (anything else is dropped to undefined), so the split
15
+ // survives at runtime (optional for older/narrow documents).
16
+ pricing: {
17
+ input_per_1m?: number
18
+ output_per_1m?: number
19
+ blended_per_1m: number
20
+ }
21
+ context: number
22
+ quality: number | null
23
+ scores: {
24
+ quality: number
25
+ price: number
26
+ context: number
27
+ age: number
28
+ overall: Record<Priority, number>
29
+ }
30
+ }
31
+
32
+ export type Catalog = {
33
+ generatedAt: string
34
+ weights: Partial<Record<Priority, Partial<Weights>>>
35
+ rankings: Partial<Record<Priority, string[]>>
36
+ models: ModelEntry[]
37
+ }
38
+
39
+ // Upstream.s PRIORITY_WEIGHTS at the pinned tag (v0.3.1; unchanged since v0.3.0). Used when a
40
+ // document carries no `parameters.weights` at all (design §5); individual
41
+ // missing keys fall back to weight 0 instead.
42
+ export const DEFAULT_WEIGHTS: Record<Priority, Weights> = {
43
+ balanced: { quality: 0.4, price: 0.4, context: 0.1, age: 0.1 },
44
+ price: { quality: 0.2, price: 0.6, context: 0.1, age: 0.1 },
45
+ quality: { quality: 0.6, price: 0.2, context: 0.1, age: 0.1 },
46
+ }
47
+
48
+ // A validated catalog plus when the plugin itself accepted it. The plugin's
49
+ // own clock, not `generated_at`, is the data age (design §5, §9).
50
+ export type Sourced = { doc: Catalog; acquiredAt: number }