model-compare-opencode-plugin 0.5.0 → 0.6.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/README.md +13 -9
- package/dist/tui.js +147 -27
- package/package.json +2 -1
- package/src/core/catalog.ts +98 -16
- package/src/core/reasoning.ts +96 -0
- package/src/core/steering.ts +36 -0
- package/src/core/types.ts +34 -1
- package/src/index.ts +38 -13
- package/src/opencode/aliases.ts +11 -5
- package/src/opencode/commands.ts +41 -3
- package/src/opencode/guard.ts +4 -2
- package/src/opencode/usebest.ts +14 -3
- package/src/rpc.ts +30 -2
- package/src/tui.tsx +135 -23
- package/vendor/UPSTREAM.json +3 -3
- package/vendor/model_compare.py +2140 -430
package/src/core/steering.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// and the steeringFor cases here); a semantic change to one must be mirrored
|
|
13
13
|
// in the other.
|
|
14
14
|
|
|
15
|
+
import { gatewayEffort } from "./reasoning"
|
|
15
16
|
import type { ModelEntry, Priority } from "./types"
|
|
16
17
|
|
|
17
18
|
// A model reference as the hook event carries it (`event.model`), mirroring
|
|
@@ -75,6 +76,30 @@ export function steeringFor(
|
|
|
75
76
|
return models.get(pick.id)?.steering ?? null
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// The reasoning effort an alias request must pin, or null when it must not:
|
|
80
|
+
// the same alias/body/pick/model gates as steeringFor, plus the explicit
|
|
81
|
+
// variant rule. `#variant` is OpenCode's per-request selection (the event
|
|
82
|
+
// carries it on `model.variant`); "default" is the no-selection sentinel
|
|
83
|
+
// OpenCode stamps on `--model` refs (spike fact 1), so it does NOT count as an
|
|
84
|
+
// explicit variant. The catalog's `quality_effort` is the effort the score was
|
|
85
|
+
// measured at; the "reasoning" sentinel is a producer label and is never sent
|
|
86
|
+
// to the gateway.
|
|
87
|
+
export function reasoningEffortFor(
|
|
88
|
+
eventModel: ModelRef,
|
|
89
|
+
variant: string | undefined,
|
|
90
|
+
bodyModel: string | undefined,
|
|
91
|
+
picks: Partial<Record<Priority, AliasTarget>>,
|
|
92
|
+
models: Map<string, ModelEntry>,
|
|
93
|
+
): string | null {
|
|
94
|
+
const p = aliasPriority(eventModel)
|
|
95
|
+
if (p === null) return null
|
|
96
|
+
if (variant !== undefined && variant !== "default") return null
|
|
97
|
+
const pick = picks[p]
|
|
98
|
+
if (pick === undefined || pick.id === null) return null
|
|
99
|
+
if (bodyModel !== undefined && bodyModel !== pick.id) return null
|
|
100
|
+
return gatewayEffort(models.get(pick.id)?.qualityEffort)
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
79
104
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
80
105
|
}
|
|
@@ -103,6 +128,17 @@ export function injectSteering(body: unknown, steering: Steering): unknown {
|
|
|
103
128
|
}
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
// The body with `reasoning: { effort }` added — or the input untouched when
|
|
132
|
+
// it already carries `reasoning` or the legacy `reasoning_effort` field (an
|
|
133
|
+
// explicit #variant, user config or another plugin wins; the plugin never
|
|
134
|
+
// clobbers a reasoning choice it did not make) or is not a JSON object.
|
|
135
|
+
// Reference inequality is the wiring's "re-create the Request" signal.
|
|
136
|
+
export function injectReasoning(body: unknown, effort: string): unknown {
|
|
137
|
+
if (!isPlainObject(body)) return body
|
|
138
|
+
if (Object.hasOwn(body, "reasoning") || Object.hasOwn(body, "reasoning_effort")) return body
|
|
139
|
+
return { ...body, reasoning: { effort } }
|
|
140
|
+
}
|
|
141
|
+
|
|
106
142
|
// normalize_provider (vendor/model_compare.py): casefold the name and strip
|
|
107
143
|
// ALL non-alphanumeric characters — "Z.AI", "z-ai" and "z ai" all normalize
|
|
108
144
|
// to "zai". JS toLowerCase stands in for Python's casefold (provider display
|
package/src/core/types.ts
CHANGED
|
@@ -6,6 +6,31 @@ export type Priority = "balanced" | "price" | "quality"
|
|
|
6
6
|
|
|
7
7
|
export type Weights = { quality: number; price: number; context: number; age: number }
|
|
8
8
|
|
|
9
|
+
// One measured reasoning effort (catalog v5): the AA intelligence index at
|
|
10
|
+
// that effort, AA's reference-price benchmark cost per task (display-only,
|
|
11
|
+
// never scored), and the record's provenance. `null` marks a missing value;
|
|
12
|
+
// the record itself survives so the effort can still be offered as a variant.
|
|
13
|
+
export type ReasoningDepth = {
|
|
14
|
+
effort: string
|
|
15
|
+
index: number | null
|
|
16
|
+
cost_per_task_usd: number | null
|
|
17
|
+
source: string | null
|
|
18
|
+
as_of: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The v5 per-model reasoning block. `supported_efforts` is what OpenRouter
|
|
22
|
+
// accepts as a discrete effort (expanded upstream, never null); `depths` is
|
|
23
|
+
// what some source measured (empty when nothing did). The raw flags are kept
|
|
24
|
+
// for provenance and for the single-state/non-reasoning display rules.
|
|
25
|
+
export type Reasoning = {
|
|
26
|
+
default_effort: string | null
|
|
27
|
+
supported_efforts: string[]
|
|
28
|
+
default_enabled: boolean | null
|
|
29
|
+
mandatory: boolean | null
|
|
30
|
+
supports_max_tokens: boolean | null
|
|
31
|
+
depths: ReasoningDepth[]
|
|
32
|
+
}
|
|
33
|
+
|
|
9
34
|
export type ModelEntry = {
|
|
10
35
|
id: string
|
|
11
36
|
name: string
|
|
@@ -20,6 +45,14 @@ export type ModelEntry = {
|
|
|
20
45
|
}
|
|
21
46
|
context: number
|
|
22
47
|
quality: number | null
|
|
48
|
+
// The effort `quality` was measured at (catalog v5 `quality_effort`). The
|
|
49
|
+
// alias request pins exactly this effort; the sentinel "reasoning" is a
|
|
50
|
+
// catalog label, never a gateway value. Optional only for hand-built /
|
|
51
|
+
// legacy projected entries: validateCatalog always writes it (null or a
|
|
52
|
+
// string), and validateStoredCatalog requires it on cached records.
|
|
53
|
+
qualityEffort?: string | null
|
|
54
|
+
// Present when the raw block projects cleanly (absent stays absent).
|
|
55
|
+
reasoning?: Reasoning
|
|
23
56
|
// The steering block (F-11/F-12): upstream records the provider's
|
|
24
57
|
// endpoint-routing hints as { model, provider: { order, ignore,
|
|
25
58
|
// quantizations?, allow_fallbacks } }; the projection unwraps the provider
|
|
@@ -47,7 +80,7 @@ export type Catalog = {
|
|
|
47
80
|
models: ModelEntry[]
|
|
48
81
|
}
|
|
49
82
|
|
|
50
|
-
// Upstream
|
|
83
|
+
// Upstream's PRIORITY_WEIGHTS at the pinned tag (v0.6.0; unchanged since v0.3.0). Used when a
|
|
51
84
|
// document carries no `parameters.weights` at all (design §5); individual
|
|
52
85
|
// missing keys fall back to weight 0 instead.
|
|
53
86
|
export const DEFAULT_WEIGHTS: Record<Priority, Weights> = {
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,13 @@ import { parseConfig } from "./config"
|
|
|
19
19
|
import { pinKey, type Pin } from "./core/pins"
|
|
20
20
|
import { effectiveWeights, rankModels } from "./core/rank"
|
|
21
21
|
import type { Catalog, EndpointCapture, ModelEntry, Priority, Sourced } from "./core/types"
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
captureEndpoint,
|
|
24
|
+
injectReasoning,
|
|
25
|
+
injectSteering,
|
|
26
|
+
reasoningEffortFor,
|
|
27
|
+
steeringFor,
|
|
28
|
+
} from "./core/steering"
|
|
23
29
|
import { formatReport, humanizeAge, reportEntries, steeringNote } from "./opencode/commands"
|
|
24
30
|
import { ModelCompare } from "./rpc"
|
|
25
31
|
import { planAlias, type AliasTarget } from "./opencode/aliases"
|
|
@@ -329,6 +335,8 @@ function applyAliases(state: WireState, editor: AliasEditor): void {
|
|
|
329
335
|
// plan: planAlias only checks for undefined (it never reads model
|
|
330
336
|
// internals — the applier does, below).
|
|
331
337
|
const source = target.id === null ? undefined : editor.get(ALIAS_PROVIDER, target.id)
|
|
338
|
+
// The concrete editor model is the presence proof; the display name
|
|
339
|
+
// carries only the pick (OpenCode renders the selected variant itself).
|
|
332
340
|
const plan = planAlias(p, target, source as unknown as ModelEntry | undefined)
|
|
333
341
|
|
|
334
342
|
if (plan.enabled && plan.modelID !== null && source !== undefined) {
|
|
@@ -558,21 +566,36 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
|
|
|
558
566
|
state.appliedSteering[event.sessionID] = null
|
|
559
567
|
return
|
|
560
568
|
}
|
|
561
|
-
const
|
|
569
|
+
const byId = new Map(
|
|
570
|
+
state.sourced.doc.models.map((entry): [string, ModelEntry] => [entry.id, entry]),
|
|
571
|
+
)
|
|
572
|
+
const bodyModel = bodyModelOf(body)
|
|
573
|
+
const steering = steeringFor(event.model, bodyModel, state.picks, byId)
|
|
574
|
+
// Two independent injections share one re-created Request: the
|
|
575
|
+
// provider-routing block and the catalog's scored reasoning effort.
|
|
576
|
+
// Each helper preserves a value the body already sets, so an explicit
|
|
577
|
+
// #variant (either signalled by the event's variant or by the body's
|
|
578
|
+
// own reasoning field) wins without withholding the other block.
|
|
579
|
+
let modified: unknown = body
|
|
580
|
+
let providerApplied = false
|
|
581
|
+
if (steering !== null) {
|
|
582
|
+
const next = injectSteering(modified, steering)
|
|
583
|
+
if (next !== modified) {
|
|
584
|
+
modified = next
|
|
585
|
+
providerApplied = true
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const effort = reasoningEffortFor(
|
|
562
589
|
event.model,
|
|
563
|
-
|
|
590
|
+
event.model.variant,
|
|
591
|
+
bodyModel,
|
|
564
592
|
state.picks,
|
|
565
|
-
|
|
593
|
+
byId,
|
|
566
594
|
)
|
|
567
|
-
if (
|
|
568
|
-
// No steering data for the pick: untouched, fail open.
|
|
569
|
-
state.appliedSteering[event.sessionID] = null
|
|
570
|
-
return
|
|
571
|
-
}
|
|
572
|
-
const modified = injectSteering(body, steering)
|
|
595
|
+
if (effort !== null) modified = injectReasoning(modified, effort)
|
|
573
596
|
if (modified === body) {
|
|
574
|
-
//
|
|
575
|
-
//
|
|
597
|
+
// Neither block had anything to add: no steering data, no scored
|
|
598
|
+
// effort, or the body already carries the corresponding field.
|
|
576
599
|
state.appliedSteering[event.sessionID] = null
|
|
577
600
|
return
|
|
578
601
|
}
|
|
@@ -592,7 +615,9 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
|
|
|
592
615
|
})
|
|
593
616
|
// Record the order actually applied — the response hook reads it as
|
|
594
617
|
// captureEndpoint's order (the `!` flag fires only on a non-empty one).
|
|
595
|
-
|
|
618
|
+
// A reasoning-only injection (or a body that already carried routing)
|
|
619
|
+
// applies no routing, so the record is null.
|
|
620
|
+
state.appliedSteering[event.sessionID] = providerApplied ? steering!.order : null
|
|
596
621
|
} catch {
|
|
597
622
|
// Fail open: any error leaves the original request proceeding
|
|
598
623
|
// untouched, and records that nothing was injected.
|
package/src/opencode/aliases.ts
CHANGED
|
@@ -24,6 +24,11 @@ export type AliasPlan = {
|
|
|
24
24
|
// §8 rule: a missing pick model DISABLES the alias with a warning — it is never
|
|
25
25
|
// silently skipped. `model` is undefined when the pick's model was removed by
|
|
26
26
|
// another plugin, or when there is no catalog to look it up in.
|
|
27
|
+
//
|
|
28
|
+
// The display name carries ONLY the pick (plus the fallback marker). OpenCode
|
|
29
|
+
// shows the selected variant beside the model itself, so baking the catalog's
|
|
30
|
+
// scored effort/alternative into the name would show a stale depth that does
|
|
31
|
+
// not follow the user's `#variant` selection.
|
|
27
32
|
export function planAlias(
|
|
28
33
|
p: Priority,
|
|
29
34
|
target: AliasTarget,
|
|
@@ -45,13 +50,14 @@ export function planAlias(
|
|
|
45
50
|
warnings: [`pick model ${target.id} missing from catalog for ${p}`],
|
|
46
51
|
}
|
|
47
52
|
}
|
|
53
|
+
// The pick is catalog-derived, so it is flattened (F-20) for the model
|
|
54
|
+
// picker and the TUI status line; `modelID` stays RAW — switching needs the
|
|
55
|
+
// real id.
|
|
48
56
|
return {
|
|
49
57
|
modelID: target.id,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// real id.
|
|
54
|
-
name: `Model Compare: ${p} → ${sanitizeCatalogText(target.id)}${target.fromFallback ? " (fallback)" : ""}`,
|
|
58
|
+
name: `Model Compare: ${p} → ${sanitizeCatalogText(target.id)}${
|
|
59
|
+
target.fromFallback ? " (fallback)" : ""
|
|
60
|
+
}`,
|
|
55
61
|
enabled: true,
|
|
56
62
|
warnings: [],
|
|
57
63
|
}
|
package/src/opencode/commands.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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,
|
package/src/opencode/guard.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
import type { Priority } from "../core/types"
|
|
12
12
|
|
|
13
|
-
// A model reference
|
|
14
|
-
|
|
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
|
|
package/src/opencode/usebest.ts
CHANGED
|
@@ -80,12 +80,23 @@ export function readAliasViews(
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
// The pick shown in an alias's display name:
|
|
83
|
-
// "Model Compare: <p> → <pick>[(
|
|
84
|
-
// for the "(unavailable)" placeholder names.
|
|
83
|
+
// "Model Compare: <p> → <pick>[ (fallback)]" → "<pick>[ (fallback)]", or null
|
|
84
|
+
// for the "(unavailable)" placeholder names. Older v5 names appended the
|
|
85
|
+
// scored effort and the best alternative (" · high [alt max 56.0]") after the
|
|
86
|
+
// pick; those annotations are no longer written, but remain stripped so a
|
|
87
|
+
// registry entry left by an older plugin still names the served model.
|
|
85
88
|
export function pickFromName(name: string): string | null {
|
|
86
89
|
const arrow = name.indexOf("→")
|
|
87
90
|
if (arrow === -1) return null
|
|
88
|
-
|
|
91
|
+
const rest = name.slice(arrow + 1).replace(/ \[alt .*\]$/, "")
|
|
92
|
+
// Legacy v5 names (no longer written — see above) separated the pick from
|
|
93
|
+
// its annotations with " · "; OpenRouter ids contain no spaces, so the
|
|
94
|
+
// first segment is the pick. The fallback marker lives in the annotation
|
|
95
|
+
// segment (or already after a pick with no annotations), so carry it over
|
|
96
|
+
// exactly once.
|
|
97
|
+
const [pick, ...annotations] = rest.split(" · ")
|
|
98
|
+
const fallback = annotations.join(" ").includes("(fallback)") ? " (fallback)" : ""
|
|
99
|
+
return `${pick.trim()}${fallback}`
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
// 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: [
|
|
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: [
|
|
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
|
@@ -117,6 +117,10 @@ type ListRow = {
|
|
|
117
117
|
// Trailing dim right-justified columns (the report table); when
|
|
118
118
|
// present, label renders in its own span so the two tones can differ.
|
|
119
119
|
columns?: string
|
|
120
|
+
// The cursor-following status line (the report table's variant and best
|
|
121
|
+
// higher variant): the dialog renders this row's status below the list and
|
|
122
|
+
// swaps it as the cursor moves.
|
|
123
|
+
status?: string
|
|
120
124
|
selected?: string
|
|
121
125
|
value?: string
|
|
122
126
|
dim?: boolean
|
|
@@ -162,6 +166,56 @@ export function cappedRowsHeight(contentLines: number, terminalHeight: number):
|
|
|
162
166
|
return Math.min(contentLines, Math.max(1, Math.floor(terminalHeight / 2) - 6))
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
// The terminal display width of one code point, in columns: 2 for the ranges
|
|
170
|
+
// terminals render double-width — East Asian Wide/Fullwidth (CJK, Hangul) and
|
|
171
|
+
// the emoji/symbol planes — and 1 for everything else. Deliberately
|
|
172
|
+
// CONSERVATIVE: it never undercounts the wide content the catalog can carry.
|
|
173
|
+
// Blocks that are only partially wide are covered at BLOCK width — the
|
|
174
|
+
// narrow tails inside the SMP symbol planes (alchemical symbols, chess)
|
|
175
|
+
// count 2 anyway — and variation selector 16 (emoji presentation) counts 1
|
|
176
|
+
// itself, so a base+VS16 sequence such as ☺️ always totals ≥ 2 even when the
|
|
177
|
+
// base alone would not. Overcounting only makes a clamp cut
|
|
178
|
+
// earlier; it can never let a line render wider than its budget. (Genuinely
|
|
179
|
+
// East-Asian-Ambiguous characters OUTSIDE these ranges — ±, an en dash,
|
|
180
|
+
// Greek — count 1; a terminal font that renders those wide is beyond the
|
|
181
|
+
// clamp's scope. Catalog text is sanitized to a single line by
|
|
182
|
+
// sanitizeCatalogText, so width is the only wrap risk left.)
|
|
183
|
+
function charWidth(codePoint: number): number {
|
|
184
|
+
// Variation selectors (VS1–VS16) are zero-width in reality. VS16 forces
|
|
185
|
+
// emoji presentation on the preceding base, so the pair renders 2 columns:
|
|
186
|
+
// counting the selector itself as 1 keeps any base+VS16 sequence at ≥ 2
|
|
187
|
+
// even when the base is NOT in a wide block (⌨️ = U+2328+VS16), while a
|
|
188
|
+
// wide base + VS16 overcounts by one — the safe direction.
|
|
189
|
+
if (codePoint >= 0xfe00 && codePoint <= 0xfe0f) return 1
|
|
190
|
+
if (
|
|
191
|
+
(codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
|
|
192
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) || // CJK radicals … Yi
|
|
193
|
+
(codePoint >= 0xa960 && codePoint <= 0xa97f) || // Hangul Jamo Ext. A
|
|
194
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul syllables
|
|
195
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compat ideographs
|
|
196
|
+
(codePoint >= 0xfe10 && codePoint <= 0xfe19) || // vertical forms
|
|
197
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) || // CJK compat forms
|
|
198
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) || // fullwidth forms
|
|
199
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) || // fullwidth signs
|
|
200
|
+
(codePoint >= 0x2600 && codePoint <= 0x27bf) || // misc symbols + dingbats (☺ ✨ ⚠ — ambiguous, commonly 2 under emoji presentation)
|
|
201
|
+
(codePoint >= 0x2b00 && codePoint <= 0x2bff) || // misc symbols and arrows (⭐)
|
|
202
|
+
(codePoint >= 0x1f000 && codePoint <= 0x1faff) || // the SMP symbol planes: mahjong/cards, enclosed ideographs, pictographs, transport (🚀), supplemental, symbols ext-A
|
|
203
|
+
(codePoint >= 0x20000 && codePoint <= 0x2fffd) || // CJK ext B+
|
|
204
|
+
(codePoint >= 0x30000 && codePoint <= 0x3fffd) // CJK ext G+
|
|
205
|
+
) {
|
|
206
|
+
return 2
|
|
207
|
+
}
|
|
208
|
+
return 1
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A string's terminal display width (the sum of its code points' widths).
|
|
212
|
+
// Exported for the unit rig, which asserts rendered lines against it.
|
|
213
|
+
export function strWidth(value: string): number {
|
|
214
|
+
let width = 0
|
|
215
|
+
for (const char of value) width += charWidth(char.codePointAt(0)!)
|
|
216
|
+
return width
|
|
217
|
+
}
|
|
218
|
+
|
|
165
219
|
// One report entry's defensive normalization, shared by the two paths that
|
|
166
220
|
// carry report data across a trust boundary (the /model-compare command's
|
|
167
221
|
// event and the TUI's direct RPC call): an entry missing any identity field
|
|
@@ -186,6 +240,8 @@ function toReportEntries(raw: unknown): ReportEntry[] {
|
|
|
186
240
|
value: entry.value,
|
|
187
241
|
score: typeof entry.score === "string" ? entry.score : "-",
|
|
188
242
|
quality: typeof entry.quality === "string" ? entry.quality : "-",
|
|
243
|
+
effort: typeof entry.effort === "string" ? entry.effort : "",
|
|
244
|
+
alternative: typeof entry.alternative === "string" ? entry.alternative : "",
|
|
189
245
|
price: typeof entry.price === "string" ? entry.price : "-",
|
|
190
246
|
current: entry.current === true,
|
|
191
247
|
fromFallback: entry.fromFallback === true,
|
|
@@ -643,6 +699,14 @@ export default Plugin.define({
|
|
|
643
699
|
})
|
|
644
700
|
})()}
|
|
645
701
|
</scrollbox>
|
|
702
|
+
{(() => {
|
|
703
|
+
const row = selectable[cursor()]
|
|
704
|
+
return row.status === undefined ? null : (
|
|
705
|
+
<box paddingLeft={4}>
|
|
706
|
+
<text fg={muted}>{row.status}</text>
|
|
707
|
+
</box>
|
|
708
|
+
)
|
|
709
|
+
})()}
|
|
646
710
|
{footerLines.map((line) => (
|
|
647
711
|
<box paddingLeft={4}>
|
|
648
712
|
<text fg={muted}>{line}</text>
|
|
@@ -706,17 +770,18 @@ export default Plugin.define({
|
|
|
706
770
|
notify("no ranking data available yet", "error")
|
|
707
771
|
return
|
|
708
772
|
}
|
|
709
|
-
// The approved table
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
773
|
+
// The approved table now carries three columns: model name, quality
|
|
774
|
+
// and price. The scored variant (the catalog's effort) and the best
|
|
775
|
+
// higher variant moved into a cursor-following status line below the
|
|
776
|
+
// list (see ListRow.status), so the table stays readable. No score
|
|
777
|
+
// column — the transcript's text report keeps it. Column widths are clamped
|
|
778
|
+
// (qual ≤ 5, $ in/out ≤ 13) and oversized cells are truncated to their
|
|
779
|
+
// clamped width, so with the name clamp below the row budget holds
|
|
780
|
+
// even for hostile/absurd column strings: what the clamp guarantees is
|
|
781
|
+
// that a row and the header stay within the dialog's fixed 52-char
|
|
782
|
+
// content width (hostile values get truncated rather than pushed into
|
|
783
|
+
// wrapping — the 40-char rankless fallback label did exactly that
|
|
784
|
+
// before the name clamp).
|
|
720
785
|
const labels = data.entries.map(
|
|
721
786
|
(entry) =>
|
|
722
787
|
`${entry.rank === null ? "·" : entry.rank}. ${entry.id}${
|
|
@@ -725,21 +790,57 @@ export default Plugin.define({
|
|
|
725
790
|
)
|
|
726
791
|
const labelOf = new Map(data.entries.map((entry, index) => [entry, labels[index]]))
|
|
727
792
|
const colW = (values: string[], floor: number, cap: number): number =>
|
|
728
|
-
Math.min(cap, Math.max(floor, ...values.map(
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
//
|
|
732
|
-
const
|
|
733
|
-
|
|
793
|
+
Math.min(cap, Math.max(floor, ...values.map(strWidth)))
|
|
794
|
+
// Quality is a one-decimal AA index (max "100.0"); the caps leave the
|
|
795
|
+
// 52-char row budget intact (see `fixed` below) while fitting real
|
|
796
|
+
// values: "10.00/100.00" (price).
|
|
797
|
+
const qualW = colW(data.entries.map((entry) => entry.quality), "qual".length, 5)
|
|
798
|
+
// The status line's variant and best-higher-variant cells keep their
|
|
799
|
+
// old column caps: the status box is a direct child of the 60-wide
|
|
800
|
+
// dialog with only its own 4-char left inset, so one text line has 56
|
|
801
|
+
// columns — the worst case, 9 ("variant: ") + 7 + 24 (" · best higher
|
|
802
|
+
// variant: ") + 13 = 53, leaves 3 columns of slack. Clamping each cell
|
|
803
|
+
// (in display columns, see cell) keeps hostile catalog strings from
|
|
804
|
+
// wrapping.
|
|
805
|
+
const effortW = 7
|
|
806
|
+
const altW = 13
|
|
807
|
+
const priceW = colW(data.entries.map((entry) => entry.price), "$ in/out".length, 13)
|
|
808
|
+
// A cell wider than its clamped column is truncated to fit — measured
|
|
809
|
+
// in terminal COLUMNS (strWidth), not string length: a 7-code-point
|
|
810
|
+
// CJK effort label renders 14 columns wide and would push the row
|
|
811
|
+
// past its budget if length were the measure.
|
|
812
|
+
const cell = (value: string, width: number): string => {
|
|
813
|
+
if (strWidth(value) <= width) return value
|
|
814
|
+
// Reserve one column for the ellipsis; walk whole code points so a
|
|
815
|
+
// wide char is never cut in half.
|
|
816
|
+
let used = 0
|
|
817
|
+
let out = ""
|
|
818
|
+
for (const char of value) {
|
|
819
|
+
const w = charWidth(char.codePointAt(0)!)
|
|
820
|
+
if (used + w > width - 1) break
|
|
821
|
+
out += char
|
|
822
|
+
used += w
|
|
823
|
+
}
|
|
824
|
+
return `${out}…`
|
|
825
|
+
}
|
|
826
|
+
// The pads match cell's measure, so a truncated wide cell and its
|
|
827
|
+
// padding land on the same column arithmetic (padEnd keeps the
|
|
828
|
+
// prices' flush right edges — see below).
|
|
829
|
+
const padEndW = (value: string, width: number): string =>
|
|
830
|
+
value + " ".repeat(Math.max(0, width - strWidth(value)))
|
|
831
|
+
const padStartW = (value: string, width: number): string =>
|
|
832
|
+
" ".repeat(Math.max(0, width - strWidth(value))) + value
|
|
734
833
|
// Prices use padEnd (not padStart): padded to a common width the
|
|
735
834
|
// right edges stay flush while the fixed 2-space gap between columns
|
|
736
835
|
// stays exactly 2 (padStart pushed each value right by its slack).
|
|
737
836
|
// The default dialog is 60 wide: 2 list insets + 6 row insets leave 52
|
|
738
837
|
// for row content; the header shares that budget (plus its 3-indent).
|
|
838
|
+
// Column caps keep the budget even for hostile strings: 5+13 plus the
|
|
839
|
+
// two 2-char paddings and the 2-char row indent = 24, leaving plenty
|
|
840
|
+
// for the name (the name column shrinks first for real content).
|
|
739
841
|
const fixed = 2 + qualW + 2 + priceW
|
|
740
|
-
const nameW = Math.max(
|
|
741
|
-
const trim = (label: string): string =>
|
|
742
|
-
label.length > nameW ? label.slice(0, nameW - 1) + "…" : label
|
|
842
|
+
const nameW = Math.max(6, Math.min(52 - fixed, Math.max(...labels.map(strWidth))))
|
|
843
|
+
const trim = (label: string): string => cell(label, nameW)
|
|
743
844
|
const headerLine =
|
|
744
845
|
" " +
|
|
745
846
|
" ".repeat(nameW) +
|
|
@@ -747,6 +848,16 @@ export default Plugin.define({
|
|
|
747
848
|
"qual".padStart(qualW) +
|
|
748
849
|
" " +
|
|
749
850
|
"$ in/out".padEnd(priceW)
|
|
851
|
+
// Catalog v5 calls this the effort the quality was measured at, and the
|
|
852
|
+
// best selectable measured alternative ("max 56.0"). OpenCode calls the
|
|
853
|
+
// selectable option a VARIANT, so the TUI uses "variant" throughout to
|
|
854
|
+
// match what the user sees in OpenCode's model picker. A missing value
|
|
855
|
+
// is "-", and "best higher variant" spells out what "alt" meant.
|
|
856
|
+
const statusOf = (entry: ReportEntry): string =>
|
|
857
|
+
`variant: ${cell(entry.effort || "-", effortW)} · best higher variant: ${cell(
|
|
858
|
+
entry.alternative || "-",
|
|
859
|
+
altW,
|
|
860
|
+
)}`
|
|
750
861
|
const rows: ListRow[] = [{ label: "", headerLine }]
|
|
751
862
|
for (const priority of PRIORITIES) {
|
|
752
863
|
const section = data.entries.filter((entry) => entry.priority === priority)
|
|
@@ -754,12 +865,13 @@ export default Plugin.define({
|
|
|
754
865
|
rows.push({ heading: priority, label: "" })
|
|
755
866
|
for (const entry of section) {
|
|
756
867
|
rows.push({
|
|
757
|
-
label: trim(labelOf.get(entry) ?? "")
|
|
868
|
+
label: padEndW(trim(labelOf.get(entry) ?? ""), nameW),
|
|
758
869
|
columns:
|
|
759
870
|
" " +
|
|
760
|
-
cell(entry.quality, qualW)
|
|
871
|
+
padStartW(cell(entry.quality, qualW), qualW) +
|
|
761
872
|
" " +
|
|
762
|
-
cell(entry.price, priceW)
|
|
873
|
+
padEndW(cell(entry.price, priceW), priceW),
|
|
874
|
+
status: statusOf(entry),
|
|
763
875
|
// F-20 id split: the row DISPLAYS the sanitized id (in `label`)
|
|
764
876
|
// but picks `value`, the raw catalog id switchToModel needs.
|
|
765
877
|
value: entry.value,
|
package/vendor/UPSTREAM.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"repo": "canonical/model-compare",
|
|
3
|
-
"tag": "v0.
|
|
4
|
-
"commit": "
|
|
5
|
-
"sha256": "
|
|
3
|
+
"tag": "v0.6.0",
|
|
4
|
+
"commit": "2f39ed320ca87703b1a91a6a89b3ac796c5212ae",
|
|
5
|
+
"sha256": "94627727c2c5ef0e5d2f7b1753952aaa02d375a4fb2bd35c82124891a5b29830"
|
|
6
6
|
}
|