llm-relay 0.80.0 → 0.82.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.
Files changed (97) hide show
  1. package/dist/backend.d.ts +43 -3
  2. package/dist/backend.js +0 -0
  3. package/dist/backend.js.map +1 -1
  4. package/dist/candidate-runner.d.ts +45 -9
  5. package/dist/candidate-runner.js +55 -22
  6. package/dist/candidate-runner.js.map +1 -1
  7. package/dist/catalog.d.ts +49 -0
  8. package/dist/catalog.js +18 -0
  9. package/dist/catalog.js.map +1 -1
  10. package/dist/circuit-breaker.d.ts +118 -1
  11. package/dist/circuit-breaker.js +96 -1
  12. package/dist/circuit-breaker.js.map +1 -1
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +23 -3
  15. package/dist/cli.js.map +1 -1
  16. package/dist/config/routing-parser.d.ts +1 -1
  17. package/dist/config/routing-parser.js +27 -1
  18. package/dist/config/routing-parser.js.map +1 -1
  19. package/dist/config-edit.js +70 -16
  20. package/dist/config-edit.js.map +1 -1
  21. package/dist/config-types.d.ts +62 -6
  22. package/dist/config-types.js +1 -1
  23. package/dist/config-types.js.map +1 -1
  24. package/dist/dashboard/.vite/manifest.json +2 -2
  25. package/dist/dashboard/assets/index-B2UQHoym.js +74 -0
  26. package/dist/dashboard/assets/index-DFEP3XPr.css +1 -0
  27. package/dist/dashboard/index.html +2 -2
  28. package/dist/dispatch-lane-stats.d.ts +59 -5
  29. package/dist/dispatch-lane-stats.js +96 -29
  30. package/dist/dispatch-lane-stats.js.map +1 -1
  31. package/dist/dispatch.d.ts +143 -1
  32. package/dist/dispatch.js +260 -98
  33. package/dist/dispatch.js.map +1 -1
  34. package/dist/lane-affinity.d.ts +8 -0
  35. package/dist/lane-affinity.js +10 -0
  36. package/dist/lane-affinity.js.map +1 -1
  37. package/dist/log.d.ts +16 -0
  38. package/dist/log.js +1 -0
  39. package/dist/log.js.map +1 -1
  40. package/dist/mcp/agy-quota-log.d.ts +37 -0
  41. package/dist/mcp/agy-quota-log.js +68 -0
  42. package/dist/mcp/agy-quota-log.js.map +1 -0
  43. package/dist/mcp/job-archive.d.ts +55 -0
  44. package/dist/mcp/job-archive.js +151 -0
  45. package/dist/mcp/job-archive.js.map +1 -0
  46. package/dist/mcp/lane-runner.d.ts +108 -5
  47. package/dist/mcp/lane-runner.js +93 -4
  48. package/dist/mcp/lane-runner.js.map +1 -1
  49. package/dist/mcp/readonly-boundary.d.ts +46 -0
  50. package/dist/mcp/readonly-boundary.js +119 -0
  51. package/dist/mcp/readonly-boundary.js.map +1 -1
  52. package/dist/mcp/server.d.ts +132 -8
  53. package/dist/mcp/server.js +267 -68
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/openai-request.d.ts +8 -0
  56. package/dist/openai-request.js +33 -9
  57. package/dist/openai-request.js.map +1 -1
  58. package/dist/pacing.d.ts +125 -0
  59. package/dist/pacing.js +83 -0
  60. package/dist/pacing.js.map +1 -0
  61. package/dist/ping/cadence.d.ts +57 -0
  62. package/dist/ping/cadence.js +58 -0
  63. package/dist/ping/cadence.js.map +1 -1
  64. package/dist/refusal-interpretation.d.ts +24 -0
  65. package/dist/refusal-interpretation.js +10 -1
  66. package/dist/refusal-interpretation.js.map +1 -1
  67. package/dist/responses-request.d.ts +14 -1
  68. package/dist/responses-request.js +21 -4
  69. package/dist/responses-request.js.map +1 -1
  70. package/dist/routes/admin.d.ts +8 -0
  71. package/dist/routes/admin.js +122 -35
  72. package/dist/routes/admin.js.map +1 -1
  73. package/dist/routes/messages.d.ts +2 -0
  74. package/dist/routes/messages.js +7 -3
  75. package/dist/routes/messages.js.map +1 -1
  76. package/dist/routes/openai-front.d.ts +1 -0
  77. package/dist/routes/openai-front.js +7 -3
  78. package/dist/routes/openai-front.js.map +1 -1
  79. package/dist/server.d.ts +4 -0
  80. package/dist/server.js +28 -4
  81. package/dist/server.js.map +1 -1
  82. package/dist/setup-claude.d.ts +2 -2
  83. package/dist/setup-claude.js +3 -3
  84. package/dist/stream-commit.d.ts +65 -0
  85. package/dist/stream-commit.js +150 -8
  86. package/dist/stream-commit.js.map +1 -1
  87. package/dist/target-facts.js +3 -0
  88. package/dist/target-facts.js.map +1 -1
  89. package/dist/telemetry.d.ts +9 -1
  90. package/dist/telemetry.js +1 -1
  91. package/dist/telemetry.js.map +1 -1
  92. package/package.json +1 -1
  93. package/scripts/install-skill.mjs +2 -2
  94. package/skills/llm-relay/SKILL.md +5 -3
  95. package/skills/llm-relay/references/dispatch-lanes.md +8 -2
  96. package/dist/dashboard/assets/index-Bqtx14ES.css +0 -1
  97. package/dist/dashboard/assets/index-CetZdLCA.js +0 -74
