model-compare-opencode-plugin 0.5.0 → 0.6.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.
@@ -5,6 +5,7 @@
5
5
  // is the applier's job (it reads them through editor.get), so this module never
6
6
  // touches model internals.
7
7
 
8
+ import { formatDepthAlternative } from "../core/reasoning"
8
9
  import type { ModelEntry, Priority } from "../core/types"
9
10
  import { sanitizeCatalogText } from "./commands"
10
11
 
@@ -24,10 +25,17 @@ export type AliasPlan = {
24
25
  // §8 rule: a missing pick model DISABLES the alias with a warning — it is never
25
26
  // silently skipped. `model` is undefined when the pick's model was removed by
26
27
  // another plugin, or when there is no catalog to look it up in.
28
+ //
29
+ // `displayModel` is the CATALOG entry for the same pick: the applier's
30
+ // presence proof is the editor's concrete model (which carries no ranking
31
+ // metadata), so the effort/alternative label reads the catalog entry instead.
32
+ // Absent (a fallback pick with no catalog, or a caller that has only the
33
+ // editor model) falls back to `model`.
27
34
  export function planAlias(
28
35
  p: Priority,
29
36
  target: AliasTarget,
30
37
  model: ModelEntry | undefined,
38
+ displayModel?: ModelEntry,
31
39
  ): AliasPlan {
32
40
  if (target.id === null) {
33
41
  return {
@@ -45,13 +53,19 @@ export function planAlias(
45
53
  warnings: [`pick model ${target.id} missing from catalog for ${p}`],
46
54
  }
47
55
  }
56
+ // The effort the pick's score represents, followed by the best measured
57
+ // selectable alternative (catalog v5). Both are catalog-derived and
58
+ // flattened (F-20) for the model picker, the TUI status line and dialogs;
59
+ // `modelID` stays RAW — switching needs the real id.
60
+ const shown = displayModel ?? model
61
+ const effort =
62
+ shown.qualityEffort == null ? "" : ` · ${sanitizeCatalogText(shown.qualityEffort)}`
63
+ const alternative = formatDepthAlternative(shown)
48
64
  return {
49
65
  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)" : ""}`,
66
+ name: `Model Compare: ${p} → ${sanitizeCatalogText(target.id)}${effort}${
67
+ target.fromFallback ? " (fallback)" : ""
68
+ }${alternative === "" ? "" : ` [alt ${sanitizeCatalogText(alternative)}]`}`,
55
69
  enabled: true,
56
70
  warnings: [],
57
71
  }
@@ -8,6 +8,7 @@
8
8
  // not upstream's `generated_at`.
9
9
 
10
10
  import { effectiveWeights, overallScore } from "../core/rank"
11
+ import { formatDepthAlternative } from "../core/reasoning"
11
12
  import type { Catalog, EndpointCapture, ModelEntry, Priority, Sourced, Weights } from "../core/types"
12
13
  import type { AliasTarget } from "./aliases"
13
14
 
@@ -141,6 +142,30 @@ function overrideInEffect(
141
142
  return eff.applied && !eff.zeroSum ? eff.weights : null
142
143
  }
143
144
 
145
+ // Catalog v5 display suffixes: the effort the quality score was measured at
146
+ // (` · high`) and the best measured selectable alternative (` · alt max 56.0`).
147
+ // Both are catalog-derived, so they are flattened before joining the report
148
+ // text / dialog (F-20). An absent effort/alternative adds nothing.
149
+ function effortLabel(model: ModelEntry | undefined): string {
150
+ const effort = model?.qualityEffort
151
+ return effort == null ? "" : sanitizeCatalogText(effort)
152
+ }
153
+
154
+ function alternativeLabel(model: ModelEntry | undefined): string {
155
+ const alternative = formatDepthAlternative(model)
156
+ return alternative === "" ? "" : sanitizeCatalogText(alternative)
157
+ }
158
+
159
+ function reasoningSuffix(model: ModelEntry | undefined): string {
160
+ const label = effortLabel(model)
161
+ return label === "" ? "" : ` · ${label}`
162
+ }
163
+
164
+ function alternativeSuffix(model: ModelEntry | undefined): string {
165
+ const label = alternativeLabel(model)
166
+ return label === "" ? "" : ` · alt ${label}`
167
+ }
168
+
144
169
  // §10: /model-compare's report. `ranks` arrives already ranked, top-first
145
170
  // (under the same overrides, see ReportContext), and only the top 5 per
146
171
  // priority are rendered; a ranked id absent from the catalog still takes its
@@ -175,18 +200,23 @@ export function formatReport(
175
200
  model === undefined ? null : weights ? overallScore(model, weights) : model.scores.overall[p]
176
201
  const score = value === null ? "-" : value.toFixed(3)
177
202
  const marker = pick?.id === id ? " ← current pick" : ""
178
- lines.push(`${index + 1}. ${sanitizeCatalogText(id)} (${score})${marker}`)
203
+ lines.push(
204
+ `${index + 1}. ${sanitizeCatalogText(id)} (${score})${reasoningSuffix(model)}${alternativeSuffix(model)}${marker}`,
205
+ )
179
206
  }
180
207
  // A pick that is not visible in the list above needs its own line.
181
208
  if (pick !== undefined && (pick.id === null || !top.includes(pick.id))) {
209
+ const pickModel = pick.id === null ? undefined : byId.get(pick.id)
182
210
  if (pick.id === null) {
183
211
  lines.push("Current pick: none (alias disabled)")
184
212
  } else if (pick.fromFallback) {
185
- lines.push(`Current pick: ${sanitizeCatalogText(pick.id)} (fallback)`)
213
+ lines.push(
214
+ `Current pick: ${sanitizeCatalogText(pick.id)} (fallback)${reasoningSuffix(pickModel)}${alternativeSuffix(pickModel)}`,
215
+ )
186
216
  } else {
187
217
  const rank = ranks[p].indexOf(pick.id)
188
218
  lines.push(
189
- `Current pick: ${sanitizeCatalogText(pick.id)} (${rank === -1 ? "not in the ranking" : `rank ${rank + 1}`})`,
219
+ `Current pick: ${sanitizeCatalogText(pick.id)} (${rank === -1 ? "not in the ranking" : `rank ${rank + 1}`})${reasoningSuffix(pickModel)}${alternativeSuffix(pickModel)}`,
190
220
  )
191
221
  }
192
222
  }
@@ -225,6 +255,10 @@ export type ReportEntry = {
225
255
  value: string
226
256
  score: string
227
257
  quality: string
258
+ // The effort behind `quality` ("" when unknown) and the best selectable
259
+ // measured alternative as "<effort> <index>" ("" when there is none).
260
+ effort: string
261
+ alternative: string
228
262
  price: string
229
263
  current: boolean
230
264
  fromFallback: boolean
@@ -279,6 +313,8 @@ export function reportEntries(
279
313
  value: id,
280
314
  score: scoreValue === null ? "-" : scoreValue.toFixed(3),
281
315
  quality: fmtQuality(model),
316
+ effort: effortLabel(model),
317
+ alternative: alternativeLabel(model),
282
318
  price: fmtPrice(model),
283
319
  current,
284
320
  // A top-5 row that IS the current pick carries the pick's fallback
@@ -302,6 +338,8 @@ export function reportEntries(
302
338
  value: pick.id,
303
339
  score: "-",
304
340
  quality: fmtQuality(model),
341
+ effort: effortLabel(model),
342
+ alternative: alternativeLabel(model),
305
343
  price: fmtPrice(model),
306
344
  current: true,
307
345
  fromFallback: pick.fromFallback,
@@ -10,8 +10,10 @@
10
10
 
11
11
  import type { Priority } from "../core/types"
12
12
 
13
- // A model reference as configured on an agent (`agent.model`).
14
- export type ModelRef = { providerID: string; id: string }
13
+ // A model reference (agent config, session selection or request event). The
14
+ // optional `variant` is the `#variant` selection; alias identification stays
15
+ // variant-blind (it is not stable across binding styles).
16
+ export type ModelRef = { providerID: string; id: string; variant?: string }
15
17
 
16
18
  const ALIAS_PREFIX = "model-compare/"
17
19
 
@@ -80,12 +80,22 @@ export function readAliasViews(
80
80
  }
81
81
 
82
82
  // The pick shown in an alias's display name:
83
- // "Model Compare: <p> → <pick>[( fallback)]" → "<pick>[( fallback)]", or null
84
- // for the "(unavailable)" placeholder names.
83
+ // "Model Compare: <p> → <pick>[ (fallback)]" → "<pick>[ (fallback)]", or null
84
+ // for the "(unavailable)" placeholder names. A v5 name appends the scored
85
+ // effort and the best alternative (" · high [alt max 56.0]") AFTER the pick
86
+ // (the fallback marker stays between them); those annotations are stripped so
87
+ // the status line and toasts still name the served model, not its depth.
85
88
  export function pickFromName(name: string): string | null {
86
89
  const arrow = name.indexOf("→")
87
90
  if (arrow === -1) return null
88
- return name.slice(arrow + 1).trim()
91
+ const rest = name.slice(arrow + 1).replace(/ \[alt .*\]$/, "")
92
+ // Our name builder separates the pick from its v5 annotations with " · ";
93
+ // OpenRouter ids contain no spaces, so the first segment is the pick. The
94
+ // fallback marker lives in the annotation segment (or already after a pick
95
+ // with no annotations), so carry it over exactly once.
96
+ const [pick, ...annotations] = rest.split(" · ")
97
+ const fallback = annotations.join(" ").includes("(fallback)") ? " (fallback)" : ""
98
+ return `${pick.trim()}${fallback}`
89
99
  }
90
100
 
91
101
  // The pick an ENABLED alias view serves, or null when nothing observable is
package/src/rpc.ts CHANGED
@@ -54,11 +54,25 @@ export const ModelCompare = Rpc.define({
54
54
  value: { type: "string" },
55
55
  score: { type: "string" },
56
56
  quality: { type: "string" },
57
+ effort: { type: "string" },
58
+ alternative: { type: "string" },
57
59
  price: { type: "string" },
58
60
  current: { type: "boolean" },
59
61
  fromFallback: { type: "boolean" },
60
62
  },
61
- required: ["priority", "rank", "id", "value", "score", "quality", "price", "current", "fromFallback"],
63
+ required: [
64
+ "priority",
65
+ "rank",
66
+ "id",
67
+ "value",
68
+ "score",
69
+ "quality",
70
+ "effort",
71
+ "alternative",
72
+ "price",
73
+ "current",
74
+ "fromFallback",
75
+ ],
62
76
  additionalProperties: false,
63
77
  },
64
78
  },
@@ -88,11 +102,25 @@ export const ModelCompare = Rpc.define({
88
102
  value: { type: "string" },
89
103
  score: { type: "string" },
90
104
  quality: { type: "string" },
105
+ effort: { type: "string" },
106
+ alternative: { type: "string" },
91
107
  price: { type: "string" },
92
108
  current: { type: "boolean" },
93
109
  fromFallback: { type: "boolean" },
94
110
  },
95
- required: ["priority", "rank", "id", "value", "score", "quality", "price", "current", "fromFallback"],
111
+ required: [
112
+ "priority",
113
+ "rank",
114
+ "id",
115
+ "value",
116
+ "score",
117
+ "quality",
118
+ "effort",
119
+ "alternative",
120
+ "price",
121
+ "current",
122
+ "fromFallback",
123
+ ],
96
124
  additionalProperties: false,
97
125
  },
98
126
  },
package/src/tui.tsx CHANGED
@@ -186,6 +186,8 @@ function toReportEntries(raw: unknown): ReportEntry[] {
186
186
  value: entry.value,
187
187
  score: typeof entry.score === "string" ? entry.score : "-",
188
188
  quality: typeof entry.quality === "string" ? entry.quality : "-",
189
+ effort: typeof entry.effort === "string" ? entry.effort : "",
190
+ alternative: typeof entry.alternative === "string" ? entry.alternative : "",
189
191
  price: typeof entry.price === "string" ? entry.price : "-",
190
192
  current: entry.current === true,
191
193
  fromFallback: entry.fromFallback === true,
@@ -726,8 +728,15 @@ export default Plugin.define({
726
728
  const labelOf = new Map(data.entries.map((entry, index) => [entry, labels[index]]))
727
729
  const colW = (values: string[], floor: number, cap: number): number =>
728
730
  Math.min(cap, Math.max(floor, ...values.map((value) => value.length)))
729
- const qualW = colW(data.entries.map((entry) => entry.quality), "qual".length, 8)
730
- const priceW = colW(data.entries.map((entry) => entry.price), "$ in/out".length, 16)
731
+ // Quality is a one-decimal AA index (max "100.0"); the caps leave the
732
+ // 52-char row budget intact (see `fixed` below) while fitting real
733
+ // values: "minimal" (effort), "minimal 100.0" (alt), "10.00/100.00".
734
+ const qualW = colW(data.entries.map((entry) => entry.quality), "qual".length, 5)
735
+ // Catalog v5: the effort the quality was measured at and the best
736
+ // selectable measured alternative ("max 56.0"). A missing value is "-".
737
+ const effortW = colW(data.entries.map((entry) => entry.effort || "-"), "effort".length, 7)
738
+ const altW = colW(data.entries.map((entry) => entry.alternative || "-"), "alt".length, 13)
739
+ const priceW = colW(data.entries.map((entry) => entry.price), "$ in/out".length, 13)
731
740
  // A cell wider than its clamped column is truncated to fit.
732
741
  const cell = (value: string, width: number): string =>
733
742
  value.length > width ? value.slice(0, width - 1) + "…" : value
@@ -736,8 +745,11 @@ export default Plugin.define({
736
745
  // stays exactly 2 (padStart pushed each value right by its slack).
737
746
  // The default dialog is 60 wide: 2 list insets + 6 row insets leave 52
738
747
  // for row content; the header shares that budget (plus its 3-indent).
739
- const fixed = 2 + qualW + 2 + priceW
740
- const nameW = Math.max(8, Math.min(52 - fixed, Math.max(...labels.map((label) => label.length))))
748
+ // Column caps keep the budget even for hostile strings: 5+7+13+13 plus
749
+ // the four 2-char paddings and the 2-char row indent = 46, leaving the
750
+ // name floor of 6 (the name column shrinks first for real content).
751
+ const fixed = 2 + qualW + 2 + effortW + 2 + altW + 2 + priceW
752
+ const nameW = Math.max(6, Math.min(52 - fixed, Math.max(...labels.map((label) => label.length))))
741
753
  const trim = (label: string): string =>
742
754
  label.length > nameW ? label.slice(0, nameW - 1) + "…" : label
743
755
  const headerLine =
@@ -746,6 +758,10 @@ export default Plugin.define({
746
758
  " " +
747
759
  "qual".padStart(qualW) +
748
760
  " " +
761
+ "effort".padStart(effortW) +
762
+ " " +
763
+ "alt".padEnd(altW) +
764
+ " " +
749
765
  "$ in/out".padEnd(priceW)
750
766
  const rows: ListRow[] = [{ label: "", headerLine }]
751
767
  for (const priority of PRIORITIES) {
@@ -759,6 +775,10 @@ export default Plugin.define({
759
775
  " " +
760
776
  cell(entry.quality, qualW).padStart(qualW) +
761
777
  " " +
778
+ cell(entry.effort || "-", effortW).padStart(effortW) +
779
+ " " +
780
+ cell(entry.alternative || "-", altW).padEnd(altW) +
781
+ " " +
762
782
  cell(entry.price, priceW).padEnd(priceW),
763
783
  // F-20 id split: the row DISPLAYS the sanitized id (in `label`)
764
784
  // but picks `value`, the raw catalog id switchToModel needs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "repo": "canonical/model-compare",
3
- "tag": "v0.5.0",
4
- "commit": "2e0e0cbb0ebf1d1b3f03b2c1c949eb930730d8a4",
5
- "sha256": "cebf2d100b020183c0236f1d127768933bca0e10ed615dd00ee549ebb86323b4"
3
+ "tag": "v0.6.0",
4
+ "commit": "2f39ed320ca87703b1a91a6a89b3ac796c5212ae",
5
+ "sha256": "94627727c2c5ef0e5d2f7b1753952aaa02d375a4fb2bd35c82124891a5b29830"
6
6
  }