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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// src/opencode/aliases.ts
|
|
2
|
+
// Alias plan computation (design §8, §9). The plan is pure data: what alias to
|
|
3
|
+
// point at, what to call it, and whether it can be enabled at all. Copying the
|
|
4
|
+
// concrete model's capabilities/limit/cost/variants/time/status onto the alias
|
|
5
|
+
// is the applier's job (it reads them through editor.get), so this module never
|
|
6
|
+
// touches model internals.
|
|
7
|
+
|
|
8
|
+
import type { ModelEntry, Priority } from "../core/types"
|
|
9
|
+
import { sanitizeCatalogText } from "./commands"
|
|
10
|
+
|
|
11
|
+
// The resolved pick for a priority: the winning model's id, or null when
|
|
12
|
+
// resolution found nothing (§9). `fromFallback` marks a pick that came from a
|
|
13
|
+
// curated ranking rather than the scored ordering.
|
|
14
|
+
export type AliasTarget = { id: string | null; fromFallback: boolean }
|
|
15
|
+
|
|
16
|
+
// What the applier needs to know about one alias.
|
|
17
|
+
export type AliasPlan = {
|
|
18
|
+
modelID: string | null
|
|
19
|
+
name: string
|
|
20
|
+
enabled: boolean
|
|
21
|
+
warnings: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// §8 rule: a missing pick model DISABLES the alias with a warning — it is never
|
|
25
|
+
// silently skipped. `model` is undefined when the pick's model was removed by
|
|
26
|
+
// another plugin, or when there is no catalog to look it up in.
|
|
27
|
+
export function planAlias(
|
|
28
|
+
p: Priority,
|
|
29
|
+
target: AliasTarget,
|
|
30
|
+
model: ModelEntry | undefined,
|
|
31
|
+
): AliasPlan {
|
|
32
|
+
if (target.id === null) {
|
|
33
|
+
return {
|
|
34
|
+
modelID: null,
|
|
35
|
+
name: `Model Compare: ${p} (unavailable)`,
|
|
36
|
+
enabled: false,
|
|
37
|
+
warnings: [`no pick resolved for ${p}`],
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (model === undefined) {
|
|
41
|
+
return {
|
|
42
|
+
modelID: null,
|
|
43
|
+
name: `Model Compare: ${p} (unavailable)`,
|
|
44
|
+
enabled: false,
|
|
45
|
+
warnings: [`pick model ${target.id} missing from catalog for ${p}`],
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
modelID: target.id,
|
|
50
|
+
// The pick in the display name is flattened (F-20): `target.id` is
|
|
51
|
+
// catalog-derived and the name surfaces in the model picker, the TUI
|
|
52
|
+
// status line and dialogs. `modelID` stays RAW — switching needs the
|
|
53
|
+
// real id.
|
|
54
|
+
name: `Model Compare: ${p} → ${sanitizeCatalogText(target.id)}${target.fromFallback ? " (fallback)" : ""}`,
|
|
55
|
+
enabled: true,
|
|
56
|
+
warnings: [],
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// src/opencode/commands.ts
|
|
2
|
+
// Command logic for /use-best and /model-compare (design §10): parsing the
|
|
3
|
+
// optional priority argument and rendering the ranking report. Pure module —
|
|
4
|
+
// no OpenCode imports, no I/O. The plugin wires the results into
|
|
5
|
+
// ctx.session.switchModel and the synthetic report message.
|
|
6
|
+
//
|
|
7
|
+
// The data age is the plugin's own acquisition time of the document (§5, §9),
|
|
8
|
+
// not upstream's `generated_at`.
|
|
9
|
+
|
|
10
|
+
import { effectiveWeights, overallScore } from "../core/rank"
|
|
11
|
+
import type { Catalog, ModelEntry, Priority, Sourced, Weights } from "../core/types"
|
|
12
|
+
import type { AliasTarget } from "./aliases"
|
|
13
|
+
|
|
14
|
+
const PRIORITIES: Priority[] = ["balanced", "price", "quality"]
|
|
15
|
+
const MINUTE = 60_000
|
|
16
|
+
const HOUR = 60 * MINUTE
|
|
17
|
+
const DAY = 24 * HOUR
|
|
18
|
+
const TOP_N = 5
|
|
19
|
+
|
|
20
|
+
// §10: /use-best's optional argument. Empty (after trimming) means the
|
|
21
|
+
// default priority; a priority name wins case-insensitively; anything else is
|
|
22
|
+
// refused with a message naming the valid priorities.
|
|
23
|
+
export function parseUseBest(
|
|
24
|
+
text: string,
|
|
25
|
+
defaultPriority: Priority,
|
|
26
|
+
): { ok: true; priority: Priority } | { ok: false; message: string } {
|
|
27
|
+
const trimmed = text.trim()
|
|
28
|
+
if (trimmed === "") return { ok: true, priority: defaultPriority }
|
|
29
|
+
const match = PRIORITIES.find((p) => p === trimmed.toLowerCase())
|
|
30
|
+
if (match !== undefined) return { ok: true, priority: match }
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
message: `model-compare: unknown priority "${trimmed}"; valid priorities: ${PRIORITIES.join(", ")}`,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Small humanizer for the report's data age (§10): days/hours/minutes only;
|
|
38
|
+
// anything under a minute (including clock skew) is "just now".
|
|
39
|
+
export function humanizeAge(ageMs: number): string {
|
|
40
|
+
if (ageMs < MINUTE) return "just now"
|
|
41
|
+
const days = Math.floor(ageMs / DAY)
|
|
42
|
+
if (days >= 1) return `${days}d old`
|
|
43
|
+
const hours = Math.floor(ageMs / HOUR)
|
|
44
|
+
if (hours >= 1) {
|
|
45
|
+
const minutes = Math.floor((ageMs % HOUR) / MINUTE)
|
|
46
|
+
return minutes > 0 ? `${hours}h ${minutes}m old` : `${hours}h old`
|
|
47
|
+
}
|
|
48
|
+
return `${Math.floor(ageMs / MINUTE)}m old`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MAX_TEXT = 120
|
|
52
|
+
|
|
53
|
+
// Catalog-sourced strings (model ids) end up in a message the model reads
|
|
54
|
+
// (the synthetic report), and `catalogURL` is user-pointable, so they are
|
|
55
|
+
// flattened to a single line first: control characters, newlines and the
|
|
56
|
+
// Unicode line/paragraph separators become spaces, whitespace runs collapse,
|
|
57
|
+
// and the length is capped (F-20). Real OpenRouter ids are unaffected.
|
|
58
|
+
export function sanitizeCatalogText(text: string): string {
|
|
59
|
+
const flat = text
|
|
60
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ")
|
|
61
|
+
.replace(/\s+/g, " ")
|
|
62
|
+
.trim()
|
|
63
|
+
return flat.length > MAX_TEXT ? `${flat.slice(0, MAX_TEXT - 1)}…` : flat
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// What the report knows beyond the ranking: the pick each alias currently
|
|
67
|
+
// serves (a pin can differ from rank 1 — minLead hysteresis) and which
|
|
68
|
+
// priorities rank under a weights override.
|
|
69
|
+
export type ReportContext = {
|
|
70
|
+
picks?: Partial<Record<Priority, AliasTarget>>
|
|
71
|
+
overrides?: Partial<Record<Priority, Partial<Weights>>>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Whether a priority's weights override actually took effect, shared by the
|
|
75
|
+
// two report builders (formatReport, reportEntries) so the "(custom weights)"
|
|
76
|
+
// label and the recomputed scores can never disagree: the effective weights
|
|
77
|
+
// are returned only when the override was applied to the merged map (at least
|
|
78
|
+
// one key landed) and the merge was NOT the zero-sum fallback to the
|
|
79
|
+
// published/default weights — in every other case the override changed
|
|
80
|
+
// nothing, so the published scores are shown under the plain header.
|
|
81
|
+
function overrideInEffect(
|
|
82
|
+
doc: Catalog,
|
|
83
|
+
p: Priority,
|
|
84
|
+
override: Partial<Weights> | undefined,
|
|
85
|
+
): Weights | null {
|
|
86
|
+
if (override === undefined) return null
|
|
87
|
+
const eff = effectiveWeights(doc, p, override)
|
|
88
|
+
return eff.applied && !eff.zeroSum ? eff.weights : null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// §10: /model-compare's report. `ranks` arrives already ranked, top-first
|
|
92
|
+
// (under the same overrides, see ReportContext), and only the top 5 per
|
|
93
|
+
// priority are rendered; a ranked id absent from the catalog still takes its
|
|
94
|
+
// line, with "-" instead of a score. An empty models list means no ranking
|
|
95
|
+
// data at all. With an override that actually took effect (overrideInEffect),
|
|
96
|
+
// the section is labelled "(custom weights)" and the shown score is the
|
|
97
|
+
// recomputed one: the published `overall` belongs to upstream's weights, not
|
|
98
|
+
// the user's. An override that applied nothing shows the plain header and the
|
|
99
|
+
// published scores.
|
|
100
|
+
export function formatReport(
|
|
101
|
+
sourced: Sourced,
|
|
102
|
+
ranks: Record<Priority, string[]>,
|
|
103
|
+
context: ReportContext = {},
|
|
104
|
+
): string {
|
|
105
|
+
if (sourced.doc.models.length === 0) {
|
|
106
|
+
return "**model-compare** — no ranking data available"
|
|
107
|
+
}
|
|
108
|
+
const byId = new Map<string, ModelEntry>()
|
|
109
|
+
for (const model of sourced.doc.models) byId.set(model.id, model)
|
|
110
|
+
const lines = [
|
|
111
|
+
`**model-compare** — top 5 per priority (data age: ${humanizeAge(Date.now() - sourced.acquiredAt)})`,
|
|
112
|
+
]
|
|
113
|
+
for (const p of PRIORITIES) {
|
|
114
|
+
const weights = overrideInEffect(sourced.doc, p, context.overrides?.[p])
|
|
115
|
+
const custom = weights !== null
|
|
116
|
+
const pick = context.picks?.[p]
|
|
117
|
+
lines.push(custom ? `### ${p} (custom weights)` : `### ${p}`)
|
|
118
|
+
const top = ranks[p].slice(0, TOP_N)
|
|
119
|
+
for (const [index, id] of top.entries()) {
|
|
120
|
+
const model = byId.get(id)
|
|
121
|
+
const value =
|
|
122
|
+
model === undefined ? null : weights ? overallScore(model, weights) : model.scores.overall[p]
|
|
123
|
+
const score = value === null ? "-" : value.toFixed(3)
|
|
124
|
+
const marker = pick?.id === id ? " ← current pick" : ""
|
|
125
|
+
lines.push(`${index + 1}. ${sanitizeCatalogText(id)} (${score})${marker}`)
|
|
126
|
+
}
|
|
127
|
+
// A pick that is not visible in the list above needs its own line.
|
|
128
|
+
if (pick !== undefined && (pick.id === null || !top.includes(pick.id))) {
|
|
129
|
+
if (pick.id === null) {
|
|
130
|
+
lines.push("Current pick: none (alias disabled)")
|
|
131
|
+
} else if (pick.fromFallback) {
|
|
132
|
+
lines.push(`Current pick: ${sanitizeCatalogText(pick.id)} (fallback)`)
|
|
133
|
+
} else {
|
|
134
|
+
const rank = ranks[p].indexOf(pick.id)
|
|
135
|
+
lines.push(
|
|
136
|
+
`Current pick: ${sanitizeCatalogText(pick.id)} (${rank === -1 ? "not in the ranking" : `rank ${rank + 1}`})`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return lines.join("\n")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The report as structured entries: the same ranking, scoring and pick-marker
|
|
145
|
+
// rules as formatReport, in a shape a client can render interactively (the
|
|
146
|
+
// TUI's selectable report dialog — one entry per listed model, selecting it
|
|
147
|
+
// switches straight to that model). The pick formatReport renders as its
|
|
148
|
+
// extra "Current pick:" line gets its own entry here: `rank` is that pick's
|
|
149
|
+
// 1-based position in the full ranking (formatReport's "rank N"), or null
|
|
150
|
+
// only when the pick is absent from ranks[p] (formatReport's "not in the
|
|
151
|
+
// ranking"); its `score` stays "-" because formatReport's Current pick line
|
|
152
|
+
// shows the rank, not a score. `score` otherwise keeps formatReport's "-" for
|
|
153
|
+
// an id missing from the catalog; `fromFallback` marks a pick the fallback is
|
|
154
|
+
// serving. F-20 id split: `id` is the DISPLAY id (sanitized like
|
|
155
|
+
// formatReport's lines) while `value` is the RAW catalog id — the one
|
|
156
|
+
// switchToModel needs — so a dialog row shows `id` but picks `value`.
|
|
157
|
+
// `quality` is upstream's quality index (one decimal, "-" when absent) and
|
|
158
|
+
// `price` the effective-tier input/output per 1M pair ("Free" for a 0/0
|
|
159
|
+
// model, "-" when either half is unknown).
|
|
160
|
+
export type ReportEntry = {
|
|
161
|
+
priority: Priority
|
|
162
|
+
rank: number | null
|
|
163
|
+
id: string
|
|
164
|
+
value: string
|
|
165
|
+
score: string
|
|
166
|
+
quality: string
|
|
167
|
+
price: string
|
|
168
|
+
current: boolean
|
|
169
|
+
fromFallback: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Quality index → one decimal ("37.9", "56.0"); absent → "-".
|
|
173
|
+
function fmtQuality(model: ModelEntry | undefined): string {
|
|
174
|
+
if (model === undefined || model.quality === null) return "-"
|
|
175
|
+
return model.quality.toFixed(1)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Effective-tier input/output per 1M: both explicitly zero → "Free" (the
|
|
179
|
+
// Switch-model dialog's convention); either half unknown → "-" (a half pair
|
|
180
|
+
// is never invented from `?? 0`); else "0.14/0.28".
|
|
181
|
+
function fmtPrice(model: ModelEntry | undefined): string {
|
|
182
|
+
if (model === undefined) return "-"
|
|
183
|
+
const input = model.pricing.input_per_1m
|
|
184
|
+
const output = model.pricing.output_per_1m
|
|
185
|
+
if (input === undefined || output === undefined) return "-"
|
|
186
|
+
if (input === 0 && output === 0) return "Free"
|
|
187
|
+
return `${input.toFixed(2)}/${output.toFixed(2)}`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function reportEntries(
|
|
191
|
+
sourced: Sourced,
|
|
192
|
+
ranks: Record<Priority, string[]>,
|
|
193
|
+
context: ReportContext = {},
|
|
194
|
+
): ReportEntry[] {
|
|
195
|
+
// The same empty-catalog guard as formatReport: the RPC's text and its
|
|
196
|
+
// entries must agree on "no ranking data", so no models means no entries —
|
|
197
|
+
// the client renders its own no-data notice beside the text's no-data line.
|
|
198
|
+
if (sourced.doc.models.length === 0) return []
|
|
199
|
+
const byId = new Map<string, ModelEntry>()
|
|
200
|
+
for (const model of sourced.doc.models) byId.set(model.id, model)
|
|
201
|
+
const entries: ReportEntry[] = []
|
|
202
|
+
for (const p of PRIORITIES) {
|
|
203
|
+
const weights = overrideInEffect(sourced.doc, p, context.overrides?.[p])
|
|
204
|
+
const pick = context.picks?.[p]
|
|
205
|
+
const top = ranks[p].slice(0, TOP_N)
|
|
206
|
+
for (const [index, id] of top.entries()) {
|
|
207
|
+
const model = byId.get(id)
|
|
208
|
+
const scoreValue =
|
|
209
|
+
model === undefined ? null : weights ? overallScore(model, weights) : model.scores.overall[p]
|
|
210
|
+
const current = pick?.id === id
|
|
211
|
+
entries.push({
|
|
212
|
+
priority: p,
|
|
213
|
+
rank: index + 1,
|
|
214
|
+
// F-20: `id` is the sanitized DISPLAY id (dialog rows, toasts);
|
|
215
|
+
// `value` keeps the raw catalog id so switchToModel still receives
|
|
216
|
+
// the real model id.
|
|
217
|
+
id: sanitizeCatalogText(id),
|
|
218
|
+
value: id,
|
|
219
|
+
score: scoreValue === null ? "-" : scoreValue.toFixed(3),
|
|
220
|
+
quality: fmtQuality(model),
|
|
221
|
+
price: fmtPrice(model),
|
|
222
|
+
current,
|
|
223
|
+
// A top-5 row that IS the current pick carries the pick's fallback
|
|
224
|
+
// marker like the outside-top-5 entry below (formatReport's
|
|
225
|
+
// "(fallback)" suffix on its Current pick line).
|
|
226
|
+
fromFallback: current && pick?.fromFallback === true,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
// The pick outside the top 5 gets its own entry, with its rank from the
|
|
230
|
+
// full ranking exactly like formatReport's extra "Current pick:" line
|
|
231
|
+
// (rank N when the pick is ranked at all, null = "not in the ranking");
|
|
232
|
+
// its score is "-" there — formatReport shows the rank, not a score. A
|
|
233
|
+
// disabled alias's "none" pick has nothing to select, so it is skipped.
|
|
234
|
+
if (pick !== undefined && pick.id !== null && !top.includes(pick.id)) {
|
|
235
|
+
const model = byId.get(pick.id)
|
|
236
|
+
const rank = ranks[p].indexOf(pick.id)
|
|
237
|
+
entries.push({
|
|
238
|
+
priority: p,
|
|
239
|
+
rank: rank === -1 ? null : rank + 1,
|
|
240
|
+
id: sanitizeCatalogText(pick.id),
|
|
241
|
+
value: pick.id,
|
|
242
|
+
score: "-",
|
|
243
|
+
quality: fmtQuality(model),
|
|
244
|
+
price: fmtPrice(model),
|
|
245
|
+
current: true,
|
|
246
|
+
fromFallback: pick.fromFallback,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return entries
|
|
251
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/opencode/guard.ts
|
|
2
|
+
// The primary-kind guard (design §9). Pure decision logic: given the request
|
|
3
|
+
// kind, the requesting agent's configured model ref, and whether each alias is
|
|
4
|
+
// enabled, decide whether the request must abort. It never touches OpenCode —
|
|
5
|
+
// the hook (Task 12) supplies the inputs and acts on the decision.
|
|
6
|
+
//
|
|
7
|
+
// Known trade-off (§9, §15): in headless runs the guard can fire even though
|
|
8
|
+
// the root-governed effective model is healthy, because it keys on the agent's
|
|
9
|
+
// configured ref. This is accepted, not a bug.
|
|
10
|
+
|
|
11
|
+
import type { Priority } from "../core/types"
|
|
12
|
+
|
|
13
|
+
// A model reference as configured on an agent (`agent.model`).
|
|
14
|
+
export type ModelRef = { providerID: string; id: string }
|
|
15
|
+
|
|
16
|
+
const ALIAS_PREFIX = "model-compare/"
|
|
17
|
+
|
|
18
|
+
const PRIORITIES: readonly Priority[] = ["balanced", "price", "quality"]
|
|
19
|
+
|
|
20
|
+
// The priority an alias ref points at, or null when the ref is not one of our
|
|
21
|
+
// aliases. Matches ONLY openrouter + `model-compare/<p>` where the remainder
|
|
22
|
+
// after the prefix IS a priority name — ids may carry more slashes in
|
|
23
|
+
// OpenRouter's vendor/model style, so anything after the priority disqualifies.
|
|
24
|
+
export function parseAliasRef(ref: ModelRef | undefined): Priority | null {
|
|
25
|
+
if (ref === undefined || ref.providerID !== "openrouter") return null
|
|
26
|
+
if (!ref.id.startsWith(ALIAS_PREFIX)) return null
|
|
27
|
+
const remainder = ref.id.slice(ALIAS_PREFIX.length)
|
|
28
|
+
return PRIORITIES.includes(remainder as Priority) ? (remainder as Priority) : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Abort only a primary request whose agent is configured with a DISABLED
|
|
32
|
+
// alias. Everything else — non-primary kinds, enabled aliases, real model
|
|
33
|
+
// refs, missing refs — passes with no error.
|
|
34
|
+
export function guardDecision(
|
|
35
|
+
kind: string,
|
|
36
|
+
agentModel: ModelRef | undefined,
|
|
37
|
+
isEnabled: (p: Priority) => boolean,
|
|
38
|
+
): { error?: string } {
|
|
39
|
+
const p = parseAliasRef(agentModel)
|
|
40
|
+
if (kind !== "primary" || p === null || isEnabled(p)) return {}
|
|
41
|
+
return {
|
|
42
|
+
error: `model-compare: no ranking data for openrouter/model-compare/${p}; set options.fallback or check the data source`,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// src/opencode/refresh.ts
|
|
2
|
+
// The 30-minute refresh tick (design §8, §9). One pass:
|
|
3
|
+
// 1. acquire fresh data; on failure keep going WITHOUT a catalog, so §9's
|
|
4
|
+
// chain (stored pin if runnable → fallback if runnable → none) is
|
|
5
|
+
// re-evaluated against what OpenRouter lists right now;
|
|
6
|
+
// 2. compute the next picks as a pure plan (resolution.ts) — nothing is
|
|
7
|
+
// mutated yet;
|
|
8
|
+
// 3. persist pin records and the doc cache; a storage failure aborts the
|
|
9
|
+
// pass with the state untouched, so the next pass redoes it;
|
|
10
|
+
// 4. publish the plan to the alias transform, replay it via the
|
|
11
|
+
// spike-verified provider→model→agent reloads, and only THEN mark it as
|
|
12
|
+
// applied. A reload that throws leaves the applied view (what the guard
|
|
13
|
+
// and /use-best read) on the old picks, so the next tick sees a
|
|
14
|
+
// difference and retries the reload.
|
|
15
|
+
// The tick never overlaps itself, is bounded by a hard deadline, and its
|
|
16
|
+
// errors are logged, never thrown: the timer must not die.
|
|
17
|
+
|
|
18
|
+
import type { Config } from "../config"
|
|
19
|
+
import type { Priority } from "../core/types"
|
|
20
|
+
import { writeCachedDoc, writeRecord } from "./storage"
|
|
21
|
+
import { PRIORITIES, planPicks, type PickPlan } from "./resolution"
|
|
22
|
+
import type { Acquired, acquire } from "../sources"
|
|
23
|
+
import type { PluginCtx, WireState } from "../index"
|
|
24
|
+
|
|
25
|
+
const ALIAS_PROVIDER = "openrouter"
|
|
26
|
+
export const REFRESH_MS = 1_800_000 // 30 minutes (design §8)
|
|
27
|
+
// Hard ceiling for one whole tick (F-01). It must outlast the legitimate worst
|
|
28
|
+
// case: the probe (10 s) + the script (60 s) + the provider→model→agent reload
|
|
29
|
+
// chain, which the integration harness measured at 90-105 s. Past it the tick
|
|
30
|
+
// is abandoned and the non-overlap guard is released.
|
|
31
|
+
export const TICK_DEADLINE_MS = 180_000
|
|
32
|
+
|
|
33
|
+
// The ids OpenRouter currently lists as enabled models — the runnable set
|
|
34
|
+
// resolution checks candidates against (§7.1). Also used at setup to decide
|
|
35
|
+
// which stored pins may enable their alias and whether the configured
|
|
36
|
+
// fallback is runnable.
|
|
37
|
+
export async function runnableSet(ctx: PluginCtx): Promise<Set<string>> {
|
|
38
|
+
const listed = await ctx.model.list()
|
|
39
|
+
const set = new Set<string>()
|
|
40
|
+
for (const model of listed.data) {
|
|
41
|
+
if (model.providerID === ALIAS_PROVIDER && model.enabled !== false) set.add(model.id)
|
|
42
|
+
}
|
|
43
|
+
return set
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// One warning per refresh when any alias is disabled (§9): sessions on it —
|
|
47
|
+
// root default or /use-best — fall back to OpenCode's own choice silently.
|
|
48
|
+
export function warnDisabledAliases(
|
|
49
|
+
enabled: Partial<Record<Priority, boolean>>,
|
|
50
|
+
when: string,
|
|
51
|
+
): void {
|
|
52
|
+
const disabled = PRIORITIES.filter((p) => enabled[p] !== true)
|
|
53
|
+
if (disabled.length === 0) return
|
|
54
|
+
console.warn(
|
|
55
|
+
"[model-compare]",
|
|
56
|
+
`${when}: aliases disabled (sessions on them fall back to OpenCode's own choice): ${disabled
|
|
57
|
+
.map((p) => `${ALIAS_PROVIDER}/model-compare/${p}`)
|
|
58
|
+
.join(", ")}`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Publishes a plan as both the desired registry state (read by the alias
|
|
63
|
+
// transform) and — at setup, where registration applies it immediately — the
|
|
64
|
+
// applied view.
|
|
65
|
+
export function adoptPlan(state: WireState, plan: PickPlan, applied: boolean): void {
|
|
66
|
+
state.plan = { picks: { ...plan.picks }, enabled: { ...plan.enabled } }
|
|
67
|
+
state.pins = { ...plan.pins }
|
|
68
|
+
if (applied) commitApplied(state, plan)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The registry now serves `plan`: the applied view follows, except for aliases
|
|
72
|
+
// the transform found it could not enable (concrete model gone from the
|
|
73
|
+
// editor) — those stay disabled for the guard and /use-best.
|
|
74
|
+
export function commitApplied(state: WireState, plan: PickPlan): void {
|
|
75
|
+
state.picks = { ...plan.picks }
|
|
76
|
+
state.enabled = { ...plan.enabled }
|
|
77
|
+
for (const p of PRIORITIES) if (state.registryDisabled[p]) state.enabled[p] = false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Does the plan differ from what the registry last applied? An alias the
|
|
81
|
+
// transform disabled by itself still counts as "applied as enabled": the plan
|
|
82
|
+
// wanted it and the registry did what it could.
|
|
83
|
+
function differsFromApplied(state: WireState, plan: PickPlan): boolean {
|
|
84
|
+
return PRIORITIES.some((p) => {
|
|
85
|
+
const previous = state.picks[p]
|
|
86
|
+
const appliedEnabled = state.enabled[p] === true || state.registryDisabled[p] === true
|
|
87
|
+
return (
|
|
88
|
+
(previous?.id ?? null) !== plan.picks[p].id ||
|
|
89
|
+
(previous?.fromFallback ?? false) !== plan.picks[p].fromFallback ||
|
|
90
|
+
appliedEnabled !== plan.enabled[p]
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Builds the tick. The refreshing flag lives in this closure so consecutive
|
|
96
|
+
// invocations never overlap (§8); the caller only schedules and cancels it.
|
|
97
|
+
export function createTick(
|
|
98
|
+
ctx: PluginCtx,
|
|
99
|
+
config: Config,
|
|
100
|
+
state: WireState,
|
|
101
|
+
deps: { acquire: typeof acquire; now: () => number; deadlineMs?: number },
|
|
102
|
+
): () => Promise<void> {
|
|
103
|
+
let refreshing = false
|
|
104
|
+
return async () => {
|
|
105
|
+
if (refreshing) return
|
|
106
|
+
refreshing = true
|
|
107
|
+
// An abandoned body (deadline hit) must not touch state if it ever wakes.
|
|
108
|
+
const run = { abandoned: false }
|
|
109
|
+
const deadlineMs = deps.deadlineMs ?? TICK_DEADLINE_MS
|
|
110
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
111
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
112
|
+
timer = setTimeout(() => {
|
|
113
|
+
run.abandoned = true
|
|
114
|
+
reject(new Error(`refresh exceeded its ${deadlineMs}ms deadline; abandoning it`))
|
|
115
|
+
}, deadlineMs)
|
|
116
|
+
})
|
|
117
|
+
try {
|
|
118
|
+
await Promise.race([body(run), deadline])
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error("[model-compare]", error)
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer)
|
|
123
|
+
refreshing = false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function body(run: { abandoned: boolean }): Promise<void> {
|
|
128
|
+
let result: Acquired | null = null
|
|
129
|
+
try {
|
|
130
|
+
result = await deps.acquire(config, { storage: ctx.storage })
|
|
131
|
+
if (!result) throw new Error("the source returned no document")
|
|
132
|
+
} catch (error) {
|
|
133
|
+
// §9: no fresh catalog is not the end of the pass — fall through and
|
|
134
|
+
// re-resolve pin → fallback → none against the current runnable set.
|
|
135
|
+
result = null
|
|
136
|
+
console.error("[model-compare]", error)
|
|
137
|
+
}
|
|
138
|
+
if (run.abandoned) return
|
|
139
|
+
if (result) for (const warning of result.warnings) console.warn("[model-compare]", warning)
|
|
140
|
+
|
|
141
|
+
const runnable = await runnableSet(ctx)
|
|
142
|
+
const next = planPicks({
|
|
143
|
+
config,
|
|
144
|
+
doc: result?.sourced.doc ?? null,
|
|
145
|
+
runnable,
|
|
146
|
+
pins: state.pins,
|
|
147
|
+
now: deps.now(),
|
|
148
|
+
})
|
|
149
|
+
for (const notice of next.notices) console.warn("[model-compare]", notice)
|
|
150
|
+
|
|
151
|
+
// Persist before anything is published: a storage failure throws here,
|
|
152
|
+
// with the state untouched, and the next tick simply redoes the pass.
|
|
153
|
+
// The deadline may have fired while the runnable set was fetched: an
|
|
154
|
+
// abandoned tick must not touch storage either (F-01 semantics —
|
|
155
|
+
// abandoned means neither state nor storage is touched).
|
|
156
|
+
if (run.abandoned) return
|
|
157
|
+
if (result) {
|
|
158
|
+
for (const { key, pin } of next.writes) await writeRecord(ctx.storage, key, pin)
|
|
159
|
+
await writeCachedDoc(ctx.storage, result.identity, result.sourced)
|
|
160
|
+
}
|
|
161
|
+
if (run.abandoned) return
|
|
162
|
+
|
|
163
|
+
const changed = differsFromApplied(state, next)
|
|
164
|
+
if (result) state.sourced = result.sourced
|
|
165
|
+
adoptPlan(state, next, false)
|
|
166
|
+
warnDisabledAliases(next.enabled, "refresh")
|
|
167
|
+
if (changed) {
|
|
168
|
+
// The spike-verified replay order (§8, §15 item 1): provider first,
|
|
169
|
+
// then model, then agent. A throw leaves the applied view stale on
|
|
170
|
+
// purpose: the next tick's comparison fails again and retries.
|
|
171
|
+
await ctx.provider.reload()
|
|
172
|
+
await ctx.model.reload()
|
|
173
|
+
await ctx.agent.reload()
|
|
174
|
+
}
|
|
175
|
+
// The reload chain is slow (measured 90-105 s) — the deadline can fire
|
|
176
|
+
// in the middle of it. An abandoned tick must not commit its plan as
|
|
177
|
+
// applied: the applied view (the guard, /use-best) stays on the old
|
|
178
|
+
// picks, and the next tick's comparison retries the reload.
|
|
179
|
+
if (run.abandoned) return
|
|
180
|
+
commitApplied(state, next)
|
|
181
|
+
console.warn(
|
|
182
|
+
"[model-compare]",
|
|
183
|
+
result
|
|
184
|
+
? `refresh complete: source=${result.source} fromCache=${result.fromCache} changed=${changed}`
|
|
185
|
+
: `refresh failed; resolved without a fresh catalog: changed=${changed}`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/opencode/resolution.ts
|
|
2
|
+
// One resolution pass over all three priorities (design §7, §9), shared by
|
|
3
|
+
// setup and the refresh tick so the two can never disagree about the chain:
|
|
4
|
+
// fresh catalog → §7.4 rules (rank, pin hysteresis, expiry) then §9 chain
|
|
5
|
+
// no catalog → stored pin (if runnable) → fallback (if runnable) → none
|
|
6
|
+
// The result is a pure plan: nothing is applied or persisted here.
|
|
7
|
+
|
|
8
|
+
import type { Config } from "../config"
|
|
9
|
+
import { pinKey, resolve, type Pin } from "../core/pins"
|
|
10
|
+
import { effectiveWeights } from "../core/rank"
|
|
11
|
+
import type { Catalog, Priority } from "../core/types"
|
|
12
|
+
import type { AliasTarget } from "./aliases"
|
|
13
|
+
|
|
14
|
+
export const PRIORITIES: readonly Priority[] = ["balanced", "price", "quality"]
|
|
15
|
+
|
|
16
|
+
export type PickPlan = {
|
|
17
|
+
picks: Record<Priority, AliasTarget>
|
|
18
|
+
enabled: Record<Priority, boolean>
|
|
19
|
+
// The pin state after this pass (a pass without a catalog never changes it).
|
|
20
|
+
pins: Partial<Record<Priority, Pin>>
|
|
21
|
+
// Pin records to persist: only produced when a fresh catalog is available.
|
|
22
|
+
writes: Array<{ key: string; pin: Pin }>
|
|
23
|
+
notices: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function planPicks(input: {
|
|
27
|
+
config: Config
|
|
28
|
+
// null = no fresh catalog (cold start, offline start, failed refresh).
|
|
29
|
+
doc: Catalog | null
|
|
30
|
+
runnable: ReadonlySet<string>
|
|
31
|
+
pins: Partial<Record<Priority, Pin>>
|
|
32
|
+
now: number
|
|
33
|
+
}): PickPlan {
|
|
34
|
+
const { config, doc, runnable, pins, now } = input
|
|
35
|
+
const plan: PickPlan = {
|
|
36
|
+
picks: {} as Record<Priority, AliasTarget>,
|
|
37
|
+
enabled: {} as Record<Priority, boolean>,
|
|
38
|
+
pins: { ...pins },
|
|
39
|
+
writes: [],
|
|
40
|
+
notices: [],
|
|
41
|
+
}
|
|
42
|
+
for (const p of PRIORITIES) {
|
|
43
|
+
const resolved = resolve({
|
|
44
|
+
catalog: doc,
|
|
45
|
+
p,
|
|
46
|
+
override: config.weights[p],
|
|
47
|
+
runnable: (id) => runnable.has(id),
|
|
48
|
+
fallback: config.fallback,
|
|
49
|
+
stored: pins[p] ?? null,
|
|
50
|
+
now,
|
|
51
|
+
pinDays: config.pinDays,
|
|
52
|
+
minLead: config.minLead,
|
|
53
|
+
})
|
|
54
|
+
if (resolved.pin === null) delete plan.pins[p]
|
|
55
|
+
else plan.pins[p] = resolved.pin
|
|
56
|
+
plan.picks[p] =
|
|
57
|
+
resolved.targetId === null
|
|
58
|
+
? { id: null, fromFallback: false }
|
|
59
|
+
: { id: resolved.targetId, fromFallback: resolved.source === "fallback" }
|
|
60
|
+
plan.enabled[p] = resolved.targetId !== null
|
|
61
|
+
if (doc !== null && resolved.pin !== null) {
|
|
62
|
+
// The effective-weights hash keys the record (§7.2).
|
|
63
|
+
const key = pinKey(p, effectiveWeights(doc, p, config.weights[p]).weights)
|
|
64
|
+
plan.writes.push({ key, pin: resolved.pin })
|
|
65
|
+
}
|
|
66
|
+
plan.notices.push(...resolved.notices)
|
|
67
|
+
}
|
|
68
|
+
return plan
|
|
69
|
+
}
|