@north-light/crouter 0.3.161 → 0.3.163
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/api/client.d.ts +3 -1
- package/dist/api/client.js +6 -0
- package/dist/api/dto/modelauth.d.ts +32 -0
- package/dist/api/dto/modelauth.js +2 -1
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/00-runtime-base.md +1 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
- package/dist/builtin-memory/init.md +38 -0
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +88 -396
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +215 -419
- package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
- package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/edit-diff.test.js +34 -0
- package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
- package/dist/clients/attach/render/chat-view.js +8 -3
- package/dist/clients/attach/render/crtr-output.js +31 -2
- package/dist/clients/attach/render/edit-diff.js +26 -2
- package/dist/clients/attach/render/tool-calls.d.ts +7 -2
- package/dist/clients/attach/render/tool-calls.js +25 -4
- package/dist/clients/attach/session/connection.d.ts +1 -1
- package/dist/clients/attach/session/editor-frame.d.ts +5 -1
- package/dist/clients/attach/session/editor-frame.js +21 -15
- package/dist/clients/attach/session/identity.d.ts +1 -1
- package/dist/clients/attach/viewer.js +441 -439
- package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
- package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
- package/dist/commands/__tests__/human.test.js +2 -0
- package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
- package/dist/commands/__tests__/search-contents.test.js +52 -0
- package/dist/commands/api-client.js +1 -0
- package/dist/commands/human/prompts.js +2 -2
- package/dist/commands/human/shared.d.ts +1 -0
- package/dist/commands/human/shared.js +7 -4
- package/dist/commands/memory/lint.d.ts +6 -0
- package/dist/commands/memory/lint.js +177 -35
- package/dist/commands/memory/read.js +22 -1
- package/dist/commands/memory/write.js +18 -4
- package/dist/commands/memory.js +1 -1
- package/dist/commands/search/contents.js +2 -2
- package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
- package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
- package/dist/commands/sys/setup-wizard.d.ts +8 -1
- package/dist/commands/sys/setup-wizard.js +209 -58
- package/dist/commands/sys/sync-deps.js +14 -18
- package/dist/commands/sys/sync-project-guidance.js +16 -19
- package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
- package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
- package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
- package/dist/core/__tests__/migration.test.js +8 -3
- package/dist/core/__tests__/model-pin-durability.test.js +7 -5
- package/dist/core/__tests__/model-routes.test.d.ts +1 -0
- package/dist/core/__tests__/model-routes.test.js +41 -0
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
- package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
- package/dist/core/__tests__/on-read-identity.test.js +8 -15
- package/dist/core/__tests__/review-model-floor.test.js +2 -2
- package/dist/core/__tests__/revive.test.js +2 -2
- package/dist/core/__tests__/tmux-surface.test.js +10 -4
- package/dist/core/__tests__/worktree.test.js +3 -3
- package/dist/core/auth-file.d.ts +3 -2
- package/dist/core/auth-file.js +5 -3
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +18 -11
- package/dist/core/canvas/labels.d.ts +4 -5
- package/dist/core/canvas/labels.js +9 -9
- package/dist/core/canvas/types.d.ts +6 -1
- package/dist/core/command-manifests/schema.js +18 -5
- package/dist/core/command.js +17 -7
- package/dist/core/config.d.ts +3 -1
- package/dist/core/config.js +88 -2
- package/dist/core/configured-clis/invoker.js +2 -0
- package/dist/core/help.d.ts +3 -1
- package/dist/core/help.js +5 -2
- package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
- package/dist/core/keybindings/catalog.d.ts +3 -2
- package/dist/core/keybindings/catalog.js +34 -25
- package/dist/core/keybindings/index.d.ts +1 -1
- package/dist/core/keybindings/resolve.js +8 -0
- package/dist/core/keybindings/types.d.ts +6 -1
- package/dist/core/memory/doc-link-grammar.d.ts +20 -0
- package/dist/core/memory/doc-link-grammar.js +110 -0
- package/dist/core/memory-resolver.d.ts +5 -0
- package/dist/core/memory-resolver.js +12 -1
- package/dist/core/model-routes.d.ts +37 -0
- package/dist/core/model-routes.js +140 -0
- package/dist/core/runtime/bearings.d.ts +8 -8
- package/dist/core/runtime/bearings.js +20 -16
- package/dist/core/runtime/broker.d.ts +0 -4
- package/dist/core/runtime/broker.js +119 -385
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/launch.d.ts +10 -12
- package/dist/core/runtime/launch.js +16 -22
- package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
- package/dist/core/runtime/managed-provider-cooling.js +0 -19
- package/dist/core/runtime/model-swap.js +7 -1
- package/dist/core/runtime/promote.js +1 -0
- package/dist/core/runtime/tmux.js +136 -100
- package/dist/core/scope.js +4 -5
- package/dist/core/subscription-state.d.ts +12 -36
- package/dist/core/subscription-state.js +19 -203
- package/dist/core/substrate/ceiling.d.ts +3 -6
- package/dist/core/substrate/ceiling.js +13 -15
- package/dist/core/substrate/index.d.ts +2 -3
- package/dist/core/substrate/index.js +2 -2
- package/dist/core/substrate/on-read.d.ts +4 -13
- package/dist/core/substrate/on-read.js +142 -262
- package/dist/core/substrate/render.js +4 -4
- package/dist/core/substrate/schema.d.ts +3 -2
- package/dist/core/substrate/schema.js +5 -5
- package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
- package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
- package/dist/daemon/api/handlers/modelauth.js +51 -2
- package/dist/daemon/api/handlers/nodes.js +2 -0
- package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
- package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
- package/dist/pi-extensions/canvas-tool-guide.js +74 -0
- package/dist/types.d.ts +17 -0
- package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts
CHANGED
|
@@ -3,7 +3,8 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { LockedJsonFile } from "../../../../core/locked-file.ts";
|
|
6
|
-
import
|
|
6
|
+
import { expandModelCandidates } from "../../../../core/model-routes.ts";
|
|
7
|
+
import type { SubscriptionCredential } from "../../lib/subscription-state.ts";
|
|
7
8
|
import { dirname, join } from "node:path";
|
|
8
9
|
import { tmpdir } from "node:os";
|
|
9
10
|
|
|
@@ -12,15 +13,18 @@ import { tmpdir } from "node:os";
|
|
|
12
13
|
const lockedFileUrl = new URL("../../../../core/locked-file.ts", import.meta.url).href;
|
|
13
14
|
|
|
14
15
|
let home: string;
|
|
16
|
+
let nodeCwd: string;
|
|
15
17
|
let helpers: typeof import("../../lib/subscription-state.ts");
|
|
16
18
|
let providerManagement: typeof import("../../../../core/provider-management.ts");
|
|
17
19
|
let rotationModule: typeof import("../provider-rotation.ts");
|
|
18
20
|
let providerRotationExtension: typeof import("../provider-rotation.ts").default;
|
|
19
21
|
const previousHome = process.env.HOME;
|
|
20
22
|
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
23
|
+
const previousModelIntent = process.env.CRTR_MODEL_INTENT;
|
|
24
|
+
const previousNodeCwd = process.env.CRTR_NODE_CWD;
|
|
21
25
|
|
|
22
26
|
function writeLadderConfig() {
|
|
23
|
-
const configPath = join(
|
|
27
|
+
const configPath = join(nodeCwd, ".crouter", "config.json");
|
|
24
28
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
25
29
|
writeFileSync(
|
|
26
30
|
configPath,
|
|
@@ -65,7 +69,7 @@ async function resetRotationState() {
|
|
|
65
69
|
rmSync(join(home, ".pi", "agent", "auth.json"), { force: true });
|
|
66
70
|
await helpers.writeSubscriptionPool("anthropic", []);
|
|
67
71
|
await helpers.writeSubscriptionPool("openai-codex", []);
|
|
68
|
-
|
|
72
|
+
rmSync(join(home, ".pi", "agent", "provider-rotation.json"), { force: true });
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
function observeLines(stream: NodeJS.ReadableStream, onLine: (line: string) => void): void {
|
|
@@ -101,8 +105,10 @@ function within<T>(promise: Promise<T>, ms: number, message: string): Promise<T>
|
|
|
101
105
|
|
|
102
106
|
before(async () => {
|
|
103
107
|
home = mkdtempSync(join(tmpdir(), "pi-provider-rotation-"));
|
|
108
|
+
nodeCwd = mkdtempSync(join(tmpdir(), "pi-provider-rotation-cwd-"));
|
|
104
109
|
process.env.HOME = home;
|
|
105
110
|
process.env.PI_CODING_AGENT_DIR = join(home, ".pi", "agent");
|
|
111
|
+
process.env.CRTR_NODE_CWD = nodeCwd;
|
|
106
112
|
helpers = await import("../../lib/subscription-state.ts");
|
|
107
113
|
providerManagement = await import("../../../../core/provider-management.ts");
|
|
108
114
|
rotationModule = await import("../provider-rotation.ts");
|
|
@@ -114,7 +120,12 @@ after(() => {
|
|
|
114
120
|
else process.env.HOME = previousHome;
|
|
115
121
|
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
116
122
|
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
|
123
|
+
if (previousModelIntent === undefined) delete process.env.CRTR_MODEL_INTENT;
|
|
124
|
+
else process.env.CRTR_MODEL_INTENT = previousModelIntent;
|
|
125
|
+
if (previousNodeCwd === undefined) delete process.env.CRTR_NODE_CWD;
|
|
126
|
+
else process.env.CRTR_NODE_CWD = previousNodeCwd;
|
|
117
127
|
rmSync(home, { recursive: true, force: true });
|
|
128
|
+
rmSync(nodeCwd, { recursive: true, force: true });
|
|
118
129
|
});
|
|
119
130
|
|
|
120
131
|
test("normalizes provider subscription pool files", async () => {
|
|
@@ -491,17 +502,21 @@ test("loginWithManualInputCleanup's readCode rejects (rather than resolving to \
|
|
|
491
502
|
);
|
|
492
503
|
});
|
|
493
504
|
|
|
494
|
-
test("
|
|
505
|
+
test("the synthesized ladder registry preserves family preference before configured strength fallback", async () => {
|
|
495
506
|
await resetRotationState();
|
|
496
507
|
writeLadderConfig();
|
|
508
|
+
helpers.writeRotationConfig({ defaultFallbackStrength: "medium" });
|
|
497
509
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
label: "openai-codex/gpt-5.6-sol:high",
|
|
510
|
+
const candidates = expandModelCandidates({
|
|
511
|
+
kind: "logical",
|
|
512
|
+
family: "anthropic",
|
|
502
513
|
strength: "strong",
|
|
503
|
-
|
|
504
|
-
});
|
|
514
|
+
spec: "anthropic/claude-opus-4-8:high",
|
|
515
|
+
}, home, null);
|
|
516
|
+
|
|
517
|
+
assert.deepEqual(candidates.map((candidate) => `${candidate.family}/${candidate.strength}`), [
|
|
518
|
+
"anthropic/strong", "openai/strong", "anthropic/medium", "openai/medium",
|
|
519
|
+
]);
|
|
505
520
|
});
|
|
506
521
|
|
|
507
522
|
test("shared launch/general candidate policy preserves a viable request, skips a cooling request, and reports total exhaustion", async () => {
|
|
@@ -525,45 +540,6 @@ test("shared launch/general candidate policy preserves a viable request, skips a
|
|
|
525
540
|
);
|
|
526
541
|
});
|
|
527
542
|
|
|
528
|
-
test("resolves OpenAI Codex to strength-matched Anthropic fallback", async () => {
|
|
529
|
-
writeLadderConfig();
|
|
530
|
-
|
|
531
|
-
assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "openai-codex/gpt-5.6-terra:medium"), {
|
|
532
|
-
providerId: "anthropic",
|
|
533
|
-
modelId: "claude-sonnet-4-6",
|
|
534
|
-
label: "anthropic/claude-sonnet-4-6:high",
|
|
535
|
-
strength: "medium",
|
|
536
|
-
thinkingLevel: "high",
|
|
537
|
-
});
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
// Ultra and strong share the bare model id gpt-5.6-sol; the live thinking level selects
|
|
541
|
-
// the matching ladder rung.
|
|
542
|
-
test("disambiguates colliding ladder rungs by the live thinking level", async () => {
|
|
543
|
-
await resetRotationState();
|
|
544
|
-
writeLadderConfig();
|
|
545
|
-
const config = helpers.readRotationConfig();
|
|
546
|
-
|
|
547
|
-
// max -> ultra rung -> Anthropic ultra (claude-fable-5)
|
|
548
|
-
assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config, "max"), {
|
|
549
|
-
providerId: "anthropic",
|
|
550
|
-
modelId: "claude-fable-5",
|
|
551
|
-
label: "anthropic/claude-fable-5:high",
|
|
552
|
-
strength: "ultra",
|
|
553
|
-
thinkingLevel: "high",
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
// high -> strong rung -> Anthropic strong (claude-opus-4-8)
|
|
557
|
-
const highTarget = helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config, "high");
|
|
558
|
-
assert.ok(highTarget);
|
|
559
|
-
assert.equal(highTarget.modelId, "claude-opus-4-8");
|
|
560
|
-
|
|
561
|
-
// No thinking level falls back to the default-strength tie-break (strong -> opus).
|
|
562
|
-
const defaultTarget = helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config);
|
|
563
|
-
assert.ok(defaultTarget);
|
|
564
|
-
assert.equal(defaultTarget.modelId, "claude-opus-4-8");
|
|
565
|
-
});
|
|
566
|
-
|
|
567
543
|
test("parses Retry-After seconds and HTTP dates", async () => {
|
|
568
544
|
const now = Date.UTC(2026, 5, 16, 18, 30, 0);
|
|
569
545
|
assert.equal(helpers.parseRetryAfterHeader("1.5", now), 1500);
|
|
@@ -575,63 +551,6 @@ test("parses Retry-After seconds and HTTP dates", async () => {
|
|
|
575
551
|
assert.equal(helpers.parseRetryAfterHeader("not-a-delay", now), undefined);
|
|
576
552
|
});
|
|
577
553
|
|
|
578
|
-
test("restores preferred model when its provider pool frees up", async () => {
|
|
579
|
-
await resetRotationState();
|
|
580
|
-
writeLadderConfig();
|
|
581
|
-
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
582
|
-
helpers.writeRotationConfig({ preferredModel: { providerId: "openai-codex", modelId: "gpt-5.6-sol" } });
|
|
583
|
-
|
|
584
|
-
const setModelCalls: any[] = [];
|
|
585
|
-
const ctx = {
|
|
586
|
-
model: { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
|
|
587
|
-
modelRegistry: {
|
|
588
|
-
find(provider: string, id: string) {
|
|
589
|
-
return { provider, id, api: provider === "openai-codex" ? "openai-codex-responses" : "anthropic-messages" };
|
|
590
|
-
},
|
|
591
|
-
},
|
|
592
|
-
} as any;
|
|
593
|
-
const pi = {
|
|
594
|
-
async setModel(model: any) {
|
|
595
|
-
setModelCalls.push(model);
|
|
596
|
-
ctx.model = model;
|
|
597
|
-
return true;
|
|
598
|
-
},
|
|
599
|
-
} as any;
|
|
600
|
-
|
|
601
|
-
const restored = await helpers.restorePreferredModelIfPossible(pi, ctx);
|
|
602
|
-
|
|
603
|
-
assert.equal(restored, true);
|
|
604
|
-
assert.deepEqual(setModelCalls[0], {
|
|
605
|
-
provider: "openai-codex",
|
|
606
|
-
id: "gpt-5.6-sol",
|
|
607
|
-
api: "openai-codex-responses",
|
|
608
|
-
});
|
|
609
|
-
assert.equal(helpers.readRotationConfig().preferredModel, undefined);
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
test("pinned and exact launches retain their captured model during startup preferred-model restoration", async () => {
|
|
613
|
-
for (const reason of ["provider-pinned", "model-exact"] as const) {
|
|
614
|
-
await resetRotationState();
|
|
615
|
-
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
616
|
-
helpers.writeRotationConfig({ preferredModel: { providerId: "openai-codex", modelId: "gpt-5.6-sol" } });
|
|
617
|
-
|
|
618
|
-
const { ctx, events, pi, setModelCalls } = makeRotationCtx();
|
|
619
|
-
ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
|
|
620
|
-
ctx.ui[Symbol.for("@crouton-kit/crtr:managed-provider-rotation-policy")] = () => ({
|
|
621
|
-
allowCrossProviderFallback: false,
|
|
622
|
-
reason,
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
await providerRotationExtension(pi);
|
|
626
|
-
await events.session_start?.({}, ctx);
|
|
627
|
-
await events.before_agent_start?.({}, ctx);
|
|
628
|
-
|
|
629
|
-
assert.deepEqual(setModelCalls, [], `${reason} launches must not restore the shared preferred model`);
|
|
630
|
-
assert.deepEqual(ctx.model, { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" });
|
|
631
|
-
assert.deepEqual(helpers.readRotationConfig().preferredModel, { providerId: "openai-codex", modelId: "gpt-5.6-sol" });
|
|
632
|
-
}
|
|
633
|
-
});
|
|
634
|
-
|
|
635
554
|
test("keeps newest Retry-After cooldown across overlapping attempts", async () => {
|
|
636
555
|
await resetRotationState();
|
|
637
556
|
await helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
|
|
@@ -1029,101 +948,13 @@ test("treats numeric subscription refs as 1-based indexes", async () => {
|
|
|
1029
948
|
);
|
|
1030
949
|
});
|
|
1031
950
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
const ctx = {
|
|
1037
|
-
model: { provider: "anthropic", id: "claude-sonnet-4-6", api: "anthropic-messages" },
|
|
1038
|
-
modelRegistry: {
|
|
1039
|
-
find(provider: string, id: string) {
|
|
1040
|
-
return provider === "openai-codex" && id === "gpt-5.6-terra" ? { provider, id, api: "openai-codex-responses" } : undefined;
|
|
1041
|
-
},
|
|
1042
|
-
},
|
|
1043
|
-
} as any;
|
|
1044
|
-
let switchedTo: any;
|
|
1045
|
-
let thinkingLevel: string | undefined;
|
|
1046
|
-
const piApi = {
|
|
1047
|
-
async setModel(model: any) {
|
|
1048
|
-
switchedTo = model;
|
|
1049
|
-
ctx.model = model;
|
|
1050
|
-
return true;
|
|
1051
|
-
},
|
|
1052
|
-
setThinkingLevel(level: string) {
|
|
1053
|
-
thinkingLevel = level;
|
|
1054
|
-
},
|
|
1055
|
-
} as any;
|
|
1056
|
-
|
|
1057
|
-
const target = await helpers.switchToFallbackIfPossible(piApi, ctx);
|
|
1058
|
-
|
|
1059
|
-
assert.deepEqual(target, {
|
|
1060
|
-
providerId: "openai-codex",
|
|
1061
|
-
modelId: "gpt-5.6-terra",
|
|
1062
|
-
label: "openai-codex/gpt-5.6-terra:medium",
|
|
1063
|
-
strength: "medium",
|
|
1064
|
-
thinkingLevel: "medium",
|
|
1065
|
-
});
|
|
1066
|
-
assert.deepEqual(switchedTo, {
|
|
1067
|
-
provider: "openai-codex",
|
|
1068
|
-
id: "gpt-5.6-terra",
|
|
1069
|
-
api: "openai-codex-responses",
|
|
1070
|
-
});
|
|
1071
|
-
assert.equal(thinkingLevel, "medium");
|
|
1072
|
-
assert.deepEqual(helpers.readRotationConfig().preferredModel, {
|
|
1073
|
-
providerId: "anthropic",
|
|
1074
|
-
modelId: "claude-sonnet-4-6",
|
|
1075
|
-
});
|
|
1076
|
-
});
|
|
1077
|
-
|
|
1078
|
-
test("switches exhausted OpenAI Codex sessions to Anthropic fallback", async () => {
|
|
1079
|
-
await resetRotationState();
|
|
1080
|
-
writeLadderConfig();
|
|
1081
|
-
|
|
951
|
+
function makeRotationCtx(modelIntent?: string) {
|
|
952
|
+
const concreteRequest = arguments.length > 0 && modelIntent === undefined;
|
|
953
|
+
if (modelIntent === undefined) delete process.env.CRTR_MODEL_INTENT;
|
|
954
|
+
else process.env.CRTR_MODEL_INTENT = modelIntent;
|
|
1082
955
|
const ctx = {
|
|
1083
956
|
model: { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
|
|
1084
|
-
modelRegistry: {
|
|
1085
|
-
find(provider: string, id: string) {
|
|
1086
|
-
return provider === "anthropic" && id === "claude-opus-4-8" ? { provider, id, api: "anthropic-messages" } : undefined;
|
|
1087
|
-
},
|
|
1088
|
-
},
|
|
1089
|
-
} as any;
|
|
1090
|
-
let switchedTo: any;
|
|
1091
|
-
let thinkingLevel: string | undefined;
|
|
1092
|
-
const piApi = {
|
|
1093
|
-
async setModel(model: any) {
|
|
1094
|
-
switchedTo = model;
|
|
1095
|
-
ctx.model = model;
|
|
1096
|
-
return true;
|
|
1097
|
-
},
|
|
1098
|
-
setThinkingLevel(level: string) {
|
|
1099
|
-
thinkingLevel = level;
|
|
1100
|
-
},
|
|
1101
|
-
} as any;
|
|
1102
|
-
|
|
1103
|
-
const target = await helpers.switchToFallbackIfPossible(piApi, ctx);
|
|
1104
|
-
|
|
1105
|
-
assert.deepEqual(target, {
|
|
1106
|
-
providerId: "anthropic",
|
|
1107
|
-
modelId: "claude-opus-4-8",
|
|
1108
|
-
label: "anthropic/claude-opus-4-8:high",
|
|
1109
|
-
strength: "strong",
|
|
1110
957
|
thinkingLevel: "high",
|
|
1111
|
-
});
|
|
1112
|
-
assert.deepEqual(switchedTo, {
|
|
1113
|
-
provider: "anthropic",
|
|
1114
|
-
id: "claude-opus-4-8",
|
|
1115
|
-
api: "anthropic-messages",
|
|
1116
|
-
});
|
|
1117
|
-
assert.equal(thinkingLevel, "high");
|
|
1118
|
-
assert.deepEqual(helpers.readRotationConfig().preferredModel, {
|
|
1119
|
-
providerId: "openai-codex",
|
|
1120
|
-
modelId: "gpt-5.6-sol",
|
|
1121
|
-
});
|
|
1122
|
-
});
|
|
1123
|
-
|
|
1124
|
-
function makeRotationCtx() {
|
|
1125
|
-
const ctx = {
|
|
1126
|
-
model: { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
|
|
1127
958
|
ui: { setStatus() {} },
|
|
1128
959
|
modelRegistry: {
|
|
1129
960
|
find(provider: string, id: string) {
|
|
@@ -1134,12 +965,22 @@ function makeRotationCtx() {
|
|
|
1134
965
|
},
|
|
1135
966
|
} as any;
|
|
1136
967
|
const setModelCalls: any[] = [];
|
|
968
|
+
const thinkingLevelCalls: string[] = [];
|
|
1137
969
|
const providers: Record<string, { streamSimple: (...args: any[]) => AsyncIterable<any> }> = {};
|
|
1138
970
|
const events: Record<string, (...args: any[]) => unknown> = {};
|
|
1139
971
|
const commands: Record<string, { handler: (args: string, ctx: any) => unknown }> = {};
|
|
1140
972
|
const pi = {
|
|
1141
973
|
registerProvider(name: string, provider: { streamSimple: (...args: any[]) => AsyncIterable<any> }) {
|
|
1142
|
-
providers[name] =
|
|
974
|
+
providers[name] = {
|
|
975
|
+
...provider,
|
|
976
|
+
streamSimple(...args: any[]) {
|
|
977
|
+
if (!concreteRequest) {
|
|
978
|
+
const model = args[0] as { provider: string };
|
|
979
|
+
process.env.CRTR_MODEL_INTENT = model.provider === "anthropic" ? "anthropic/strong" : "openai/strong";
|
|
980
|
+
}
|
|
981
|
+
return provider.streamSimple(...args);
|
|
982
|
+
},
|
|
983
|
+
};
|
|
1143
984
|
},
|
|
1144
985
|
registerCommand(name: string, command: { handler: (args: string, ctx: any) => unknown }) {
|
|
1145
986
|
commands[name] = command;
|
|
@@ -1152,11 +993,42 @@ function makeRotationCtx() {
|
|
|
1152
993
|
ctx.model = model;
|
|
1153
994
|
return true;
|
|
1154
995
|
},
|
|
1155
|
-
setThinkingLevel() {
|
|
996
|
+
setThinkingLevel(level: string) {
|
|
997
|
+
thinkingLevelCalls.push(level);
|
|
998
|
+
ctx.thinkingLevel = level;
|
|
999
|
+
},
|
|
1156
1000
|
} as any;
|
|
1157
|
-
return { ctx, setModelCalls, providers, events, commands, pi };
|
|
1001
|
+
return { ctx, setModelCalls, thinkingLevelCalls, providers, events, commands, pi };
|
|
1158
1002
|
}
|
|
1159
1003
|
|
|
1004
|
+
// A live route carries the session's exact thinking level through every provider
|
|
1005
|
+
// request, including the requests that continue a turn after tool results.
|
|
1006
|
+
test("preserves a manually selected thinking level across provider requests", async () => {
|
|
1007
|
+
await resetRotationState();
|
|
1008
|
+
writeLadderConfig();
|
|
1009
|
+
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
1010
|
+
|
|
1011
|
+
const { ctx, thinkingLevelCalls, providers, events, pi } = makeRotationCtx("openai/strong");
|
|
1012
|
+
ctx.thinkingLevel = "max";
|
|
1013
|
+
rotationModule.__setStreamForProviderForTest(() => (async function* () {
|
|
1014
|
+
yield { type: "text_delta", text: "continued after tool result" };
|
|
1015
|
+
})() as any);
|
|
1016
|
+
|
|
1017
|
+
try {
|
|
1018
|
+
await providerRotationExtension(pi);
|
|
1019
|
+
await events.session_start?.({}, ctx);
|
|
1020
|
+
const stream = providers["openai-codex"].streamSimple(ctx.model, ctx);
|
|
1021
|
+
for await (const _event of stream) {
|
|
1022
|
+
// Drain the managed-provider request to completion.
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
assert.deepEqual(thinkingLevelCalls, ["max"]);
|
|
1026
|
+
assert.equal(ctx.thinkingLevel, "max");
|
|
1027
|
+
} finally {
|
|
1028
|
+
rotationModule.__setStreamForProviderForTest(undefined);
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1160
1032
|
// Codex exhaustion switches the model and serves the same turn on the Anthropic fallback.
|
|
1161
1033
|
test("falls back transparently and serves the turn on Anthropic when Codex is exhausted", async () => {
|
|
1162
1034
|
await resetRotationState();
|
|
@@ -1195,12 +1067,9 @@ test("falls back transparently and serves the turn on Anthropic when Codex is ex
|
|
|
1195
1067
|
}
|
|
1196
1068
|
});
|
|
1197
1069
|
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
|
|
1201
|
-
// cooldown then rotated the supposedly-pinned session through every Anthropic
|
|
1202
|
-
// credential. The live broker policy must be consulted on every provider call.
|
|
1203
|
-
test("an explicit live Sol pin forbids Anthropic fallback on a post-tool provider continuation", async () => {
|
|
1070
|
+
// A concrete request only expands to routes carrying its exact model id. A
|
|
1071
|
+
// cooling Codex Sol route must not substitute Anthropic's different model.
|
|
1072
|
+
test("a cooling concrete model never substitutes a different-provider model", async () => {
|
|
1204
1073
|
await resetRotationState();
|
|
1205
1074
|
writeLadderConfig();
|
|
1206
1075
|
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
@@ -1208,11 +1077,7 @@ test("an explicit live Sol pin forbids Anthropic fallback on a post-tool provide
|
|
|
1208
1077
|
const expectedDeadline = helpers.readSubscriptionPool("openai-codex")[0].rateLimitedUntil;
|
|
1209
1078
|
await helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
|
|
1210
1079
|
|
|
1211
|
-
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
|
|
1212
|
-
ctx.ui[Symbol.for("@crouton-kit/crtr:managed-provider-rotation-policy")] = () => ({
|
|
1213
|
-
allowCrossProviderFallback: false,
|
|
1214
|
-
reason: "provider-pinned",
|
|
1215
|
-
});
|
|
1080
|
+
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx(undefined);
|
|
1216
1081
|
let streamCalls = 0;
|
|
1217
1082
|
rotationModule.__setStreamForProviderForTest((_model) => {
|
|
1218
1083
|
streamCalls += 1;
|
|
@@ -1231,10 +1096,10 @@ test("an explicit live Sol pin forbids Anthropic fallback on a post-tool provide
|
|
|
1231
1096
|
const emitted = [] as any[];
|
|
1232
1097
|
for await (const event of stream) emitted.push(event);
|
|
1233
1098
|
|
|
1234
|
-
assert.deepEqual(setModelCalls, [], "the
|
|
1099
|
+
assert.deepEqual(setModelCalls, [], "the concrete Sol request never switches to Anthropic");
|
|
1235
1100
|
assert.equal(streamCalls, 0);
|
|
1236
1101
|
assert.equal(emitted.length, 1);
|
|
1237
|
-
assert.match(emitted[0].error.errorMessage, /
|
|
1102
|
+
assert.match(emitted[0].error.errorMessage, /All provider routes are rate limited/i);
|
|
1238
1103
|
assert.equal(emitted[0].error.diagnostics?.[0]?.details?.rateLimitedUntil, expectedDeadline);
|
|
1239
1104
|
} finally {
|
|
1240
1105
|
rotationModule.__setStreamForProviderForTest(undefined);
|
|
@@ -1346,8 +1211,6 @@ test("surfaces the transient error and rotates to the fallback once the in-place
|
|
|
1346
1211
|
// The turn was served on the fallback, never failed with a terminal error.
|
|
1347
1212
|
assert.ok(emitted.some((e) => e.type === "text_delta"));
|
|
1348
1213
|
assert.ok(!emitted.some((e) => e.type === "error"));
|
|
1349
|
-
// The user was told about the transient failure AND that it rotated.
|
|
1350
|
-
assert.ok(notifications.some((n) => /transient error after 3 attempts/.test(n.message) && /rotating/i.test(n.message)));
|
|
1351
1214
|
// The struggling anthropic credential was cooled down so rotation moved on.
|
|
1352
1215
|
assert.ok(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil > Date.now());
|
|
1353
1216
|
} finally {
|
|
@@ -1758,14 +1621,14 @@ test("emits an actionable 'not authenticated' error without switching models whe
|
|
|
1758
1621
|
assert.deepEqual(setModelCalls, []);
|
|
1759
1622
|
assert.equal(emitted.length, 1);
|
|
1760
1623
|
assert.equal(emitted[0].type, "error");
|
|
1761
|
-
assert.match(emitted[0].error.errorMessage, /
|
|
1624
|
+
assert.match(emitted[0].error.errorMessage, /All provider routes exhausted \(tried openai\)/);
|
|
1762
1625
|
});
|
|
1763
1626
|
|
|
1764
1627
|
// The builtin ladder is available even with no ~/.crouter/config.json, so a config-less
|
|
1765
1628
|
// user can still resolve cross-provider fallbacks.
|
|
1766
1629
|
test("degrades gracefully via the builtin ladder when a config-less user has no cross-provider credentials", async () => {
|
|
1767
1630
|
await resetRotationState();
|
|
1768
|
-
rmSync(join(
|
|
1631
|
+
rmSync(join(nodeCwd, ".crouter", "config.json"), { force: true });
|
|
1769
1632
|
|
|
1770
1633
|
const ctx = {
|
|
1771
1634
|
model: { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
|
|
@@ -1797,6 +1660,7 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
|
|
|
1797
1660
|
setThinkingLevel() {},
|
|
1798
1661
|
} as any;
|
|
1799
1662
|
|
|
1663
|
+
process.env.CRTR_MODEL_INTENT = "anthropic/strong";
|
|
1800
1664
|
await providerRotationExtension(pi);
|
|
1801
1665
|
await events.session_start?.({}, ctx);
|
|
1802
1666
|
// Anthropic's pool is empty, so the credential loop never runs. The builtin ladder resolves
|
|
@@ -1809,26 +1673,10 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
|
|
|
1809
1673
|
assert.deepEqual(setModelCalls, []);
|
|
1810
1674
|
assert.equal(emitted.length, 1);
|
|
1811
1675
|
assert.equal(emitted[0].type, "error");
|
|
1812
|
-
assert.match(emitted[0].error.errorMessage, /
|
|
1676
|
+
assert.match(emitted[0].error.errorMessage, /All provider routes exhausted \(tried live:anthropic\/claude-opus-4-8\)/);
|
|
1813
1677
|
assert.ok(!emitted[0].error.errorMessage.includes("configure a openai ladder"));
|
|
1814
1678
|
});
|
|
1815
1679
|
|
|
1816
|
-
// readModelLadders() reads the builtin anthropic/openai ladder when config.json is absent.
|
|
1817
|
-
test("resolves the builtin anthropic ladder (medium = sonnet-5) with no ~/.crouter/config.json present", async () => {
|
|
1818
|
-
await resetRotationState();
|
|
1819
|
-
rmSync(join(home, ".crouter", "config.json"), { force: true });
|
|
1820
|
-
|
|
1821
|
-
const target = helpers.resolveFallbackTarget("anthropic", "anthropic/claude-sonnet-5:xhigh");
|
|
1822
|
-
|
|
1823
|
-
assert.deepEqual(target, {
|
|
1824
|
-
providerId: "openai-codex",
|
|
1825
|
-
modelId: "gpt-5.6-terra",
|
|
1826
|
-
label: "openai-codex/gpt-5.6-terra:medium",
|
|
1827
|
-
strength: "medium",
|
|
1828
|
-
thinkingLevel: "medium",
|
|
1829
|
-
});
|
|
1830
|
-
});
|
|
1831
|
-
|
|
1832
1680
|
// A cooling provider must settle the turn with a typed retry deadline. Holding
|
|
1833
1681
|
// the stream open here rendered the node as generic `Working...` for five silent
|
|
1834
1682
|
// minutes and prevented the broker's existing scheduled retry path from owning
|
|
@@ -1952,9 +1800,7 @@ test("emits a typed rate-limit cooling error for a long provider deadline", asyn
|
|
|
1952
1800
|
assert.equal(emitted[0].type, "error");
|
|
1953
1801
|
// Rate-limit-classifiable so classify('pi→provider', msg) → rate-limit/auto (not fatal other).
|
|
1954
1802
|
assert.match(emitted[0].error.errorMessage, /rate limit/i);
|
|
1955
|
-
//
|
|
1956
|
-
assert.match(emitted[0].error.errorMessage, /authenticate a fallback provider/);
|
|
1957
|
-
// And the typed cooling diagnostic carrying the exact pool deadline for the stophook.
|
|
1803
|
+
// The typed cooling diagnostic carries the exact pool deadline for the stophook.
|
|
1958
1804
|
assert.equal(emitted[0].error.diagnostics?.[0]?.type, "crtr:managed-provider-cooling");
|
|
1959
1805
|
assert.equal(emitted[0].error.diagnostics?.[0]?.details?.rateLimitedUntil, expectedDeadline);
|
|
1960
1806
|
} finally {
|
|
@@ -1988,10 +1834,6 @@ test("an excluded invalid_grant credential does not mask a cooling credential",
|
|
|
1988
1834
|
assert.equal(emitted[0].error.diagnostics?.[0]?.details?.rateLimitedUntil, expectedDeadline);
|
|
1989
1835
|
});
|
|
1990
1836
|
|
|
1991
|
-
// Fix 1 (switchToFallbackIfPossible): pi's `setModel` returns false when the target lacks
|
|
1992
|
-
// configured auth and leaves the model unchanged. A concurrent transition can still land on
|
|
1993
|
-
// the target while that call is in flight; accept only that observed live result and reject
|
|
1994
|
-
// every other false return.
|
|
1995
1837
|
test("a same-turn 429 settles immediately instead of starting cooldown continuations", async () => {
|
|
1996
1838
|
await resetRotationState();
|
|
1997
1839
|
writeLadderConfig();
|
|
@@ -2059,54 +1901,6 @@ test("does not requeue a zero Retry-After credential without a later success", a
|
|
|
2059
1901
|
}
|
|
2060
1902
|
});
|
|
2061
1903
|
|
|
2062
|
-
test("switchToFallbackIfPossible accepts a false setModel only when the live model already equals the target", async () => {
|
|
2063
|
-
await resetRotationState();
|
|
2064
|
-
writeLadderConfig();
|
|
2065
|
-
|
|
2066
|
-
const target = helpers.resolveFallbackTarget("anthropic", "anthropic/claude-opus-4-8:high");
|
|
2067
|
-
assert.ok(target && target.providerId === "openai-codex" && target.modelId === "gpt-5.6-sol", "anthropic opus falls back to codex sol");
|
|
2068
|
-
|
|
2069
|
-
// Benign: setModel returns false without changing the model, while an independent
|
|
2070
|
-
// concurrent transition lands on the exact target before the required live re-read.
|
|
2071
|
-
{
|
|
2072
|
-
let modelReads = 0;
|
|
2073
|
-
const ctx = {
|
|
2074
|
-
get model() {
|
|
2075
|
-
modelReads++;
|
|
2076
|
-
return modelReads === 1
|
|
2077
|
-
? { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" }
|
|
2078
|
-
: { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" };
|
|
2079
|
-
},
|
|
2080
|
-
modelRegistry: { find: (provider: string, id: string) => ({ provider, id, api: "x" }) },
|
|
2081
|
-
} as any;
|
|
2082
|
-
const pi = {
|
|
2083
|
-
async setModel(_m: any) { return false; },
|
|
2084
|
-
setThinkingLevel() {},
|
|
2085
|
-
getThinkingLevel() { return undefined; },
|
|
2086
|
-
} as any;
|
|
2087
|
-
const result = await helpers.switchToFallbackIfPossible(pi, ctx);
|
|
2088
|
-
assert.ok(result, "a false setModel with the target already current must be accepted");
|
|
2089
|
-
assert.equal(result!.providerId, "openai-codex");
|
|
2090
|
-
assert.equal(result!.modelId, "gpt-5.6-sol");
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
// Real failure: setModel returns false and the live model stays DIFFERENT from the target
|
|
2094
|
-
// → the switch is a genuine failure and must be rejected (undefined).
|
|
2095
|
-
{
|
|
2096
|
-
const ctx = {
|
|
2097
|
-
model: { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
|
|
2098
|
-
modelRegistry: { find: (provider: string, id: string) => ({ provider, id, api: "x" }) },
|
|
2099
|
-
} as any;
|
|
2100
|
-
const pi = {
|
|
2101
|
-
async setModel(_m: any) { return false; }, // leaves ctx.model on anthropic
|
|
2102
|
-
setThinkingLevel() {},
|
|
2103
|
-
getThinkingLevel() { return undefined; },
|
|
2104
|
-
} as any;
|
|
2105
|
-
const result = await helpers.switchToFallbackIfPossible(pi, ctx);
|
|
2106
|
-
assert.equal(result, undefined, "a false setModel that did not land on the target is a real failure");
|
|
2107
|
-
}
|
|
2108
|
-
});
|
|
2109
|
-
|
|
2110
1904
|
// Fresh-availability re-entry (Fix 3a): a credential that becomes ready AFTER the initial
|
|
2111
1905
|
// `available` snapshot (a concurrent turn's cooldown expiring, or a causally valid success)
|
|
2112
1906
|
// must be consumed by re-entering the SAME provider, not lost to a stale snapshot that would
|
|
@@ -2221,103 +2015,6 @@ test("both pools cooling: emits a rate-limit-classifiable terminal carrying the
|
|
|
2221
2015
|
}
|
|
2222
2016
|
});
|
|
2223
2017
|
|
|
2224
|
-
// Live-incident regression (Hearth: a home's config.json had `gpt-5.6-*` ladder rungs baked
|
|
2225
|
-
// in while the installed guest's model registry only had `gpt-5.5`): when the ONLY viable
|
|
2226
|
-
// fallback target is authenticated (its pool has a credential) but its resolved model isn't
|
|
2227
|
-
// in this build's registry, the terminal error must name that specific reason -- not the old
|
|
2228
|
-
// generic "authenticate a fallback provider" text, which is actively wrong when the real
|
|
2229
|
-
// blocker is a stale/out-of-sync ladder, not a missing login.
|
|
2230
|
-
test("surfaces a specific 'not in model registry' reason instead of misleadingly telling the user to authenticate", async () => {
|
|
2231
|
-
await resetRotationState();
|
|
2232
|
-
writeLadderConfig();
|
|
2233
|
-
await helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
|
|
2234
|
-
await helpers.markSubscriptionRateLimited("anthropic", "primary", 30 * 24 * 60 * 60 * 1000);
|
|
2235
|
-
// OpenAI Codex IS authenticated -- the fallback rejection must NOT be "no credentials".
|
|
2236
|
-
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
2237
|
-
|
|
2238
|
-
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
|
|
2239
|
-
ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
|
|
2240
|
-
// Registry recognizes anthropic's own model but NOT the configured openai-codex fallback
|
|
2241
|
-
// rung (`gpt-5.6-sol`, from writeLadderConfig) -- simulating an installed guest whose
|
|
2242
|
-
// catalog hasn't caught up with the configured ladder.
|
|
2243
|
-
ctx.modelRegistry = {
|
|
2244
|
-
find(provider: string, id: string) {
|
|
2245
|
-
return provider === "anthropic" && id === "claude-opus-4-8" ? { provider, id, api: "anthropic-messages" } : undefined;
|
|
2246
|
-
},
|
|
2247
|
-
};
|
|
2248
|
-
const sleepCalls: number[] = [];
|
|
2249
|
-
rotationModule.__setSleepForTest(async (ms) => {
|
|
2250
|
-
sleepCalls.push(ms);
|
|
2251
|
-
});
|
|
2252
|
-
|
|
2253
|
-
try {
|
|
2254
|
-
await providerRotationExtension(pi);
|
|
2255
|
-
await events.session_start?.({}, ctx);
|
|
2256
|
-
const stream = providers["anthropic"].streamSimple(
|
|
2257
|
-
{ provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
|
|
2258
|
-
ctx,
|
|
2259
|
-
);
|
|
2260
|
-
const emitted = [] as any[];
|
|
2261
|
-
for await (const event of stream) emitted.push(event);
|
|
2262
|
-
|
|
2263
|
-
assert.deepEqual(setModelCalls, []);
|
|
2264
|
-
assert.deepEqual(sleepCalls, []);
|
|
2265
|
-
assert.equal(emitted.length, 1);
|
|
2266
|
-
assert.equal(emitted[0].type, "error");
|
|
2267
|
-
assert.match(emitted[0].error.errorMessage, /openai-codex\/gpt-5\.6-sol:high is not in this build's model registry/);
|
|
2268
|
-
assert.match(emitted[0].error.errorMessage, /modelLadders config is out of sync with the installed model catalog/);
|
|
2269
|
-
assert.doesNotMatch(emitted[0].error.errorMessage, /authenticate a fallback provider/);
|
|
2270
|
-
// The repair command must be EXECUTABLE as printed: `--value`, the CONFIG ladder
|
|
2271
|
-
// provider key (`openai`, not the runtime `openai-codex`), and the target's strength.
|
|
2272
|
-
assert.match(
|
|
2273
|
-
emitted[0].error.errorMessage,
|
|
2274
|
-
/crtr sys config set modelLadders\.openai\.strong --value <installed-model-id>/,
|
|
2275
|
-
);
|
|
2276
|
-
} finally {
|
|
2277
|
-
rotationModule.__setSleepForTest(undefined);
|
|
2278
|
-
}
|
|
2279
|
-
});
|
|
2280
|
-
|
|
2281
|
-
// Every fallback-rejection discriminant maps to actionable advice. Only `no_credentials`
|
|
2282
|
-
// advises authentication because the other rejection states require a different remedy.
|
|
2283
|
-
test("fallback rejection hints: only no_credentials advises authentication; every other discriminant gets accurate advice", async () => {
|
|
2284
|
-
const codex = {
|
|
2285
|
-
providerId: "openai-codex",
|
|
2286
|
-
modelId: "gpt-5.6-sol",
|
|
2287
|
-
label: "openai-codex/gpt-5.6-sol:high",
|
|
2288
|
-
strength: "strong",
|
|
2289
|
-
} as FallbackTarget;
|
|
2290
|
-
|
|
2291
|
-
const noCreds = rotationModule.__fallbackRejectionHintForTest("no_credentials", codex);
|
|
2292
|
-
assert.match(noCreds, /authenticate a fallback provider/);
|
|
2293
|
-
assert.match(noCreds, /OpenAI Codex/);
|
|
2294
|
-
assert.match(noCreds, /\/provider-sub openai-codex add/);
|
|
2295
|
-
|
|
2296
|
-
const unregistered = rotationModule.__fallbackRejectionHintForTest("model_unregistered", codex);
|
|
2297
|
-
assert.doesNotMatch(unregistered, /authenticate a fallback provider/);
|
|
2298
|
-
assert.match(unregistered, /not in this build's model registry/);
|
|
2299
|
-
// Executable repair command: --value, CONFIG ladder key (openai), target strength.
|
|
2300
|
-
assert.match(unregistered, /crtr sys config set modelLadders\.openai\.strong --value <installed-model-id>/);
|
|
2301
|
-
|
|
2302
|
-
const alreadyTried = rotationModule.__fallbackRejectionHintForTest("already_attempted", codex);
|
|
2303
|
-
assert.doesNotMatch(alreadyTried, /authenticate a fallback provider/);
|
|
2304
|
-
assert.match(alreadyTried, /already tried this turn/);
|
|
2305
|
-
assert.match(alreadyTried, /try again later/);
|
|
2306
|
-
|
|
2307
|
-
const cooling = rotationModule.__fallbackRejectionHintForTest("cooling", codex);
|
|
2308
|
-
assert.doesNotMatch(cooling, /authenticate a fallback provider/);
|
|
2309
|
-
assert.match(cooling, /also cooling down/);
|
|
2310
|
-
|
|
2311
|
-
const pinned = rotationModule.__fallbackRejectionHintForTest("provider_pinned");
|
|
2312
|
-
assert.doesNotMatch(pinned, /authenticate a fallback provider/);
|
|
2313
|
-
assert.match(pinned, /explicitly pinned/);
|
|
2314
|
-
|
|
2315
|
-
const noLadder = rotationModule.__fallbackRejectionHintForTest("no_ladder_entry");
|
|
2316
|
-
assert.doesNotMatch(noLadder, /authenticate a fallback provider/);
|
|
2317
|
-
assert.match(noLadder, /no cross-provider fallback is configured/);
|
|
2318
|
-
assert.match(noLadder, /modelLadders/);
|
|
2319
|
-
});
|
|
2320
|
-
|
|
2321
2018
|
// A 429 observed during this provider call must author the deadline from the
|
|
2322
2019
|
// freshly-mutated pool, without retrying or sleeping inside the active turn.
|
|
2323
2020
|
test("schedules a sole credential cooldown observed in this turn from fresh pool state", async () => {
|
|
@@ -2417,18 +2114,14 @@ test("treats a headerless Codex usage-limit message as turn-local and falls back
|
|
|
2417
2114
|
}
|
|
2418
2115
|
});
|
|
2419
2116
|
|
|
2420
|
-
test("a
|
|
2117
|
+
test("a concrete model's headerless usage-limit stays turn-local and carries its retry deadline", async () => {
|
|
2421
2118
|
await resetRotationState();
|
|
2422
2119
|
writeLadderConfig();
|
|
2423
2120
|
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
2424
2121
|
await helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
|
|
2425
2122
|
|
|
2426
|
-
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
|
|
2123
|
+
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx(undefined);
|
|
2427
2124
|
ctx.model = { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" };
|
|
2428
|
-
ctx.ui[Symbol.for("@crouton-kit/crtr:managed-provider-rotation-policy")] = () => ({
|
|
2429
|
-
allowCrossProviderFallback: false,
|
|
2430
|
-
reason: "provider-pinned",
|
|
2431
|
-
});
|
|
2432
2125
|
rotationModule.__setStreamForProviderForTest(() => {
|
|
2433
2126
|
return (async function* () {
|
|
2434
2127
|
yield {
|
|
@@ -2564,9 +2257,8 @@ test("a provider with no account: rotation never switches onto it", async () =>
|
|
|
2564
2257
|
assert.deepEqual(setModelCalls, [], "never switches the session model onto an unconfigured provider");
|
|
2565
2258
|
assert.equal(emitted.length, 1);
|
|
2566
2259
|
assert.equal(emitted[0].type, "error");
|
|
2567
|
-
//
|
|
2568
|
-
assert.match(emitted[0].error.errorMessage, /
|
|
2569
|
-
assert.match(emitted[0].error.errorMessage, /\/provider-sub openai-codex add/);
|
|
2260
|
+
// An unauthenticated fallback never receives a model switch, while the live route's cooldown remains actionable.
|
|
2261
|
+
assert.match(emitted[0].error.errorMessage, /All provider routes are rate limited/);
|
|
2570
2262
|
});
|
|
2571
2263
|
|
|
2572
2264
|
|