package/dist/dispatch.js CHANGED
@@ -1,7 +1,9 @@
1
- import { expandPoolSpecs, offloadRule, splitSpec, POOL_PREFIX } from "./config.js";
1
+ import { existsSync } from "node:fs";
2
+ import { AUTO_MODEL, expandPoolSpecs, offloadRule, splitSpec, POOL_PREFIX } from "./config.js";
2
3
  import { unsupportedArgValues, verifyModel } from "./lane-manifest.js";
3
- import { allLaneStats, laneStatsFor, medianWallClockMs, p95WallClockMs, quantileWallClockMs } from "./dispatch-lane-stats.js";
4
+ import { allLaneStats, DISPATCH_MODES, laneStatsFor, medianWallClockMs, p95WallClockMs, quantileWallClockMs } from "./dispatch-lane-stats.js";
4
5
  import { laneDemotion, lanePin } from "./lane-affinity.js";
6
+ import { relayStatePath } from "./state-paths.js";
5
7
  export const DEFAULT_EXHAUSTED_MS = 15 * 60 * 1000;
6
8
  export const MAX_EXHAUSTED_MS = 30 * 24 * 60 * 60 * 1000;
7
9
  export const OUTCOME_DEFAULT_MS = {
@@ -9,6 +11,9 @@ export const OUTCOME_DEFAULT_MS = {
9
11
  quota_exhausted: 60 * 60 * 1000,
10
12
  };
11
13
  const MAX_ECHOED_ID = 120;
14
+ export const MAX_ATTEMPT_BUDGET_MS = 3_600_000;
15
+ export const LANE_UNRELIABLE_STREAK = 3;
16
+ export const FAILING_LANE_STREAK = 5;
12
17
  export function formatLaneStats(stats) {
13
18
  const seconds = (ms) => (ms === null ? "n/a" : `${Math.round(ms / 100) / 10}s`);
14
19
  return (`stats: ${stats.calls} calls, ${stats.successes} ok, ${stats.failures} failed, ` +
@@ -17,15 +22,19 @@ export function formatLaneStats(stats) {
17
22
  export function formatAttemptBudget(budget) {
18
23
  const render = (ms) => `${Math.round(ms / 100) / 10}s`;
19
24
  const seconds = render(budget.ms);
25
+ const raised = budget.raisedBy
26
+ ? `; raised after ${budget.raisedBy} abandoned run${budget.raisedBy === 1 ? "" : "s"}, ` +
27
+ `×2 each, at most ${render(MAX_ATTEMPT_BUDGET_MS)}`
28
+ : "";
20
29
  switch (budget.basis) {
21
30
  case "history":
22
- return `budget: ${seconds} (from ${budget.samples} recorded runs)`;
31
+ return `budget: ${seconds} (from ${budget.samples} recorded runs${raised})`;
23
32
  case "clamped":
24
33
  return budget.quantileMs === undefined
25
- ? `budget: ${seconds} (flat floor — this lane's ${budget.samples} runs are faster)`
26
- : `budget: ${seconds} (flat floor — this lane's ${budget.samples} runs put it at ${render(budget.quantileMs)})`;
34
+ ? `budget: ${seconds} (flat floor — this lane's ${budget.samples} runs are faster${raised})`
35
+ : `budget: ${seconds} (flat floor — this lane's ${budget.samples} runs put it at ${render(budget.quantileMs)}${raised})`;
27
36
  case "floor":
28
- return `budget: ${seconds} (flat — too few recorded runs, ${budget.samples})`;
37
+ return `budget: ${seconds} (flat — too few recorded runs, ${budget.samples}${raised})`;
29
38
  default: {
30
39
  const _never = budget.basis;
31
40
  return _never;
@@ -64,9 +73,7 @@ function clampExhaustedDeadline(untilMs, now) {
64
73
  return Math.min(Math.max(now, untilMs), now + MAX_EXHAUSTED_MS);
65
74
  }
66
75
  export function markExhausted(cfg, id, ttlMs = DEFAULT_EXHAUSTED_MS, tier) {
67
- if (typeof id !== "string" || id.length === 0)
68
- return false;
69
- const rung = selectLadder(cfg, tier).rungs.find((r) => r.id === id);
76
+ const { rung } = lookupLadderRung(cfg, id, tier);
70
77
  if (!rung)
71
78
  return false;
72
79
  markExhaustedKey(cfg, cooldownKey(rung), Date.now() + normalizeTtl(ttlMs));
@@ -81,12 +88,17 @@ export function clearExhausted(cfg, id, tier) {
81
88
  }
82
89
  return;
83
90
  }
84
- if (typeof id !== "string")
85
- return;
86
- const rung = selectLadder(cfg, tier).rungs.find((r) => r.id === id);
91
+ const { rung } = lookupLadderRung(cfg, id, tier);
87
92
  if (rung)
88
93
  clearExhaustedKey(cfg, cooldownKey(rung));
89
94
  }
95
+ export function lookupLadderRung(cfg, laneId, tier) {
96
+ const selected = selectLadder(cfg, tier);
97
+ const rung = typeof laneId === "string" && laneId.length > 0
98
+ ? selected.rungs.find((r) => r.id === laneId)
99
+ : undefined;
100
+ return { tier: selected.tier, rung, missingTier: selected.missing };
101
+ }
90
102
  const exhaustionListeners = new WeakMap();
91
103
  export function onExhaustionChanged(cfg, listener) {
92
104
  let set = exhaustionListeners.get(cfg);
@@ -192,6 +204,13 @@ function reachableWithoutRelay(spec, cfg) {
192
204
  provider.credentialMode !== "contained");
193
205
  });
194
206
  }
207
+ export function isPassThroughSpec(spec, cfg) {
208
+ return reachableWithoutRelay(spec, cfg);
209
+ }
210
+ export function mcpPassThroughReason(spec) {
211
+ return (`the MCP server cannot run "${describeId(spec)}": it forwards the caller's own Anthropic credential, ` +
212
+ "which only the calling host holds, so the host must reach it with its own subagent");
213
+ }
195
214
  function transposeToCli(spec, lane, task, platform, contextWindow) {
196
215
  const fillShared = (value) => {
197
216
  const withSpec = value.split(SPEC_TOKEN).join(spec);
@@ -223,6 +242,36 @@ function transposeToCli(spec, lane, task, platform, contextWindow) {
223
242
  export function normalizeCliCommand(command, platform = process.platform) {
224
243
  return platform === "win32" && /^agy$/i.test(command) ? `${command}.exe` : command;
225
244
  }
245
+ export function resolveLaneLauncherPath(platform = process.platform, exists) {
246
+ if (platform !== "win32")
247
+ return null;
248
+ if (exists === undefined && process.env["VITEST"])
249
+ return null;
250
+ const path = relayStatePath("config", ["bin", "lane-launch.ps1"]);
251
+ return (exists ?? existsSync)(path) ? path : null;
252
+ }
253
+ function isAlreadyLaneLaunched(command, args) {
254
+ if (!/^(pwsh|powershell)(\.exe)?$/i.test(command))
255
+ return false;
256
+ const fileIdx = args.findIndex((a) => a === "-File" || a === "/File");
257
+ if (fileIdx === -1)
258
+ return false;
259
+ const target = args[fileIdx + 1];
260
+ if (!target)
261
+ return false;
262
+ const basename = target.split(/[\\/]/).pop() ?? "";
263
+ return /^lane-launch\.ps1$/i.test(basename);
264
+ }
265
+ function wrapForWindowsConsoleSafety(invoke, launcherPath, platform) {
266
+ if (platform !== "win32" || launcherPath === null)
267
+ return invoke;
268
+ if (isAlreadyLaneLaunched(invoke.command, invoke.args))
269
+ return invoke;
270
+ return {
271
+ command: "pwsh",
272
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", launcherPath, invoke.command, ...invoke.args],
273
+ };
274
+ }
226
275
  function normalizeOptions(opts) {
227
276
  const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
228
277
  const out = {};
@@ -253,6 +302,14 @@ function normalizeOptions(opts) {
253
302
  if (manifest && typeof manifest === "object" && manifest.version === 1 && typeof manifest.lanes === "object" && manifest.lanes !== null) {
254
303
  out.manifest = manifest;
255
304
  }
305
+ if (opts.requester === "mcp")
306
+ out.requester = "mcp";
307
+ const mode = str(opts.mode);
308
+ if (mode !== undefined && DISPATCH_MODES.includes(mode))
309
+ out.mode = mode;
310
+ const model = str(opts.model);
311
+ if (model !== undefined && model.length <= 200)
312
+ out.model = model;
256
313
  return out;
257
314
  }
258
315
  function inferredTier(cfg) {
@@ -294,7 +351,7 @@ function stripControlCharacters(value) {
294
351
  }
295
352
  return out;
296
353
  }
297
- function describeId(id) {
354
+ export function describeId(id) {
298
355
  const clean = stripControlCharacters(id);
299
356
  return clean.length > MAX_ECHOED_ID ? `${clean.slice(0, MAX_ECHOED_ID)}\u2026` : clean;
300
357
  }
@@ -309,7 +366,7 @@ function unreachableReason(host, who) {
309
366
  ? `${who} is not running inside a Claude Code session, so it has no subagent mechanism to reach`
310
367
  : `${who} does not route its traffic through this relay, so a subagent cannot reach`;
311
368
  }
312
- function toLane(rung, position, cfg, opts, now, client, platform, host, entrypoint) {
369
+ function toLane(rung, position, cfg, opts, now, client, platform, host, entrypoint, launcherPath) {
313
370
  const until = cooldownUntil(cfg, rung, now);
314
371
  const state = !rung.enabled ? "disabled" : until !== null ? "exhausted" : "ready";
315
372
  const lane = { id: rung.id, kind: rung.kind, position, state };
@@ -339,7 +396,11 @@ function toLane(rung, position, cfg, opts, now, client, platform, host, entrypoi
339
396
  delete lane.invoke;
340
397
  }
341
398
  }
342
- if (rung.kind === "relay" && rung.spec) {
399
+ if (rung.kind === "relay" && rung.spec && opts.requester === "mcp" && reachableWithoutRelay(rung.spec, cfg)) {
400
+ lane.spec = rung.spec;
401
+ lane.unreachable = mcpPassThroughReason(rung.spec);
402
+ }
403
+ else if (rung.kind === "relay" && rung.spec) {
343
404
  lane.spec = rung.spec;
344
405
  if (canAddressAsSubagent(host)) {
345
406
  lane.requiresDirective = !offloadRule(cfg, client).enabled;
@@ -366,6 +427,10 @@ function toLane(rung, position, cfg, opts, now, client, platform, host, entrypoi
366
427
  }
367
428
  }
368
429
  }
430
+ if (lane.invoke) {
431
+ const wrapped = wrapForWindowsConsoleSafety(lane.invoke, launcherPath, platform);
432
+ lane.invoke = { ...lane.invoke, command: wrapped.command, args: wrapped.args };
433
+ }
369
434
  return lane;
370
435
  }
371
436
  function rungModel(rung) {
@@ -440,14 +505,102 @@ function stripArgs(args, drop) {
440
505
  }
441
506
  return out;
442
507
  }
443
- export function buildDispatch(cfg, rawOpts = {}, platform = process.platform) {
508
+ export function buildDispatch(cfg, rawOpts = {}, platform = process.platform, launcherPath = null) {
444
509
  const opts = normalizeOptions(rawOpts ?? {});
445
510
  const client = opts.client ?? "default";
446
511
  const host = opts.host ?? "unknown";
447
512
  const now = Date.now();
448
513
  const selected = selectLadder(cfg, opts.tier);
449
- const rungs = selected.rungs;
450
- const ladder = rungs.map((r, i) => toLane(r, i + 1, cfg, opts, now, client, platform, opts.host, opts.entrypoint));
514
+ const makeLane = (rung, position) => toLane(rung, position, cfg, opts, now, client, platform, opts.host, opts.entrypoint, launcherPath);
515
+ const ladder = selected.rungs.map((r, i) => makeLane(r, i + 1));
516
+ annotateLaneStats(cfg, ladder);
517
+ annotateLaneHistory(cfg, ladder, selected.tier, opts.mode ?? null);
518
+ annotateAffinity(cfg, ladder, selected.tier, now);
519
+ const offload = offloadRule(cfg, client).enabled;
520
+ const base = { tier: selected.tier, offload, client, host, ladder };
521
+ if (opts.model !== undefined)
522
+ return modelView(cfg, base, opts.model, opts.lane, makeLane);
523
+ if (selected.missing) {
524
+ return {
525
+ ...base,
526
+ order: [],
527
+ next: null,
528
+ reason: `no dispatch tier "${describeId(selected.missing)}" configured (have: ${Object.keys(cfg.routing.ladders ?? {}).join(", ")})`,
529
+ };
530
+ }
531
+ if (ladder.length === 0) {
532
+ return {
533
+ ...base,
534
+ order: [],
535
+ next: null,
536
+ reason: "no routing.ladder configured — dispatch order is the host's to choose",
537
+ };
538
+ }
539
+ if (opts.lane !== undefined)
540
+ return forcedLaneView(base, opts.lane);
541
+ return rankedView(base, opts.after);
542
+ }
543
+ function laneWindows(cfg, laneId, tier, mode) {
544
+ const keys = [];
545
+ const add = (t, m) => {
546
+ if (!keys.some(([kt, km]) => kt === t && km === m))
547
+ keys.push([t, m]);
548
+ };
549
+ add(tier, mode);
550
+ add(tier, null);
551
+ add(null, mode);
552
+ add(null, null);
553
+ const out = [];
554
+ for (const [t, m] of keys) {
555
+ const w = laneStatsFor(cfg, laneId, t, m);
556
+ if (w)
557
+ out.push(w);
558
+ }
559
+ return out;
560
+ }
561
+ function laneHistoryFacts(cfg, laneId, tier, mode) {
562
+ const windows = laneWindows(cfg, laneId, tier, mode);
563
+ const walk = cfg.routing.dispatchWalk;
564
+ const minSamples = walk?.attemptMinSamples ?? 5;
565
+ const sampled = windows.find((w) => w.wallClockMs.length >= minSamples);
566
+ const hinted = sampled ?? windows.find((w) => w.wallClockMs.length > 0);
567
+ const timeToAnswer = hinted === undefined
568
+ ? undefined
569
+ : {
570
+ medianMs: Math.round(medianWallClockMs(hinted.wallClockMs) ?? 0),
571
+ p80Ms: Math.round(quantileWallClockMs(hinted.wallClockMs, 0.8) ?? 0),
572
+ samples: hinted.wallClockMs.length,
573
+ mode: hinted.mode,
574
+ };
575
+ const newest = windows[0];
576
+ const recentFailures = newest?.consecutiveFailures ?? 0;
577
+ if (!walk || !walk.enabled)
578
+ return { budget: undefined, timeToAnswer, recentFailures };
579
+ const floorMs = mode === "agent" ? Math.max(walk.attemptMs, walk.agentAttemptMs) : walk.attemptMs;
580
+ const base = budgetFromSamples(sampled?.wallClockMs ?? newest?.wallClockMs ?? [], { ...walk, attemptMs: floorMs });
581
+ const raisedBy = newest?.abandonedSinceSuccess ?? 0;
582
+ if (base === undefined || raisedBy <= 0)
583
+ return { budget: base, timeToAnswer, recentFailures };
584
+ const ms = Math.min(MAX_ATTEMPT_BUDGET_MS, base.ms * 2 ** Math.min(raisedBy, 16));
585
+ return { budget: { ...base, ms, raisedBy }, timeToAnswer, recentFailures };
586
+ }
587
+ function budgetFromSamples(samples, walk) {
588
+ if (samples.length < walk.attemptMinSamples) {
589
+ return { ms: walk.attemptMs, basis: "floor", samples: samples.length };
590
+ }
591
+ const quantile = quantileWallClockMs(samples, walk.attemptQuantile);
592
+ if (quantile === null)
593
+ return { ms: walk.attemptMs, basis: "floor", samples: samples.length };
594
+ const own = Math.round(quantile);
595
+ if (own < walk.attemptMs) {
596
+ return { ms: walk.attemptMs, basis: "clamped", samples: samples.length, quantileMs: own };
597
+ }
598
+ return { ms: own, basis: "history", samples: samples.length };
599
+ }
600
+ function isSelectable(lane) {
601
+ return lane.state === "ready" && lane.unreachable === undefined && lane.notServable === undefined;
602
+ }
603
+ function annotateLaneStats(cfg, ladder) {
451
604
  const statsByLane = new Map();
452
605
  for (const row of allLaneStats(cfg)) {
453
606
  const agg = statsByLane.get(row.laneId);
@@ -483,108 +636,112 @@ export function buildDispatch(cfg, rawOpts = {}, platform = process.platform) {
483
636
  lastAt: row.lastAt,
484
637
  };
485
638
  }
639
+ }
640
+ function annotateLaneHistory(cfg, ladder, tier, mode) {
641
+ const walkOn = cfg.routing.dispatchWalk?.enabled === true;
486
642
  for (const lane of ladder) {
487
- const budget = attemptBudget(cfg, lane.id, selected.tier);
488
- if (budget !== undefined)
489
- lane.attemptBudget = budget;
490
- }
491
- annotateAffinity(cfg, ladder, selected.tier, now);
492
- const offload = offloadRule(cfg, client).enabled;
493
- const base = { tier: selected.tier, offload, client, host, ladder };
494
- if (selected.missing) {
495
- return {
496
- ...base,
497
- order: [],
498
- next: null,
499
- reason: `no dispatch tier "${describeId(selected.missing)}" configured (have: ${Object.keys(cfg.routing.ladders ?? {}).join(", ")})`,
500
- };
643
+ const facts = laneHistoryFacts(cfg, lane.id, tier, mode);
644
+ if (facts.budget !== undefined)
645
+ lane.attemptBudget = facts.budget;
646
+ if (facts.timeToAnswer !== undefined)
647
+ lane.timeToAnswer = facts.timeToAnswer;
648
+ if (facts.recentFailures > 0)
649
+ lane.recentFailures = facts.recentFailures;
650
+ if (walkOn && facts.recentFailures >= FAILING_LANE_STREAK && isSelectable(lane)) {
651
+ lane.failing = {
652
+ streak: facts.recentFailures,
653
+ reason: `${facts.recentFailures} own failures in a row; ordered behind the other lanes until it answers again`,
654
+ };
655
+ }
501
656
  }
502
- if (ladder.length === 0) {
657
+ }
658
+ function forcedLaneView(base, laneId) {
659
+ const forced = base.ladder.find((l) => l.id === laneId);
660
+ if (!forced) {
503
661
  return {
504
662
  ...base,
505
663
  order: [],
506
664
  next: null,
507
- reason: "no routing.ladder configured — dispatch order is the host's to choose",
508
- };
509
- }
510
- if (opts.lane !== undefined) {
511
- const forced = ladder.find((l) => l.id === opts.lane);
512
- if (!forced) {
513
- return {
514
- ...base,
515
- order: [],
516
- next: null,
517
- reason: `no lane "${describeId(opts.lane)}" in the ladder (have: ${ladder.map((l) => l.id).join(", ")})`,
518
- };
519
- }
520
- return {
521
- ...base,
522
- order: [forced.id],
523
- next: forced,
524
- reason: forced.unreachable !== undefined
525
- ? `lane "${forced.id}" selected by host override (${forced.unreachable})`
526
- : forced.state === "ready"
527
- ? `lane "${forced.id}" selected by host override`
528
- : `lane "${forced.id}" selected by host override (currently ${forced.state})`,
665
+ reason: `no lane "${describeId(laneId)}" in the ladder (have: ${base.ladder.map((l) => l.id).join(", ")})`,
529
666
  };
530
667
  }
531
- let pool = ladder;
532
- if (opts.after !== undefined) {
533
- const idx = ladder.findIndex((l) => l.id === opts.after);
668
+ return {
669
+ ...base,
670
+ order: [forced.id],
671
+ next: forced,
672
+ reason: forced.unreachable !== undefined
673
+ ? `lane "${forced.id}" selected by host override (${forced.unreachable})`
674
+ : forced.state === "ready"
675
+ ? `lane "${forced.id}" selected by host override`
676
+ : `lane "${forced.id}" selected by host override (currently ${forced.state})`,
677
+ };
678
+ }
679
+ function rankedView(base, after) {
680
+ let pool = base.ladder;
681
+ if (after !== undefined) {
682
+ const idx = base.ladder.findIndex((l) => l.id === after);
534
683
  if (idx < 0) {
535
684
  return {
536
685
  ...base,
537
686
  order: [],
538
687
  next: null,
539
- reason: `no lane "${describeId(opts.after)}" in the ladder (have: ${ladder.map((l) => l.id).join(", ")})`,
688
+ reason: `no lane "${describeId(after)}" in the ladder (have: ${base.ladder.map((l) => l.id).join(", ")})`,
540
689
  };
541
690
  }
542
- pool = ladder.slice(idx + 1);
691
+ pool = base.ladder.slice(idx + 1);
543
692
  }
544
- const usable = pool.filter((l) => l.state === "ready" && l.unreachable === undefined && l.notServable === undefined);
693
+ const usable = pool.filter(isSelectable);
545
694
  const ranked = rankSelectable(usable);
546
695
  const next = ranked[0] ?? null;
547
696
  if (!next) {
548
697
  const blocked = pool.filter((l) => l.unreachable !== undefined).length;
549
- const why = opts.after !== undefined
550
- ? `no ready lane after "${describeId(opts.after)}" — the ladder is exhausted`
698
+ const why = after !== undefined
699
+ ? `no ready lane after "${describeId(after)}" — the ladder is exhausted`
551
700
  : "every lane is exhausted or disabled";
701
+ return { ...base, order: [], next: null, reason: blocked > 0 ? `${why} (${blocked} unreachable from this host)` : why };
702
+ }
703
+ return { ...base, order: ranked.map((l) => l.id), next, reason: selectionReason(next, after, usable) };
704
+ }
705
+ function unknownModelReason(cfg, spec) {
706
+ if (/\s/.test(spec) || stripControlCharacters(spec) !== spec) {
707
+ return `model "${describeId(spec)}" is not a routing spec: it holds whitespace or control characters`;
708
+ }
709
+ if (spec === AUTO_MODEL)
710
+ return null;
711
+ const { provider, model } = splitSpec(spec);
712
+ if (provider === POOL_PREFIX) {
713
+ const pools = Object.keys(cfg.routing.pools ?? {});
714
+ if (model !== undefined && pools.includes(model))
715
+ return null;
716
+ return `no pool "${describeId(spec)}" configured (have: ${pools.map((p) => `${POOL_PREFIX}/${p}`).join(", ")})`;
717
+ }
718
+ if (Object.keys(cfg.providers).includes(provider))
719
+ return null;
720
+ return `model "${describeId(spec)}" names no configured provider or pool (providers: ${Object.keys(cfg.providers).join(", ")})`;
721
+ }
722
+ function modelView(cfg, base, spec, laneOverride, makeLane) {
723
+ if (laneOverride !== undefined) {
552
724
  return {
553
725
  ...base,
554
726
  order: [],
555
727
  next: null,
556
- reason: blocked > 0 ? `${why} (${blocked} unreachable from this host)` : why,
728
+ reason: `pass lane or model, not both: lane "${describeId(laneOverride)}" names a ladder rung and ` +
729
+ `model "${describeId(spec)}" names a routing spec`,
557
730
  };
558
731
  }
559
- const why = selectionReason(next, opts.after, usable);
560
- return { ...base, order: ranked.map((l) => l.id), next, reason: why };
561
- }
562
- function attemptBudget(cfg, laneId, tier) {
563
- const walk = cfg.routing.dispatchWalk;
564
- if (!walk || !walk.enabled)
565
- return undefined;
566
- const tiered = laneStatsFor(cfg, laneId, tier)?.wallClockMs ?? [];
567
- if (tiered.length >= walk.attemptMinSamples) {
568
- return budgetFromSamples(tiered, walk);
569
- }
570
- if (tier === null) {
571
- return budgetFromSamples(tiered, walk);
572
- }
573
- const legacy = laneStatsFor(cfg, laneId, null)?.wallClockMs ?? [];
574
- return budgetFromSamples(legacy, walk);
575
- }
576
- function budgetFromSamples(samples, walk) {
577
- if (samples.length < walk.attemptMinSamples) {
578
- return { ms: walk.attemptMs, basis: "floor", samples: samples.length };
579
- }
580
- const quantile = quantileWallClockMs(samples, walk.attemptQuantile);
581
- if (quantile === null)
582
- return { ms: walk.attemptMs, basis: "floor", samples: samples.length };
583
- const own = Math.round(quantile);
584
- if (own < walk.attemptMs) {
585
- return { ms: walk.attemptMs, basis: "clamped", samples: samples.length, quantileMs: own };
586
- }
587
- return { ms: own, basis: "history", samples: samples.length };
732
+ const unknown = unknownModelReason(cfg, spec);
733
+ if (unknown !== null)
734
+ return { ...base, order: [], next: null, reason: unknown };
735
+ const lane = makeLane({ id: `model:${spec}`, kind: "relay", enabled: true, spec }, base.ladder.length + 1);
736
+ lane.adHoc = true;
737
+ const named = `model "${spec}" named by the caller`;
738
+ return {
739
+ ...base,
740
+ ladder: [...base.ladder, lane],
741
+ order: [lane.id],
742
+ next: lane,
743
+ reason: lane.unreachable === undefined ? named : `${named} (${lane.unreachable})`,
744
+ };
588
745
  }
589
746
  function annotateAffinity(cfg, ladder, tier, now) {
590
747
  const walk = cfg.routing.dispatchWalk;
@@ -606,7 +763,7 @@ function annotateAffinity(cfg, ladder, tier, now) {
606
763
  }
607
764
  }
608
765
  function rankSelectable(usable) {
609
- const rank = (lane) => (lane.pinned ? 0 : lane.demoted ? 2 : 1);
766
+ const rank = (lane) => (lane.pinned ? 0 : lane.demoted || lane.failing ? 2 : 1);
610
767
  return [...usable].sort((a, b) => rank(a) - rank(b));
611
768
  }
612
769
  function selectionReason(next, after, usable) {
@@ -614,17 +771,22 @@ function selectionReason(next, after, usable) {
614
771
  return `lane "${next.id}" is pinned (${next.pinned.reason})`;
615
772
  if (after !== undefined)
616
773
  return `first ready lane after "${describeId(after)}"`;
617
- if (next.demoted)
618
- return `every ready lane is demoted; "${next.id}" is first among them in the ladder`;
774
+ if (next.demoted || next.failing) {
775
+ return `every ready lane is demoted or failing; "${next.id}" is first among them in the ladder`;
776
+ }
619
777
  if (next.position === 1)
620
778
  return "first lane in the ladder";
621
- const aheadDemoted = usable.filter((l) => l.position < next.position).length;
622
- const aheadUnavailable = next.position - 1 - aheadDemoted;
779
+ const ahead = usable.filter((l) => l.position < next.position);
780
+ const aheadFailing = ahead.filter((l) => l.failing !== undefined).length;
781
+ const aheadDemoted = ahead.length - aheadFailing;
782
+ const aheadUnavailable = next.position - 1 - ahead.length;
623
783
  const parts = [];
624
784
  if (aheadUnavailable > 0)
625
785
  parts.push(`${aheadUnavailable} ahead of it unavailable`);
626
786
  if (aheadDemoted > 0)
627
787
  parts.push(`${aheadDemoted} ahead of it ready but demoted`);
788
+ if (aheadFailing > 0)
789
+ parts.push(`${aheadFailing} ahead of it ready but failing`);
628
790
  return parts.length === 0 ? "first lane in the ladder" : `first undemoted lane (${parts.join(", ")})`;
629
791
  }
630
792
  export const AUTO_TIERS = ["low", "medium", "high", "xhigh"];