@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/LICENSE +168 -0
- package/README.md +183 -0
- package/adapters.js +83 -0
- package/capability.js +121 -0
- package/citations.js +79 -0
- package/event.js +170 -0
- package/harness.js +101 -0
- package/index.js +44 -0
- package/invariants.js +174 -0
- package/kernel.js +255 -0
- package/loop.js +132 -0
- package/manifest.js +107 -0
- package/mcp-errors.js +87 -0
- package/meeting-analyzers.js +83 -0
- package/order.js +78 -0
- package/package.json +85 -0
- package/ref.js +52 -0
- package/registry.js +240 -0
- package/route-graph.js +115 -0
- package/router.js +831 -0
- package/rules.js +142 -0
- package/search-engines.js +81 -0
- package/sources-retrieval.js +189 -0
- package/sources.js +256 -0
- package/store.js +171 -0
- package/tool-groups.js +81 -0
- package/tool-need.js +96 -0
- package/trajectory.js +509 -0
- package/upcast.js +37 -0
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
|
+
}
|