@crouton-kit/crouter 0.3.39 → 0.3.41
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/dist/build-root.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
- package/dist/clients/attach/attach-cmd.js +706 -733
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/web/server.js +5 -28
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
- package/dist/commands/canvas-rebuild-index.js +5 -5
- package/dist/commands/node-snapshot.js +9 -1
- package/dist/commands/node.js +37 -17
- package/dist/commands/push.js +8 -0
- package/dist/commands/revive.d.ts +10 -0
- package/dist/commands/revive.js +28 -10
- package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/setup-core.js +3 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/commands/worktree.d.ts +2 -0
- package/dist/commands/worktree.js +94 -0
- package/dist/core/__tests__/boot.test.js +4 -4
- package/dist/core/__tests__/canvas.test.js +19 -7
- package/dist/core/__tests__/child-followup.test.js +15 -5
- package/dist/core/__tests__/daemon-boot.test.js +6 -1
- package/dist/core/__tests__/daemon-wedge.test.js +18 -1
- package/dist/core/__tests__/fault-classifier.test.js +30 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
- package/dist/core/__tests__/full/spike-harness.test.js +8 -5
- package/dist/core/__tests__/grace-clock.test.js +18 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
- package/dist/core/__tests__/prune-to-limit.test.js +14 -0
- package/dist/core/__tests__/review-model-floor.test.js +32 -0
- package/dist/core/__tests__/revive.test.js +18 -4
- package/dist/core/__tests__/session-cycles.test.js +77 -0
- package/dist/core/__tests__/worktree.test.js +85 -0
- package/dist/core/canvas/boot.js +12 -7
- package/dist/core/canvas/canvas.d.ts +27 -8
- package/dist/core/canvas/canvas.js +54 -26
- package/dist/core/canvas/db.js +14 -0
- package/dist/core/canvas/history.js +1 -0
- package/dist/core/canvas/paths.d.ts +10 -9
- package/dist/core/canvas/paths.js +10 -9
- package/dist/core/canvas/pid.d.ts +155 -1
- package/dist/core/canvas/pid.js +306 -1
- package/dist/core/canvas/status-glyph.d.ts +7 -0
- package/dist/core/canvas/status-glyph.js +10 -1
- package/dist/core/canvas/types.d.ts +34 -0
- package/dist/core/fault-classifier.js +5 -1
- package/dist/core/runtime/bearings.js +4 -0
- package/dist/core/runtime/branded-host.d.ts +7 -0
- package/dist/core/runtime/branded-host.js +44 -17
- package/dist/core/runtime/broker-sdk.js +28 -68
- package/dist/core/runtime/broker.js +35 -4
- package/dist/core/runtime/host.d.ts +3 -3
- package/dist/core/runtime/host.js +148 -33
- package/dist/core/runtime/launch.d.ts +15 -15
- package/dist/core/runtime/launch.js +58 -4
- package/dist/core/runtime/naming.js +3 -2
- package/dist/core/runtime/nodes.d.ts +3 -1
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/pi-cli.d.ts +2 -0
- package/dist/core/runtime/pi-cli.js +51 -0
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +5 -2
- package/dist/core/runtime/recap.js +2 -1
- package/dist/core/runtime/revive.d.ts +21 -15
- package/dist/core/runtime/revive.js +87 -52
- package/dist/core/runtime/session-cycles.d.ts +30 -0
- package/dist/core/runtime/session-cycles.js +77 -0
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +152 -100
- package/dist/core/runtime/tmux.d.ts +7 -10
- package/dist/core/runtime/tmux.js +9 -11
- package/dist/core/worktree.d.ts +35 -0
- package/dist/core/worktree.js +158 -0
- package/dist/daemon/crtrd.d.ts +17 -8
- package/dist/daemon/crtrd.js +191 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
- package/dist/pi-extensions/canvas-context-intro.js +34 -23
- package/dist/pi-extensions/canvas-stophook.js +11 -5
- package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
- package/dist/web-client/assets/index-DrkcvANq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/docs/compat/hearth-crtr-v1.md +191 -0
- package/docs/public-api.md +75 -0
- package/package.json +3 -4
- package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
- package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
- package/dist/core/hearth/config.d.ts +0 -3
- package/dist/core/hearth/config.js +0 -108
- package/dist/core/hearth/guest-env.d.ts +0 -9
- package/dist/core/hearth/guest-env.js +0 -27
- package/dist/core/hearth/index.d.ts +0 -4
- package/dist/core/hearth/index.js +0 -4
- package/dist/core/hearth/model-auth-guest.d.ts +0 -8
- package/dist/core/hearth/model-auth-guest.js +0 -430
- package/dist/core/hearth/provider.d.ts +0 -36
- package/dist/core/hearth/provider.js +0 -10
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
- package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
- package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
- package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
- package/dist/core/hearth/providers/blaxel-home.js +0 -386
- package/dist/core/hearth/providers/blaxel.d.ts +0 -36
- package/dist/core/hearth/providers/blaxel.js +0 -364
- package/dist/core/hearth/providers/types.d.ts +0 -93
- package/dist/core/hearth/types.d.ts +0 -155
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
- package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
- package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
- package/dist/hearth/control-plane/config.d.ts +0 -21
- package/dist/hearth/control-plane/config.js +0 -77
- package/dist/hearth/control-plane/db.d.ts +0 -30
- package/dist/hearth/control-plane/db.js +0 -561
- package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
- package/dist/hearth/control-plane/hearth-target.js +0 -68
- package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
- package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
- package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
- package/dist/hearth/control-plane/ingress/route-store.js +0 -61
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
- package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
- package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
- package/dist/hearth/control-plane/main.d.ts +0 -1
- package/dist/hearth/control-plane/main.js +0 -88
- package/dist/hearth/control-plane/node-message.d.ts +0 -31
- package/dist/hearth/control-plane/node-message.js +0 -98
- package/dist/hearth/control-plane/register.d.ts +0 -15
- package/dist/hearth/control-plane/register.js +0 -34
- package/dist/hearth/control-plane/registry.d.ts +0 -22
- package/dist/hearth/control-plane/registry.js +0 -168
- package/dist/hearth/control-plane/relay.d.ts +0 -44
- package/dist/hearth/control-plane/relay.js +0 -711
- package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
- package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
- package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
- package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
- package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
- package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
- package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
- package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
- package/dist/hearth/control-plane/secrets.d.ts +0 -31
- package/dist/hearth/control-plane/secrets.js +0 -134
- package/dist/hearth/control-plane/server.d.ts +0 -27
- package/dist/hearth/control-plane/server.js +0 -482
- package/dist/hearth/control-plane/serving.d.ts +0 -15
- package/dist/hearth/control-plane/serving.js +0 -106
- package/dist/hearth/control-plane/session.d.ts +0 -68
- package/dist/hearth/control-plane/session.js +0 -273
- package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
- package/dist/hearth/control-plane/triggers/acl.js +0 -52
- package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
- package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
- package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
- package/dist/hearth/control-plane/triggers/deliver.js +0 -76
- package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
- package/dist/hearth/control-plane/triggers/envelope.js +0 -38
- package/dist/hearth/control-plane/types.d.ts +0 -86
- package/dist/hearth/control-plane/types.js +0 -1
- package/dist/hearth/control-plane/wake.d.ts +0 -86
- package/dist/hearth/control-plane/wake.js +0 -550
- package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
- /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
- /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
package/dist/build-root.js
CHANGED
|
@@ -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. */
|
package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
658
|
-
assert.deepEqual(setModelCalls
|
|
659
|
-
|
|
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.
|
|
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
|
});
|