model-compare-opencode-plugin 0.1.1 → 0.3.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.
@@ -8,7 +8,7 @@
8
8
  // not upstream's `generated_at`.
9
9
 
10
10
  import { effectiveWeights, overallScore } from "../core/rank"
11
- import type { Catalog, ModelEntry, Priority, Sourced, Weights } from "../core/types"
11
+ import type { Catalog, EndpointCapture, ModelEntry, Priority, Sourced, Weights } from "../core/types"
12
12
  import type { AliasTarget } from "./aliases"
13
13
 
14
14
  const PRIORITIES: Priority[] = ["balanced", "price", "quality"]
@@ -64,13 +64,66 @@ export function sanitizeCatalogText(text: string): string {
64
64
  }
65
65
 
66
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.
67
+ // serves (a pin can differ from rank 1 — minLead hysteresis), which priorities
68
+ // rank under a weights override, the aliases' enabled flags (the steering
69
+ // note's "every enabled alias" scope), and the session's last-endpoint
70
+ // capture (the footer's last-response line — absent when nothing was
71
+ // captured for the session the report belongs to).
69
72
  export type ReportContext = {
70
73
  picks?: Partial<Record<Priority, AliasTarget>>
71
74
  overrides?: Partial<Record<Priority, Partial<Weights>>>
75
+ enabled?: Partial<Record<Priority, boolean>>
76
+ capture?: { endpoint: string; model: string; downgrade: boolean } | null
72
77
  }
73
78
 
79
+ // The report footer's steering note (design §Steering visibility): the union
80
+ // of the current picks' steering blocks' `quantizations` lists, resolved
81
+ // through the catalog's ModelEntry map, across the three ENABLED aliases.
82
+ // It renders only when EVERY enabled alias's current pick carries
83
+ // quantizations data (a pick missing from the catalog, or a steering block
84
+ // without the list, withholds the whole note — no claim on partial data);
85
+ // a disabled alias's pick is excluded from both the union and the check, and
86
+ // an empty quantizations list contributes nothing without withholding. No
87
+ // enabled aliases → no note. Steering shipped with the Q2 verdict PASS, so
88
+ // the note renders purely per this data rule. (That data-only rule holds
89
+ // because steering injection IS wired — Q2 PASS; in the hypothetical Q2 FAIL
90
+ // world the note's suppression would be a code change, not a data condition.)
91
+ export function steeringNote(
92
+ doc: Catalog,
93
+ picks: Partial<Record<Priority, AliasTarget>> = {},
94
+ enabled: Partial<Record<Priority, boolean>> = {},
95
+ ): string | null {
96
+ const byId = new Map(doc.models.map((model) => [model.id, model]))
97
+ const union: string[] = []
98
+ for (const p of PRIORITIES) {
99
+ if (enabled[p] !== true) continue
100
+ const pick = picks[p]
101
+ const steering =
102
+ pick === undefined || pick.id === null ? undefined : byId.get(pick.id)?.steering
103
+ const quants = steering?.quantizations
104
+ if (quants === undefined) return null
105
+ for (const quant of quants) if (!union.includes(quant)) union.push(quant)
106
+ }
107
+ return union.length === 0 ? null : `routing steered to ${union.join(", ")} endpoints (aliases only)`
108
+ }
109
+
110
+ // The report footer's last-response line (design §Steering visibility, the
111
+ // user's option C): `last response: <model> @ <endpoint>` — the captured
112
+ // response's concrete model id and the served endpoint verbatim. An empty
113
+ // model (a response whose provider carried no model id) omits the model part
114
+ // rather than rendering an empty `@` slot. No capture → null: the line is
115
+ // omitted entirely, never a placeholder.
116
+ export function lastResponseLine(
117
+ capture: Pick<EndpointCapture, "endpoint" | "model" | "downgrade"> | null | undefined,
118
+ ): string | null {
119
+ if (capture === null || capture === undefined) return null
120
+ const endpoint = sanitizeCatalogText(capture.endpoint)
121
+ return capture.model === ""
122
+ ? `last response: ${endpoint}`
123
+ : `last response: ${sanitizeCatalogText(capture.model)} @ ${endpoint}`
124
+ }
125
+
126
+
74
127
  // Whether a priority's weights override actually took effect, shared by the
75
128
  // two report builders (formatReport, reportEntries) so the "(custom weights)"
76
129
  // label and the recomputed scores can never disagree: the effective weights
@@ -138,6 +191,14 @@ export function formatReport(
138
191
  }
139
192
  }
140
193
  }
