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/index.ts
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
// Plugin entrypoint (design §8–§10, §15 item 5): everything the earlier tasks
|
|
3
|
+
// built becomes a working OpenCode v2 plugin. setup parses options (§3:
|
|
4
|
+
// warn-and-default, never throws), seeds state from storage (§8's startup
|
|
5
|
+
// chain: pin → fallback → disabled) without ever blocking on fresh data,
|
|
6
|
+
// registers the alias transform — after seeding, so the registration-time
|
|
7
|
+
// application sees the seeded picks — then the guard hook and the two
|
|
8
|
+
// commands, kicks off the first refresh in the background, schedules the
|
|
9
|
+
// 30-minute refresh tick, and returns a cleanup that clears the timer.
|
|
10
|
+
//
|
|
11
|
+
// `wire` is the testable core — the same setup with injected seams (acquire,
|
|
12
|
+
// timer, clock). The default export hands OpenCode the real context; it is
|
|
13
|
+
// cast once at this boundary because the plugin keeps a structural, mockable
|
|
14
|
+
// view of the context (plain strings instead of OpenCode's branded ids).
|
|
15
|
+
|
|
16
|
+
import { Plugin } from "@opencode/plugin"
|
|
17
|
+
import { parseConfig } from "./config"
|
|
18
|
+
import { pinKey, type Pin } from "./core/pins"
|
|
19
|
+
import { effectiveWeights, rankModels } from "./core/rank"
|
|
20
|
+
import type { Catalog, ModelEntry, Priority, Sourced } from "./core/types"
|
|
21
|
+
import { formatReport, humanizeAge, reportEntries } from "./opencode/commands"
|
|
22
|
+
import { ModelCompare } from "./rpc"
|
|
23
|
+
import { planAlias, type AliasTarget } from "./opencode/aliases"
|
|
24
|
+
import { guardDecision, type ModelRef } from "./opencode/guard"
|
|
25
|
+
import { readNewestCachedDoc, readRecord, type Storage } from "./opencode/storage"
|
|
26
|
+
import { acquire, cacheIdentities } from "./sources"
|
|
27
|
+
import {
|
|
28
|
+
REFRESH_MS,
|
|
29
|
+
adoptPlan,
|
|
30
|
+
createTick,
|
|
31
|
+
runnableSet,
|
|
32
|
+
warnDisabledAliases,
|
|
33
|
+
} from "./opencode/refresh"
|
|
34
|
+
import { PRIORITIES, planPicks } from "./opencode/resolution"
|
|
35
|
+
import {
|
|
36
|
+
ALIAS_PROVIDER,
|
|
37
|
+
aliasId,
|
|
38
|
+
useBestDecision,
|
|
39
|
+
useBestNotice,
|
|
40
|
+
} from "./opencode/usebest"
|
|
41
|
+
|
|
42
|
+
const PROVIDER_PREFIX = "openrouter/"
|
|
43
|
+
|
|
44
|
+
// Mirrors core/pins.ts' bareModelId (kept private there; the config value
|
|
45
|
+
// looks like "openrouter/<id>", catalog ids are bare). Strip exactly one
|
|
46
|
+
// leading prefix — a bare id may itself contain slashes, so nothing beyond
|
|
47
|
+
// the prefix is touched.
|
|
48
|
+
function bareModelId(configValue: string): string {
|
|
49
|
+
return configValue.startsWith(PROVIDER_PREFIX)
|
|
50
|
+
? configValue.slice(PROVIDER_PREFIX.length)
|
|
51
|
+
: configValue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- the slice of the OpenCode v2 plugin context the plugin touches --------
|
|
55
|
+
|
|
56
|
+
type Registration = { dispose: () => Promise<void> }
|
|
57
|
+
|
|
58
|
+
// The rpc.register return: disposal plus the event emitter the command's
|
|
59
|
+
// execute publishes the report through.
|
|
60
|
+
type RpcRegistration = Registration & {
|
|
61
|
+
events: { emit(name: string, data: unknown): Promise<void> }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The editor slice the alias applier touches (§8): `get` returns the concrete
|
|
65
|
+
// model's immutable definition; `update` mutates (or creates) one model.
|
|
66
|
+
export type AliasEditor = {
|
|
67
|
+
get(providerID: string, modelID: string): AliasConcrete | undefined
|
|
68
|
+
update(providerID: string, modelID: string, update: (model: AliasModel) => void): void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type AliasConcrete = {
|
|
72
|
+
capabilities: unknown
|
|
73
|
+
limit: unknown
|
|
74
|
+
cost: unknown
|
|
75
|
+
variants: unknown
|
|
76
|
+
time: unknown
|
|
77
|
+
status: unknown
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type AliasModel = AliasConcrete & {
|
|
81
|
+
modelID?: string
|
|
82
|
+
name: string
|
|
83
|
+
enabled: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type CommandDefinition = {
|
|
87
|
+
name: string
|
|
88
|
+
description?: string
|
|
89
|
+
execute: (input: { sessionID: string; prompt: { text: string } }) => Promise<void>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The `http.request` event (§9): the guard keys on the requesting agent, not
|
|
93
|
+
// on event.model, which already shows the effective (fallback) reference.
|
|
94
|
+
export type HttpEvent = {
|
|
95
|
+
sessionID: string
|
|
96
|
+
agent: string
|
|
97
|
+
model: ModelRef
|
|
98
|
+
kind: "primary" | "compaction" | "title" | "generate"
|
|
99
|
+
request: unknown
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type ModelListEntry = { id: string; providerID: string; enabled: boolean }
|
|
103
|
+
|
|
104
|
+
export type PluginCtx = {
|
|
105
|
+
options: unknown
|
|
106
|
+
app: { version: string }
|
|
107
|
+
storage: Storage
|
|
108
|
+
model: {
|
|
109
|
+
transform(callback: (editor: AliasEditor) => void): Promise<Registration>
|
|
110
|
+
// The real client returns a promise; a plain value is tolerated too.
|
|
111
|
+
list():
|
|
112
|
+
| Promise<{ location: unknown; data: ModelListEntry[] }>
|
|
113
|
+
| { location: unknown; data: ModelListEntry[] }
|
|
114
|
+
reload(): Promise<void>
|
|
115
|
+
}
|
|
116
|
+
provider: { reload(): Promise<void> }
|
|
117
|
+
agent: {
|
|
118
|
+
get(input: { agentID: string }): Promise<{ location: unknown; data: { model?: ModelRef } }>
|
|
119
|
+
reload(): Promise<void>
|
|
120
|
+
}
|
|
121
|
+
session: {
|
|
122
|
+
hook(
|
|
123
|
+
name: "http.request",
|
|
124
|
+
callback: (event: HttpEvent) => Promise<void> | void,
|
|
125
|
+
): Promise<Registration>
|
|
126
|
+
switchModel(input: { sessionID: string; model: ModelRef }): Promise<void>
|
|
127
|
+
// `resume: false` records the notice without scheduling a model turn.
|
|
128
|
+
synthetic(input: { sessionID: string; text: string; resume?: boolean | null }): Promise<unknown>
|
|
129
|
+
}
|
|
130
|
+
command: {
|
|
131
|
+
transform(
|
|
132
|
+
callback: (editor: { add(definition: CommandDefinition): void }) => void,
|
|
133
|
+
): Promise<Registration>
|
|
134
|
+
}
|
|
135
|
+
rpc: {
|
|
136
|
+
register(
|
|
137
|
+
definition: { id: string },
|
|
138
|
+
handlers: Record<string, (input: unknown) => Promise<unknown> | unknown>,
|
|
139
|
+
): Promise<RpcRegistration>
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type WireDeps = {
|
|
144
|
+
acquire?: typeof acquire
|
|
145
|
+
setInterval?: (fn: () => void, ms: number) => () => void
|
|
146
|
+
now?: () => number
|
|
147
|
+
// Per-tick hard deadline (F-01); tests shrink it.
|
|
148
|
+
deadlineMs?: number
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- captured state and small helpers --------------------------------------
|
|
152
|
+
|
|
153
|
+
// The picks and enabled flags a registry state is built from.
|
|
154
|
+
export type AliasPlan = {
|
|
155
|
+
picks: Partial<Record<Priority, AliasTarget>>
|
|
156
|
+
enabled: Partial<Record<Priority, boolean>>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The mutable state the registrations capture (§8). Two views of the picks:
|
|
160
|
+
// - `plan` is the DESIRED registry state: the alias transform reads it at
|
|
161
|
+
// every replay, so a reload always serves the newest plan;
|
|
162
|
+
// - `picks`/`enabled` are the APPLIED view — what the registry is known to
|
|
163
|
+
// serve: the guard and the commands read it. The tick advances it only
|
|
164
|
+
// after the reload chain succeeded, so a failed reload is never reported
|
|
165
|
+
// to users as a change that happened (and is retried by the next tick).
|
|
166
|
+
// `registryDisabled[p]` records that the transform wanted alias p enabled but
|
|
167
|
+
// its concrete model was missing from the editor; the applied view then says
|
|
168
|
+
// disabled, whatever the plan wanted.
|
|
169
|
+
export type WireState = {
|
|
170
|
+
sourced: Sourced | null
|
|
171
|
+
pins: Partial<Record<Priority, Pin>>
|
|
172
|
+
picks: Partial<Record<Priority, AliasTarget>>
|
|
173
|
+
enabled: Partial<Record<Priority, boolean>>
|
|
174
|
+
plan: AliasPlan
|
|
175
|
+
registryDisabled: Partial<Record<Priority, boolean>>
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// readRecord only guarantees a v=1 object; the pin fields are checked so a
|
|
179
|
+
// garbage record warns and is ignored instead of poisoning resolution (§7.2).
|
|
180
|
+
// The id must be non-empty and `since` a FINITE number — JSON.parse("1e999")
|
|
181
|
+
// and hand-edited records can carry Infinity, which would make every
|
|
182
|
+
// pin-expiry comparison (now - since) NaN.
|
|
183
|
+
function isValidPin(value: Pin): boolean {
|
|
184
|
+
return (
|
|
185
|
+
typeof value.id === "string" &&
|
|
186
|
+
value.id !== "" &&
|
|
187
|
+
typeof value.since === "number" &&
|
|
188
|
+
Number.isFinite(value.since)
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- the alias transform (design §8) ---------------------------------------
|
|
193
|
+
|
|
194
|
+
// The applier: computes each alias's plan from the captured state and copies
|
|
195
|
+
// the concrete model's identity fields onto the alias. A missing pick or a
|
|
196
|
+
// pick whose model is gone from the editor DISABLES the alias with a warning —
|
|
197
|
+
// never silently skipped (§8). Deep-copies via structuredClone to avoid
|
|
198
|
+
// aliasing the concrete model's objects.
|
|
199
|
+
function applyAliases(state: WireState, editor: AliasEditor): void {
|
|
200
|
+
for (const p of PRIORITIES) {
|
|
201
|
+
const target: AliasTarget = state.plan.picks[p] ?? { id: null, fromFallback: false }
|
|
202
|
+
// The editor's concrete definition doubles as the presence proof for the
|
|
203
|
+
// plan: planAlias only checks for undefined (it never reads model
|
|
204
|
+
// internals — the applier does, below).
|
|
205
|
+
const source = target.id === null ? undefined : editor.get(ALIAS_PROVIDER, target.id)
|
|
206
|
+
const plan = planAlias(p, target, source as unknown as ModelEntry | undefined)
|
|
207
|
+
|
|
208
|
+
if (plan.enabled && plan.modelID !== null && source !== undefined) {
|
|
209
|
+
editor.update(ALIAS_PROVIDER, aliasId(p), (model) => {
|
|
210
|
+
model.modelID = plan.modelID as string
|
|
211
|
+
model.name = plan.name
|
|
212
|
+
model.enabled = true
|
|
213
|
+
model.capabilities = structuredClone(source.capabilities)
|
|
214
|
+
model.limit = structuredClone(source.limit)
|
|
215
|
+
model.cost = structuredClone(source.cost)
|
|
216
|
+
model.variants = structuredClone(source.variants)
|
|
217
|
+
model.time = structuredClone(source.time)
|
|
218
|
+
model.status = source.status
|
|
219
|
+
})
|
|
220
|
+
state.registryDisabled[p] = false
|
|
221
|
+
} else {
|
|
222
|
+
// The plan wanted this alias enabled but the concrete model is gone:
|
|
223
|
+
// the registry now disables it, so the guard and /use-best must agree.
|
|
224
|
+
const wanted = target.id !== null
|
|
225
|
+
state.registryDisabled[p] = wanted
|
|
226
|
+
if (wanted) state.enabled[p] = false
|
|
227
|
+
editor.update(ALIAS_PROVIDER, aliasId(p), (model) => {
|
|
228
|
+
// modelID stays as-is; only the display name and the flag change.
|
|
229
|
+
model.enabled = false
|
|
230
|
+
model.name = plan.name
|
|
231
|
+
})
|
|
232
|
+
for (const warning of plan.warnings) console.warn("[model-compare]", warning)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- setup -----------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => void> {
|
|
240
|
+
const doAcquire = deps.acquire ?? acquire
|
|
241
|
+
const now = deps.now ?? Date.now
|
|
242
|
+
|
|
243
|
+
// §3: every option violation warns and defaults; parseConfig never throws.
|
|
244
|
+
const { config, warnings } = parseConfig(ctx.options)
|
|
245
|
+
for (const warning of warnings) console.warn("[model-compare]", warning)
|
|
246
|
+
|
|
247
|
+
const state: WireState = {
|
|
248
|
+
sourced: null,
|
|
249
|
+
pins: {},
|
|
250
|
+
picks: {},
|
|
251
|
+
enabled: {},
|
|
252
|
+
plan: { picks: {}, enabled: {} },
|
|
253
|
+
registryDisabled: {},
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Seed state from storage (§8 startup) BEFORE registering the transform:
|
|
257
|
+
// OpenCode applies transforms at registration time (design-spike-verified),
|
|
258
|
+
// so the first application must already see the seeded picks. Registering
|
|
259
|
+
// first would compute every alias from empty state, and a warm start whose
|
|
260
|
+
// first tick resolves the same picks then never reloads — the registry's
|
|
261
|
+
// aliases stay disabled while state.enabled says enabled. This needs only
|
|
262
|
+
// storage + ctx.model.list(); acquire is NOT awaited here.
|
|
263
|
+
//
|
|
264
|
+
// The seed is the §9 chain WITHOUT a fresh catalog — the same resolution
|
|
265
|
+
// the tick falls back to when a refresh fails (planPicks with doc: null):
|
|
266
|
+
// stored pin if runnable → configured fallback if runnable → disabled.
|
|
267
|
+
try {
|
|
268
|
+
const cached = await readNewestCachedDoc(ctx.storage, cacheIdentities(config))
|
|
269
|
+
if (cached !== null) {
|
|
270
|
+
// Warm start: read the pins — keyed by the effective weights of the
|
|
271
|
+
// cached document, since without fresh ranking the weights are
|
|
272
|
+
// unknowable.
|
|
273
|
+
state.sourced = cached
|
|
274
|
+
for (const p of PRIORITIES) {
|
|
275
|
+
const key = pinKey(p, effectiveWeights(cached.doc, p, config.weights[p]).weights)
|
|
276
|
+
const record = await readRecord<Pin>(ctx.storage, key)
|
|
277
|
+
if (record !== null && !isValidPin(record)) {
|
|
278
|
+
console.warn("[model-compare]", `corrupt pin record for ${p} (${key}); ignoring it`)
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
if (record !== null) state.pins[p] = record
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// The runnable set is only needed when something could use it.
|
|
285
|
+
const needsRunnable = Object.keys(state.pins).length > 0 || config.fallback !== null
|
|
286
|
+
const runnable = needsRunnable ? await runnableSet(ctx) : new Set<string>()
|
|
287
|
+
const plan = planPicks({ config, doc: null, runnable, pins: state.pins, now: now() })
|
|
288
|
+
adoptPlan(state, plan, true)
|
|
289
|
+
for (const notice of plan.notices) console.warn("[model-compare]", notice)
|
|
290
|
+
|
|
291
|
+
if (cached !== null) {
|
|
292
|
+
warnDisabledAliases(state.enabled, "startup")
|
|
293
|
+
} else {
|
|
294
|
+
// Cold start: the pin keys hash the effective weights of a catalog we
|
|
295
|
+
// don't have, so the chain starts at the configured fallback. It is
|
|
296
|
+
// never stored as a pin (§7.8).
|
|
297
|
+
const bare = config.fallback === null ? null : bareModelId(config.fallback)
|
|
298
|
+
if (bare !== null && state.enabled.balanced === true) {
|
|
299
|
+
console.warn(
|
|
300
|
+
"[model-compare]",
|
|
301
|
+
`no cached ranking; aliases start on the configured fallback ${bare}`,
|
|
302
|
+
)
|
|
303
|
+
} else if (bare !== null) {
|
|
304
|
+
console.warn(
|
|
305
|
+
"[model-compare]",
|
|
306
|
+
`no cached ranking; startup fallback ${bare} is not runnable; aliases disabled until first refresh`,
|
|
307
|
+
)
|
|
308
|
+
} else {
|
|
309
|
+
console.warn("[model-compare]", "no cached ranking; aliases disabled until first refresh")
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} catch (error) {
|
|
313
|
+
// Storage problems degrade to the disabled state, never break setup (§9).
|
|
314
|
+
console.error("[model-compare]", error)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Register the model transform (§8): aliases are computed inside the
|
|
318
|
+
// transform from the captured state, so any replay applies the current pick.
|
|
319
|
+
await ctx.model.transform((editor) => applyAliases(state, editor))
|
|
320
|
+
|
|
321
|
+
// The guard (§9): a primary request whose agent is configured with a
|
|
322
|
+
// DISABLED alias aborts with the exact error; everything else passes. The
|
|
323
|
+
// agent lookup is wrapped — a missing agent must not break the request path.
|
|
324
|
+
await ctx.session.hook("http.request", async (event) => {
|
|
325
|
+
let agentModel: ModelRef | undefined
|
|
326
|
+
try {
|
|
327
|
+
const agent = await ctx.agent.get({ agentID: event.agent })
|
|
328
|
+
agentModel = agent.data?.model
|
|
329
|
+
} catch {
|
|
330
|
+
agentModel = undefined
|
|
331
|
+
}
|
|
332
|
+
const decision = guardDecision(event.kind, agentModel, (p) => state.enabled[p] === true)
|
|
333
|
+
if (decision.error !== undefined) throw new Error(decision.error)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// The RPC (V2 plugins RPC): the report method serves the SAME text the
|
|
337
|
+
// /model-compare command synthesizes (one builder — the two can never
|
|
338
|
+
// drift), and the registration carries the report EVENT the command emits
|
|
339
|
+
// so the TUI half can open the report as an instant selectable dialog.
|
|
340
|
+
// Registered BEFORE the commands: the command's execute emits through it.
|
|
341
|
+
const rpcRegistration = await ctx.rpc.register(ModelCompare, {
|
|
342
|
+
report: async () => {
|
|
343
|
+
const ranks = reportRanks()
|
|
344
|
+
if (state.sourced === null || ranks === null) {
|
|
345
|
+
return { text: "model-compare: no ranking data available yet", age: null, entries: [] }
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
text: formatReport(state.sourced, ranks, { picks: state.picks, overrides: config.weights }),
|
|
349
|
+
age: humanizeAge(Date.now() - state.sourced.acquiredAt),
|
|
350
|
+
entries: reportEntries(state.sourced, ranks, { picks: state.picks, overrides: config.weights }),
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
// The report behind /model-compare: the text (transcript + the RPC's
|
|
356
|
+
// report method) and the structured entries (the RPC's report method and
|
|
357
|
+
// event — the TUI renders them as a selectable dialog). One ranking, so
|
|
358
|
+
// the text, the entries and the picks can never disagree. Ranks under the
|
|
359
|
+
// same weights overrides the aliases resolve with, so the report agrees
|
|
360
|
+
// with the picks.
|
|
361
|
+
const reportRanks = () => {
|
|
362
|
+
if (state.sourced === null) return null
|
|
363
|
+
const doc = state.sourced.doc
|
|
364
|
+
return {
|
|
365
|
+
balanced: rankModels(doc, "balanced", config.weights.balanced).ids,
|
|
366
|
+
price: rankModels(doc, "price", config.weights.price).ids,
|
|
367
|
+
quality: rankModels(doc, "quality", config.weights.quality).ids,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// The commands (§10).
|
|
372
|
+
await ctx.command.transform((editor) => {
|
|
373
|
+
editor.add({
|
|
374
|
+
name: "use-best",
|
|
375
|
+
description: "Switch this session to a model-compare priority (balanced, price or quality)",
|
|
376
|
+
execute: async ({ sessionID, prompt }) => {
|
|
377
|
+
const decision = useBestDecision({
|
|
378
|
+
argument: prompt.text,
|
|
379
|
+
defaultPriority: config.defaultPriority,
|
|
380
|
+
enabled: (p) => state.enabled[p] === true,
|
|
381
|
+
})
|
|
382
|
+
if (!decision.ok) {
|
|
383
|
+
await ctx.session.synthetic({ sessionID, text: decision.message, resume: false })
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
const p = decision.priority
|
|
387
|
+
await ctx.session.switchModel({
|
|
388
|
+
sessionID,
|
|
389
|
+
model: { providerID: ALIAS_PROVIDER, id: aliasId(p) },
|
|
390
|
+
})
|
|
391
|
+
const pickId = state.picks[p]?.id ?? null
|
|
392
|
+
try {
|
|
393
|
+
await ctx.session.synthetic({
|
|
394
|
+
sessionID,
|
|
395
|
+
text: useBestNotice(p, pickId),
|
|
396
|
+
resume: false,
|
|
397
|
+
})
|
|
398
|
+
} catch {
|
|
399
|
+
// The switch already happened; the notice is best-effort.
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
})
|
|
403
|
+
editor.add({
|
|
404
|
+
name: "model-compare",
|
|
405
|
+
description: "Show the top 5 models per priority with scores and data age",
|
|
406
|
+
execute: async ({ sessionID }) => {
|
|
407
|
+
// Snapshot the catalog once, before anything awaits: the event
|
|
408
|
+
// emission and the synthetic's admission leave gaps in which a
|
|
409
|
+
// refresh can swap state.sourced, and the event's entries and the
|
|
410
|
+
// transcript text must come from the SAME document.
|
|
411
|
+
const sourced = state.sourced
|
|
412
|
+
const ranks = reportRanks()
|
|
413
|
+
// The EVENT goes first: the TUI's instant dialog must not wait behind
|
|
414
|
+
// the synthetic's admission, which measures ~8 s in this build (the
|
|
415
|
+
// transcript notice is the durable copy, not the fast path).
|
|
416
|
+
if (sourced === null || ranks === null) {
|
|
417
|
+
try {
|
|
418
|
+
await rpcRegistration.events.emit("report", { sessionID, age: null, entries: [] })
|
|
419
|
+
} catch {
|
|
420
|
+
// Emission failures must not fail the command.
|
|
421
|
+
}
|
|
422
|
+
await ctx.session.synthetic({
|
|
423
|
+
sessionID,
|
|
424
|
+
text: "model-compare: no ranking data available yet",
|
|
425
|
+
resume: false,
|
|
426
|
+
})
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
const context = { picks: state.picks, overrides: config.weights }
|
|
430
|
+
try {
|
|
431
|
+
await rpcRegistration.events.emit("report", {
|
|
432
|
+
sessionID,
|
|
433
|
+
age: humanizeAge(Date.now() - sourced.acquiredAt),
|
|
434
|
+
entries: reportEntries(sourced, ranks, context),
|
|
435
|
+
})
|
|
436
|
+
} catch {
|
|
437
|
+
// Emission failures must not fail the command.
|
|
438
|
+
}
|
|
439
|
+
await ctx.session.synthetic({
|
|
440
|
+
sessionID,
|
|
441
|
+
text: formatReport(sourced, ranks, context),
|
|
442
|
+
resume: false,
|
|
443
|
+
})
|
|
444
|
+
},
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// The refresh tick (§8, §9), scheduled every 30 minutes — and, per §8, the
|
|
449
|
+
// FIRST refresh starts right here in the background: a cold start with
|
|
450
|
+
// working network must not wait 30 minutes for its aliases. Not awaited;
|
|
451
|
+
// the tick's non-overlap guard keeps the kick-off and the timer from
|
|
452
|
+
// colliding, and its catch-all keeps failures from escaping setup.
|
|
453
|
+
const tick = createTick(ctx, config, state, {
|
|
454
|
+
acquire: doAcquire,
|
|
455
|
+
now,
|
|
456
|
+
deadlineMs: deps.deadlineMs,
|
|
457
|
+
})
|
|
458
|
+
const cancelTimer =
|
|
459
|
+
deps.setInterval !== undefined
|
|
460
|
+
? deps.setInterval(tick, REFRESH_MS)
|
|
461
|
+
: (() => {
|
|
462
|
+
const handle = setInterval(tick, REFRESH_MS)
|
|
463
|
+
return () => clearInterval(handle)
|
|
464
|
+
})()
|
|
465
|
+
void tick()
|
|
466
|
+
|
|
467
|
+
// Registrations are cleaned up by OpenCode on unload; only the timer is ours.
|
|
468
|
+
return () => {
|
|
469
|
+
cancelTimer()
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export default Plugin.define({
|
|
474
|
+
id: "model-compare",
|
|
475
|
+
async setup(ctx) {
|
|
476
|
+
return wire(ctx as unknown as PluginCtx, {})
|
|
477
|
+
},
|
|
478
|
+
})
|
|
@@ -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
|
+
}
|