@crouton-kit/crouter 0.3.40 → 0.3.42

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 (195) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +720 -747
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/attach/view-socket.d.ts +8 -1
  9. package/dist/clients/attach/view-socket.js +15 -1
  10. package/dist/clients/web/server.js +5 -28
  11. package/dist/clients/web/web-cmd.js +1 -1
  12. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  13. package/dist/commands/canvas-rebuild-index.js +5 -5
  14. package/dist/commands/memory/shared.d.ts +7 -3
  15. package/dist/commands/memory/shared.js +35 -5
  16. package/dist/commands/memory/write.js +5 -3
  17. package/dist/commands/node-snapshot.js +9 -1
  18. package/dist/commands/node.js +37 -17
  19. package/dist/commands/push.js +8 -0
  20. package/dist/commands/revive.d.ts +10 -0
  21. package/dist/commands/revive.js +28 -10
  22. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  23. package/dist/commands/sys/doctor.js +1 -1
  24. package/dist/commands/sys/setup-core.js +3 -2
  25. package/dist/commands/sys/setup.js +1 -1
  26. package/dist/commands/worktree.d.ts +2 -0
  27. package/dist/commands/worktree.js +94 -0
  28. package/dist/core/__tests__/boot.test.js +4 -4
  29. package/dist/core/__tests__/canvas.test.js +19 -7
  30. package/dist/core/__tests__/child-followup.test.js +15 -5
  31. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  32. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  33. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  34. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  35. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  36. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  37. package/dist/core/__tests__/grace-clock.test.js +18 -2
  38. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  39. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  40. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  41. package/dist/core/__tests__/revive.test.js +18 -4
  42. package/dist/core/__tests__/session-cycles.test.js +77 -0
  43. package/dist/core/__tests__/worktree.test.js +85 -0
  44. package/dist/core/canvas/boot.js +12 -7
  45. package/dist/core/canvas/canvas.d.ts +27 -8
  46. package/dist/core/canvas/canvas.js +54 -26
  47. package/dist/core/canvas/db.js +14 -0
  48. package/dist/core/canvas/history.js +1 -0
  49. package/dist/core/canvas/paths.d.ts +10 -9
  50. package/dist/core/canvas/paths.js +10 -9
  51. package/dist/core/canvas/pid.d.ts +155 -1
  52. package/dist/core/canvas/pid.js +306 -1
  53. package/dist/core/canvas/status-glyph.d.ts +7 -0
  54. package/dist/core/canvas/status-glyph.js +10 -1
  55. package/dist/core/canvas/types.d.ts +34 -0
  56. package/dist/core/fault-classifier.js +5 -1
  57. package/dist/core/profiles/select.d.ts +4 -2
  58. package/dist/core/profiles/select.js +30 -4
  59. package/dist/core/runtime/bearings.js +4 -0
  60. package/dist/core/runtime/branded-host.d.ts +7 -0
  61. package/dist/core/runtime/branded-host.js +44 -17
  62. package/dist/core/runtime/broker-sdk.js +28 -68
  63. package/dist/core/runtime/broker.js +35 -4
  64. package/dist/core/runtime/host.d.ts +3 -3
  65. package/dist/core/runtime/host.js +148 -33
  66. package/dist/core/runtime/launch.d.ts +15 -15
  67. package/dist/core/runtime/launch.js +58 -4
  68. package/dist/core/runtime/naming.js +3 -2
  69. package/dist/core/runtime/nodes.d.ts +3 -1
  70. package/dist/core/runtime/nodes.js +1 -0
  71. package/dist/core/runtime/pi-cli.d.ts +2 -0
  72. package/dist/core/runtime/pi-cli.js +51 -0
  73. package/dist/core/runtime/placement.d.ts +1 -1
  74. package/dist/core/runtime/placement.js +5 -2
  75. package/dist/core/runtime/recap.js +2 -1
  76. package/dist/core/runtime/revive.d.ts +21 -15
  77. package/dist/core/runtime/revive.js +87 -52
  78. package/dist/core/runtime/session-cycles.d.ts +30 -0
  79. package/dist/core/runtime/session-cycles.js +77 -0
  80. package/dist/core/runtime/spawn.d.ts +4 -0
  81. package/dist/core/runtime/spawn.js +152 -100
  82. package/dist/core/runtime/tmux.d.ts +7 -10
  83. package/dist/core/runtime/tmux.js +9 -11
  84. package/dist/core/worktree.d.ts +35 -0
  85. package/dist/core/worktree.js +158 -0
  86. package/dist/daemon/crtrd.d.ts +17 -8
  87. package/dist/daemon/crtrd.js +191 -40
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.js +7 -0
  90. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  91. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  92. package/dist/pi-extensions/canvas-stophook.js +11 -5
  93. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  94. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  95. package/dist/web-client/index.html +2 -2
  96. package/docs/compat/hearth-crtr-v1.md +191 -0
  97. package/docs/public-api.md +75 -0
  98. package/package.json +3 -4
  99. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  100. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  101. package/dist/core/hearth/config.d.ts +0 -3
  102. package/dist/core/hearth/config.js +0 -108
  103. package/dist/core/hearth/guest-env.d.ts +0 -9
  104. package/dist/core/hearth/guest-env.js +0 -27
  105. package/dist/core/hearth/index.d.ts +0 -4
  106. package/dist/core/hearth/index.js +0 -4
  107. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  108. package/dist/core/hearth/model-auth-guest.js +0 -430
  109. package/dist/core/hearth/provider.d.ts +0 -36
  110. package/dist/core/hearth/provider.js +0 -10
  111. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
  112. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  113. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  114. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
  115. package/dist/core/hearth/providers/blaxel-home.js +0 -405
  116. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  117. package/dist/core/hearth/providers/blaxel.js +0 -364
  118. package/dist/core/hearth/providers/types.d.ts +0 -93
  119. package/dist/core/hearth/types.d.ts +0 -155
  120. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  121. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  122. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  123. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  124. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  125. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  126. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  127. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  128. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  129. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  130. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  131. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  132. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  133. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  134. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  135. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  136. package/dist/hearth/control-plane/config.d.ts +0 -21
  137. package/dist/hearth/control-plane/config.js +0 -77
  138. package/dist/hearth/control-plane/db.d.ts +0 -30
  139. package/dist/hearth/control-plane/db.js +0 -561
  140. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  141. package/dist/hearth/control-plane/hearth-target.js +0 -68
  142. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  143. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  144. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  145. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  146. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  147. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  148. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  149. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  150. package/dist/hearth/control-plane/main.d.ts +0 -1
  151. package/dist/hearth/control-plane/main.js +0 -88
  152. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  153. package/dist/hearth/control-plane/node-message.js +0 -98
  154. package/dist/hearth/control-plane/register.d.ts +0 -15
  155. package/dist/hearth/control-plane/register.js +0 -34
  156. package/dist/hearth/control-plane/registry.d.ts +0 -22
  157. package/dist/hearth/control-plane/registry.js +0 -168
  158. package/dist/hearth/control-plane/relay.d.ts +0 -44
  159. package/dist/hearth/control-plane/relay.js +0 -711
  160. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  161. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  162. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  163. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  164. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  165. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  166. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  167. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  168. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  169. package/dist/hearth/control-plane/secrets.js +0 -134
  170. package/dist/hearth/control-plane/server.d.ts +0 -27
  171. package/dist/hearth/control-plane/server.js +0 -482
  172. package/dist/hearth/control-plane/serving.d.ts +0 -15
  173. package/dist/hearth/control-plane/serving.js +0 -106
  174. package/dist/hearth/control-plane/session.d.ts +0 -68
  175. package/dist/hearth/control-plane/session.js +0 -273
  176. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  177. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  178. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  179. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  180. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  181. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  182. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  183. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  184. package/dist/hearth/control-plane/types.d.ts +0 -86
  185. package/dist/hearth/control-plane/types.js +0 -1
  186. package/dist/hearth/control-plane/wake.d.ts +0 -86
  187. package/dist/hearth/control-plane/wake.js +0 -550
  188. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  189. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  190. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  191. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  192. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  193. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  194. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  195. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -18,6 +18,7 @@ const SUBTREE_LOADERS = {
18
18
  push: async () => (await import('./commands/push.js')).registerPush(),
19
19
  canvas: async () => (await import('./commands/canvas.js')).registerCanvas(),
20
20
  surface: async () => (await import('./commands/surface.js')).registerSurface(),
21
+ worktree: async () => (await import('./commands/worktree.js')).registerWorktree(),
21
22
  };
