dsh-model-pool 0.0.0-stage → 0.1.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/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +508 -2
- package/README.zh-CN.md +331 -0
- package/client.js +2847 -0
- package/cordis.patch.yml +15 -0
- package/docs/installation.md +115 -0
- package/docs/installation.zh-CN.md +107 -0
- package/icon.svg +17 -0
- package/index.js +718 -0
- package/lib/adapter.js +151 -0
- package/lib/capabilities.js +198 -0
- package/lib/catalog.js +175 -0
- package/lib/effort.js +151 -0
- package/lib/grouping.js +404 -0
- package/lib/health.js +175 -0
- package/lib/holiday-calendar.js +595 -0
- package/lib/peak.js +495 -0
- package/lib/router.js +584 -0
- package/lib/strategies.js +215 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +77 -4
package/lib/router.js
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The routing generator for `dsh-model-pool`.
|
|
3
|
+
*
|
|
4
|
+
* One `llm/stream` interception per pooled call. The listener in `index.js`
|
|
5
|
+
* hands every request naming the virtual pool route to `Router.route()`, which
|
|
6
|
+
* turns it into a sequence of concrete-provider attempts.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a listener and not only the adapter
|
|
9
|
+
*
|
|
10
|
+
* `LlmRuntime.streamWithRegistration` runs the `llm/stream` waterfall *before*
|
|
11
|
+
* `adapterStream` projects files to text, images to placeholders, and developer
|
|
12
|
+
* messages to tool declarations (`dsh-llm/lib/index.js:2396-2398` vs
|
|
13
|
+
* `:2309-2349`). Intercepting at the waterfall therefore hands the inner
|
|
14
|
+
* `ctx.llm.stream()` the raw request, and the runtime performs exactly one
|
|
15
|
+
* correct projection for the real target route. Routing inside the adapter
|
|
16
|
+
* would project twice and could drop tool-addition history.
|
|
17
|
+
*
|
|
18
|
+
* ## Failover boundary
|
|
19
|
+
*
|
|
20
|
+
* An attempt may only be replaced while no content reached the consumer.
|
|
21
|
+
* Block/usage chunks are held in a small buffer until the first content-bearing
|
|
22
|
+
* chunk arrives, so an eagerly emitted `block-start` does not disable failover,
|
|
23
|
+
* while a stream that already produced text is never replayed.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-model-pool/lib/router
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { order } from './strategies.js';
|
|
29
|
+
import { OFF_PEAK, PEAK, periodAt, priorityRanking, rankOf } from './peak.js';
|
|
30
|
+
import { canonicalEffortId, pickEffort } from './effort.js';
|
|
31
|
+
import { filterByModalities, requiredModalities } from './capabilities.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Display name advertised for one effort id in an effort list.
|
|
35
|
+
* @param {object[]|undefined} efforts - an advertised effort list.
|
|
36
|
+
* @param {string} id - the effort id to name.
|
|
37
|
+
* @returns {string|undefined} the advertised name, or undefined when the list has no such id.
|
|
38
|
+
*/
|
|
39
|
+
function effortDisplayName(efforts, id) {
|
|
40
|
+
const found = (Array.isArray(efforts) ? efforts : []).find(
|
|
41
|
+
(candidate) => candidate !== null && typeof candidate === "object" && candidate.id === id,
|
|
42
|
+
);
|
|
43
|
+
return found !== undefined && typeof found.name === "string" && found.name !== "" ? found.name : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Marks a failure that is safe to retry on the next candidate. */
|
|
47
|
+
class AttemptFailure extends Error {
|
|
48
|
+
/**
|
|
49
|
+
* @param {{ message: string, code: string, [key: string]: unknown }} failure - provider-neutral facts.
|
|
50
|
+
*/
|
|
51
|
+
constructor(failure) {
|
|
52
|
+
super(failure.message);
|
|
53
|
+
this.name = 'AttemptFailure';
|
|
54
|
+
this.failure = failure;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether one chunk carries model output that the consumer has already seen.
|
|
60
|
+
* `block-start` and `usage` carry no output, so they stay buffered.
|
|
61
|
+
* @param {object} chunk - one stream chunk.
|
|
62
|
+
* @returns {boolean} true once replaying the stream would duplicate output.
|
|
63
|
+
*/
|
|
64
|
+
function hasContent(chunk) {
|
|
65
|
+
switch (chunk.type) {
|
|
66
|
+
case 'text-delta':
|
|
67
|
+
case 'reasoning-delta':
|
|
68
|
+
return typeof chunk.text === 'string' && chunk.text !== '';
|
|
69
|
+
case 'tool-call-delta':
|
|
70
|
+
return (typeof chunk.argumentsDelta === 'string' && chunk.argumentsDelta !== '')
|
|
71
|
+
|| typeof chunk.name === 'string';
|
|
72
|
+
case 'block-end':
|
|
73
|
+
return chunk.block !== undefined && chunk.block !== null;
|
|
74
|
+
default:
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the terminal `finish` chunk for a routing failure.
|
|
81
|
+
* @param {{ message: string, code: string }} failure - provider-neutral facts.
|
|
82
|
+
* @returns {object} a terminal error chunk.
|
|
83
|
+
*/
|
|
84
|
+
function errorFinish(failure) {
|
|
85
|
+
return { type: 'finish', reason: { kind: 'error', failure } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the peak/idle period and the provider ranking that applies to it.
|
|
90
|
+
*
|
|
91
|
+
* Separate from `periodOf` so `candidatesFor` can resolve it from the config it
|
|
92
|
+
* was already handed, instead of re-reading the live config halfway through a
|
|
93
|
+
* decision. `peakEnabled: false` short-circuits to `none` rather than to an
|
|
94
|
+
* empty ranking, so Settings can tell "switched off" from "no list configured".
|
|
95
|
+
*
|
|
96
|
+
* @param {object} cfg - current plain config.
|
|
97
|
+
* @param {number} now - current epoch milliseconds.
|
|
98
|
+
* @returns {{ period: string, ranking: string[]|undefined, rankingSource: string }} the resolved period facts.
|
|
99
|
+
*/
|
|
100
|
+
function periodFacts(cfg, now) {
|
|
101
|
+
if (cfg.peakEnabled === false) return { period: OFF_PEAK, ranking: undefined, rankingSource: 'none' };
|
|
102
|
+
const period = periodAt(now, cfg);
|
|
103
|
+
const ranking = priorityRanking(period, cfg);
|
|
104
|
+
// Only the period's own list can produce a ranking, so the label names it. With
|
|
105
|
+
// no such list the feature is inert and `providerOrder` governs instead — an
|
|
106
|
+
// empty `providerOrder` there simply leaves registration order in charge.
|
|
107
|
+
const rankingSource = ranking === undefined
|
|
108
|
+
? 'providerOrder'
|
|
109
|
+
: period === PEAK ? 'peakProviderOrder' : 'offPeakProviderOrder';
|
|
110
|
+
return { period, ranking, rankingSource };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Stable re-order of candidates by their period rank.
|
|
115
|
+
*
|
|
116
|
+
* `Array#sort` is not required to be stable, and equal ranks must keep the
|
|
117
|
+
* `model.routes` order that the strategy treats as its own tie-break, so the
|
|
118
|
+
* input index is carried as the final key.
|
|
119
|
+
*
|
|
120
|
+
* @param {object[]} routes - candidates in their existing order.
|
|
121
|
+
* @param {string[]} ranking - effective provider ranking for the period.
|
|
122
|
+
* @returns {object[]} a new array, ranked first and otherwise untouched.
|
|
123
|
+
*/
|
|
124
|
+
function rankByPeriod(routes, ranking) {
|
|
125
|
+
return routes
|
|
126
|
+
.map((route, index) => ({ route, index }))
|
|
127
|
+
.sort((left, right) => rankOf(ranking, left.route.provider, left.route.priority)
|
|
128
|
+
- rankOf(ranking, right.route.provider, right.route.priority)
|
|
129
|
+
|| left.index - right.index)
|
|
130
|
+
.map((wrapped) => wrapped.route);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** How many routing decisions the state endpoint remembers. */
|
|
134
|
+
const RECENT_LIMIT = 20;
|
|
135
|
+
|
|
136
|
+
/** How many per-session route summaries are kept for the composer dock. */
|
|
137
|
+
const SESSION_LIMIT = 50;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One pool router bound to a live plugin instance.
|
|
141
|
+
*/
|
|
142
|
+
export class Router {
|
|
143
|
+
/**
|
|
144
|
+
* @param {object} options - router dependencies.
|
|
145
|
+
* @param {object} options.llm - the live `llm` service.
|
|
146
|
+
* @param {() => object} options.config - reads the current plain config.
|
|
147
|
+
* @param {() => object} options.snapshot - reads the current pool snapshot.
|
|
148
|
+
* @param {import('./health.js').HealthRegistry} options.health - live health registry.
|
|
149
|
+
* @param {object} [options.logger] - optional host logger.
|
|
150
|
+
*/
|
|
151
|
+
constructor({ llm, config, snapshot, health, logger }) {
|
|
152
|
+
this.llm = llm;
|
|
153
|
+
this.config = config;
|
|
154
|
+
this.snapshot = snapshot;
|
|
155
|
+
this.health = health;
|
|
156
|
+
this.logger = logger;
|
|
157
|
+
/** Round-robin cursor, shared by every pool model. */
|
|
158
|
+
this.roundRobin = { cursor: 0 };
|
|
159
|
+
/** Most recent routing decisions, newest last. */
|
|
160
|
+
this.recent = [];
|
|
161
|
+
/**
|
|
162
|
+
* Peak/idle facts of the most recent `candidatesFor` call: `{ at, period,
|
|
163
|
+
* ranking, rankingSource }`, or undefined before the first decision — a
|
|
164
|
+
* Settings read then says "nothing routed yet" instead of guessing a period.
|
|
165
|
+
*/
|
|
166
|
+
this.lastPeriod = undefined;
|
|
167
|
+
/**
|
|
168
|
+
* Last successful route per session, keyed by session id. The composer dock
|
|
169
|
+
* renders this so a user can see which concrete provider actually served
|
|
170
|
+
* their conversation, and the sticky strategy reads the same record to keep
|
|
171
|
+
* a conversation on one route. Bounded because sessions come and go.
|
|
172
|
+
*/
|
|
173
|
+
this.sessions = new Map();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Record the route that actually served one session's most recent call.
|
|
178
|
+
* @param {string|undefined} sessionId - the calling session, when known.
|
|
179
|
+
* @param {object} model - the pool model that was selected.
|
|
180
|
+
* @param {object} route - the concrete route that produced content.
|
|
181
|
+
* @param {string|undefined} effort - the reasoning effort actually sent to the provider.
|
|
182
|
+
* @param {string|undefined} requestedEffort - the effort the composer asked for.
|
|
183
|
+
* @param {string|undefined} effortName - the provider's display name for `effort`.
|
|
184
|
+
* @returns {void}
|
|
185
|
+
*/
|
|
186
|
+
recordSession(sessionId, model, route, effort, requestedEffort, effortName) {
|
|
187
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
188
|
+
this.sessions.delete(sessionId);
|
|
189
|
+
const entry = {
|
|
190
|
+
at: Date.now(),
|
|
191
|
+
model: model.id,
|
|
192
|
+
modelName: model.name,
|
|
193
|
+
// Names first: the dock shows these, never the ids, so a surface that only
|
|
194
|
+
// reads the display fields stays correct without re-reading the catalog.
|
|
195
|
+
provider: route.provider,
|
|
196
|
+
providerName: route.providerName,
|
|
197
|
+
providerModel: route.model,
|
|
198
|
+
providerModelName: route.name,
|
|
199
|
+
};
|
|
200
|
+
// `effort` is what the provider received. `requestedEffort` is what the
|
|
201
|
+
// composer said, kept only when the two mean different tiers so the UI can
|
|
202
|
+
// say so. Compared on the canonical tier, because `off` and `disabled` are
|
|
203
|
+
// the same request and must not look like an adjustment.
|
|
204
|
+
if (effort !== undefined) entry.effort = effort;
|
|
205
|
+
if (typeof effortName === "string" && effortName !== "") entry.effortName = effortName;
|
|
206
|
+
const sameTier = typeof requestedEffort === "string" && canonicalEffortId(requestedEffort) === canonicalEffortId(effort);
|
|
207
|
+
if (typeof requestedEffort === "string" && requestedEffort !== "" && !sameTier) {
|
|
208
|
+
entry.requestedEffort = requestedEffort;
|
|
209
|
+
// Resolve the requested tier's display name from the list the composer showed
|
|
210
|
+
// (the pool's union) first: the target route need not advertise that tier, and
|
|
211
|
+
// the badge should then still say `Off` rather than a bare `off`.
|
|
212
|
+
const requestedName = effortDisplayName(model.reasoning?.efforts, requestedEffort)
|
|
213
|
+
?? effortDisplayName(route.reasoning?.efforts, requestedEffort)
|
|
214
|
+
?? requestedEffort;
|
|
215
|
+
entry.requestedEffortName = requestedName;
|
|
216
|
+
}
|
|
217
|
+
this.sessions.set(sessionId, entry);
|
|
218
|
+
// Insertion order doubles as recency, so the oldest key is the eviction.
|
|
219
|
+
while (this.sessions.size > SESSION_LIMIT) {
|
|
220
|
+
const oldest = this.sessions.keys().next();
|
|
221
|
+
if (oldest.done === true) break;
|
|
222
|
+
this.sessions.delete(oldest.value);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read the last route a session used, or undefined when it never used the pool.
|
|
228
|
+
* @param {string|undefined} sessionId - session to look up.
|
|
229
|
+
* @returns {object|undefined} the recorded route summary.
|
|
230
|
+
*/
|
|
231
|
+
sessionOf(sessionId) {
|
|
232
|
+
if (typeof sessionId !== "string" || sessionId === "") return undefined;
|
|
233
|
+
return this.sessions.get(sessionId);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every live session summary, newest last.
|
|
238
|
+
* @returns {object[]} entries shaped as `{ sessionId, ...summary }`.
|
|
239
|
+
*/
|
|
240
|
+
sessionEntries() {
|
|
241
|
+
return [...this.sessions].map(([sessionId, value]) => ({ sessionId, ...value }));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Record one routing decision for the Settings page.
|
|
246
|
+
* @param {object} entry - decision facts.
|
|
247
|
+
*/
|
|
248
|
+
remember(entry) {
|
|
249
|
+
this.recent.push(entry);
|
|
250
|
+
if (this.recent.length > RECENT_LIMIT) this.recent.splice(0, this.recent.length - RECENT_LIMIT);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Order one pool model's candidates under the configured strategy and health.
|
|
255
|
+
* @param {object} cfg - current plain config.
|
|
256
|
+
* @param {object} model - the pool model.
|
|
257
|
+
* @param {number} now - current epoch milliseconds.
|
|
258
|
+
* @param {string|undefined} sessionId - the calling session, for sticky routing.
|
|
259
|
+
* @param {string[]} [required] - input modalities the request actually carries.
|
|
260
|
+
* @returns {object[]} candidate routes, best first.
|
|
261
|
+
*/
|
|
262
|
+
candidatesFor(cfg, model, now, sessionId, required) {
|
|
263
|
+
const routes = model.routes.filter((route) => route.provider !== cfg.providerId);
|
|
264
|
+
// The pool advertises the UNION of its candidates' modalities, so the promise
|
|
265
|
+
// that an image is accepted is only kept by choosing an image-capable route.
|
|
266
|
+
// The filter runs first: narrowing after ordering could pick a body that the
|
|
267
|
+
// strategy ranked first but that cannot see the screenshot.
|
|
268
|
+
let usable = required === undefined ? routes : filterByModalities(routes, required);
|
|
269
|
+
if (cfg.skipUnavailable === true) {
|
|
270
|
+
// Narrow whatever the modality filter left, never the raw list: falling
|
|
271
|
+
// back to `routes` here would quietly restore a text-only route to an
|
|
272
|
+
// image call, which is the exact mismatch this filter exists to prevent.
|
|
273
|
+
const capable = usable;
|
|
274
|
+
const available = capable.filter((route) => this.health.isAvailable(route.provider, route.model, now, cfg.cooldownMs));
|
|
275
|
+
// A pool whose every candidate is cooling down still has to try: refusing
|
|
276
|
+
// outright would turn a transient outage into a hard failure.
|
|
277
|
+
if (available.length > 0) usable = available;
|
|
278
|
+
}
|
|
279
|
+
// The peak/idle overlay is applied to the candidate list *before* the
|
|
280
|
+
// strategy runs, so every strategy keeps its own key and inherits the period
|
|
281
|
+
// order as its stable tie-break; `priority` passes it straight through. The
|
|
282
|
+
// period is resolved once here, from the same `now` the rest of this call
|
|
283
|
+
// reasons about, so one decision is reproducible and cannot straddle a flip.
|
|
284
|
+
const { period, ranking, rankingSource } = periodFacts(cfg, now);
|
|
285
|
+
this.lastPeriod = { at: now, period, ranking, rankingSource };
|
|
286
|
+
if (ranking !== undefined) usable = rankByPeriod(usable, ranking);
|
|
287
|
+
const ordered = order(cfg.strategy, usable, {
|
|
288
|
+
now,
|
|
289
|
+
health: this.health,
|
|
290
|
+
roundRobin: this.roundRobin,
|
|
291
|
+
weights: cfg.weights,
|
|
292
|
+
costs: cfg.costs,
|
|
293
|
+
random: Math.random,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Sticky routing: a session that already ran on one route keeps it, so the
|
|
297
|
+
// provider's prompt cache stays warm across turns. The binding is only a
|
|
298
|
+
// preference — it moves the remembered route to the front of the list that
|
|
299
|
+
// the strategy produced, so failover still walks the other candidates and a
|
|
300
|
+
// route that went unhealthy simply stops matching.
|
|
301
|
+
const bound = cfg.sticky === true ? this.stickyRouteFor(sessionId, model, ordered) : undefined;
|
|
302
|
+
const finalOrder = bound === undefined
|
|
303
|
+
? ordered
|
|
304
|
+
: [bound, ...ordered.filter((route) => route !== bound)];
|
|
305
|
+
|
|
306
|
+
const limit = Math.max(1, Math.min(finalOrder.length, cfg.maxAttempts));
|
|
307
|
+
return finalOrder.slice(0, limit);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Resolve the period and provider ranking one instant would route under.
|
|
312
|
+
*
|
|
313
|
+
* Exposed for Settings: it answers "what would the pool do right now" without
|
|
314
|
+
* running a call, and it reads the live config so the answer tracks an edited
|
|
315
|
+
* policy. `candidatesFor` resolves the same facts from its own `cfg`/`now`, so
|
|
316
|
+
* the two can never disagree about an in-flight decision.
|
|
317
|
+
*
|
|
318
|
+
* @param {number} now - current epoch milliseconds.
|
|
319
|
+
* @returns {{ period: string, ranking: string[]|undefined, rankingSource: string }} the resolved period facts.
|
|
320
|
+
*/
|
|
321
|
+
periodOf(now) {
|
|
322
|
+
return periodFacts(this.config(), now);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Find the remembered route for a session within this model's candidates.
|
|
327
|
+
* @param {string|undefined} sessionId - the calling session.
|
|
328
|
+
* @param {object} model - the pool model being routed.
|
|
329
|
+
* @param {object[]} candidates - the strategy-ordered candidates.
|
|
330
|
+
* @returns {object|undefined} the candidate to pin first, when the session already has one.
|
|
331
|
+
*/
|
|
332
|
+
stickyRouteFor(sessionId, model, candidates) {
|
|
333
|
+
const entry = this.sessionOf(sessionId);
|
|
334
|
+
if (entry === undefined) return undefined;
|
|
335
|
+
// A pooled model that was re-selected with a different id must not inherit
|
|
336
|
+
// the previous model's route.
|
|
337
|
+
if (entry.model !== model.id) return undefined;
|
|
338
|
+
return candidates.find(
|
|
339
|
+
(route) => route.provider === entry.provider && route.model === entry.providerModel,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Run one concrete attempt, yielding its chunks.
|
|
345
|
+
*
|
|
346
|
+
* Throws `AttemptFailure` when the attempt failed before producing content,
|
|
347
|
+
* which is the only case the caller may retry.
|
|
348
|
+
*
|
|
349
|
+
* @param {object} options - the original pooled request.
|
|
350
|
+
* @param {object} route - the concrete candidate.
|
|
351
|
+
* @param {object} cfg - current plain config.
|
|
352
|
+
* @param {object} report - per-attempt facts; `commit` is invoked once, before the
|
|
353
|
+
* terminal chunk is yielded, so the caller can record the outcome without depending
|
|
354
|
+
* on the consumer draining this generator.
|
|
355
|
+
* @returns {AsyncGenerator<object>} the attempt's chunk stream.
|
|
356
|
+
*/
|
|
357
|
+
async *attempt(options, route, cfg, report) {
|
|
358
|
+
// The pooled request carries the *selection* id. What reaches the provider is
|
|
359
|
+
// the tier this route actually supports, which may differ (a gateway without
|
|
360
|
+
// `off` gets `low`, `xhigh` collapses to `max`). Report the resolved value so
|
|
361
|
+
// the UI shows what really ran instead of what was asked for.
|
|
362
|
+
const effort = pickEffort(route.reasoning?.efforts, options.reasoningEffort);
|
|
363
|
+
if (report !== undefined && report !== null) {
|
|
364
|
+
report.effort = effort;
|
|
365
|
+
// The route advertises a display name per tier; carry it so the UI never
|
|
366
|
+
// has to show a raw id like `xhigh`.
|
|
367
|
+
const chosen = (Array.isArray(route.reasoning?.efforts) ? route.reasoning.efforts : []).find(
|
|
368
|
+
(candidate) => candidate !== null && typeof candidate === "object" && candidate.id === effort,
|
|
369
|
+
);
|
|
370
|
+
if (chosen !== undefined && typeof chosen.name === "string" && chosen.name !== "") {
|
|
371
|
+
report.effortName = chosen.name;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Settle the route before the terminal chunk is handed to the consumer.
|
|
375
|
+
// An `llm/stream` wrapper above us (an answer-rewriting plugin, for one) may
|
|
376
|
+
// stop at `finish` and close the iterator instead of draining it, so nothing
|
|
377
|
+
// after our own last `yield` is guaranteed to run. Recording the session and
|
|
378
|
+
// the routing decision here makes the observation independent of whether the
|
|
379
|
+
// consumer asks for another chunk.
|
|
380
|
+
const settle = () => {
|
|
381
|
+
if (report === undefined || report === null || report.settled === true) return;
|
|
382
|
+
report.settled = true;
|
|
383
|
+
report.commit?.();
|
|
384
|
+
};
|
|
385
|
+
const { provider: _provider, model: _model, reasoningEffort: _effort, ...rest } = options;
|
|
386
|
+
const request = {
|
|
387
|
+
...rest,
|
|
388
|
+
provider: route.provider,
|
|
389
|
+
model: route.model,
|
|
390
|
+
...effort === undefined ? {} : { reasoningEffort: effort },
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const startedAt = Date.now();
|
|
394
|
+
let firstChunkAt;
|
|
395
|
+
let committed = false;
|
|
396
|
+
const buffered = [];
|
|
397
|
+
let sawFinish = false;
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
for await (const chunk of this.llm.stream(request)) {
|
|
401
|
+
if (firstChunkAt === undefined) firstChunkAt = Date.now();
|
|
402
|
+
if (chunk.type === 'finish') {
|
|
403
|
+
sawFinish = true;
|
|
404
|
+
const reason = chunk.reason;
|
|
405
|
+
if (reason?.kind === 'error' && !committed) {
|
|
406
|
+
this.health.recordFailure(route.provider, route.model, reason.failure?.code ?? 'ERROR');
|
|
407
|
+
throw new AttemptFailure(reason.failure ?? { message: 'provider failed', code: 'ERROR' });
|
|
408
|
+
}
|
|
409
|
+
if (reason?.kind === 'error') {
|
|
410
|
+
// Content already reached the consumer: forward the failure instead
|
|
411
|
+
// of replaying a partial answer.
|
|
412
|
+
this.health.recordFailure(route.provider, route.model, reason.failure?.code ?? 'ERROR');
|
|
413
|
+
settle();
|
|
414
|
+
for (const held of buffered) yield held;
|
|
415
|
+
yield chunk;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (reason?.kind === 'aborted') {
|
|
419
|
+
settle();
|
|
420
|
+
for (const held of buffered) yield held;
|
|
421
|
+
yield chunk;
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
this.health.recordSuccess(route.provider, route.model, firstChunkAt === undefined ? 0 : firstChunkAt - startedAt);
|
|
425
|
+
settle();
|
|
426
|
+
for (const held of buffered) yield held;
|
|
427
|
+
yield chunk;
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (committed) {
|
|
432
|
+
yield chunk;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
buffered.push(chunk);
|
|
436
|
+
if (hasContent(chunk)) {
|
|
437
|
+
committed = true;
|
|
438
|
+
for (const held of buffered) yield held;
|
|
439
|
+
buffered.length = 0;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (error instanceof AttemptFailure) throw error;
|
|
444
|
+
if (options.signal?.aborted) throw error;
|
|
445
|
+
this.health.recordFailure(route.provider, route.model, 'STREAM_FAILED');
|
|
446
|
+
throw new AttemptFailure({
|
|
447
|
+
message: error instanceof Error ? error.message : String(error),
|
|
448
|
+
code: 'STREAM_FAILED',
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// A stream that ended without a terminal chunk is a provider bug. Treat it
|
|
453
|
+
// as a retryable failure when nothing was shown, and as a normal end when
|
|
454
|
+
// content was already delivered — but always close the protocol, because
|
|
455
|
+
// every consumer expects exactly one terminal `finish` chunk.
|
|
456
|
+
if (!sawFinish) {
|
|
457
|
+
if (committed) {
|
|
458
|
+
settle();
|
|
459
|
+
for (const held of buffered) yield held;
|
|
460
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
this.health.recordFailure(route.provider, route.model, 'NO_FINISH');
|
|
464
|
+
throw new AttemptFailure({ message: `provider "${route.provider}" ended its stream without a finish chunk`, code: 'NO_FINISH' });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Route one pooled request to a concrete provider.
|
|
470
|
+
* @param {object} options - the pooled `GenerateOptions`.
|
|
471
|
+
* @returns {AsyncGenerator<object>} the chunk stream the agent loop consumes.
|
|
472
|
+
*/
|
|
473
|
+
async *route(options) {
|
|
474
|
+
// Diagnostic breadcrumb: the effort the runtime handed us, before any
|
|
475
|
+
// mapping. Without it a mismatch is indistinguishable from a bad selection.
|
|
476
|
+
// The carried modalities ride along so a route that was skipped (or a text-only
|
|
477
|
+
// route that was never eligible) is explainable from the Settings payload
|
|
478
|
+
// instead of looking like an arbitrary choice.
|
|
479
|
+
const carried = requiredModalities(options.messages);
|
|
480
|
+
this.lastInbound = {
|
|
481
|
+
at: Date.now(),
|
|
482
|
+
model: options.model,
|
|
483
|
+
reasoningEffort: options.reasoningEffort,
|
|
484
|
+
sessionId: options.sessionId,
|
|
485
|
+
purpose: options.purpose ?? null,
|
|
486
|
+
requiredModalities: carried,
|
|
487
|
+
};
|
|
488
|
+
const cfg = this.config();
|
|
489
|
+
const snapshot = this.snapshot();
|
|
490
|
+
const model = snapshot.models.find((candidate) => candidate.id === options.model);
|
|
491
|
+
|
|
492
|
+
if (model === undefined) {
|
|
493
|
+
this.remember({ at: Date.now(), model: options.model, outcome: 'unknown-model', provider: undefined });
|
|
494
|
+
yield errorFinish({
|
|
495
|
+
message: `the model pool has no model "${options.model}"; it may have been removed from every provider — refresh the pool or pick another model`,
|
|
496
|
+
code: 'NO_ADAPTER',
|
|
497
|
+
});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// The modalities the request actually carries decide which candidates are
|
|
502
|
+
// eligible. A text-only call keeps every candidate; a screenshot must reach a
|
|
503
|
+
// route that can see it, because the pool told the user this model takes images.
|
|
504
|
+
const candidates = this.candidatesFor(cfg, model, Date.now(), options.sessionId, carried);
|
|
505
|
+
// Captured synchronously: `lastPeriod` is shared state, and a concurrent call
|
|
506
|
+
// may overwrite it before this one settles. The record must keep describing
|
|
507
|
+
// the period this decision was actually made under.
|
|
508
|
+
const { period, rankingSource } = this.lastPeriod;
|
|
509
|
+
if (candidates.length === 0) {
|
|
510
|
+
this.remember({ at: Date.now(), model: options.model, outcome: 'no-candidate', provider: undefined });
|
|
511
|
+
yield errorFinish({
|
|
512
|
+
message: `no provider currently serves "${options.model}" in the model pool`,
|
|
513
|
+
code: 'NO_ADAPTER',
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let lastFailure;
|
|
519
|
+
for (const route of candidates) {
|
|
520
|
+
if (options.signal?.aborted) break;
|
|
521
|
+
try {
|
|
522
|
+
const report = {};
|
|
523
|
+
// Auxiliary calls (session titles, compaction summaries) share the session's
|
|
524
|
+
// route but are not what the conversation ran on: they pick their own effort
|
|
525
|
+
// — usually `off`, which a route without an `off` tier maps to its cheapest
|
|
526
|
+
// tier. Letting one define the session record would report `Low` for a
|
|
527
|
+
// conversation that actually ran `High`, so they only reach `recent`.
|
|
528
|
+
const auxiliary = typeof options.purpose === 'string' && options.purpose !== '';
|
|
529
|
+
// Success is settled inside `attempt`, right before it yields the terminal
|
|
530
|
+
// chunk, rather than after `yield*` returns: a consumer above us may stop at
|
|
531
|
+
// `finish` and close the iterator, so the tail of this generator is not
|
|
532
|
+
// guaranteed to run. Settling first keeps the session dock and sticky
|
|
533
|
+
// routing correct under such a wrapper.
|
|
534
|
+
report.commit = () => {
|
|
535
|
+
this.remember({
|
|
536
|
+
at: Date.now(),
|
|
537
|
+
model: options.model,
|
|
538
|
+
outcome: 'routed',
|
|
539
|
+
provider: route.provider,
|
|
540
|
+
providerModel: route.model,
|
|
541
|
+
// Which period governed the ordering, and which list supplied the
|
|
542
|
+
// ranking, so Settings can explain the choice instead of implying it.
|
|
543
|
+
period,
|
|
544
|
+
rankingSource,
|
|
545
|
+
...auxiliary ? { purpose: options.purpose } : {},
|
|
546
|
+
});
|
|
547
|
+
if (auxiliary) return;
|
|
548
|
+
// Record what this session actually ran on, so the composer dock can
|
|
549
|
+
// show the concrete provider behind the pool. `report.effort` is the tier
|
|
550
|
+
// that reached the provider, not the one the user selected.
|
|
551
|
+
this.recordSession(options.sessionId, model, route, report.effort, options.reasoningEffort, report.effortName);
|
|
552
|
+
};
|
|
553
|
+
yield* this.attempt(options, route, cfg, report);
|
|
554
|
+
// Reached only when the consumer drained the generator. A consumer that
|
|
555
|
+
// stopped at the terminal chunk already settled it inside `attempt`.
|
|
556
|
+
if (report.settled !== true) {
|
|
557
|
+
report.settled = true;
|
|
558
|
+
report.commit();
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (!(error instanceof AttemptFailure)) throw error;
|
|
563
|
+
lastFailure = error.failure;
|
|
564
|
+
this.logger?.debug?.(
|
|
565
|
+
'model-pool: %s via %s failed (%s), trying the next candidate',
|
|
566
|
+
options.model,
|
|
567
|
+
route.provider,
|
|
568
|
+
error.failure.code,
|
|
569
|
+
);
|
|
570
|
+
if (cfg.fallback !== true) break;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const failure = lastFailure ?? { message: 'the model pool request was cancelled', code: 'ABORTED' };
|
|
575
|
+
this.remember({
|
|
576
|
+
at: Date.now(),
|
|
577
|
+
model: options.model,
|
|
578
|
+
outcome: options.signal?.aborted ? 'aborted' : 'failed',
|
|
579
|
+
provider: candidates.at(-1)?.provider,
|
|
580
|
+
reason: failure.code,
|
|
581
|
+
});
|
|
582
|
+
yield errorFinish(failure);
|
|
583
|
+
}
|
|
584
|
+
}
|