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/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
|
+
}
|
package/src/core/rank.ts
ADDED
|
@@ -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 }
|