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.
@@ -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
+ }