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/src/index.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// warn-and-default, never throws), seeds state from storage (§8's startup
|
|
5
5
|
// chain: pin → fallback → disabled) without ever blocking on fresh data,
|
|
6
6
|
// registers the alias transform — after seeding, so the registration-time
|
|
7
|
-
// application sees the seeded picks — then the guard hook
|
|
8
|
-
// commands, kicks off the first refresh in the
|
|
9
|
-
// 30-minute refresh tick, and returns a cleanup
|
|
7
|
+
// application sees the seeded picks — then the guard hook, the steering
|
|
8
|
+
// injection hook and the two commands, kicks off the first refresh in the
|
|
9
|
+
// background, schedules the 30-minute refresh tick, and returns a cleanup
|
|
10
|
+
// that clears the timer.
|
|
10
11
|
//
|
|
11
12
|
// `wire` is the testable core — the same setup with injected seams (acquire,
|
|
12
13
|
// timer, clock). The default export hands OpenCode the real context; it is
|
|
@@ -17,11 +18,12 @@ import { Plugin } from "@opencode/plugin"
|
|
|
17
18
|
import { parseConfig } from "./config"
|
|
18
19
|
import { pinKey, type Pin } from "./core/pins"
|
|
19
20
|
import { effectiveWeights, rankModels } from "./core/rank"
|
|
20
|
-
import type { Catalog, ModelEntry, Priority, Sourced } from "./core/types"
|
|
21
|
-
import {
|
|
21
|
+
import type { Catalog, EndpointCapture, ModelEntry, Priority, Sourced } from "./core/types"
|
|
22
|
+
import { captureEndpoint, injectSteering, steeringFor } from "./core/steering"
|
|
23
|
+
import { formatReport, humanizeAge, reportEntries, steeringNote } from "./opencode/commands"
|
|
22
24
|
import { ModelCompare } from "./rpc"
|
|
23
25
|
import { planAlias, type AliasTarget } from "./opencode/aliases"
|
|
24
|
-
import { guardDecision, type ModelRef } from "./opencode/guard"
|
|
26
|
+
import { guardDecision, parseAliasRef, type ModelRef } from "./opencode/guard"
|
|
25
27
|
import { readNewestCachedDoc, readRecord, type Storage } from "./opencode/storage"
|
|
26
28
|
import { acquire, cacheIdentities } from "./sources"
|
|
27
29
|
import {
|
|
@@ -51,6 +53,89 @@ function bareModelId(configValue: string): string {
|
|
|
51
53
|
: configValue
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
// The request body's `model` field when it is a string, else undefined — the
|
|
57
|
+
// gate steeringFor checks against the pick's id (the live alias-bound body
|
|
58
|
+
// carries the pick id verbatim, spike fact 1). Any non-object body reads as
|
|
59
|
+
// undefined; injectSteering rejects those on its own.
|
|
60
|
+
function bodyModelOf(body: unknown): string | undefined {
|
|
61
|
+
if (typeof body !== "object" || body === null) return undefined
|
|
62
|
+
const model = (body as Record<string, unknown>).model
|
|
63
|
+
return typeof model === "string" ? model : undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- response capture helpers ----------------------------------------------
|
|
67
|
+
|
|
68
|
+
// The bounded peek (global constraint: never buffer or consume a response
|
|
69
|
+
// stream to inspect it — only a bounded first-chunk peek through a
|
|
70
|
+
// pass-through transform, the rest forwarding byte-for-byte). At most this
|
|
71
|
+
// many BYTES of a response stream are decoded for inspection; everything past
|
|
72
|
+
// the bound is untouched. Sized so a first SSE data chunk (a comment line
|
|
73
|
+
// plus one data event in the spike's observations) always fits.
|
|
74
|
+
const PEEK_MAX_BYTES = 4096
|
|
75
|
+
|
|
76
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A response field that must be a string, else null (a numeric/absent
|
|
81
|
+
// `provider` or `model` is nothing to capture).
|
|
82
|
+
function stringField(obj: Record<string, unknown>, key: string): string | null {
|
|
83
|
+
const value = obj[key]
|
|
84
|
+
return typeof value === "string" ? value : null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The served `provider`/`model` pair carried by a response's first bytes, or
|
|
88
|
+
// null while the prefix is not yet conclusive (the peek keeps going). Two
|
|
89
|
+
// shapes the spike observed:
|
|
90
|
+
// - SSE (streaming): data lines — the first one carrying a NON-null
|
|
91
|
+
// `provider` decides, and `data: [DONE]` concludes without a capture. A
|
|
92
|
+
// parseable line that carries no provider must NOT conclude anything:
|
|
93
|
+
// the provider may arrive in a later data line within the byte bound.
|
|
94
|
+
// Leading comment or blank lines (`: OPENROUTER PROCESSING`, spike fact
|
|
95
|
+
// 5/warmup-B) carry neither, so the peek simply continues to the next
|
|
96
|
+
// chunk rather than concluding anything from them.
|
|
97
|
+
// - plain JSON (non-streaming): the whole prefix parses to an object. A
|
|
98
|
+
// prefix whose JSON is still truncated (e.g. the `provider` key beyond
|
|
99
|
+
// the peek window) stays inconclusive — the body passes through
|
|
100
|
+
// uncaptured rather than being buffered.
|
|
101
|
+
function extractServedFields(
|
|
102
|
+
prefix: string,
|
|
103
|
+
): { provider: string | null; model: string | null } | null {
|
|
104
|
+
let sawData = false
|
|
105
|
+
for (const line of prefix.split("\n")) {
|
|
106
|
+
const trimmed = line.trim()
|
|
107
|
+
if (!trimmed.startsWith("data:")) continue
|
|
108
|
+
sawData = true
|
|
109
|
+
const payload = trimmed.slice("data:".length).trim()
|
|
110
|
+
if (payload === "[DONE]") return { provider: null, model: null }
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(payload) as unknown
|
|
113
|
+
if (isPlainObject(parsed)) {
|
|
114
|
+
// Only a NON-null provider concludes the peek: a parseable line
|
|
115
|
+
// without one must not end the search — the provider may arrive in a
|
|
116
|
+
// later data line within the byte bound. `data: [DONE]` (above) is
|
|
117
|
+
// the one line that concludes without a capture.
|
|
118
|
+
const provider = stringField(parsed, "provider")
|
|
119
|
+
if (provider !== null) {
|
|
120
|
+
return { provider, model: stringField(parsed, "model") }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// A line split across a chunk boundary — keep peeking.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (sawData) return null
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(prefix) as unknown
|
|
130
|
+
if (isPlainObject(parsed)) {
|
|
131
|
+
return { provider: stringField(parsed, "provider"), model: stringField(parsed, "model") }
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// Not (yet) JSON — keep peeking.
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
54
139
|
// ---- the slice of the OpenCode v2 plugin context the plugin touches --------
|
|
55
140
|
|
|
56
141
|
type Registration = { dispose: () => Promise<void> }
|
|
@@ -89,14 +174,19 @@ export type CommandDefinition = {
|
|
|
89
174
|
execute: (input: { sessionID: string; prompt: { text: string } }) => Promise<void>
|
|
90
175
|
}
|
|
91
176
|
|
|
92
|
-
// The `http.request` event (§9): the guard keys on the
|
|
93
|
-
// on event.model, which already shows the effective
|
|
177
|
+
// The `http.request` / `http.response` event (§9): the guard keys on the
|
|
178
|
+
// requesting agent, not on event.model, which already shows the effective
|
|
179
|
+
// (fallback) reference. `response` is present only on http.response events
|
|
180
|
+
// (the real SDK carries both hook events' shapes; only the response's hook
|
|
181
|
+
// receives one) — and it must stay MUTABLE: the capture hook re-creates the
|
|
182
|
+
// Response around a pass-through stream and writes it back.
|
|
94
183
|
export type HttpEvent = {
|
|
95
184
|
sessionID: string
|
|
96
185
|
agent: string
|
|
97
186
|
model: ModelRef
|
|
98
187
|
kind: "primary" | "compaction" | "title" | "generate"
|
|
99
188
|
request: unknown
|
|
189
|
+
response?: Response
|
|
100
190
|
}
|
|
101
191
|
|
|
102
192
|
export type ModelListEntry = { id: string; providerID: string; enabled: boolean }
|
|
@@ -120,7 +210,7 @@ export type PluginCtx = {
|
|
|
120
210
|
}
|
|
121
211
|
session: {
|
|
122
212
|
hook(
|
|
123
|
-
name: "http.request",
|
|
213
|
+
name: "http.request" | "http.response",
|
|
124
214
|
callback: (event: HttpEvent) => Promise<void> | void,
|
|
125
215
|
): Promise<Registration>
|
|
126
216
|
switchModel(input: { sessionID: string; model: ModelRef }): Promise<void>
|
|
@@ -146,6 +236,11 @@ export type WireDeps = {
|
|
|
146
236
|
now?: () => number
|
|
147
237
|
// Per-tick hard deadline (F-01); tests shrink it.
|
|
148
238
|
deadlineMs?: number
|
|
239
|
+
// State-observation seam (tests): hands the LIVE WireState object to the
|
|
240
|
+
// caller once setup finishes wiring it — every later mutation (steering
|
|
241
|
+
// records, endpoint captures) lands on the same object, so the holder sees
|
|
242
|
+
// the updates in real time. Nothing in production passes it.
|
|
243
|
+
onState?: (state: WireState) => void
|
|
149
244
|
}
|
|
150
245
|
|
|
151
246
|
// ---- captured state and small helpers --------------------------------------
|
|
@@ -166,6 +261,34 @@ export type AliasPlan = {
|
|
|
166
261
|
// `registryDisabled[p]` records that the transform wanted alias p enabled but
|
|
167
262
|
// its concrete model was missing from the editor; the applied view then says
|
|
168
263
|
// disabled, whatever the plan wanted.
|
|
264
|
+
//
|
|
265
|
+
// `appliedSteering[sessionID]` records the steering order the request hook
|
|
266
|
+
// ACTUALLY injected into that session's most recent request: the order array
|
|
267
|
+
// when it injected (catalog-projected blocks always carry a non-empty order),
|
|
268
|
+
// null when it did not (non-alias ref, no steering data, a pre-existing body
|
|
269
|
+
// `provider`, or any failure — fail open). The response hook consumes it as
|
|
270
|
+
// captureEndpoint's `order`, so the `!` downgrade flag fires only when a
|
|
271
|
+
// non-empty order was really applied. Last request per session wins.
|
|
272
|
+
//
|
|
273
|
+
// `lastEndpoints[sessionID]` records the served-endpoint capture for that
|
|
274
|
+
// session's most recent primary response: the served `provider` string
|
|
275
|
+
// verbatim, the response's concrete `model` id, the downgrade verdict, and
|
|
276
|
+
// the wall-clock moment of the capture. Last-writer-wins WITHIN a session
|
|
277
|
+
// (hook invocations interleave concurrently — every write is per-event, from
|
|
278
|
+
// the event's own sessionID); stale entries for ended sessions are
|
|
279
|
+
// acceptable (no session-removal hook exists to prune them).
|
|
280
|
+
//
|
|
281
|
+
// `cannotSteerToasted[sessionID]` records that the Q2 fallback's cannot-steer
|
|
282
|
+
// event was already successfully emitted for that session: when a steerable
|
|
283
|
+
// alias request (alias ref + a current pick) finds NO steering data — no
|
|
284
|
+
// catalog source, `sourced === null` — the injection hook emits the event
|
|
285
|
+
// exactly ONCE PER SESSION (never once per request), leaving the request body
|
|
286
|
+
// untouched. A body that already carries a `provider` key binds the
|
|
287
|
+
// exclusion: the emit is skipped (user-configured routing makes the "default
|
|
288
|
+
// routing applies" copy false) without consuming the flag, so a later
|
|
289
|
+
// provider-free request still toasts. The flag is set only AFTER a successful
|
|
290
|
+
// emit (a failed one leaves it unset so a later request retries) and is never
|
|
291
|
+
// re-armed: a session that has seen the toast keeps it for its lifetime.
|
|
169
292
|
export type WireState = {
|
|
170
293
|
sourced: Sourced | null
|
|
171
294
|
pins: Partial<Record<Priority, Pin>>
|
|
@@ -173,6 +296,9 @@ export type WireState = {
|
|
|
173
296
|
enabled: Partial<Record<Priority, boolean>>
|
|
174
297
|
plan: AliasPlan
|
|
175
298
|
registryDisabled: Partial<Record<Priority, boolean>>
|
|
299
|
+
appliedSteering: Record<string, string[] | null>
|
|
300
|
+
lastEndpoints: Record<string, EndpointCapture>
|
|
301
|
+
cannotSteerToasted: Record<string, boolean>
|
|
176
302
|
}
|
|
177
303
|
|
|
178
304
|
// readRecord only guarantees a v=1 object; the pin fields are checked so a
|
|
@@ -251,7 +377,18 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
|
|
|
251
377
|
enabled: {},
|
|
252
378
|
plan: { picks: {}, enabled: {} },
|
|
253
379
|
registryDisabled: {},
|
|
380
|
+
// The three per-session records are keyed by ids that reach them from
|
|
381
|
+
// OUTSIDE (hook events, RPC input): null-prototype objects so a key like
|
|
382
|
+
// "constructor"/"toString"/"__proto__" can never resolve through — or
|
|
383
|
+
// mutate — the prototype chain (a plain-object lookup would return
|
|
384
|
+
// Object.prototype junk and poison the report footer).
|
|
385
|
+
appliedSteering: Object.create(null) as Record<string, string[] | null>,
|
|
386
|
+
lastEndpoints: Object.create(null) as Record<string, EndpointCapture>,
|
|
387
|
+
cannotSteerToasted: Object.create(null) as Record<string, boolean>,
|
|
254
388
|
}
|
|
389
|
+
// Test seam: hand the live state object out BEFORE any registration can
|
|
390
|
+
// mutate it — the reference never changes, only its contents do.
|
|
391
|
+
deps.onState?.(state)
|
|
255
392
|
|
|
256
393
|
// Seed state from storage (§8 startup) BEFORE registering the transform:
|
|
257
394
|
// OpenCode applies transforms at registration time (design-spike-verified),
|
|
@@ -318,6 +455,13 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
|
|
|
318
455
|
// transform from the captured state, so any replay applies the current pick.
|
|
319
456
|
await ctx.model.transform((editor) => applyAliases(state, editor))
|
|
320
457
|
|
|
458
|
+
// The RPC registration (below, before the commands) is the event bridge
|
|
459
|
+
// the steering hook emits the cannot-steer fallback through; the binding is
|
|
460
|
+
// hoisted here so the hook's closure sees it declared. No request can
|
|
461
|
+
// arrive before setup completes, and the emit site guards on the
|
|
462
|
+
// registration being ready anyway (fail open).
|
|
463
|
+
let rpcRegistration: RpcRegistration | undefined
|
|
464
|
+
|
|
321
465
|
// The guard (§9): a primary request whose agent is configured with a
|
|
322
466
|
// DISABLED alias aborts with the exact error; everything else passes. The
|
|
323
467
|
// agent lookup is wrapped — a missing agent must not break the request path.
|
|
@@ -333,20 +477,311 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
|
|
|
333
477
|
if (decision.error !== undefined) throw new Error(decision.error)
|
|
334
478
|
})
|
|
335
479
|
|
|
480
|
+
// The steering injection (design §Steering injection; spike fact 2):
|
|
481
|
+
// alias-scoped requests whose pick's catalog entry carries a steering block
|
|
482
|
+
// get the block's provider sub-object injected into the JSON body and the
|
|
483
|
+
// Request re-created. No kind filter — alias-bound refs arrive for primary
|
|
484
|
+
// AND compaction (spike scoping), and a directly-picked ref is never an
|
|
485
|
+
// alias ref, so it falls through untouched (the anti-goal). Every path
|
|
486
|
+
// that does not inject leaves the original request untouched (fail open,
|
|
487
|
+
// spec anti-goal) and nulls the session's appliedSteering record.
|
|
488
|
+
await ctx.session.hook("http.request", async (event) => {
|
|
489
|
+
try {
|
|
490
|
+
// Cheap gate before any body read (the guard's parseAliasRef rule;
|
|
491
|
+
// steeringFor re-checks variant-blind): concrete refs — title kinds,
|
|
492
|
+
// direct picks, fallback-bound sessions — skip the body entirely.
|
|
493
|
+
const p = parseAliasRef(event.model)
|
|
494
|
+
if (p === null) {
|
|
495
|
+
state.appliedSteering[event.sessionID] = null
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
// The Q2 fallback (Task 6): a steerable alias request (alias ref + a
|
|
499
|
+
// current pick) with NO steering data — no catalog source,
|
|
500
|
+
// `sourced === null` — proceeds untouched, but the TUI is told so it
|
|
501
|
+
// can toast the fallback: exactly ONCE PER SESSION (the
|
|
502
|
+
// cannotSteerToasted flag), never once per request. A disabled alias
|
|
503
|
+
// (no pick) stays silent — that is the guard's domain, not a toast.
|
|
504
|
+
// The body is never reassigned — the request proceeds untouched.
|
|
505
|
+
if (state.sourced === null) {
|
|
506
|
+
state.appliedSteering[event.sessionID] = null
|
|
507
|
+
const pick = state.picks[p]
|
|
508
|
+
// The whole toast block needs the RPC registration (the emit's event
|
|
509
|
+
// bridge): while it is still undefined the block is skipped ENTIRELY —
|
|
510
|
+
// no body read, no flag write, no emit — so nothing is consumed while
|
|
511
|
+
// there is nowhere to emit to (a later request retries).
|
|
512
|
+
const registration = rpcRegistration
|
|
513
|
+
if (
|
|
514
|
+
registration !== undefined &&
|
|
515
|
+
pick !== undefined &&
|
|
516
|
+
pick.id !== null &&
|
|
517
|
+
state.cannotSteerToasted[event.sessionID] !== true
|
|
518
|
+
) {
|
|
519
|
+
// The one-off body read (fix round 1): gated behind the once-flag
|
|
520
|
+
// so steady-state requests are never parsed. A body that already
|
|
521
|
+
// carries a `provider` key binds the exclusion — user-configured
|
|
522
|
+
// routing makes the "default routing applies" toast false copy —
|
|
523
|
+
// so the emit is skipped WITHOUT consuming the flag: a later
|
|
524
|
+
// provider-free request for the session still toasts. The read is
|
|
525
|
+
// a cheap clone-parse; the original Request is never reassigned.
|
|
526
|
+
let bodyHasProvider = false
|
|
527
|
+
try {
|
|
528
|
+
const body: unknown = JSON.parse(await (event.request as Request).clone().text())
|
|
529
|
+
bodyHasProvider =
|
|
530
|
+
typeof body === "object" && body !== null && Object.hasOwn(body, "provider")
|
|
531
|
+
} catch {
|
|
532
|
+
// Unreadable or missing body: no provider key is knowable —
|
|
533
|
+
// fall back to the plain emit (fail open).
|
|
534
|
+
}
|
|
535
|
+
if (!bodyHasProvider) {
|
|
536
|
+
// Fire-and-forget: the emit must never block the request path.
|
|
537
|
+
// The once-flag is set only AFTER a successful emit; a failed
|
|
538
|
+
// emit leaves it unset so a later request for the session
|
|
539
|
+
// retries — never a permanently lost toast.
|
|
540
|
+
void registration.events
|
|
541
|
+
.emit("cannotSteer", { sessionID: event.sessionID })
|
|
542
|
+
.then(() => {
|
|
543
|
+
state.cannotSteerToasted[event.sessionID] = true
|
|
544
|
+
})
|
|
545
|
+
.catch(() => {
|
|
546
|
+
// Leave the once-flag unset: a later request retries.
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
const request = event.request as Request
|
|
553
|
+
let body: unknown
|
|
554
|
+
try {
|
|
555
|
+
body = JSON.parse(await request.clone().text())
|
|
556
|
+
} catch {
|
|
557
|
+
// No JSON body (or unreadable): untouched, fail open.
|
|
558
|
+
state.appliedSteering[event.sessionID] = null
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
const steering = steeringFor(
|
|
562
|
+
event.model,
|
|
563
|
+
bodyModelOf(body),
|
|
564
|
+
state.picks,
|
|
565
|
+
new Map(state.sourced.doc.models.map((entry): [string, ModelEntry] => [entry.id, entry])),
|
|
566
|
+
)
|
|
567
|
+
if (steering === null) {
|
|
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)
|
|
573
|
+
if (modified === body) {
|
|
574
|
+
// The body already carries a `provider` key (or is not an object):
|
|
575
|
+
// never clobber routing the user or a provider configured.
|
|
576
|
+
state.appliedSteering[event.sessionID] = null
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
// Spike fact 2's verified recipe: re-create the Request around the
|
|
580
|
+
// modified body, dropping the stale length/encoding headers (the
|
|
581
|
+
// runtime re-derives content-length for the new body). The original's
|
|
582
|
+
// `signal` is forwarded so abort/interrupt handling stays wired to the
|
|
583
|
+
// request the chain handed us.
|
|
584
|
+
const headers = new Headers(request.headers)
|
|
585
|
+
headers.delete("content-length")
|
|
586
|
+
headers.delete("content-encoding")
|
|
587
|
+
event.request = new Request(request.url, {
|
|
588
|
+
method: request.method,
|
|
589
|
+
headers,
|
|
590
|
+
body: JSON.stringify(modified),
|
|
591
|
+
signal: request.signal,
|
|
592
|
+
})
|
|
593
|
+
// Record the order actually applied — the response hook reads it as
|
|
594
|
+
// captureEndpoint's order (the `!` flag fires only on a non-empty one).
|
|
595
|
+
state.appliedSteering[event.sessionID] = steering.order
|
|
596
|
+
} catch {
|
|
597
|
+
// Fail open: any error leaves the original request proceeding
|
|
598
|
+
// untouched, and records that nothing was injected.
|
|
599
|
+
state.appliedSteering[event.sessionID] = null
|
|
600
|
+
}
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
// The response capture (design §Steering visibility): a primary response's
|
|
604
|
+
// first few bytes are peeked THROUGH a pass-through transform (never
|
|
605
|
+
// buffered or consumed — the peeked chunks are forwarded untouched, so the
|
|
606
|
+
// rest of the body flows byte-for-byte) to read the served `provider` and
|
|
607
|
+
// `model`, and the session's lastEndpoints record is written per-event from
|
|
608
|
+
// the event's OWN sessionID (hook invocations interleave concurrently —
|
|
609
|
+
// never write from a shared "current session"). Only primary responses of
|
|
610
|
+
// alias-bound sessions update the capture — a non-alias model ref returns
|
|
611
|
+
// before any wrap (zero modification, the anti-goal); any body the peek
|
|
612
|
+
// cannot parse passes through unmodified, uncaptured; every error is
|
|
613
|
+
// swallowed — the capture is best-effort and must never break the response
|
|
614
|
+
// itself.
|
|
615
|
+
await ctx.session.hook("http.response", async (event) => {
|
|
616
|
+
try {
|
|
617
|
+
if (event.kind !== "primary") return
|
|
618
|
+
// Alias-scoped (the spec's binding constraint): capture fires only when
|
|
619
|
+
// the session's model is one of the three alias refs. A non-alias
|
|
620
|
+
// session — a direct pick, another agent, any provider — gets ZERO
|
|
621
|
+
// modification: no wrap, no peek, no capture. The request-side half of
|
|
622
|
+
// the binding (the request's model equals that alias's current pick) is
|
|
623
|
+
// inherited via appliedSteering, which only the injection hook's
|
|
624
|
+
// injecting paths set to a non-empty order; a disabled alias's session
|
|
625
|
+
// carries its concrete fallback ref here and captures nothing.
|
|
626
|
+
if (parseAliasRef(event.model) === null) return
|
|
627
|
+
const response = event.response
|
|
628
|
+
if (response === undefined) return
|
|
629
|
+
const body = response.body
|
|
630
|
+
if (body === null) return
|
|
631
|
+
|
|
632
|
+
// Read ONCE, per-event, from the event's own sessionID. The record
|
|
633
|
+
// holds the session's most recent REQUEST's order — best-effort ONLY:
|
|
634
|
+
// with concurrent same-session requests (compaction/title alongside
|
|
635
|
+
// primary) the verdict can be wrong in EITHER direction. A newer
|
|
636
|
+
// non-injecting request nulls the record and this response's downgrade
|
|
637
|
+
// then reads `false` even if its own request did inject (false
|
|
638
|
+
// negative); a newer injecting request leaves ITS order here, and a
|
|
639
|
+
// response produced under the older order may be flagged `!` against
|
|
640
|
+
// the wrong order (false positive). Display-only impact — the alt+e
|
|
641
|
+
// toast and the report footer; nothing about the sent request depends
|
|
642
|
+
// on this read.
|
|
643
|
+
const order = state.appliedSteering[event.sessionID] ?? null
|
|
644
|
+
|
|
645
|
+
const decoder = new TextDecoder()
|
|
646
|
+
let captured = ""
|
|
647
|
+
let peekedBytes = 0
|
|
648
|
+
let done = false
|
|
649
|
+
const record = (provider: string | null, model: string | null) => {
|
|
650
|
+
done = true
|
|
651
|
+
// Nothing to show (no provider at all, or a name with no provider
|
|
652
|
+
// part): treat as no capture — captureEndpoint's endpoint === null
|
|
653
|
+
// means exactly that.
|
|
654
|
+
if (provider === null) return
|
|
655
|
+
const verdict = captureEndpoint(provider, order)
|
|
656
|
+
if (verdict.endpoint === null) return
|
|
657
|
+
state.lastEndpoints[event.sessionID] = {
|
|
658
|
+
sessionID: event.sessionID,
|
|
659
|
+
endpoint: verdict.endpoint,
|
|
660
|
+
// The spike established OpenRouter responses always carry `model`;
|
|
661
|
+
// an outlier without one still records the endpoint (the capture's
|
|
662
|
+
// point), with an empty id standing in for the missing field.
|
|
663
|
+
model: model ?? "",
|
|
664
|
+
downgrade: verdict.downgrade,
|
|
665
|
+
at: now(),
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const inspect = () => {
|
|
669
|
+
const peek = extractServedFields(captured)
|
|
670
|
+
if (peek !== null) record(peek.provider, peek.model)
|
|
671
|
+
}
|
|
672
|
+
const transform = new TransformStream<Uint8Array, Uint8Array>({
|
|
673
|
+
transform: (chunk, controller) => {
|
|
674
|
+
try {
|
|
675
|
+
if (!done) {
|
|
676
|
+
// The peek is byte-bounded: only the first PEEK_MAX_BYTES
|
|
677
|
+
// bytes of the stream are ever decoded for inspection — a
|
|
678
|
+
// chunk that crosses the bound contributes only its head, and
|
|
679
|
+
// the rest is never decoded (it still passes through
|
|
680
|
+
// untouched, below).
|
|
681
|
+
const take = Math.min(chunk.byteLength, PEEK_MAX_BYTES - peekedBytes)
|
|
682
|
+
if (take > 0) captured += decoder.decode(chunk.subarray(0, take), { stream: true })
|
|
683
|
+
peekedBytes += take
|
|
684
|
+
// Extraction runs on every peeked chunk: a first chunk that is
|
|
685
|
+
// only a comment line must not conclude anything — the data
|
|
686
|
+
// line may arrive in a later chunk (spike fact 5).
|
|
687
|
+
inspect()
|
|
688
|
+
if (peekedBytes >= PEEK_MAX_BYTES) done = true
|
|
689
|
+
}
|
|
690
|
+
} catch {
|
|
691
|
+
// Inspection never breaks the stream: give up on the peek, keep
|
|
692
|
+
// forwarding.
|
|
693
|
+
done = true
|
|
694
|
+
}
|
|
695
|
+
controller.enqueue(chunk)
|
|
696
|
+
},
|
|
697
|
+
flush: () => {
|
|
698
|
+
try {
|
|
699
|
+
if (!done) {
|
|
700
|
+
// The stream ended before the bound: flush any held-back
|
|
701
|
+
// multi-byte tail and make one final extraction attempt.
|
|
702
|
+
captured += decoder.decode()
|
|
703
|
+
inspect()
|
|
704
|
+
}
|
|
705
|
+
} catch {
|
|
706
|
+
done = true
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
})
|
|
710
|
+
// Re-create the Response around the pass-through stream (mirror of the
|
|
711
|
+
// request hook's re-creation): the original body is owned by the chain
|
|
712
|
+
// and never consumed by the plugin. Deliberate deviation from the spike
|
|
713
|
+
// recipe: the spike DELETED content-length/content-encoding on BOTH the
|
|
714
|
+
// request and the response re-creation, while here the body passes
|
|
715
|
+
// through unchanged, so the response's exact headers are kept verbatim
|
|
716
|
+
// for byte-identical passthrough. Known cost of the re-creation: the
|
|
717
|
+
// new Response cannot carry `url`, `type` or `redirected` (they reset
|
|
718
|
+
// to ""/"default"/false) — no consumer of those fields is known in this
|
|
719
|
+
// plugin; flagged for any downstream hook that reads them.
|
|
720
|
+
event.response = new Response(body.pipeThrough(transform), {
|
|
721
|
+
status: response.status,
|
|
722
|
+
statusText: response.statusText,
|
|
723
|
+
headers: response.headers,
|
|
724
|
+
})
|
|
725
|
+
} catch {
|
|
726
|
+
// Fail open: any error leaves the response untouched and uncaptured.
|
|
727
|
+
}
|
|
728
|
+
})
|
|
729
|
+
|
|
336
730
|
// The RPC (V2 plugins RPC): the report method serves the SAME text the
|
|
337
731
|
// /model-compare command synthesizes (one builder — the two can never
|
|
338
732
|
// drift), and the registration carries the report EVENT the command emits
|
|
339
733
|
// so the TUI half can open the report as an instant selectable dialog.
|
|
340
734
|
// Registered BEFORE the commands: the command's execute emits through it.
|
|
341
|
-
|
|
342
|
-
|
|
735
|
+
// One registration per rpcID: the host resolves duplicates last-one-wins,
|
|
736
|
+
// so a second register() would silently rebind the events bridge.
|
|
737
|
+
//
|
|
738
|
+
// The report footer (design §Steering visibility): the payload carries the
|
|
739
|
+
// session's last-endpoint capture and the steering note. One derivation
|
|
740
|
+
// (reportFooter, below) serves the RPC method, the event emit and
|
|
741
|
+
// formatReport, so the dialog's footer and the transcript text agree.
|
|
742
|
+
const reportFooter = (sessionID: string | null) => {
|
|
743
|
+
// The capture is keyed by sessionID (Task 5's per-session map): a null
|
|
744
|
+
// session (the opening screen) has nothing to look up. The record's
|
|
745
|
+
// display fields are projected — the toast needs `downgrade` too, so all
|
|
746
|
+
// three travel.
|
|
747
|
+
const record = sessionID === null ? undefined : state.lastEndpoints[sessionID]
|
|
748
|
+
const capture =
|
|
749
|
+
record === undefined
|
|
750
|
+
? null
|
|
751
|
+
: { endpoint: record.endpoint, model: record.model, downgrade: record.downgrade }
|
|
752
|
+
// The steering note needs the catalog: without a source there is no data
|
|
753
|
+
// to derive it from, and the note does not render.
|
|
754
|
+
const steeringNoteFor =
|
|
755
|
+
state.sourced === null ? null : steeringNote(state.sourced.doc, state.picks, state.enabled)
|
|
756
|
+
return { capture, steeringNote: steeringNoteFor }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
rpcRegistration = await ctx.rpc.register(ModelCompare, {
|
|
760
|
+
report: async (rawInput: unknown) => {
|
|
761
|
+
const input = rawInput as { sessionID?: string | null } | undefined
|
|
762
|
+
const sessionID = input?.sessionID ?? null
|
|
343
763
|
const ranks = reportRanks()
|
|
764
|
+
// ONE derivation per call: the same footer fields feed formatReport's
|
|
765
|
+
// context and the returned payload, so the transcript text and the
|
|
766
|
+
// dialog footer can never disagree.
|
|
767
|
+
const footer = reportFooter(sessionID)
|
|
344
768
|
if (state.sourced === null || ranks === null) {
|
|
345
|
-
return {
|
|
769
|
+
return {
|
|
770
|
+
text: "model-compare: no ranking data available yet",
|
|
771
|
+
age: null,
|
|
772
|
+
...footer,
|
|
773
|
+
entries: [],
|
|
774
|
+
}
|
|
346
775
|
}
|
|
347
776
|
return {
|
|
348
|
-
text: formatReport(state.sourced, ranks, {
|
|
777
|
+
text: formatReport(state.sourced, ranks, {
|
|
778
|
+
picks: state.picks,
|
|
779
|
+
overrides: config.weights,
|
|
780
|
+
enabled: state.enabled,
|
|
781
|
+
capture: footer.capture,
|
|
782
|
+
}),
|
|
349
783
|
age: humanizeAge(Date.now() - state.sourced.acquiredAt),
|
|
784
|
+
...footer,
|
|
350
785
|
entries: reportEntries(state.sourced, ranks, { picks: state.picks, overrides: config.weights }),
|
|
351
786
|
}
|
|
352
787
|
},
|
|
@@ -415,7 +850,12 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
|
|
|
415
850
|
// transcript notice is the durable copy, not the fast path).
|
|
416
851
|
if (sourced === null || ranks === null) {
|
|
417
852
|
try {
|
|
418
|
-
await rpcRegistration.events.emit("report", {
|
|
853
|
+
await rpcRegistration.events.emit("report", {
|
|
854
|
+
sessionID,
|
|
855
|
+
age: null,
|
|
856
|
+
...reportFooter(sessionID),
|
|
857
|
+
entries: [],
|
|
858
|
+
})
|
|
419
859
|
} catch {
|
|
420
860
|
// Emission failures must not fail the command.
|
|
421
861
|
}
|
|
@@ -426,11 +866,19 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
|
|
|
426
866
|
})
|
|
427
867
|
return
|
|
428
868
|
}
|
|
429
|
-
const
|
|
869
|
+
const footer = reportFooter(sessionID)
|
|
870
|
+
const context = {
|
|
871
|
+
picks: state.picks,
|
|
872
|
+
overrides: config.weights,
|
|
873
|
+
enabled: state.enabled,
|
|
874
|
+
capture: footer.capture,
|
|
875
|
+
}
|
|
430
876
|
try {
|
|
431
877
|
await rpcRegistration.events.emit("report", {
|
|
432
878
|
sessionID,
|
|
433
879
|
age: humanizeAge(Date.now() - sourced.acquiredAt),
|
|
880
|
+
capture: footer.capture,
|
|
881
|
+
steeringNote: footer.steeringNote,
|
|
434
882
|
entries: reportEntries(sourced, ranks, context),
|
|
435
883
|
})
|
|
436
884
|
} catch {
|