22
23
  /** Every shipped subtree name. Cheap (no module loading) — the front-door
23
24
  * recursion guard and the dispatcher's first-token routing need only names. */
@@ -503,7 +503,12 @@ test("falls back transparently and serves the turn on Anthropic when Codex is ex
503
503
  }
504
504
  });
505
505
 
506
- test("treats request timeouts as fallback-like and serves the turn on the fallback provider", async () => {
506
+ // Regression: request timeouts (and other transient network/5xx blips) used to be treated
507
+ // exactly like a genuine rate limit -- cooling the credential down for 5 minutes and
508
+ // rotating away from a perfectly healthy subscription over one blip (#122). They must
509
+ // instead get a short in-place retry on the SAME credential/provider, no cooldown, no
510
+ // rotation -- proven here by recovering on the second attempt without ever switching models.
511
+ test("treats a request timeout as transient: retries the SAME provider in place, no cooldown or rotation", async () => {
507
512
  resetRotationState();
508
513
  writeLadderConfig();
509
514
  helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
@@ -512,11 +517,121 @@ test("treats request timeouts as fallback-like and serves the turn on the fallba
512
517
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
513
518
  ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
514
519
  const streamedModels: any[] = [];
520
+ const sleepCalls: number[] = [];
521
+ let anthropicCalls = 0;
522
+ rotationModule.__setSleepForTest(async (ms) => {
523
+ sleepCalls.push(ms);
524
+ });
515
525
  rotationModule.__setStreamForProviderForTest((model) => {
516
526
  streamedModels.push(model);
517
527
  return (async function* () {
518
528
  if (model.provider === "anthropic") {
519
- yield { type: "error", error: Object.assign(new Error("Request timed out."), { errorMessage: "Request timed out." }) };
529
+ anthropicCalls += 1;
530
+ if (anthropicCalls === 1) {
531
+ yield { type: "error", error: Object.assign(new Error("Request timed out."), { errorMessage: "Request timed out." }) };
532
+ return;
533
+ }
534
+ yield { type: "text_delta", text: "hello from anthropic, second try" };
535
+ return;
536
+ }
537
+ yield { type: "text_delta", text: "hello from codex" };
538
+ })() as any;
539
+ });
540
+
541
+ try {
542
+ await providerRotationExtension(pi);
543
+ await events.session_start?.({}, ctx);
544
+ const stream = providers["anthropic"].streamSimple(
545
+ { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
546
+ ctx,
547
+ );
548
+ const emitted = [] as any[];
549
+ for await (const event of stream) emitted.push(event);
550
+
551
+ // Never switched providers or models -- both attempts stayed on anthropic.
552
+ assert.deepEqual(setModelCalls, []);
553
+ assert.deepEqual(streamedModels.map((m) => m.provider), ["anthropic", "anthropic"]);
554
+ assert.deepEqual(sleepCalls, [250]);
555
+ assert.ok(emitted.some((e) => e.type === "text_delta"));
556
+ assert.ok(!emitted.some((e) => e.type === "error"));
557
+ // No cooldown -- a transient blip must never cool down a healthy subscription.
558
+ assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 0);
559
+ } finally {
560
+ rotationModule.__setStreamForProviderForTest(undefined);
561
+ rotationModule.__setSleepForTest(undefined);
562
+ }
563
+ });
564
+
565
+ // Regression: when a transient blip never recovers, the turn must end with a clear,
566
+ // actionable error instead of spinning forever or silently rotating a healthy credential
567
+ // away to a fallback provider that was never the actual problem.
568
+ test("ends the turn without rotating once the transient retry budget is exhausted", async () => {
569
+ resetRotationState();
570
+ writeLadderConfig();
571
+ helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
572
+ helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
573
+
574
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
575
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
576
+ const streamedModels: any[] = [];
577
+ const sleepCalls: number[] = [];
578
+ rotationModule.__setSleepForTest(async (ms) => {
579
+ sleepCalls.push(ms);
580
+ });
581
+ rotationModule.__setStreamForProviderForTest((model) => {
582
+ streamedModels.push(model);
583
+ return (async function* () {
584
+ // 503 with no retry-after header is transient by STATUS, not just by message.
585
+ yield { type: "error", error: Object.assign(new Error("Service Unavailable"), { status: 503 }) };
586
+ })() as any;
587
+ });
588
+
589
+ try {
590
+ await providerRotationExtension(pi);
591
+ await events.session_start?.({}, ctx);
592
+ const stream = providers["anthropic"].streamSimple(
593
+ { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
594
+ ctx,
595
+ );
596
+ const emitted = [] as any[];
597
+ for await (const event of stream) emitted.push(event);
598
+
599
+ assert.deepEqual(setModelCalls, []);
600
+ assert.deepEqual(streamedModels.map((m) => m.provider), ["anthropic", "anthropic", "anthropic"]);
601
+ assert.deepEqual(sleepCalls, [250, 750]);
602
+ assert.equal(emitted.length, 1);
603
+ assert.equal(emitted[0].type, "error");
604
+ assert.match(emitted[0].error.errorMessage, /transient error after 3 attempts/);
605
+ assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 0);
606
+ } finally {
607
+ rotationModule.__setStreamForProviderForTest(undefined);
608
+ rotationModule.__setSleepForTest(undefined);
609
+ }
610
+ });
611
+
612
+ // Regression: a genuine rate limit (HTTP 429) must never get the transient in-place
613
+ // retry treatment -- it cools the credential down and rotates on the FIRST failure. The
614
+ // cooldown duration must reflect a real Retry-After header (5s here) rather than always
615
+ // falling back to the 5-minute default, so a misclassification is obvious from the numbers.
616
+ test("treats HTTP 429 as a genuine rate limit: rotates immediately using the Retry-After header, no in-place retry", async () => {
617
+ resetRotationState();
618
+ writeLadderConfig();
619
+ helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
620
+ helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
621
+
622
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
623
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
624
+ const streamedModels: any[] = [];
625
+ const sleepCalls: number[] = [];
626
+ rotationModule.__setSleepForTest(async (ms) => {
627
+ sleepCalls.push(ms);
628
+ });
629
+ rotationModule.__setStreamForProviderForTest((model, _context, options) => {
630
+ streamedModels.push(model);
631
+ return (async function* () {
632
+ if (model.provider === "anthropic") {
633
+ await options?.onResponse?.({ status: 429, headers: { "retry-after": "5" } }, model);
634
+ yield { type: "error", error: Object.assign(new Error("Too Many Requests"), { status: 429 }) };
520
635
  return;
521
636
  }
522
637
  yield { type: "text_delta", text: "hello from codex" };
@@ -533,11 +648,70 @@ test("treats request timeouts as fallback-like and serves the turn on the fallba
533
648
  const emitted = [] as any[];
534
649
  for await (const event of stream) emitted.push(event);
535
650
 
651
+ assert.equal(streamedModels.filter((m) => m.provider === "anthropic").length, 1);
652
+ assert.deepEqual(sleepCalls, []); // a genuine rate limit rotates immediately, it never sleeps in place
536
653
  assert.deepEqual(setModelCalls.map((m) => m.provider), ["openai-codex"]);
537
- assert.deepEqual(streamedModels.map((m) => m.provider), ["anthropic", "openai-codex"]);
538
654
  assert.ok(emitted.some((e) => e.type === "text_delta"));
539
655
  assert.ok(!emitted.some((e) => e.type === "error"));
540
- assert.ok(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil > Date.now());
656
+ const [entry] = helpers.readSubscriptionPool("anthropic");
657
+ // Retry-After: 5 -> ~5s cooldown, not the 5-minute default backoff.
658
+ assert.ok(entry.rateLimitedUntil > Date.now() + 4_000 && entry.rateLimitedUntil <= Date.now() + 5_000);
659
+ } finally {
660
+ rotationModule.__setStreamForProviderForTest(undefined);
661
+ rotationModule.__setSleepForTest(undefined);
662
+ }
663
+ });
664
+
665
+ test("does not cool down credentials or switch providers when the user aborts", async () => {
666
+ resetRotationState();
667
+ writeLadderConfig();
668
+ helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
669
+ helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
670
+
671
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
672
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
673
+ const streamedModels: any[] = [];
674
+ rotationModule.__setStreamForProviderForTest((model) => {
675
+ streamedModels.push(model);
676
+ return (async function* () {
677
+ yield {
678
+ type: "error",
679
+ reason: "aborted",
680
+ error: {
681
+ role: "assistant",
682
+ content: [],
683
+ api: model.api,
684
+ provider: model.provider,
685
+ model: model.id,
686
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
687
+ stopReason: "aborted",
688
+ errorMessage: "Request was aborted",
689
+ timestamp: Date.now(),
690
+ },
691
+ };
692
+ })() as any;
693
+ });
694
+
695
+ try {
696
+ await providerRotationExtension(pi);
697
+ await events.session_start?.({}, ctx);
698
+ // No AbortSignal is threaded through options here on purpose: the abort must be recognized
699
+ // from the event's own `reason: "aborted"` marker alone. Requiring the signal is exactly the
700
+ // regression that let an ESC cool down every credential and drain both provider pools.
701
+ const stream = providers["anthropic"].streamSimple(
702
+ { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
703
+ ctx,
704
+ );
705
+ const emitted = [] as any[];
706
+ for await (const event of stream) emitted.push(event);
707
+
708
+ assert.deepEqual(setModelCalls, []);
709
+ assert.deepEqual(streamedModels.map((m) => m.provider), ["anthropic"]);
710
+ assert.equal(emitted.length, 1);
711
+ assert.equal(emitted[0].type, "error");
712
+ assert.equal(emitted[0].reason, "aborted");
713
+ assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 0);
714
+ assert.equal(helpers.readSubscriptionPool("openai-codex")[0].rateLimitedUntil, 0);
541
715
  } finally {
542
716
  rotationModule.__setStreamForProviderForTest(undefined);
543
717
  }
@@ -642,7 +816,12 @@ test("automatically reauthenticates invalid refresh tokens when UI is available"
642
816
  }
643
817
  });
644
818
 
645
- test("emits a single terminal error only when BOTH provider pools are exhausted", async () => {
819
+ // Regression (#123): a resolved-but-unauthenticated fallback used to still get switched
820
+ // to blindly (the session model would flip to a provider nobody logged into) before
821
+ // finally giving up. Neither provider has ever been authenticated here at all, so there
822
+ // is nothing viable to switch to -- it must NOT touch the session model, and the terminal
823
+ // message must be actionable ("not authenticated") rather than a generic exhaustion notice.
824
+ test("emits an actionable 'not authenticated' error without switching models when neither provider has ever logged in", async () => {
646
825
  resetRotationState();
647
826
  writeLadderConfig();
648
827
 
@@ -654,10 +833,294 @@ test("emits a single terminal error only when BOTH provider pools are exhausted"
654
833
  const emitted = [] as any[];
655
834
  for await (const event of stream) emitted.push(event);
656
835
 
657
- // It still switches the session model toward the fallback before giving up.
658
- assert.deepEqual(setModelCalls.map((m) => m.provider), ["anthropic"]);
659
- // No ping-pong: a single terminal error naming both exhausted providers.
836
+ // Never switches to the unauthenticated fallback -- nothing to try it with.
837
+ assert.deepEqual(setModelCalls, []);
838
+ assert.equal(emitted.length, 1);
839
+ assert.equal(emitted[0].type, "error");
840
+ assert.match(emitted[0].error.errorMessage, /OpenAI Codex not authenticated.*crtr sys setup/);
841
+ });
842
+
843
+ // Regression: a config-less user (fresh install, no ~/.crouter/config.json at all —
844
+ // the common case) used to have `readModelLadders()` return nothing, so the moment
845
+ // the (empty) Anthropic pool needed a fallback hop, resolution failed with "Unable to
846
+ // resolve fallback model for strength strong; configure a openai ladder in
847
+ // ~/.crouter/config.json". `readModelLadders()` now always falls back to crouter's
848
+ // builtin anthropic/openai ladder (mirroring what crtr itself launches nodes with), so
849
+ // the hop resolves to a real target -- but this user genuinely has no working OpenAI
850
+ // credentials either, so (#123) it must degrade to an actionable "not authenticated"
851
+ // terminal error WITHOUT ever switching the session model to that unauthed target, never
852
+ // the raw config error.
853
+ test("degrades gracefully via the builtin ladder when a config-less user has no cross-provider credentials", async () => {
854
+ resetRotationState();
855
+ rmSync(join(home, ".crouter", "config.json"), { force: true });
856
+
857
+ const ctx = {
858
+ model: { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
859
+ ui: { setStatus() {} },
860
+ modelRegistry: {
861
+ find(provider: string, id: string) {
862
+ if (provider === "anthropic" && id === "claude-opus-4-8") return { provider, id, api: "anthropic-messages" };
863
+ if (provider === "openai-codex" && id === "gpt-5.5") return { provider, id, api: "openai-codex-responses" };
864
+ return undefined;
865
+ },
866
+ },
867
+ } as any;
868
+ const setModelCalls: any[] = [];
869
+ const providers: Record<string, { streamSimple: (...args: any[]) => AsyncIterable<any> }> = {};
870
+ const events: Record<string, (...args: any[]) => unknown> = {};
871
+ const pi = {
872
+ registerProvider(name: string, provider: { streamSimple: (...args: any[]) => AsyncIterable<any> }) {
873
+ providers[name] = provider;
874
+ },
875
+ registerCommand() {},
876
+ on(eventName: string, handler: (...args: any[]) => unknown) {
877
+ events[eventName] = handler;
878
+ },
879
+ async setModel(model: any) {
880
+ setModelCalls.push(model);
881
+ ctx.model = model;
882
+ return true;
883
+ },
884
+ setThinkingLevel() {},
885
+ } as any;
886
+
887
+ await providerRotationExtension(pi);
888
+ await events.session_start?.({}, ctx);
889
+ // Anthropic pool is empty (reset above), so the credential loop never runs. The builtin
890
+ // ladder resolves a cross-provider target (openai-codex/gpt-5.5), but that provider was
891
+ // never authenticated either (#123 gates fallback on the target actually being authed),
892
+ // so it must NOT switch the session model to it -- straight to an actionable message.
893
+ const stream = providers["anthropic"].streamSimple(ctx.model, ctx);
894
+ const emitted = [] as any[];
895
+ for await (const event of stream) emitted.push(event);
896
+
897
+ assert.deepEqual(setModelCalls, []);
660
898
  assert.equal(emitted.length, 1);
661
899
  assert.equal(emitted[0].type, "error");
662
- assert.equal(emitted[0].error.errorMessage, "All managed provider pools exhausted (tried openai-codex, anthropic)");
900
+ assert.match(emitted[0].error.errorMessage, /Claude not authenticated.*crtr sys setup/);
901
+ assert.ok(!emitted[0].error.errorMessage.includes("configure a openai ladder"));
902
+ });
903
+
904
+ // Regression: this is the actual root-cause fix — `readModelLadders()` used to read
905
+ // ONLY the literal ~/.crouter/config.json with no builtin default, so a config-less
906
+ // user (fresh install, only logged into Anthropic) saw an EMPTY ladder even though
907
+ // crtr itself launches nodes on the builtin anthropic/openai defaults
908
+ // (`defaultModelLaddersConfig()` in src/types.ts). Prove the builtin ladder resolves
909
+ // on its own, with no config.json present at all: the current model's own strength tier
910
+ // (medium = claude-sonnet-5) must match, and a same-strength cross-provider target
911
+ // must resolve without throwing.
912
+ test("resolves the builtin anthropic ladder (medium = sonnet-5) with no ~/.crouter/config.json present", () => {
913
+ resetRotationState();
914
+ rmSync(join(home, ".crouter", "config.json"), { force: true });
915
+
916
+ const target = helpers.resolveFallbackTarget("anthropic", "anthropic/claude-sonnet-5:high");
917
+
918
+ assert.deepEqual(target, {
919
+ providerId: "openai-codex",
920
+ modelId: "gpt-5.5",
921
+ label: "openai-codex/gpt-5.5:medium",
922
+ strength: "medium",
923
+ thinkingLevel: "medium",
924
+ });
925
+ });
926
+
927
+ // Regression (#123a): a SOLE authenticated provider that is merely cooling down (its one
928
+ // credential is rate-limited, but not permanently broken) used to jump straight to a
929
+ // fallback/terminal failure the instant its pool went empty, instead of waiting out the
930
+ // (short) remaining cooldown. With no OpenAI credentials at all to fall back to, it must
931
+ // sleep out the shortest rateLimitedUntil and retry the SAME provider -- and succeed.
932
+ test("waits out a sole provider's cooldown and retries it when no authenticated fallback exists", async () => {
933
+ resetRotationState();
934
+ writeLadderConfig();
935
+ helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
936
+ helpers.markSubscriptionRateLimited("anthropic", "anthropic", 30_000);
937
+
938
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
939
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
940
+ const sleepCalls: number[] = [];
941
+ rotationModule.__setSleepForTest(async (ms) => {
942
+ sleepCalls.push(ms);
943
+ // Simulate the cooldown having elapsed by the time the wait completes, rather than
944
+ // actually sleeping in the test.
945
+ const pool = helpers.readSubscriptionPool("anthropic");
946
+ helpers.writeSubscriptionPool("anthropic", pool.map((entry) => ({ ...entry, rateLimitedUntil: 0 })));
947
+ });
948
+ rotationModule.__setStreamForProviderForTest((model) => {
949
+ return (async function* () {
950
+ yield { type: "text_delta", text: `hello from ${model.provider} after waiting` };
951
+ })() as any;
952
+ });
953
+
954
+ try {
955
+ await providerRotationExtension(pi);
956
+ await events.session_start?.({}, ctx);
957
+ const stream = providers["anthropic"].streamSimple(
958
+ { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
959
+ ctx,
960
+ );
961
+ const emitted = [] as any[];
962
+ for await (const event of stream) emitted.push(event);
963
+
964
+ // Never switched away from anthropic -- it waited, then retried the same provider.
965
+ assert.deepEqual(setModelCalls, []);
966
+ assert.equal(sleepCalls.length, 1);
967
+ assert.ok(sleepCalls[0] > 0 && sleepCalls[0] <= 30_000);
968
+ assert.ok(emitted.some((e) => e.type === "text_delta"));
969
+ assert.ok(!emitted.some((e) => e.type === "error"));
970
+ } finally {
971
+ rotationModule.__setStreamForProviderForTest(undefined);
972
+ rotationModule.__setSleepForTest(undefined);
973
+ }
974
+ });
975
+
976
+ // Regression (#123b): a cooldown long enough that it isn't worth an automatic wait (e.g.
977
+ // the 30-day invalid-refresh-token backoff also lands in `rateLimitedUntil`) must NOT be
978
+ // slept out -- it needs reauth, not patience. It must fall through to an actionable
979
+ // terminal error instead, and must never call the sleep seam at all.
980
+ test("does not wait out a cooldown longer than the sole-provider wait cap; surfaces an actionable error instead", async () => {
981
+ resetRotationState();
982
+ writeLadderConfig();
983
+ helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
984
+ helpers.markSubscriptionRateLimited("anthropic", "anthropic", 30 * 24 * 60 * 60 * 1000);
985
+
986
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
987
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
988
+ const sleepCalls: number[] = [];
989
+ rotationModule.__setSleepForTest(async (ms) => {
990
+ sleepCalls.push(ms);
991
+ });
992
+
993
+ try {
994
+ await providerRotationExtension(pi);
995
+ await events.session_start?.({}, ctx);
996
+ const stream = providers["anthropic"].streamSimple(
997
+ { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
998
+ ctx,
999
+ );
1000
+ const emitted = [] as any[];
1001
+ for await (const event of stream) emitted.push(event);
1002
+
1003
+ assert.deepEqual(setModelCalls, []);
1004
+ assert.deepEqual(sleepCalls, []);
1005
+ assert.equal(emitted.length, 1);
1006
+ assert.equal(emitted[0].type, "error");
1007
+ assert.match(emitted[0].error.errorMessage, /Claude cooling down for .*too long to wait automatically/);
1008
+ } finally {
1009
+ rotationModule.__setSleepForTest(undefined);
1010
+ }
1011
+ });
1012
+
1013
+ // Regression (review of 10c72d4, Bug 1): the wait-out decision used to be computed from
1014
+ // the `rawPool` snapshot taken BEFORE the credential loop ran, not from the pool the loop
1015
+ // just mutated via `markSubscriptionRateLimited`. So when the SOLE credential gets rate-
1016
+ // limited in THIS SAME call (as opposed to having been pre-marked before the turn even
1017
+ // started, like the #123a test above), the stale snapshot still shows `rateLimitedUntil: 0`
1018
+ // for every entry, `Math.min(...)` comes out to 0, `waitMs` goes negative, and the code
1019
+ // falls straight through to "All managed provider pools exhausted" instead of sleeping out
1020
+ // the (short) real cooldown. Proves the fix: a fresh 429 with Retry-After: 1s on the only
1021
+ // credential, no authenticated fallback, must sleep and retry -- never emit exhaustion.
1022
+ test("waits out a sole credential's cooldown observed in THIS turn, not a stale pre-attempt pool snapshot", async () => {
1023
+ resetRotationState();
1024
+ writeLadderConfig();
1025
+ helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
1026
+ // openai-codex pool stays empty -- no authenticated fallback to switch to.
1027
+
1028
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
1029
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
1030
+ const sleepCalls: number[] = [];
1031
+ let anthropicCalls = 0;
1032
+ rotationModule.__setSleepForTest(async (ms) => {
1033
+ sleepCalls.push(ms);
1034
+ // Simulate the 1s retry-after cooldown having elapsed by the time the wait completes.
1035
+ const pool = helpers.readSubscriptionPool("anthropic");
1036
+ helpers.writeSubscriptionPool("anthropic", pool.map((entry) => ({ ...entry, rateLimitedUntil: 0 })));
1037
+ });
1038
+ rotationModule.__setStreamForProviderForTest((model, _context, options) => {
1039
+ return (async function* () {
1040
+ anthropicCalls += 1;
1041
+ if (anthropicCalls === 1) {
1042
+ await options?.onResponse?.({ status: 429, headers: { "retry-after": "1" } }, model);
1043
+ yield { type: "error", error: Object.assign(new Error("Too Many Requests"), { status: 429 }) };
1044
+ return;
1045
+ }
1046
+ yield { type: "text_delta", text: "hello from anthropic after waiting out a same-turn 429" };
1047
+ })() as any;
1048
+ });
1049
+
1050
+ try {
1051
+ await providerRotationExtension(pi);
1052
+ await events.session_start?.({}, ctx);
1053
+ const stream = providers["anthropic"].streamSimple(
1054
+ { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
1055
+ ctx,
1056
+ );
1057
+ const emitted = [] as any[];
1058
+ for await (const event of stream) emitted.push(event);
1059
+
1060
+ // Never switches providers -- there is nothing authenticated to switch to.
1061
+ assert.deepEqual(setModelCalls, []);
1062
+ // The bug: it used to skip straight to "exhausted" without ever sleeping.
1063
+ assert.equal(sleepCalls.length, 1);
1064
+ assert.ok(sleepCalls[0] > 0 && sleepCalls[0] <= 1000);
1065
+ assert.equal(anthropicCalls, 2);
1066
+ assert.ok(emitted.some((e) => e.type === "text_delta"));
1067
+ assert.ok(!emitted.some((e) => e.type === "error"));
1068
+ } finally {
1069
+ rotationModule.__setStreamForProviderForTest(undefined);
1070
+ rotationModule.__setSleepForTest(undefined);
1071
+ }
1072
+ });
1073
+
1074
+ // Regression (review of 10c72d4, Bug 2): pi-ai's Codex adapter can surface a genuine
1075
+ // usage-limit exhaustion (`usage_limit_reached` / "You have hit your ChatGPT usage
1076
+ // limit...") on a NON-429 status. The classifier used to only recognize 429/503-with-
1077
+ // retry-after or `rate.?limit|too many requests` text, so this landed in the `fatal`
1078
+ // bucket -- it neither cooled the credential down nor rotated to the authenticated
1079
+ // fallback. Prove it now cools down AND falls back transparently, serving the same turn.
1080
+ test("treats a Codex usage-limit message on a non-429 status as a rate limit: cools down and falls back", async () => {
1081
+ resetRotationState();
1082
+ writeLadderConfig();
1083
+ helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
1084
+ helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
1085
+
1086
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
1087
+ ctx.model = { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" };
1088
+ const streamedModels: any[] = [];
1089
+ rotationModule.__setStreamForProviderForTest((model) => {
1090
+ streamedModels.push(model);
1091
+ return (async function* () {
1092
+ if (model.provider === "openai-codex") {
1093
+ yield {
1094
+ type: "error",
1095
+ error: Object.assign(new Error("You have hit your ChatGPT usage limit. Please try again later."), {
1096
+ status: 403,
1097
+ errorMessage: "You have hit your ChatGPT usage limit. usage_limit_reached",
1098
+ }),
1099
+ };
1100
+ return;
1101
+ }
1102
+ yield { type: "text_delta", text: "hello from anthropic fallback" };
1103
+ })() as any;
1104
+ });
1105
+
1106
+ try {
1107
+ await providerRotationExtension(pi);
1108
+ await events.session_start?.({}, ctx);
1109
+ const stream = providers["openai-codex"].streamSimple(
1110
+ { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" },
1111
+ ctx,
1112
+ );
1113
+ const emitted = [] as any[];
1114
+ for await (const event of stream) emitted.push(event);
1115
+
1116
+ // Cooled the Codex credential down instead of leaving it fatally dead.
1117
+ assert.ok(helpers.readSubscriptionPool("openai-codex")[0].rateLimitedUntil > Date.now());
1118
+ // Fell back to the authenticated Anthropic subscription and served the turn on it.
1119
+ assert.deepEqual(setModelCalls.map((m) => m.provider), ["anthropic"]);
1120
+ assert.equal(streamedModels.filter((m) => m.provider === "anthropic").length, 1);
1121
+ assert.ok(emitted.some((e) => e.type === "text_delta"));
1122
+ assert.ok(!emitted.some((e) => e.type === "error"));
1123
+ } finally {
1124
+ rotationModule.__setStreamForProviderForTest(undefined);
1125
+ }
663
1126
  });