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/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
+ }