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.
- package/README.md +40 -8
- package/dist/tui.js +415 -67
- package/package.json +1 -1
- package/src/config.ts +5 -2
- package/src/core/catalog.ts +57 -12
- package/src/core/steering.ts +145 -0
- package/src/core/types.ts +28 -1
- package/src/index.ts +463 -15
- package/src/opencode/commands.ts +64 -3
- package/src/opencode/storage.ts +6 -1
- package/src/rpc.ts +45 -3
- package/src/sources/drops.ts +37 -0
- package/src/sources/index.ts +18 -6
- package/src/sources/local.ts +7 -3
- package/src/sources/remote.ts +68 -10
- package/src/tui.tsx +455 -86
- package/vendor/UPSTREAM.json +3 -3
- package/vendor/model_compare.py +1129 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "model-compare-opencode-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "OpenCode plugin that switches OpenRouter model aliases to the best value-for-money pick from canonical/model-compare's ranking (balanced, price, quality).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
package/src/config.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type Config = {
|
|
|
21
21
|
weights: Partial<Record<Priority, Partial<Weights>>>
|
|
22
22
|
pinDays: number // default 14
|
|
23
23
|
minLead: number // default 0.03
|
|
24
|
-
catalogURL: string // default "https://
|
|
24
|
+
catalogURL: string // default "https://model-compare.dev/catalog.json"
|
|
25
25
|
scriptPath: string | null // null → vendored copy
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -29,7 +29,10 @@ const SOURCES = ["auto", "local", "remote"] as const
|
|
|
29
29
|
const PRIORITIES = ["balanced", "price", "quality"] as const
|
|
30
30
|
const WEIGHT_KEYS = ["quality", "price", "context", "age"] as const
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// The published catalog's direct https URL — no redirect involved (the old
|
|
33
|
+
// canonical.github.io home redirected to a plaintext http target, which
|
|
34
|
+
// fetchRemote now refuses; see Ruling 9).
|
|
35
|
+
const DEFAULT_CATALOG_URL = "https://model-compare.dev/catalog.json"
|
|
33
36
|
const FALLBACK_PREFIX = "openrouter/"
|
|
34
37
|
|
|
35
38
|
// True for plain config objects. Arrays are excluded: their index keys are
|
package/src/core/catalog.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/core/catalog.ts
|
|
2
|
-
// Catalog document validation (design §5). Accepts schema
|
|
2
|
+
// Catalog document validation (design §5). Accepts schema v3 documents (only) from
|
|
3
3
|
// either source (vendored script or remote catalog) and projects them onto
|
|
4
4
|
// the plugin's Catalog type. Validation only — ranking lives elsewhere.
|
|
5
5
|
|
|
@@ -12,19 +12,48 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
12
12
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function isNonEmptyStringList(value: unknown): value is string[] {
|
|
16
|
+
return Array.isArray(value) && value.length > 0 && value.every((id) => typeof id === "string")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The steering block (F-11/F-12): the raw upstream entry carries
|
|
20
|
+
// { model, provider: { order, ignore, quantizations?, allow_fallbacks } }.
|
|
21
|
+
// The projection unwraps `provider` as ModelEntry.steering and never projects
|
|
22
|
+
// the top-level `model`. Per-field absence (the pricing-split posture), no
|
|
23
|
+
// throw, no partial field: order/ignore must be non-empty string arrays (an
|
|
24
|
+
// empty list would filter out every endpoint) and allow_fallbacks a boolean —
|
|
25
|
+
// any miss drops the whole block; quantizations is optional and kept only as
|
|
26
|
+
// a non-empty string array, otherwise that field alone is dropped and the
|
|
27
|
+
// block survives.
|
|
28
|
+
function steer(raw: unknown): ModelEntry["steering"] {
|
|
29
|
+
if (!isObject(raw)) return undefined
|
|
30
|
+
const provider = raw.provider
|
|
31
|
+
if (!isObject(provider)) return undefined
|
|
32
|
+
const order = provider.order
|
|
33
|
+
const ignore = provider.ignore
|
|
34
|
+
const fallbacks = provider.allow_fallbacks
|
|
35
|
+
if (!isNonEmptyStringList(order) || !isNonEmptyStringList(ignore) || typeof fallbacks !== "boolean") {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
const steering: NonNullable<ModelEntry["steering"]> = {
|
|
39
|
+
order: [...order],
|
|
40
|
+
ignore: [...ignore],
|
|
41
|
+
allow_fallbacks: fallbacks,
|
|
42
|
+
}
|
|
43
|
+
if (isNonEmptyStringList(provider.quantizations)) steering.quantizations = [...provider.quantizations]
|
|
44
|
+
return steering
|
|
45
|
+
}
|
|
46
|
+
|
|
15
47
|
export function validateCatalog(
|
|
16
48
|
raw: unknown,
|
|
17
49
|
): { ok: true; catalog: Catalog } | { ok: false; reason: string } {
|
|
18
50
|
if (!isObject(raw)) return { ok: false, reason: "catalog must be a JSON object" }
|
|
19
51
|
|
|
20
|
-
// Supported schema:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// 2026-10-10.
|
|
26
|
-
if (raw.schema_version !== 2) {
|
|
27
|
-
return { ok: false, reason: `schema_version must be 2, got ${JSON.stringify(raw.schema_version)}` }
|
|
52
|
+
// Supported schema: v3 only — upstream's current contract (0.4.0's
|
|
53
|
+
// schema_version 3). Every other version is rejected with the reason string
|
|
54
|
+
// the canary and the stored-document path rely on.
|
|
55
|
+
if (raw.schema_version !== 3) {
|
|
56
|
+
return { ok: false, reason: `schema_version must be 3, got ${JSON.stringify(raw.schema_version)}` }
|
|
28
57
|
}
|
|
29
58
|
if (typeof raw.generated_at !== "string") {
|
|
30
59
|
return { ok: false, reason: "generated_at must be a string" }
|
|
@@ -128,6 +157,11 @@ export function validateCatalog(
|
|
|
128
157
|
// stays absent, present-but-invalid behaves as absent).
|
|
129
158
|
const input = pricing.input_per_1m
|
|
130
159
|
const output = pricing.output_per_1m
|
|
160
|
+
// The steering block projects (or reads as absent) per the steer() rules.
|
|
161
|
+
// The assignment sits AFTER the spread so it explicitly overrides the raw
|
|
162
|
+
// block that `...entry` would otherwise carry through verbatim — a
|
|
163
|
+
// hostile steering block must never survive onto the projected entry.
|
|
164
|
+
const steering = steer(entry.steering)
|
|
131
165
|
models.push({
|
|
132
166
|
...entry,
|
|
133
167
|
id: entry.id,
|
|
@@ -141,6 +175,7 @@ export function validateCatalog(
|
|
|
141
175
|
context: entry.context,
|
|
142
176
|
scores: { ...components, overall },
|
|
143
177
|
quality,
|
|
178
|
+
steering,
|
|
144
179
|
})
|
|
145
180
|
}
|
|
146
181
|
|
|
@@ -174,16 +209,26 @@ export function validateCatalog(
|
|
|
174
209
|
// `Catalog`, not the raw upstream document) by rebuilding the raw shape and
|
|
175
210
|
// running it through validateCatalog: a corrupt or hand-edited record is
|
|
176
211
|
// rejected instead of reaching ranking code. The round trip is lossless for a
|
|
177
|
-
// catalog validateCatalog produced.
|
|
212
|
+
// catalog validateCatalog produced. A model carrying a projected steering
|
|
213
|
+
// block is re-wrapped into the raw { model, provider } shape first — the
|
|
214
|
+
// top-level `model` is never projected, so entry.id stands in for it (the
|
|
215
|
+
// projection ignores it either way). A hand-edited steering block re-projects
|
|
216
|
+
// or reads as absent, exactly like a raw one would.
|
|
178
217
|
export function validateStoredCatalog(
|
|
179
218
|
stored: unknown,
|
|
180
219
|
): { ok: true; catalog: Catalog } | { ok: false; reason: string } {
|
|
181
220
|
if (!isObject(stored)) return { ok: false, reason: "stored catalog must be an object" }
|
|
221
|
+
const models = Array.isArray(stored.models)
|
|
222
|
+
? stored.models.map((entry) => {
|
|
223
|
+
if (!isObject(entry) || entry.steering === undefined) return entry
|
|
224
|
+
return { ...entry, steering: { model: entry.id, provider: entry.steering } }
|
|
225
|
+
})
|
|
226
|
+
: stored.models
|
|
182
227
|
return validateCatalog({
|
|
183
|
-
schema_version:
|
|
228
|
+
schema_version: 3,
|
|
184
229
|
generated_at: stored.generatedAt,
|
|
185
230
|
parameters: { weights: stored.weights },
|
|
186
|
-
models
|
|
231
|
+
models,
|
|
187
232
|
rankings: stored.rankings,
|
|
188
233
|
})
|
|
189
234
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/core/steering.ts
|
|
2
|
+
// The steering decision core (design §Steering injection + served-endpoint
|
|
3
|
+
// visibility): whether a request may carry the catalog's steering block, the
|
|
4
|
+
// injection shape, and the served-endpoint membership verdict. Pure functions
|
|
5
|
+
// over the data the wiring hooks supply — no I/O, no OpenCode imports.
|
|
6
|
+
//
|
|
7
|
+
// Layering note: the guard module (src/opencode/guard.ts) owns the canonical
|
|
8
|
+
// alias-ref parse, but core modules never import from src/opencode (this
|
|
9
|
+
// repo's rule: only opencode → core). The identification below mirrors the
|
|
10
|
+
// guard's parseAliasRef exactly — variant-blind, exact-priority remainder —
|
|
11
|
+
// and both copies are pinned to the same vectors (tests/unit/guard.test.ts
|
|
12
|
+
// and the steeringFor cases here); a semantic change to one must be mirrored
|
|
13
|
+
// in the other.
|
|
14
|
+
|
|
15
|
+
import type { ModelEntry, Priority } from "./types"
|
|
16
|
+
|
|
17
|
+
// A model reference as the hook event carries it (`event.model`), mirroring
|
|
18
|
+
// the guard's ModelRef (src/opencode/guard.ts) structurally — the wiring
|
|
19
|
+
// passes the event's ref straight through, and any `variant` key the event
|
|
20
|
+
// adds is ignored (spike fact 1: the alias ref's variant is not stable across
|
|
21
|
+
// binding styles — absent for root-default/frontmatter, "default" for
|
|
22
|
+
// `--model` — so identification must stay variant-blind).
|
|
23
|
+
export type ModelRef = { providerID: string; id: string }
|
|
24
|
+
|
|
25
|
+
// The resolved pick for one priority, mirroring the alias registry's
|
|
26
|
+
// AliasTarget (src/opencode/aliases.ts) structurally for the same layering
|
|
27
|
+
// reason: the wiring passes its plan's picks directly.
|
|
28
|
+
export type AliasTarget = { id: string | null; fromFallback: boolean }
|
|
29
|
+
|
|
30
|
+
// The steering block exactly as ModelEntry.steering projects it (Task 2):
|
|
31
|
+
// OpenRouter's provider routing sub-object. The raw upstream block's
|
|
32
|
+
// top-level `model` field is never part of it.
|
|
33
|
+
export type Steering = NonNullable<ModelEntry["steering"]>
|
|
34
|
+
|
|
35
|
+
const ALIAS_PREFIX = "model-compare/"
|
|
36
|
+
|
|
37
|
+
const PRIORITIES: readonly Priority[] = ["balanced", "price", "quality"]
|
|
38
|
+
|
|
39
|
+
// Alias identification over the event's model ref — the guard's parseAliasRef
|
|
40
|
+
// rule mirrored (see the layering note above): ONLY openrouter plus
|
|
41
|
+
// `model-compare/<p>` whose remainder is exactly a priority name; ids may
|
|
42
|
+
// carry more slashes in OpenRouter's vendor/model style, so anything after
|
|
43
|
+
// the priority disqualifies. Accepts undefined for runtime robustness (the
|
|
44
|
+
// caller's ref is runtime data).
|
|
45
|
+
function aliasPriority(ref: ModelRef | undefined): Priority | null {
|
|
46
|
+
if (ref === undefined || ref.providerID !== "openrouter") return null
|
|
47
|
+
if (!ref.id.startsWith(ALIAS_PREFIX)) return null
|
|
48
|
+
const remainder = ref.id.slice(ALIAS_PREFIX.length)
|
|
49
|
+
return PRIORITIES.includes(remainder as Priority) ? (remainder as Priority) : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The steering block to inject for a request, or null when the request must
|
|
53
|
+
// not be steered: a non-alias ref (including a DIRECTLY-PICKED model — even
|
|
54
|
+
// when its id equals the current pick's; matching on that equality would
|
|
55
|
+
// INVERT the rule, since an alias-bound ref is never equal to a concrete pick
|
|
56
|
+
// id while a directly-picked one is — and steer requests outside the aliases,
|
|
57
|
+
// the anti-goal), a pick that resolved to nothing, a body model that is not
|
|
58
|
+
// the pick's id, or a pick whose catalog entry carries no steering block.
|
|
59
|
+
//
|
|
60
|
+
// `bodyModel` is the request body's `model` field when the wiring has read it
|
|
61
|
+
// (the live body carries the pick id verbatim — spike fact 1); undefined
|
|
62
|
+
// skips that gate and lets the alias signal decide. The `models` map is keyed
|
|
63
|
+
// by bare catalog id — the pick ids' form.
|
|
64
|
+
export function steeringFor(
|
|
65
|
+
eventModel: ModelRef,
|
|
66
|
+
bodyModel: string | undefined,
|
|
67
|
+
picks: Partial<Record<Priority, AliasTarget>>,
|
|
68
|
+
models: Map<string, ModelEntry>,
|
|
69
|
+
): Steering | null {
|
|
70
|
+
const p = aliasPriority(eventModel)
|
|
71
|
+
if (p === null) return null
|
|
72
|
+
const pick = picks[p]
|
|
73
|
+
if (pick === undefined || pick.id === null) return null
|
|
74
|
+
if (bodyModel !== undefined && bodyModel !== pick.id) return null
|
|
75
|
+
return models.get(pick.id)?.steering ?? null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
79
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The body with the steering block's provider sub-object injected under the
|
|
83
|
+
// `provider` key — or the input returned untouched when it already carries a
|
|
84
|
+
// `provider` key (the plugin never clobbers routing the user or a provider
|
|
85
|
+
// configured) or is not a JSON object. The injected routing object is built
|
|
86
|
+
// field-by-field, never spread from the steering block, so the raw block's
|
|
87
|
+
// top-level `model` field (or any unknown key) cannot reach the body; an
|
|
88
|
+
// absent `quantizations` stays absent (OpenRouter reads the key's presence).
|
|
89
|
+
//
|
|
90
|
+
// Contract the wiring relies on: the SAME reference comes back when nothing
|
|
91
|
+
// was injected, a NEW object when it was — reference inequality is the
|
|
92
|
+
// "re-create the Request" signal.
|
|
93
|
+
export function injectSteering(body: unknown, steering: Steering): unknown {
|
|
94
|
+
if (!isPlainObject(body) || Object.hasOwn(body, "provider")) return body
|
|
95
|
+
return {
|
|
96
|
+
...body,
|
|
97
|
+
provider: {
|
|
98
|
+
order: steering.order,
|
|
99
|
+
ignore: steering.ignore,
|
|
100
|
+
...(steering.quantizations === undefined ? {} : { quantizations: steering.quantizations }),
|
|
101
|
+
allow_fallbacks: steering.allow_fallbacks,
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// normalize_provider (vendor/model_compare.py): casefold the name and strip
|
|
107
|
+
// ALL non-alphanumeric characters — "Z.AI", "z-ai" and "z ai" all normalize
|
|
108
|
+
// to "zai". JS toLowerCase stands in for Python's casefold (provider display
|
|
109
|
+
// names are ASCII in practice; a non-ASCII residue the casefold would keep is
|
|
110
|
+
// stripped by the character class either way).
|
|
111
|
+
function normalizeProvider(name: string): string {
|
|
112
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The provider part of a routing reference: the text before the first `/`
|
|
116
|
+
// (`novita/fp8`, `deepinfra/fp8/flex` → `novita`/`deepinfra`), or the whole
|
|
117
|
+
// string when it carries none — a served display name (`Novita`) is therefore
|
|
118
|
+
// its own provider part. Applied to BOTH sides symmetrically (spike fact 3:
|
|
119
|
+
// the served string is a display name, never the script's tagged-slug form).
|
|
120
|
+
function providerPart(ref: string): string {
|
|
121
|
+
const slash = ref.indexOf("/")
|
|
122
|
+
return slash === -1 ? ref : ref.slice(0, slash)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The served-endpoint verdict for one response: compare the response's
|
|
126
|
+
// `provider` string against the steering `order` that was actually applied to
|
|
127
|
+
// the request. `endpoint` is the served string verbatim (the alt+e toast's
|
|
128
|
+
// text) — null only when there is nothing to record (no provider at all, or a
|
|
129
|
+
// name with no provider part). `downgrade` is true ONLY when a parseable
|
|
130
|
+
// provider matches no entry of a NON-EMPTY order; an absent or empty order
|
|
131
|
+
// (no steering data — offline, or a model without a steering block) records
|
|
132
|
+
// the endpoint with no verdict, because `!` may only assert a fallback
|
|
133
|
+
// "despite the steering" when a non-empty order was actually applied.
|
|
134
|
+
export function captureEndpoint(
|
|
135
|
+
provider: string | null,
|
|
136
|
+
order: string[] | null,
|
|
137
|
+
): { endpoint: string | null; downgrade: boolean } {
|
|
138
|
+
if (provider === null) return { endpoint: null, downgrade: false }
|
|
139
|
+
const served = normalizeProvider(providerPart(provider))
|
|
140
|
+
if (served === "") return { endpoint: null, downgrade: false }
|
|
141
|
+
const entries = order ?? []
|
|
142
|
+
if (entries.length === 0) return { endpoint: provider, downgrade: false }
|
|
143
|
+
const matched = entries.some((entry) => normalizeProvider(providerPart(entry)) === served)
|
|
144
|
+
return { endpoint: provider, downgrade: !matched }
|
|
145
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -20,6 +20,17 @@ export type ModelEntry = {
|
|
|
20
20
|
}
|
|
21
21
|
context: number
|
|
22
22
|
quality: number | null
|
|
23
|
+
// The steering block (F-11/F-12): upstream records the provider's
|
|
24
|
+
// endpoint-routing hints as { model, provider: { order, ignore,
|
|
25
|
+
// quantizations?, allow_fallbacks } }; the projection unwraps the provider
|
|
26
|
+
// sub-object into this shape and never projects the top-level `model`.
|
|
27
|
+
// Present only when the raw block projects cleanly (absent stays absent).
|
|
28
|
+
steering?: {
|
|
29
|
+
order: string[]
|
|
30
|
+
ignore: string[]
|
|
31
|
+
quantizations?: string[]
|
|
32
|
+
allow_fallbacks: boolean
|
|
33
|
+
}
|
|
23
34
|
scores: {
|
|
24
35
|
quality: number
|
|
25
36
|
price: number
|
|
@@ -36,7 +47,7 @@ export type Catalog = {
|
|
|
36
47
|
models: ModelEntry[]
|
|
37
48
|
}
|
|
38
49
|
|
|
39
|
-
// Upstream.s PRIORITY_WEIGHTS at the pinned tag (v0.
|
|
50
|
+
// Upstream.s PRIORITY_WEIGHTS at the pinned tag (v0.4.0; unchanged since v0.3.0). Used when a
|
|
40
51
|
// document carries no `parameters.weights` at all (design §5); individual
|
|
41
52
|
// missing keys fall back to weight 0 instead.
|
|
42
53
|
export const DEFAULT_WEIGHTS: Record<Priority, Weights> = {
|
|
@@ -48,3 +59,19 @@ export const DEFAULT_WEIGHTS: Record<Priority, Weights> = {
|
|
|
48
59
|
// A validated catalog plus when the plugin itself accepted it. The plugin's
|
|
49
60
|
// own clock, not `generated_at`, is the data age (design §5, §9).
|
|
50
61
|
export type Sourced = { doc: Catalog; acquiredAt: number }
|
|
62
|
+
|
|
63
|
+
// The served-endpoint capture record (design §Steering visibility): what the
|
|
64
|
+
// http.response hook wrote for one session's most recent primary response.
|
|
65
|
+
// `endpoint` is the served `provider` string verbatim (a display name like
|
|
66
|
+
// "Novita" or a tagged slug like "novita/fp8" — spike fact 3); `model` is the
|
|
67
|
+
// response's concrete model id, captured alongside it (the spike confirmed
|
|
68
|
+
// OpenRouter responses carry both). `downgrade` is captureEndpoint's verdict
|
|
69
|
+
// — true ONLY when a non-empty steering order was actually applied to the
|
|
70
|
+
// request and the served provider matches none of it.
|
|
71
|
+
export type EndpointCapture = {
|
|
72
|
+
sessionID: string
|
|
73
|
+
endpoint: string
|
|
74
|
+
model: string
|
|
75
|
+
downgrade: boolean
|
|
76
|
+
at: number
|
|
77
|
+
}
|