194
+ // The footer (design §Steering visibility): the steering note ABOVE the
195
+ // last-response line, both omitted when their data is absent. The
196
+ // transcript's text report and the TUI dialog's footer agree — one
197
+ // derivation, two surfaces.
198
+ const note = steeringNote(sourced.doc, context.picks, context.enabled)
199
+ if (note !== null) lines.push(note)
200
+ const lastResponse = lastResponseLine(context.capture)
201
+ if (lastResponse !== null) lines.push(lastResponse)
141
202
  return lines.join("\n")
142
203
  }
143
204
 
@@ -16,6 +16,7 @@ export type Storage = {
16
16
  // Reads a versioned record. Returns null — never throws — when the key is
17
17
  // missing, the value is not a JSON object, or `v` is missing or not 1 (§7.2:
18
18
  // corrupt or unknown-version entries are discarded and treated as absent).
19
+ // The doc-cache layer adds its own field checks (readCachedDoc below).
19
20
  export async function readRecord<T extends { v: number }>(
20
21
  storage: Storage,
21
22
  key: string,
@@ -60,7 +61,11 @@ export async function readCachedDoc(storage: Storage, identity: string): Promise
60
61
  const record = await readRecord<DocCacheRecord>(storage, docCacheKey(identity))
61
62
  if (record === null) return null
62
63
  if (record.source !== identity) return null
63
- if (typeof record.acquiredAt !== "number") return null
64
+ // acquiredAt must be a FINITE number, mirroring isValidPin (src/index.ts):
65
+ // JSON.parse("1e999") parses to Infinity, and now() - Infinity < ttlMs is
66
+ // always true — an unchecked Infinity would be served as perpetually fresh
67
+ // and never re-fetched under this identity (final-review BUG-04).
68
+ if (typeof record.acquiredAt !== "number" || !Number.isFinite(record.acquiredAt)) return null
64
69
  const validated = validateStoredCatalog(record.doc)
65
70
  if (!validated.ok) {
66
71
  console.warn("[model-compare]", `discarding the cached catalog: ${validated.reason}`)
package/src/rpc.ts CHANGED
@@ -8,16 +8,41 @@
8
8
  // surfaces at the session's next turn.
9
9
  import { Rpc } from "@opencode/plugin/rpc"
10
10
 
11
+ // The capture record the report payload carries (the footer's last-response
12
+ // line and the alt+e toast's data): the served endpoint verbatim, the
13
+ // response's concrete model id (empty when the response had none), and the
14
+ // downgrade verdict. Null when nothing was captured for the session.
15
+ const captureSchema = {
16
+ type: ["object", "null"],
17
+ properties: {
18
+ endpoint: { type: "string" },
19
+ model: { type: "string" },
20
+ downgrade: { type: "boolean" },
21
+ },
22
+ required: ["endpoint", "model", "downgrade"],
23
+ additionalProperties: false,
24
+ } as const
25
+
11
26
  export const ModelCompare = Rpc.define({
12
27
  id: "model-compare",
13
28
  methods: {
14
29
  report: {
15
- input: { type: "object", properties: {}, additionalProperties: false },
30
+ // The session whose capture the footer fields report: the TUI passes
31
+ // the CURRENT session's id (null on the opening screen — no capture
32
+ // can belong to no session). Optional for the opening screen's
33
+ // existing `{}` calls.
34
+ input: {
35
+ type: "object",
36
+ properties: { sessionID: { type: ["string", "null"] } },
37
+ additionalProperties: false,
38
+ },
16
39
  output: {
17
40
  type: "object",
18
41
  properties: {
19
42
  text: { type: "string" },
20
43
  age: { type: ["string", "null"] },
44
+ capture: captureSchema,
45
+ steeringNote: { type: ["string", "null"] },
21
46
  entries: {
22
47
  type: "array",
23
48
  items: {
@@ -38,7 +63,7 @@ export const ModelCompare = Rpc.define({
38
63
  },
39
64
  },
40
65
  },
41
- required: ["text", "age", "entries"],
66
+ required: ["text", "age", "capture", "steeringNote", "entries"],
42
67
  additionalProperties: false,
43
68
  },
44
69
  },
@@ -50,6 +75,8 @@ export const ModelCompare = Rpc.define({
50
75
  properties: {
51
76
  sessionID: { type: "string" },
52
77
  age: { type: ["string", "null"] },
78
+ capture: captureSchema,
79
+ steeringNote: { type: ["string", "null"] },
53
80
  entries: {
54
81
  type: "array",
55
82
  items: {
@@ -70,7 +97,22 @@ export const ModelCompare = Rpc.define({
70
97
  },
71
98
  },
72
99
  },
73
- required: ["sessionID", "age", "entries"],
100
+ required: ["sessionID", "age", "capture", "steeringNote", "entries"],
101
+ additionalProperties: false,
102
+ },
103
+ },
104
+ // The Q2 fallback's cannot-steer notification (the runtime hook emits it
105
+ // once per session when a steerable alias request finds no steering
106
+ // data): the TUI's toast listens for `rpc.model-compare.cannotSteer`.
107
+ // Declared here like every other event — the host fails emits of
108
+ // undeclared names with "Unknown RPC event".
109
+ cannotSteer: {
110
+ schema: {
111
+ type: "object",
112
+ properties: {
113
+ sessionID: { type: "string" },
114
+ },
115
+ required: ["sessionID"],
74
116
  additionalProperties: false,
75
117
  },
76
118
  },
@@ -0,0 +1,37 @@
1
+ // src/sources/drops.ts
2
+ // Acquisition diagnostics (final-review BUG-02): since upstream 0.4.0 the
3
+ // vendored script can drop models at its endpoint-pricing stage when their
4
+ // endpoint fetch fails (retries exhausted), reporting it in the raw
5
+ // document's `pool.dropped` map under `endpoint_fetch_failed`. The catalog
6
+ // projection discards that field, so without this check a cold-cache run on
7
+ // a degraded network silently serves a smaller pool — and the smaller pool
8
+ // can change which model a pin switches to. The warning travels in
9
+ // Acquired.warnings, which the refresh tick logs.
10
+ //
11
+ // Scope (orchestrator ruling on the fix-wave-2 brief): `pool.dropped` also
12
+ // carries the by-design funnel drops (a healthy 468-listed run reports
13
+ // ~397 models dropped for context/pricing/batch/ZDR/... to reach its 71
14
+ // candidates). Only `endpoint_fetch_failed` signals a degraded-network
15
+ // shrink; warning on the funnel would fire on every healthy run. Limitation
16
+ // (README, Troubleshooting): under `opencode serve` plugin console output
17
+ // reaches no log — this is diagnostics for contexts that see plugin output,
18
+ // not a user-visible alarm.
19
+
20
+ // Reads the raw upstream document's `pool.dropped` map and returns one
21
+ // warning string when models were dropped at the endpoint stage. Defensive
22
+ // at every step: a document without a well-formed pool.dropped emits nothing
23
+ // (the warning is best-effort diagnostics, never a validation gate).
24
+ export function dropWarningsFromRaw(raw: unknown): string[] {
25
+ if (typeof raw !== "object" || raw === null) return []
26
+ const pool = (raw as { pool?: unknown }).pool
27
+ if (typeof pool !== "object" || pool === null) return []
28
+ const dropped = (pool as { dropped?: unknown }).dropped
29
+ if (typeof dropped !== "object" || dropped === null) return []
30
+ const endpointFailed = (dropped as Record<string, unknown>).endpoint_fetch_failed
31
+ if (typeof endpointFailed !== "number" || !Number.isFinite(endpointFailed) || endpointFailed <= 0) {
32
+ return []
33
+ }
34
+ return [
35
+ `catalog pool shrunk: ${endpointFailed} model(s) dropped at the endpoint stage (endpoint_fetch_failed: ${endpointFailed})`,
36
+ ]
37
+ }
@@ -57,19 +57,28 @@ export async function acquire(cfg: Config, deps: AcquireDeps): Promise<Acquired>
57
57
  // fromCache flag is lifted to the result; sourced itself stays a plain
58
58
  // Sourced (§5).
59
59
  if (cfg.source === "remote") {
60
- const { doc, acquiredAt, fromCache } = await doFetch(cfg.catalogURL, deps.storage)
60
+ const { doc, acquiredAt, fromCache, warnings: remoteWarnings } = await doFetch(
61
+ cfg.catalogURL,
62
+ deps.storage,
63
+ )
61
64
  return {
62
65
  sourced: { doc, acquiredAt },
63
66
  source: "remote",
64
67
  identity: remoteIdentity(cfg.catalogURL),
65
68
  fromCache,
66
- warnings: [],
69
+ warnings: remoteWarnings ?? [],
67
70
  }
68
71
  }
69
72
 
70
73
  if (cfg.source === "local") {
71
74
  const sourced = await runLocal(scriptPath)
72
- return { sourced, source: "local", identity: localIdentity(cfg.scriptPath), fromCache: false, warnings: [] }
75
+ return {
76
+ sourced,
77
+ source: "local",
78
+ identity: localIdentity(cfg.scriptPath),
79
+ fromCache: false,
80
+ warnings: sourced.warnings ?? [],
81
+ }
73
82
  }
74
83
 
75
84
  // Auto: probe first; a usable python gets one local attempt. Either
@@ -84,7 +93,7 @@ export async function acquire(cfg: Config, deps: AcquireDeps): Promise<Acquired>
84
93
  source: "local",
85
94
  identity: localIdentity(cfg.scriptPath),
86
95
  fromCache: false,
87
- warnings: [],
96
+ warnings: sourced.warnings ?? [],
88
97
  }
89
98
  } catch (error) {
90
99
  const cause = asReason(error).replace(LOCAL_PREFIX, "")
@@ -94,13 +103,16 @@ export async function acquire(cfg: Config, deps: AcquireDeps): Promise<Acquired>
94
103
  warnings.push(`${probed.reason}; using remote catalog`)
95
104
  }
96
105
 
97
- const { doc, acquiredAt, fromCache } = await doFetch(cfg.catalogURL, deps.storage)
106
+ const { doc, acquiredAt, fromCache, warnings: remoteWarnings } = await doFetch(
107
+ cfg.catalogURL,
108
+ deps.storage,
109
+ )
98
110
  return {
99
111
  sourced: { doc, acquiredAt },
100
112
  source: "remote",
101
113
  identity: remoteIdentity(cfg.catalogURL),
102
114
  fromCache,
103
- warnings,
115
+ warnings: [...warnings, ...(remoteWarnings ?? [])],
104
116
  }
105
117
  }
106
118
 
@@ -10,6 +10,7 @@ import { execFile as execFileCallback } from "node:child_process"
10
10
  import { promisify } from "node:util"
11
11
  import { validateCatalog } from "../core/catalog"
12
12
  import type { Sourced } from "../core/types"
13
+ import { dropWarningsFromRaw } from "./drops"
13
14
 
14
15
  const execFile = promisify(execFileCallback)
15
16
 
@@ -120,11 +121,14 @@ export async function probePython(
120
121
 
121
122
  // Run the script (an explicit path — resolving the vendored default is the
122
123
  // caller's job), validate its stdout, and stamp the plugin's own acquisition
123
- // time on the document (design §5, §9).
124
+ // time on the document (design §5, §9). The raw document's pool.dropped map
125
+ // (models the vendored script dropped at its endpoint stage) travels as
126
+ // acquisition warnings — the projection discards it, so this is the only
127
+ // place the shrink can be observed.
124
128
  export async function runLocalScript(
125
129
  scriptPath: string,
126
130
  timeoutMs = 60_000,
127
- ): Promise<Sourced> {
131
+ ): Promise<Sourced & { warnings?: string[] }> {
128
132
  let stdout: string
129
133
  try {
130
134
  const result = await execFile("python3", [scriptPath, "--catalog"], {
@@ -171,5 +175,5 @@ export async function runLocalScript(
171
175
  if (!validated.ok) {
172
176
  throw new Error(`local source failed: invalid catalog document: ${validated.reason}`)
173
177
  }
174
- return { doc: validated.catalog, acquiredAt: Date.now() }
178
+ return { doc: validated.catalog, acquiredAt: Date.now(), warnings: dropWarningsFromRaw(raw) }
175
179
  }
@@ -14,6 +14,7 @@ import {
14
14
  writeCachedDoc,
15
15
  type Storage,
16
16
  } from "../opencode/storage"
17
+ import { dropWarningsFromRaw } from "./drops"
17
18
 
18
19
  // The cache is revalidated after six hours (design §8).
19
20
  const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000
@@ -83,18 +84,69 @@ export type RemoteDeps = {
83
84
  // HTTP status, the body parse, and the document each get their own reason).
84
85
  // Network errors surface with their own message; non-OK statuses name the
85
86
  // status code.
87
+ //
88
+ // Redirects are followed MANUALLY (Ruling 9): a redirect target is not
89
+ // re-checked by the config's https-only catalogURL rule, so a 301 to a
90
+ // plaintext http URL would otherwise deliver the catalog unencrypted. Every
91
+ // hop's resolved target must be https — the first non-https target fails the
92
+ // attempt BEFORE that request is made — and one signal bounds the whole
93
+ // chain.
94
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
95
+ const MAX_REDIRECT_HOPS = 5
96
+
86
97
  async function fetchAndValidate(
87
98
  url: string,
88
99
  doFetch: typeof fetch,
89
100
  now: () => number,
90
101
  timeoutMs: number,
91
- ): Promise<{ ok: true; sourced: Sourced } | { ok: false; reason: string }> {
92
- let response: Response
93
- try {
94
- response = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) })
95
- } catch (error) {
96
- return { ok: false, reason: error instanceof Error ? error.message : String(error) }
102
+ ): Promise<{ ok: true; sourced: Sourced & { warnings: string[] } } | { ok: false; reason: string }> {
103
+ const signal = AbortSignal.timeout(timeoutMs)
104
+ let current = url
105
+ for (let hops = 0; ; hops++) {
106
+ let response: Response
107
+ try {
108
+ response = await doFetch(current, { signal, redirect: "manual" })
109
+ } catch (error) {
110
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
111
+ }
112
+ if (REDIRECT_STATUSES.has(response.status)) {
113
+ await response.body?.cancel().catch(() => {})
114
+ if (hops >= MAX_REDIRECT_HOPS) {
115
+ return { ok: false, reason: `too many redirects (more than ${MAX_REDIRECT_HOPS} hops)` }
116
+ }
117
+ const location = response.headers?.get("location")
118
+ if (location === null || location.trim() === "") {
119
+ return {
120
+ ok: false,
121
+ reason: `redirect response ${response.status} without a Location header`,
122
+ }
123
+ }
124
+ let target: URL
125
+ try {
126
+ target = new URL(location, current)
127
+ } catch {
128
+ return {
129
+ ok: false,
130
+ reason: `redirect response ${response.status} with an unparseable Location header`,
131
+ }
132
+ }
133
+ if (target.protocol !== "https:") {
134
+ return {
135
+ ok: false,
136
+ reason: `refusing redirect that leaves https (${target.href})`,
137
+ }
138
+ }
139
+ current = target.href
140
+ continue
141
+ }
142
+ return await acceptResponse(response, now)
97
143
  }
144
+ }
145
+
146
+ async function acceptResponse(
147
+ response: Response,
148
+ now: () => number,
149
+ ): Promise<{ ok: true; sourced: Sourced & { warnings: string[] } } | { ok: false; reason: string }> {
98
150
  if (!response.ok) {
99
151
  return { ok: false, reason: `HTTP status ${response.status}` }
100
152
  }
@@ -121,7 +173,10 @@ async function fetchAndValidate(
121
173
  if (!validated.ok) {
122
174
  return { ok: false, reason: `invalid catalog document: ${validated.reason}` }
123
175
  }
124
- return { ok: true, sourced: { doc: validated.catalog, acquiredAt: now() } }
176
+ return {
177
+ ok: true,
178
+ sourced: { doc: validated.catalog, acquiredAt: now(), warnings: dropWarningsFromRaw(raw) },
179
+ }
125
180
  }
126
181
 
127
182
  // Fetch the remote catalog with a persisted six-hour cache (design §5, §8):
@@ -133,7 +188,7 @@ export async function fetchRemote(
133
188
  url: string,
134
189
  storage: Storage,
135
190
  deps: RemoteDeps = {},
136
- ): Promise<Sourced & { fromCache: boolean }> {
191
+ ): Promise<Sourced & { fromCache: boolean; warnings?: string[] }> {
137
192
  const doFetch = deps.fetch ?? globalThis.fetch
138
193
  const now = deps.now ?? Date.now
139
194
  const ttlMs = deps.ttlMs ?? DEFAULT_TTL_MS
@@ -141,14 +196,17 @@ export async function fetchRemote(
141
196
  const identity = remoteIdentity(url)
142
197
  const cached = await readCachedDoc(storage, identity)
143
198
  if (cached !== null && now() - cached.acquiredAt < ttlMs) {
199
+ // A TTL-fresh cache hit carries no warnings: the cached record is the
200
+ // projected catalog (no raw pool), and re-warning on every cache read
201
+ // would turn one shrink into an alarm on each 30-minute tick.
144
202
  return { ...cached, fromCache: true }
145
203
  }
146
204
 
147
205
  const attempt = await fetchAndValidate(url, doFetch, now, deps.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS)
148
206
  if (attempt.ok) {
149
- const { doc, acquiredAt } = attempt.sourced
207
+ const { doc, acquiredAt, warnings } = attempt.sourced
150
208
  await writeCachedDoc(storage, identity, { doc, acquiredAt })
151
- return { doc, acquiredAt, fromCache: false }
209
+ return { doc, acquiredAt, warnings, fromCache: false }
152
210
  }
153
211
  if (cached !== null) return { ...cached, fromCache: true }
154
212
  throw new Error(`remote source failed: ${attempt.reason}`)