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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-compare-opencode-plugin",
3
- "version": "0.1.1",
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://canonical.github.io/model-compare/catalog.json"
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
- const DEFAULT_CATALOG_URL = "https://canonical.github.io/model-compare/catalog.json"
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
@@ -1,5 +1,5 @@
1
1
  // src/core/catalog.ts
2
- // Catalog document validation (design §5). Accepts schema v2 documents (only) from
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: v2 only — upstream's current contract (0.3.0: pricing
21
- // scalars are effective-tier values; pricing.base/tiers/schedule and a
22
- // top-level schedules index were added — ignored here, see the roadmap
23
- // for consumer ideas). The plugin has never been released, so no stored
24
- // v1 documents exist anywhere; the published catalog has served v2 since
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: 2,
228
+ schema_version: 3,
184
229
  generated_at: stored.generatedAt,
185
230
  parameters: { weights: stored.weights },
186
- models: stored.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.3.1; unchanged since v0.3.0). Used when a
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
+ }