@chatpanel/events 0.2.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/router.js ADDED
@@ -0,0 +1,831 @@
1
+ // The model router — which model answers, and what happens to the request on the way.
2
+ //
3
+ // Two things that look separate and are not. WHERE a request goes decides what may happen
4
+ // to it (a local model needs no redaction; a third-party one does), and what happens to it
5
+ // decides where it may go (a redacted request is safe somewhere the raw one is not). Wiring
6
+ // them separately is how a request reaches a cloud model with the redaction step skipped,
7
+ // which is the single worst bug this codebase could have.
8
+ //
9
+ // So routing and composition are one object, and the ordering guarantee is STRUCTURAL:
10
+ // every request passes through the same pipeline, and egress happens at a point the
11
+ // pipeline defines rather than wherever a caller remembered to put it.
12
+ //
13
+ // ROUTING IS CLASS R. A rule picks the model — declared attributes in, a decision plus its
14
+ // reasons out. Deterministic, instant, free, and explainable. Asking a language model which
15
+ // language model to use would be slower, cost tokens, and produce an answer nobody can
16
+ // check.
17
+ //
18
+ // HARD CONSTRAINTS ARE NOT SCORES. Privacy and capability eliminate candidates; latency,
19
+ // cost and load only order the survivors. A privacy requirement that could be outweighed by
20
+ // a cheap model is not a requirement — and "cheapest wins" is exactly the pressure that
21
+ // would erode it.
22
+
23
+ export class RouterError extends Error {
24
+ constructor(code, message) { super(message); this.name = 'RouterError'; this.code = code; }
25
+ }
26
+
27
+ /** Where a request may go. Ordered: each level permits everything below it. */
28
+ export const REACH = Object.freeze(['device', 'trusted', 'any']);
29
+
30
+ const reachRank = (r) => Math.max(0, REACH.indexOf(r));
31
+
32
+ /**
33
+ * Declare a model a request can be routed to.
34
+ *
35
+ * @param reach the furthest a request may travel to reach it: 'device' (never leaves),
36
+ * 'trusted' (the user's own server or gateway), 'any' (a third party).
37
+ * @param classUsed R/M/L/C/A per the execution classes — what guarantee it offers.
38
+ * @param capabilities what it can do: 'tools', 'vision', 'json', 'long-context'…
39
+ * @param costPer1k relative cost. Unitless on purpose: the router compares candidates, it
40
+ * does not bill anyone, and a fake precision here would invite trusting it.
41
+ * @param latencyMs typical time to first token.
42
+ * @param load 0..1, how busy it is right now — supplied by the host, not remembered
43
+ * here, because a router that cached load would be routing on stale facts.
44
+ */
45
+ export function defineModel({
46
+ id, label, reach = 'any', classUsed = 'C', capabilities = [],
47
+ costPer1k = 1, latencyMs = 1000, load = 0, available = true,
48
+ rateLimited = false, observedLatencyMs = null, quality = null, model = '', providerRank = 50, orderPinned = false,
49
+ }) {
50
+ if (!id) throw new RouterError('BAD_MODEL', 'model.id required');
51
+ if (!REACH.includes(reach)) throw new RouterError('BAD_MODEL', `model '${id}': unknown reach '${reach}'`);
52
+ return Object.freeze({
53
+ id, label: label || id, reach, classUsed,
54
+ // The underlying model name, so the same model at a different provider is recognisable
55
+ // as the closest possible replacement when one of them declines.
56
+ model,
57
+ capabilities: [...capabilities], costPer1k,
58
+ // MEASURED BEATS DECLARED. A latency someone typed into a config is a guess about a
59
+ // service that changes hourly; a latency we recorded is what it actually did. The
60
+ // declared number stays as the fallback for a model we have never called.
61
+ latencyMs: Number.isFinite(observedLatencyMs) ? observedLatencyMs : latencyMs,
62
+ declaredLatencyMs: latencyMs,
63
+ observedLatencyMs,
64
+ load, available, rateLimited,
65
+ // Which provider to prefer when two of them offer the same thing. Lower wins. Ties were
66
+ // breaking alphabetically, which is not a preference — it is the absence of one, and it
67
+ // sent every equal choice to whichever provider happened to sort first.
68
+ providerRank,
69
+ // Did a PERSON set that order, or did we guess it? Our guess must not overrule a real
70
+ // difference in cost or speed — a stated preference must. Without this distinction the
71
+ // provider ranking we inferred from a URL would quietly outrank a route that is genuinely
72
+ // cheaper, and the user would never see why.
73
+ orderPinned: Boolean(orderPinned),
74
+ // 0..1 benchmark or observed success, when the host has one. Null means unknown, which
75
+ // is different from bad — and scoring an unknown as zero would bury every new model.
76
+ quality,
77
+ });
78
+ }
79
+
80
+ /**
81
+ * A step in the pipeline every request passes through.
82
+ *
83
+ * @param stage 'request' — before the model is called (redaction, trimming, tool selection)
84
+ * 'response' — after it answers (restoring placeholders, citations)
85
+ * @param priority lower runs first on the request and LAST on the response, so a step that
86
+ * wraps something unwraps it symmetrically. Getting this backwards is how a
87
+ * vault gets restored before the text it protects comes back.
88
+ */
89
+ export function defineMiddleware({ id, label, stage, priority = 100, run, requiredFor = null }) {
90
+ if (!id) throw new RouterError('BAD_MIDDLEWARE', 'middleware.id required');
91
+ if (!['request', 'response'].includes(stage)) throw new RouterError('BAD_MIDDLEWARE', `middleware '${id}': stage must be request or response`);
92
+ if (typeof run !== 'function') throw new RouterError('BAD_MIDDLEWARE', `middleware '${id}': run required`);
93
+ return Object.freeze({ id, label: label || id, stage, priority, run, requiredFor });
94
+ }
95
+
96
+ /**
97
+ * A way of CHOOSING among candidates — itself a plugin.
98
+ *
99
+ * Scoring by latency and cost is one strategy, not the only defensible one. A small
100
+ * classifier could route by task type; a learned model could route by what has worked
101
+ * before; a user could pin a model for a project. Hard-coding one of those would make the
102
+ * others a rewrite.
103
+ *
104
+ * THE INVARIANT THAT SURVIVES ANY STRATEGY: a strategy may REORDER or NARROW the eligible
105
+ * set. It can never widen it. Hard constraints — reach and capability — are applied before
106
+ * any strategy runs, so no clever router, learned or otherwise, can send a device-only
107
+ * request to a third party. That is why the constraints are not scores: a score is
108
+ * something a strategy could outweigh.
109
+ *
110
+ * @param classUsed what the strategy costs to run. 'R' is a rule, 'M' a small local model.
111
+ * Declared, because a router that quietly spends tokens to save tokens should be
112
+ * visible in the log as exactly that.
113
+ * @param decide async (eligible, need, ctx) => ordered candidates, a single candidate, or
114
+ * null to abstain. Abstaining is normal — a strategy with no opinion should say so
115
+ * rather than guess.
116
+ */
117
+ export function defineRouteStrategy({ id, label, classUsed = 'R', timeoutMs = 0, decide }) {
118
+ if (!id) throw new RouterError('BAD_STRATEGY', 'strategy.id required');
119
+ if (typeof decide !== 'function') throw new RouterError('BAD_STRATEGY', `strategy '${id}': decide required`);
120
+ return Object.freeze({ id, label: label || id, classUsed, timeoutMs, decide });
121
+ }
122
+
123
+ /**
124
+ * Cheap, deterministic signals read straight off the request — the bottom rung of the
125
+ * escalation ladder.
126
+ *
127
+ * Task complexity, modality, volume and language are all things a rule can estimate in
128
+ * microseconds. Sending a request to a classifier to discover it contains an image, or is
129
+ * four hundred tokens long, would spend a model call to learn something already visible.
130
+ * Escalation earns its cost only above whatever this can answer.
131
+ *
132
+ * Every value is a HINT, and named as one. A heuristic presented as a fact is how a
133
+ * mis-detected language quietly routes someone to the wrong model forever.
134
+ */
135
+ // How close two scores must be before they count as the same. Latency and cost are
136
+ // ESTIMATES; a gap smaller than this says nothing real, so the user's Order decides instead.
137
+ const TIE_BAND = 0.10;
138
+
139
+ // The exchange rate the balanced trade runs on: how many seconds of waiting one unit of
140
+ // cost (per 1k tokens) is worth. Adding raw seconds to raw cost would set this to 1 —
141
+ // "a second is worth a dollar" — and that is not a decision anyone made, it is the accident
142
+ // of two numbers sharing an addition. At 5, a free model on the user's own machine is not
143
+ // outbid by a paid one merely for being nearer: no quota, no outage and no third party are
144
+ // worth a few seconds, which is the same reasoning the provider order already encodes.
145
+ const SECONDS_PER_UNIT_COST = 5;
146
+
147
+ /**
148
+ * The model name stripped of provider prefix and tag, so the SAME model matches across hosts:
149
+ * `deepseek-ai/DeepSeek-V4-Flash` and `deepseek/deepseek-v4-flash` are one model reached two
150
+ * ways. Both the scorer and failover need this identity, so it belongs to the contract rather
151
+ * than to whichever client asked first.
152
+ */
153
+ export function sameModelKey(m) {
154
+ return String(m?.model || m?.label || '')
155
+ .toLowerCase()
156
+ .replace(/^[^/]+\//, '')
157
+ .replace(/[:@].*$/, '')
158
+ .replace(/[^a-z0-9.]+/g, '');
159
+ }
160
+
161
+ export function signalsFrom(request = {}) {
162
+ const text = String(request.text || (request.messages || []).map((m) => m?.content || '').join('\n') || '');
163
+ const chars = text.length;
164
+ const hasImage = !!(request.images?.length || request.attachments?.some?.((a) => /^image\//.test(a?.type || '')));
165
+ const hasAudio = !!request.audio || !!request.attachments?.some?.((a) => /^audio\//.test(a?.type || ''));
166
+ return {
167
+ // ~4 chars per token, the same rule the dispatcher budget uses. Consistency matters more
168
+ // than accuracy here: two different estimates of "how big is this" is worse than one
169
+ // rough one.
170
+ approxTokens: Math.round(chars / 4),
171
+ modality: hasAudio ? 'audio' : (hasImage ? 'vision' : 'text'),
172
+ // Complexity, from what actually distinguishes a hard request from a simple one at zero
173
+ // cost: length, code, and explicit multi-step language.
174
+ // A keyword in a twenty-character message is not a complex request. "refactor this
175
+ // please" is a sentence, not a project — so keywords only raise complexity once there is
176
+ // enough text for them to be describing something. A code fence is the exception: it
177
+ // carries the work itself, whatever its length.
178
+ // Code is its own signal, not merely a complexity hint: it decides whether the coding
179
+ // capability is required at all.
180
+ //
181
+ // A FENCE IS UNAMBIGUOUS at any length — "```js\nfunction f(){}\n```" is code however
182
+ // short. The keyword heuristics are not, so those need enough surrounding text to be
183
+ // describing code rather than mentioning it: prose can say "import" or end a line with a
184
+ // semicolon without being a programming task.
185
+ code: /```/.test(text)
186
+ || (chars > 80 && /\bfunction\b|\bclass\b|=>|;\s*$|\bdef\b|\bimport\b|\bconst\b/m.test(text)),
187
+ complexity: (chars > 4000 || /```/.test(text)
188
+ || (chars >= 200 && /\bstep by step\b|\bplan\b|\brefactor\b|\bmigrate\b|\banalyse|\banalyze/i.test(text)))
189
+ ? 'high'
190
+ : (chars < 200 ? 'low' : 'medium'),
191
+ // Non-Latin script is the one language signal a rule can read reliably. Anything finer
192
+ // is a guess, so it is not offered.
193
+ nonLatin: /[^\u0000-\u024F\u2000-\u206F]/.test(text),
194
+ // ASKS FOR NOTHING. "hello" and "what can you help with" are conversation, not work —
195
+ // and the equipment a turn happens to carry must not make them expensive.
196
+ //
197
+ // Detected by what is ABSENT rather than by matching greetings: a greeting list fails on
198
+ // the first typo ("what can yo uhelp with" is still small talk), while the absence of any
199
+ // action verb and of any reference to the user's own data is robust to spelling. 'my',
200
+ // 'this page' and 'here' count as references, so "whats my longest streak" is work — it
201
+ // needs the page — even though it is shorter than most greetings.
202
+ // `summar` and `analy` carry an explicit \w* because the alternation ends in \b: written
203
+ // bare, they demanded a word boundary immediately after the prefix, so "summarize this
204
+ // document" matched nothing and was classified as SMALL TALK. A prefix that can never
205
+ // fire is worse than an absent one — it reads as covered.
206
+ smalltalk: chars < 100 && !/```/.test(text)
207
+ && !/\b(draw|click|open|fill|read|find|search|edit|write|create|update|delete|run|fix|change|add|remove|select|scroll|extract|summar\w*|analy\w*|check|review|list|show|go to|navigate|my|mine|this page|here|it|that)\b/i.test(text),
208
+ chars,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * What a request REQUIRES, derived from what it is.
214
+ *
215
+ * The escalation strategy only ever expressed a preference, so a drawing task that needed
216
+ * exact coordinates and a structured payload was allowed to consider an 8B instant model —
217
+ * it merely ranked lower, and ranked lower still wins when the better ones decline. A
218
+ * requirement eliminates; a preference does not, and the difference is the whole reason the
219
+ * chain kept reaching models that could not do the job.
220
+ *
221
+ * Requirements first, then cost and speed among whatever survives. Nothing here is a
222
+ * judgement call a model needs to make — length, code fences, images and adapter tools are
223
+ * all readable for free, which is what makes this class R and instant.
224
+ */
225
+ export function requirementsFor(signals = {}, { structured = false, hasTools = false, pageTools = false, background = false } = {}) {
226
+ const required = new Set();
227
+ let minQuality = 0;
228
+ const why = [];
229
+
230
+ if (hasTools) { required.add('tools'); why.push('the turn carries tools'); }
231
+ if (signals.modality === 'vision') { required.add('vision'); why.push('the request includes an image'); }
232
+ // DRIVING A PAGE NEEDS TOOLS AND JUDGEMENT — vision only for the steps that look.
233
+ //
234
+ // Requiring vision for the whole turn would rule out a strong reasoning model that would
235
+ // drive the page well and only needs to see a screenshot occasionally. The step that reads
236
+ // an image is one call in a loop, not the character of the whole task, and per-STEP
237
+ // requirements (see requirementsForStep) are where that belongs.
238
+ // EQUIPMENT IS NOT DEMAND. `pageTools` and `structured` say what the turn CARRIES, not what
239
+ // it was asked for — so with page actions switched on, every message got a quality floor
240
+ // and "hello" was routed to a CLI coding agent. What a floor should come from is the
241
+ // request; a turn that asks for nothing needs nothing.
242
+ //
243
+ // The tools stay armed and stay required for anything that is not small talk, so a follow-up
244
+ // that does need the page is unaffected — and once per-step routing lands, the step that
245
+ // actually acts on the page carries its own requirements anyway.
246
+ if (pageTools && !signals.smalltalk) {
247
+ required.add('tools');
248
+ minQuality = Math.max(minQuality, 0.55);
249
+ why.push('driving a page needs exact actions');
250
+ }
251
+ if (signals.approxTokens > 20_000) { required.add('long-context'); why.push('the request is large'); }
252
+ if (signals.code) { required.add('coding'); why.push('the request contains code'); }
253
+
254
+ // Structured work — a canvas, a spreadsheet — is exact. A model that fumbles coordinates
255
+ // produces something visibly wrong rather than merely worse, so this sets a FLOOR rather
256
+ // than a preference.
257
+ if (structured && !signals.smalltalk) {
258
+ required.add('tools');
259
+ minQuality = Math.max(minQuality, 0.55);
260
+ why.push('it must produce an exact structured payload');
261
+ }
262
+ // LENGTH IS NOT DIFFICULTY WHEN THE LENGTH IS THE MATERIAL.
263
+ //
264
+ // Complexity is read from the whole prompt, which is right for a chat turn — a long message
265
+ // usually is a harder request. It is backwards for BACKGROUND extraction: the topic pass
266
+ // inlines an entire transcript, so a conversation carrying one pasted dashboard produced a
267
+ // 6,300-character prompt, read as 'high', and a quality floor that eliminated every local
268
+ // model. The one call that should always be cheap got more expensive the more material
269
+ // there was to chew through.
270
+ //
271
+ // Needing to FIT is a separate requirement and is still applied above (long-context).
272
+ // What is dropped here is only the quality FLOOR, and only for work nobody is waiting on.
273
+ if (signals.complexity === 'high' && !background) {
274
+ minQuality = Math.max(minQuality, 0.55);
275
+ why.push('the task is complex');
276
+ }
277
+
278
+ // Which of these can be given up if nothing qualifies, and which cannot.
279
+ //
280
+ // `tools` is not negotiable: a turn that carries tools cannot be done by a model that
281
+ // cannot call them, so relaxing it would produce an answer that ignores half the request.
282
+ // The others are strong preferences dressed as requirements — a text-only model CAN drive
283
+ // a page badly, and badly beats not at all.
284
+ // `tools` is normally non-negotiable — but nothing is going to be called for small talk,
285
+ // so insisting on it there would eliminate a perfectly good model over a capability the
286
+ // turn will not use.
287
+ const negotiable = signals.smalltalk ? [...required] : [...required].filter((c) => c !== 'tools');
288
+ return { required: [...required], negotiable, minQuality, why };
289
+ }
290
+
291
+ /**
292
+ * WHICH AXIS THIS REQUEST CARES ABOUT — quality, speed, or the trade between them.
293
+ *
294
+ * `prefer` was hardcoded to 'balanced' for every turn, on the reasoning that "reasonably
295
+ * fast and reasonably cheap is what anybody means". It is not. A greeting means fast: no
296
+ * answer to "hi" is improved by a frontier model thinking about it, and waiting is the only
297
+ * thing the user can perceive. A refactor across five files means good: saving three seconds
298
+ * on an answer that has to be redone is not a saving. The axis that matters is a property of
299
+ * the REQUEST, and it is readable for free from the same signals everything else here uses.
300
+ *
301
+ * Requirements still come first and are unaffected — this only ORDERS what already qualifies.
302
+ * That is what makes 'latency' safe to mean literally the fastest model: a task with a
303
+ * quality floor has already eliminated everything below it, so "fastest" can only ever pick
304
+ * the fastest model that was good enough.
305
+ *
306
+ * Class R: length, code fences, images. No model call.
307
+ */
308
+ export function preferenceFor(signals = {}, { structured = false, minQuality = 0, hasTools = false, background = false } = {}) {
309
+ // NOBODY IS WAITING, AND NOBODY IS READING IT. A title, a topic pass, a grammar fix: work
310
+ // the user did not ask for, whose output is a short structured artifact, and which has a
311
+ // deterministic fallback when the model declines. The honest axis for that is what it
312
+ // costs — and it comes first, because such a pass reads as 'high' complexity purely from
313
+ // the size of the material it was handed.
314
+ if (background) return { prefer: 'cost', why: 'background work — spend as little as possible' };
315
+ // EXACTNESS AND DIFFICULTY BUY QUALITY. A structured payload is visibly wrong when it is
316
+ // wrong, and a complex task redone is slower than a slow task done once.
317
+ if (structured || signals.complexity === 'high' || signals.code || signals.modality === 'vision') {
318
+ return { prefer: 'quality', why: 'the task is exact or hard — get it right rather than quick' };
319
+ }
320
+ // NOTHING TO GET RIGHT MEANS GET IT BACK — but only when the turn genuinely asks for
321
+ // nothing, and `smalltalk` alone is too generous to decide that. It calls "what did we
322
+ // decide in the standup" trivial, and answering THAT on an 8B instant model to save half a
323
+ // second is the same mistake as the greeting on a frontier model, pointing the other way.
324
+ //
325
+ // So the turn must ALSO be carrying no tools. That is not a second guess at triviality: it
326
+ // is the answer toolNeedFor already gave, from a much narrower test, and a turn armed with
327
+ // the means to look something up is by definition one that might. Composing the two beats
328
+ // restating either.
329
+ if (signals.smalltalk && !hasTools && !minQuality) {
330
+ return { prefer: 'latency', why: 'nothing to look up — answer fast' };
331
+ }
332
+ return { prefer: 'balanced', why: 'no reason to favour speed or quality' };
333
+ }
334
+
335
+ /**
336
+ * The order to try replacements in when a model declines — CLOSEST FIRST, in both directions.
337
+ *
338
+ * Lives here rather than in the strategy that calls it because two things need this answer:
339
+ * the failover itself, and anything that wants to SHOW the chain before it happens. A
340
+ * projected chain computed by a second implementation would eventually disagree with the real
341
+ * one, and a picture that lies about what the router will do is worse than no picture.
342
+ *
343
+ * Ranking by absolute quality made failover a one-way escalator and was wrong at both ends
344
+ * at once: a greeting on an 8B that declined climbed to a frontier model, and a frontier model
345
+ * that declined dropped to an 8B. The task did not get easier when the provider said no, and
346
+ * it did not get harder when a small one did.
347
+ *
348
+ * So the ordering is DISTANCE from what failed. Quality distance is the spine; being a
349
+ * different KIND of thing (an API model answers a request, a CLI agent runs its own loop) and
350
+ * missing a capability the failed model had are each additional distance rather than separate
351
+ * tiers — a tier ordering let "same class" outrank a two-tier quality drop, which is exactly
352
+ * how the frontier-to-8B hop happened.
353
+ *
354
+ * Nothing is ELIMINATED. A distant model still beats no answer, so the last hop of a long
355
+ * chain is allowed to be a poor match; this decides order, never eligibility.
356
+ *
357
+ * @param failed { model, quality, capabilities, classUsed, reason } — the model that declined.
358
+ * `reason: 'gone'` means the MODEL is retired rather than the provider saying no, so
359
+ * the same name elsewhere is equally dead and is dropped instead of preferred.
360
+ */
361
+ /**
362
+ * The position the user CHOSE, or Infinity when we only guessed one.
363
+ *
364
+ * A hand-set Order was honoured in the score path and ignored everywhere else, so the moment
365
+ * any strategy had an opinion the user's stated preference stopped existing: three models of
366
+ * identical quality, one of them pinned to Order 1, and escalation picked a different one
367
+ * because it costs less per 1k. The reasoning already written for the score path applies
368
+ * verbatim here — they can see the prices and chose anyway.
369
+ *
370
+ * Strictly a TIE-BREAK, and only for pinned orders. It never outranks the axis a strategy
371
+ * exists to judge: a genuinely better model still wins, and an order we inferred from a URL
372
+ * stays below cost where it belongs.
373
+ */
374
+ export function pinnedOrderOf(m) {
375
+ return m?.orderPinned && Number.isFinite(m.providerRank) ? m.providerRank : Infinity;
376
+ }
377
+
378
+ export const FAILOVER_CLASS_GAP = 0.25;
379
+ export const FAILOVER_CAPABILITY_GAP = 0.25;
380
+
381
+ export function failoverOrder(candidates = [], failed = {}) {
382
+ const sameModel = (m) => !!failed.model && sameModelKey(m) === sameModelKey(failed);
383
+ // A RETIRED MODEL IS RETIRED EVERYWHERE. "Same model at another provider" is the ideal
384
+ // replacement when the provider declined — out of credits, rate limited — and the worst
385
+ // possible one when the model is gone: deepseek-v4-flash reaching end of life on one host
386
+ // means the identical name on another is equally dead, so preferring it walks into the
387
+ // same wall.
388
+ let pool = [...candidates];
389
+ if (failed.reason === 'gone') {
390
+ const alive = pool.filter((m) => !sameModel(m));
391
+ if (alive.length) pool = alive;
392
+ }
393
+ const q = (m) => (Number.isFinite(m.quality) ? m.quality : 0.5);
394
+ const covers = (m) => (failed.capabilities || []).every((c) => (m.capabilities || []).includes(c));
395
+ const sameClass = (m) => !failed.classUsed || m.classUsed === failed.classUsed;
396
+ const qf = Number.isFinite(failed.quality) ? failed.quality : null;
397
+
398
+ if (qf !== null) {
399
+ // The same model elsewhere is distance zero by definition: identical capability, merely a
400
+ // different bill.
401
+ const distance = (m) => (sameModel(m) ? -1 : Math.abs(q(m) - qf)
402
+ + (sameClass(m) ? 0 : FAILOVER_CLASS_GAP)
403
+ + (covers(m) ? 0 : FAILOVER_CAPABILITY_GAP));
404
+ return pool
405
+ .map((m) => ({ m, d: distance(m) }))
406
+ .sort((a, b) => a.d - b.d
407
+ || pinnedOrderOf(a.m) - pinnedOrderOf(b.m)
408
+ || a.m.costPer1k - b.m.costPer1k
409
+ || String(a.m.id).localeCompare(String(b.m.id)))
410
+ .map((x) => x.m);
411
+ }
412
+
413
+ // WITHOUT A QUALITY TO BE CLOSE TO, closeness is not a question that can be asked — so fall
414
+ // back to tiers, which at least keep like with like. A caller inside ChatPanel always knows
415
+ // the quality of the model it just called; an external one describing a failure it did not
416
+ // route may not.
417
+ const rank = (m) => {
418
+ if (sameModel(m)) return 0;
419
+ if (sameClass(m) && covers(m)) return 1;
420
+ if (sameClass(m)) return 2;
421
+ if (covers(m)) return 3; // capable but a different kind of thing
422
+ return 4;
423
+ };
424
+ return pool.sort((a, b) => rank(a) - rank(b) || q(b) - q(a)
425
+ || pinnedOrderOf(a) - pinnedOrderOf(b) || a.costPer1k - b.costPer1k);
426
+ }
427
+
428
+ /**
429
+ * What ONE step needs — the unit routing should eventually work at.
430
+ *
431
+ * A turn is a chain of sub-tasks with different demands: read the canvas (structure), decide
432
+ * what to draw (reasoning), look at the result (vision), write it (structure again).
433
+ * Choosing one model for all of them means either paying frontier prices to run a loop or
434
+ * doing the hard parts with something that cannot. The honest unit is the step.
435
+ *
436
+ * This is the contract; the loop that acts on it is separate work. Exposed now so a caller
437
+ * can already ask "what does this call need" rather than inferring it from the turn — and so
438
+ * the answer lives in one place when the loop is ready to use it.
439
+ */
440
+ export function requirementsForStep(toolName, args = {}) {
441
+ const name = String(toolName || '');
442
+ const action = String(args?.action || '');
443
+ const both = `${name}.${action}`;
444
+
445
+ // Looking at pixels — the only steps that genuinely need vision.
446
+ if (/screenshot|marked_screenshot|read_canvas|sense_canvas/.test(both)) {
447
+ return { required: ['vision'], why: 'this step reads an image' };
448
+ }
449
+ // Producing an exact payload: structure matters, sight does not.
450
+ if (/structured_insert|sheet_write|fill_form|input_sequence|draw_path/.test(both)) {
451
+ return { required: ['tools'], minQuality: 0.55, why: 'this step writes an exact payload' };
452
+ }
453
+ // Everything else is ordinary tool use.
454
+ return { required: name ? ['tools'] : [], why: '' };
455
+ }
456
+
457
+ export function createModelRouter({ models = [], middleware = [], strategies = [], admit = null } = {}) {
458
+ const registry = [...models];
459
+ const chain = [...middleware];
460
+ const plans = [...strategies];
461
+
462
+ /** Order once: request steps ascending, response steps descending — see defineMiddleware. */
463
+ const stepsFor = (stage) => chain
464
+ .filter((m) => m.stage === stage && (!admit || admit(m)))
465
+ .sort((a, b) => (stage === 'request' ? a.priority - b.priority : b.priority - a.priority));
466
+
467
+ return {
468
+ addModel(m) { registry.push(m); return () => { const i = registry.indexOf(m); if (i >= 0) registry.splice(i, 1); }; },
469
+ use(m) { chain.push(m); return () => { const i = chain.indexOf(m); if (i >= 0) chain.splice(i, 1); }; },
470
+ addStrategy(s) { plans.push(s); return () => { const i = plans.indexOf(s); if (i >= 0) plans.splice(i, 1); }; },
471
+ models: () => [...registry],
472
+ middleware: () => [...chain],
473
+ strategies: () => [...plans],
474
+
475
+ /**
476
+ * Choose a model. Returns the decision AND why every rejected candidate lost, because
477
+ * "it used the wrong model" is unanswerable otherwise.
478
+ *
479
+ * @param need { reach, capabilities, prefer } — `prefer` is 'latency' | 'cost' |
480
+ * 'balanced'. Preference orders survivors; it never revives a rejected one.
481
+ */
482
+ route(need = {}) {
483
+ const wantReach = REACH.includes(need.reach) ? need.reach : 'any';
484
+ const wantCaps = need.capabilities || [];
485
+ // WHY THE CONSTRAINTS ARE WHAT THEY ARE. `requirementReasons` was built on every turn
486
+ // and read by nothing, so the single most consequential thing that can happen to a
487
+ // route — a source capping its reach — was computed, named, and thrown away. The trace
488
+ // said "reach 'trusted' within 'trusted'" and could not say which page made it trusted,
489
+ // which is the difference between a restriction someone understands and one they switch
490
+ // off wholesale.
491
+ //
492
+ // Declared up here because the no-candidate return cites it too: "nothing qualified" is
493
+ // the case where knowing what narrowed the field matters most. Carried separately from
494
+ // `reasons` — that is the one-line summary in the activity strip, this is the detail the
495
+ // graph exists to show.
496
+ const constraints = need.requirementReasons || [];
497
+ const rejected = [];
498
+ const eligible = registry.filter((m) => {
499
+ if (!m.available) { rejected.push({ id: m.id, why: 'unavailable' }); return false; }
500
+ if (admit && !admit(m)) { rejected.push({ id: m.id, why: 'disabled' }); return false; }
501
+ // PRIVACY IS A CEILING, NOT A PREFERENCE. A request allowed only on-device can never
502
+ // be routed to a third party, however cheap or fast that party is.
503
+ if (reachRank(m.reach) > reachRank(wantReach)) { rejected.push({ id: m.id, why: `reach '${m.reach}' exceeds '${wantReach}'` }); return false; }
504
+ const missing = wantCaps.filter((c) => !m.capabilities.includes(c));
505
+ if (missing.length) { rejected.push({ id: m.id, why: `missing ${missing.join(', ')}` }); return false; }
506
+ // A DEADLINE AND A BUDGET ELIMINATE, they do not discount.
507
+ //
508
+ // "Answer within 800ms" and "spend at most this" are requirements in the same sense
509
+ // privacy is: a model that cannot meet them has not merely scored badly, it cannot do
510
+ // the job. Treating them as weights is how a live voice reply ends up routed to the
511
+ // cheapest model that takes four seconds.
512
+ if (need.maxLatencyMs > 0 && m.latencyMs > need.maxLatencyMs) {
513
+ rejected.push({ id: m.id, why: `${m.latencyMs}ms exceeds the ${need.maxLatencyMs}ms deadline` });
514
+ return false;
515
+ }
516
+ if (need.maxCostPer1k >= 0 && need.maxCostPer1k !== undefined && m.costPer1k > need.maxCostPer1k) {
517
+ rejected.push({ id: m.id, why: `costs ${m.costPer1k} over the ${need.maxCostPer1k} budget` });
518
+ return false;
519
+ }
520
+ // A model that is rate-limited right now is unavailable right now. Ranking it lower
521
+ // would still let it win when it is the only one left, and then fail.
522
+ if (m.rateLimited) { rejected.push({ id: m.id, why: 'rate limited' }); return false; }
523
+ // A model that already failed this request is not a candidate for it. Without this,
524
+ // failover re-picks the model that just returned 402 and the retry is a loop.
525
+ if (need.exclude?.includes?.(m.id)) { rejected.push({ id: m.id, why: 'already failed this request' }); return false; }
526
+ // A QUALITY FLOOR IS A REQUIREMENT, not a ranking. A model that fumbles exact
527
+ // coordinates produces something visibly wrong rather than merely worse — and a
528
+ // model that merely ranks lower still wins once the better ones decline, which is
529
+ // how a chain of five ended on one that could not do the job.
530
+ if (need.minQuality > 0) {
531
+ const q = Number.isFinite(m.quality) ? m.quality : 0.5;
532
+ if (q < need.minQuality) { rejected.push({ id: m.id, why: `below the quality this task needs (${q} < ${need.minQuality})` }); return false; }
533
+ }
534
+ return true;
535
+ });
536
+
537
+ if (!eligible.length) {
538
+ // RELAX, VISIBLY. A quality floor that leaves nothing is worse than a mediocre
539
+ // answer, but relaxing it silently would hide why the result is poor. The floor is
540
+ // dropped, the fact is stated, and the hard constraints — reach, capability — are
541
+ // never relaxed, because those are not preferences about how well something goes.
542
+ // RELAX IN ORDER, AND SAY SO. Quality first, since a weaker model doing the right
543
+ // kind of work beats a capable one doing the wrong kind. Then the negotiable
544
+ // capabilities, one group at a time. `tools` and reach are never relaxed: one would
545
+ // ignore half the request, the other would send it somewhere it may not go.
546
+ if (need.minQuality > 0) {
547
+ const relaxed = this.route({ ...need, minQuality: 0 });
548
+ if (relaxed.model) {
549
+ return {
550
+ ...relaxed,
551
+ relaxed: true,
552
+ reasons: [...relaxed.reasons, `no model met the quality this task needs (${need.minQuality}) — used the best available instead`],
553
+ };
554
+ }
555
+ }
556
+ if (need.negotiable?.length) {
557
+ const kept = (need.capabilities || []).filter((c) => !need.negotiable.includes(c));
558
+ const relaxed = this.route({ ...need, minQuality: 0, capabilities: kept, negotiable: [] });
559
+ if (relaxed.model) {
560
+ return {
561
+ ...relaxed,
562
+ relaxed: true,
563
+ reasons: [...relaxed.reasons, `no model offers ${need.negotiable.join(', ')} — used one without it, which may do this task poorly`],
564
+ };
565
+ }
566
+ }
567
+ return { model: null, reasons: ['no candidate satisfies the constraints'], constraints, rejected };
568
+ }
569
+
570
+ const prefer = need.prefer || 'balanced';
571
+ // LOWER IS BETTER, and each preference optimises the axis it names.
572
+ //
573
+ // Two bugs lived here. The balanced score MULTIPLIED time by money, so a single zero
574
+ // annihilated everything else: every free model scored exactly 0 — a local 8B and a
575
+ // local 26B were indistinguishable, and the winner among them fell to provider order
576
+ // and then to alphabetical id. Meanwhile 'latency' and 'cost' both DIVIDED by quality,
577
+ // which inverts them: asking for speed ranked a frontier model above an 8B at the same
578
+ // latency, and asking for cheap ranked a $5 model above a $2 one. A preference that
579
+ // does the opposite of what it is named is worse than not offering it.
580
+ //
581
+ // So: a single-axis preference reads that axis and nothing else, and only `balanced`
582
+ // trades — adding time and money (never multiplying) and dividing by quality, which is
583
+ // what "worth paying for" means. Requirements have already eliminated everything
584
+ // unsuitable by this point (see requirementsFor), so optimising cost or speed hard
585
+ // cannot reach a model that could not do the job: minQuality is the floor, this is
586
+ // only the ordering above it.
587
+ const score = (m) => {
588
+ // Load is a multiplier rather than a term: a busy model is worse at everything it
589
+ // offers, not merely a bit more expensive.
590
+ const busy = 1 + Math.max(0, Math.min(1, m.load));
591
+ // Known quality divides, and an UNKNOWN one is treated as average rather than as
592
+ // zero — burying every model we have not benchmarked would make the router
593
+ // permanently prefer whatever it happened to measure first.
594
+ const q = Number.isFinite(m.quality) ? Math.max(0.1, m.quality) : 0.5;
595
+ if (prefer === 'latency') return m.latencyMs * busy;
596
+ if (prefer === 'cost') return m.costPer1k * busy;
597
+ if (prefer === 'quality') return busy / q;
598
+ return ((m.latencyMs / 1000) + m.costPer1k * SECONDS_PER_UNIT_COST) * busy / q;
599
+ };
600
+ // ORDER IS A SETTING, NOT A TIE-BREAK THAT NEVER FIRES.
601
+ //
602
+ // `score` multiplies latency, cost and load into a float, so two candidates are almost
603
+ // never bit-for-bit equal — which made the providerRank tie-break that used to sit
604
+ // below it dead code. The Order a user set by hand did nothing at all. Two rules give
605
+ // it effect:
606
+ //
607
+ // 1. SAME MODEL -> ORDER DECIDES, outright. When one model is offered by three
608
+ // providers only the PATH differs, and Order is exactly the "which path"
609
+ // preference. A guess at cost has no business overruling a stated preference
610
+ // between two things that are the same model.
611
+ // 2. CLOSE SCORES ARE A TIE. Latency and cost here are estimates, not measurements;
612
+ // treating a 3% gap as decisive is false precision. Near-equal scores cluster,
613
+ // and Order orders the cluster.
614
+ //
615
+ // Clustering by a linear scan, rather than rounding scores into fixed buckets: with
616
+ // buckets, two scores 3% apart still split whenever they straddle an edge, so the tie
617
+ // band would hold or not hold depending on where the numbers happened to land. A scan
618
+ // that grows each cluster from its own leader has no edges to straddle and stays a
619
+ // valid total order, which a bare "within 10%" predicate — not being transitive —
620
+ // would not.
621
+ const scored = new Map();
622
+ const scoreOf = (m) => {
623
+ if (!scored.has(m)) scored.set(m, score(m));
624
+ return scored.get(m);
625
+ };
626
+ const byOrderThenScore = (a, b) => a.providerRank - b.providerRank
627
+ || scoreOf(a) - scoreOf(b)
628
+ || a.id.localeCompare(b.id);
629
+
630
+ const byScoreThenOrder = (a, b) => scoreOf(a) - scoreOf(b)
631
+ || a.providerRank - b.providerRank
632
+ || a.id.localeCompare(b.id);
633
+
634
+ // Rule 1: collapse each same-model group behind whichever route the user ranked first.
635
+ const groups = new Map();
636
+ for (const m of eligible) {
637
+ // An unnamed model is its own group: with no name we cannot claim it is the same
638
+ // thing as anything else, and guessing would silently hide one of two real choices.
639
+ const key = sameModelKey(m) || `#${m.id}`;
640
+ if (!groups.has(key)) groups.set(key, []);
641
+ groups.get(key).push(m);
642
+ }
643
+ // Same-model siblings stay together directly behind their representative: when the
644
+ // leader declines, the identical model elsewhere is the closest replacement there is.
645
+ // WHOSE ORDER IS IT. A person who ranked these routes by hand gets that ranking
646
+ // honoured outright — they can see the prices and chose anyway. An order we INFERRED
647
+ // from a URL is only a guess, and a guess must not overrule a route that is really
648
+ // cheaper or faster; there it drops back to a tie-break.
649
+ const groupList = [...groups.values()].map((g) => [...g].sort(
650
+ g.some((m) => m.orderPinned) ? byOrderThenScore : byScoreThenOrder,
651
+ ));
652
+ groupList.sort((a, b) => scoreOf(a[0]) - scoreOf(b[0]) || byOrderThenScore(a[0], b[0]));
653
+
654
+ // Rule 2: walk the score-ordered groups, gathering each run that is within the band of
655
+ // its own leader, and let Order arrange each run.
656
+ const clusters = [];
657
+ for (const group of groupList) {
658
+ const open = clusters[clusters.length - 1];
659
+ const leader = open ? scoreOf(open[0][0]) : 0;
660
+ if (open && scoreOf(group[0]) <= leader * (1 + TIE_BAND)) open.push(group);
661
+ else clusters.push([group]);
662
+ }
663
+ for (const cluster of clusters) cluster.sort((a, b) => byOrderThenScore(a[0], b[0]));
664
+ let ranked = clusters.flat(2);
665
+
666
+ // THE CATCH-ALL IS A MODEL THE USER NAMED, not a guess the score made.
667
+ //
668
+ // When no strategy has an opinion and the request has no axis it cares about, the
669
+ // score was still producing an answer — from inferred latency and a cost regex, i.e.
670
+ // from guesses, and presenting it as a decision. That is the one situation where there
671
+ // IS a right answer and it is not ours to invent: the model the user put at Order 1 is
672
+ // them saying "this one, unless there is a reason". Honouring it everywhere else and
673
+ // then overriding it here, on a guess, is the router ignoring the only explicit
674
+ // instruction it was given.
675
+ //
676
+ // PINNED ONLY. An inferred rank is our guess at provider preference and must not act
677
+ // as a declaration; `orderPinned` is what separates a number the user chose from a
678
+ // number we made up. Most setups pin nothing and are unaffected — the score still
679
+ // decides, exactly as before.
680
+ //
681
+ // AND ONLY WHEN NOTHING HAS AN OPINION. A derived preference IS a rule speaking: a
682
+ // greeting asking for speed, a structured payload asking for quality. Those outrank
683
+ // the default, or every turn would land on Order 1 and the rules would be decoration.
684
+ // 'balanced' is the literal "no reason to favour either axis" case (see preferenceFor),
685
+ // which is precisely when a default is the honest answer.
686
+ //
687
+ // Strategies outrank it too, by construction: routeWith consults them after this and
688
+ // the first opinion wins. Rules first, the user's default when they are silent.
689
+ const declared = prefer === 'balanced'
690
+ ? ranked.filter((m) => m.orderPinned).sort((a, b) => a.providerRank - b.providerRank)[0]
691
+ : null;
692
+ if (declared) ranked = [declared, ...ranked.filter((m) => m !== declared)];
693
+
694
+ // Order decided whenever the winning cluster held more than one distinct model — the
695
+ // score alone did not separate them.
696
+ const orderDecided = clusters[0]?.length > 1;
697
+ // HOW MANY ACTUALLY TIED, not how many were eligible. "order 1 broke a tie among 16
698
+ // eligible" reads as sixteen models scoring the same when two did, which sends anyone
699
+ // debugging a surprising route looking in entirely the wrong place.
700
+ const tiedCount = (clusters[0] || []).flat().length;
701
+ const chosen = ranked[0];
702
+ return {
703
+ model: chosen,
704
+ eligible: ranked,
705
+ constraints,
706
+ strategy: declared ? 'declared-default' : 'default-score',
707
+ reasons: [
708
+ `reach '${chosen.reach}' within '${wantReach}'`,
709
+ wantCaps.length ? `has ${wantCaps.join(', ')}` : 'no special capability needed',
710
+ // Say WHICH lever decided. "best by balanced" when the real reason was the
711
+ // Order the user set reads as the router ignoring them — the complaint that
712
+ // surfaced this bug in the first place.
713
+ declared
714
+ ? `your default (Order ${chosen.providerRank}) — nothing about this turn asked for anything else`
715
+ : orderDecided
716
+ ? `order ${chosen.providerRank} broke a ${tiedCount}-way tie by ${prefer}, of ${ranked.length} eligible`
717
+ : `best by ${prefer} (${ranked.length} eligible)`,
718
+ ],
719
+ rejected,
720
+ runnersUp: ranked.slice(1).map((m) => m.id),
721
+ };
722
+ },
723
+
724
+ /**
725
+ * Route, letting strategies choose among the candidates the hard constraints allowed.
726
+ *
727
+ * Strategies run in order and the FIRST opinion wins; the rest are not asked, because a
728
+ * chain that kept consulting after an answer would spend a model call per strategy to
729
+ * produce one decision.
730
+ *
731
+ * Every failure mode falls back to the deterministic score rather than failing the
732
+ * request: routing must never break because the thing that picks a model was slow,
733
+ * offline, or wrong. A router that can fail is worse than a router that is occasionally
734
+ * suboptimal.
735
+ */
736
+ async routeWith(need = {}, ctx = {}) {
737
+ const base = this.route(need);
738
+ if (!base.model || !plans.length) return base;
739
+ const allowed = new Map(base.eligible.map((m) => [m.id, m]));
740
+
741
+ for (const plan of plans) {
742
+ if (admit && !admit(plan)) continue;
743
+ let picked = null;
744
+ try {
745
+ const call = plan.decide([...base.eligible], need, ctx);
746
+ picked = plan.timeoutMs > 0
747
+ ? await Promise.race([call, new Promise((r) => setTimeout(() => r(null), plan.timeoutMs))])
748
+ : await call;
749
+ } catch {
750
+ picked = null; // a strategy that throws has no opinion
751
+ }
752
+ if (!picked) continue;
753
+
754
+ // NARROW OR REORDER, NEVER WIDEN. Anything the strategy names that was not eligible
755
+ // is dropped — the hard constraints already decided that question, and a learned
756
+ // router confidently naming a forbidden model must not be able to overrule them.
757
+ const list = (Array.isArray(picked) ? picked : [picked])
758
+ .map((m) => (typeof m === 'string' ? allowed.get(m) : allowed.get(m?.id)))
759
+ .filter(Boolean);
760
+ const invented = (Array.isArray(picked) ? picked : [picked]).length - list.length;
761
+ if (!list.length) continue;
762
+
763
+ // THE ORDERING IS THE DECISION'S, NOT THE SCORE'S.
764
+ //
765
+ // `eligible` was left as the base ranking while `model` became the strategy's pick,
766
+ // so the two disagreed the moment any strategy fired: the list said opus was first
767
+ // and the chosen model was Codex, sitting somewhere in the middle. Everything
768
+ // downstream reads `eligible` as "the order this decision put them in" — the graph
769
+ // ranks nodes by it, runnersUp slices it — so a stale ordering is not a cosmetic
770
+ // problem, it is the picture contradicting itself in front of the person using it to
771
+ // debug the router.
772
+ //
773
+ // The strategy's list first, then whatever it did not rank, in the order the score
774
+ // left them. A strategy narrows and reorders; it does not delete the rest.
775
+ const rest = base.eligible.filter((m) => !list.includes(m));
776
+ const ordered = [...list, ...rest];
777
+ return {
778
+ ...base,
779
+ model: list[0],
780
+ eligible: ordered,
781
+ strategy: plan.id,
782
+ runnersUp: ordered.slice(1).map((m) => m.id),
783
+ reasons: [
784
+ ...base.reasons.slice(0, -1),
785
+ `chosen by '${plan.id}' (class ${plan.classUsed}) from ${base.eligible.length} eligible`,
786
+ ...(invented > 0 ? [`${invented} suggestion(s) ignored — not eligible under the constraints`] : []),
787
+ ],
788
+ };
789
+ }
790
+ return base;
791
+ },
792
+
793
+ /**
794
+ * Run a request through the pipeline and back.
795
+ *
796
+ * `dispatch` is injected — the router composes and decides; it does not know how to talk
797
+ * to a model. A step that declares `requiredFor` and is missing FAILS the request rather
798
+ * than being skipped: that is how "redaction must run before a third party sees this"
799
+ * becomes a property of the system instead of a habit.
800
+ */
801
+ async run(request, { dispatch, need = {} } = {}) {
802
+ if (typeof dispatch !== 'function') throw new RouterError('NO_DISPATCH', 'run needs a dispatch function');
803
+ const decision = await this.routeWith(need, { request });
804
+ if (!decision.model) throw new RouterError('NO_ROUTE', decision.reasons[0]);
805
+
806
+ const applies = (m) => !m.requiredFor || m.requiredFor(decision.model, need);
807
+ const required = chain.filter((m) => m.requiredFor && m.requiredFor(decision.model, need));
808
+ const active = new Set(stepsFor('request').concat(stepsFor('response')).map((m) => m.id));
809
+ const missing = required.filter((m) => !active.has(m.id));
810
+ if (missing.length) {
811
+ // Fail loud. Silently proceeding without a required step is the exact failure this
812
+ // whole structure exists to prevent, and a disabled-plugin toggle must not be able
813
+ // to cause it.
814
+ throw new RouterError('MISSING_REQUIRED', `route to '${decision.model.id}' requires ${missing.map((m) => m.id).join(', ')}, which is not active`);
815
+ }
816
+
817
+ const ctx = { model: decision.model, need, decision };
818
+ let payload = request;
819
+ for (const step of stepsFor('request')) {
820
+ if (!applies(step)) continue;
821
+ payload = (await step.run(payload, ctx)) ?? payload;
822
+ }
823
+ let answer = await dispatch(payload, ctx);
824
+ for (const step of stepsFor('response')) {
825
+ if (!applies(step)) continue;
826
+ answer = (await step.run(answer, ctx)) ?? answer;
827
+ }
828
+ return { answer, decision };
829
+ },
830
+ };
831
+ }