@north-light/crouter 0.3.160 → 0.3.162
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-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +55 -395
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +204 -419
- package/dist/clients/attach/__tests__/markdown-headings.test.js +8 -0
- package/dist/clients/attach/render/glyph-codepoints.d.ts +3 -0
- package/dist/clients/attach/render/glyph-codepoints.js +42 -0
- package/dist/clients/attach/render/markdown-headings.js +15 -4
- package/dist/clients/attach/viewer.js +558 -558
- 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__/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__/review-model-floor.test.js +2 -2
- package/dist/core/auth-file.d.ts +3 -2
- package/dist/core/auth-file.js +5 -3
- package/dist/core/canvas/labels.d.ts +5 -3
- package/dist/core/canvas/labels.js +7 -4
- package/dist/core/canvas/types.d.ts +6 -1
- package/dist/core/config.d.ts +3 -1
- package/dist/core/config.js +88 -2
- package/dist/core/model-routes.d.ts +37 -0
- package/dist/core/model-routes.js +140 -0
- package/dist/core/runtime/broker.d.ts +0 -4
- package/dist/core/runtime/broker.js +116 -372
- package/dist/core/runtime/launch.d.ts +9 -11
- package/dist/core/runtime/launch.js +15 -21
- 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/naming.js +21 -14
- package/dist/core/runtime/promote.js +1 -0
- package/dist/core/subscription-state.d.ts +12 -36
- package/dist/core/subscription-state.js +19 -203
- 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/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 +8 -8
- package/runtime.lock.json +161 -161
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) {
|
|
@@ -1139,7 +970,16 @@ function makeRotationCtx() {
|
|
|
1139
970
|
const commands: Record<string, { handler: (args: string, ctx: any) => unknown }> = {};
|
|
1140
971
|
const pi = {
|
|
1141
972
|
registerProvider(name: string, provider: { streamSimple: (...args: any[]) => AsyncIterable<any> }) {
|
|
1142
|
-
providers[name] =
|
|
973
|
+
providers[name] = {
|
|
974
|
+
...provider,
|
|
975
|
+
streamSimple(...args: any[]) {
|
|
976
|
+
if (!concreteRequest) {
|
|
977
|
+
const model = args[0] as { provider: string };
|
|
978
|
+
process.env.CRTR_MODEL_INTENT = model.provider === "anthropic" ? "anthropic/strong" : "openai/strong";
|
|
979
|
+
}
|
|
980
|
+
return provider.streamSimple(...args);
|
|
981
|
+
},
|
|
982
|
+
};
|
|
1143
983
|
},
|
|
1144
984
|
registerCommand(name: string, command: { handler: (args: string, ctx: any) => unknown }) {
|
|
1145
985
|
commands[name] = command;
|
|
@@ -1152,7 +992,7 @@ function makeRotationCtx() {
|
|
|
1152
992
|
ctx.model = model;
|
|
1153
993
|
return true;
|
|
1154
994
|
},
|
|
1155
|
-
setThinkingLevel() {},
|
|
995
|
+
setThinkingLevel(level: string) { ctx.thinkingLevel = level; },
|
|
1156
996
|
} as any;
|
|
1157
997
|
return { ctx, setModelCalls, providers, events, commands, pi };
|
|
1158
998
|
}
|
|
@@ -1195,12 +1035,9 @@ test("falls back transparently and serves the turn on Anthropic when Codex is ex
|
|
|
1195
1035
|
}
|
|
1196
1036
|
});
|
|
1197
1037
|
|
|
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 () => {
|
|
1038
|
+
// A concrete request only expands to routes carrying its exact model id. A
|
|
1039
|
+
// cooling Codex Sol route must not substitute Anthropic's different model.
|
|
1040
|
+
test("a cooling concrete model never substitutes a different-provider model", async () => {
|
|
1204
1041
|
await resetRotationState();
|
|
1205
1042
|
writeLadderConfig();
|
|
1206
1043
|
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
@@ -1208,11 +1045,7 @@ test("an explicit live Sol pin forbids Anthropic fallback on a post-tool provide
|
|
|
1208
1045
|
const expectedDeadline = helpers.readSubscriptionPool("openai-codex")[0].rateLimitedUntil;
|
|
1209
1046
|
await helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
|
|
1210
1047
|
|
|
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
|
-
});
|
|
1048
|
+
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx(undefined);
|
|
1216
1049
|
let streamCalls = 0;
|
|
1217
1050
|
rotationModule.__setStreamForProviderForTest((_model) => {
|
|
1218
1051
|
streamCalls += 1;
|
|
@@ -1231,10 +1064,10 @@ test("an explicit live Sol pin forbids Anthropic fallback on a post-tool provide
|
|
|
1231
1064
|
const emitted = [] as any[];
|
|
1232
1065
|
for await (const event of stream) emitted.push(event);
|
|
1233
1066
|
|
|
1234
|
-
assert.deepEqual(setModelCalls, [], "the
|
|
1067
|
+
assert.deepEqual(setModelCalls, [], "the concrete Sol request never switches to Anthropic");
|
|
1235
1068
|
assert.equal(streamCalls, 0);
|
|
1236
1069
|
assert.equal(emitted.length, 1);
|
|
1237
|
-
assert.match(emitted[0].error.errorMessage, /
|
|
1070
|
+
assert.match(emitted[0].error.errorMessage, /All provider routes are rate limited/i);
|
|
1238
1071
|
assert.equal(emitted[0].error.diagnostics?.[0]?.details?.rateLimitedUntil, expectedDeadline);
|
|
1239
1072
|
} finally {
|
|
1240
1073
|
rotationModule.__setStreamForProviderForTest(undefined);
|
|
@@ -1346,8 +1179,6 @@ test("surfaces the transient error and rotates to the fallback once the in-place
|
|
|
1346
1179
|
// The turn was served on the fallback, never failed with a terminal error.
|
|
1347
1180
|
assert.ok(emitted.some((e) => e.type === "text_delta"));
|
|
1348
1181
|
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
1182
|
// The struggling anthropic credential was cooled down so rotation moved on.
|
|
1352
1183
|
assert.ok(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil > Date.now());
|
|
1353
1184
|
} finally {
|
|
@@ -1758,14 +1589,14 @@ test("emits an actionable 'not authenticated' error without switching models whe
|
|
|
1758
1589
|
assert.deepEqual(setModelCalls, []);
|
|
1759
1590
|
assert.equal(emitted.length, 1);
|
|
1760
1591
|
assert.equal(emitted[0].type, "error");
|
|
1761
|
-
assert.match(emitted[0].error.errorMessage, /
|
|
1592
|
+
assert.match(emitted[0].error.errorMessage, /All provider routes exhausted \(tried openai\)/);
|
|
1762
1593
|
});
|
|
1763
1594
|
|
|
1764
1595
|
// The builtin ladder is available even with no ~/.crouter/config.json, so a config-less
|
|
1765
1596
|
// user can still resolve cross-provider fallbacks.
|
|
1766
1597
|
test("degrades gracefully via the builtin ladder when a config-less user has no cross-provider credentials", async () => {
|
|
1767
1598
|
await resetRotationState();
|
|
1768
|
-
rmSync(join(
|
|
1599
|
+
rmSync(join(nodeCwd, ".crouter", "config.json"), { force: true });
|
|
1769
1600
|
|
|
1770
1601
|
const ctx = {
|
|
1771
1602
|
model: { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
|
|
@@ -1797,6 +1628,7 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
|
|
|
1797
1628
|
setThinkingLevel() {},
|
|
1798
1629
|
} as any;
|
|
1799
1630
|
|
|
1631
|
+
process.env.CRTR_MODEL_INTENT = "anthropic/strong";
|
|
1800
1632
|
await providerRotationExtension(pi);
|
|
1801
1633
|
await events.session_start?.({}, ctx);
|
|
1802
1634
|
// Anthropic's pool is empty, so the credential loop never runs. The builtin ladder resolves
|
|
@@ -1809,26 +1641,10 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
|
|
|
1809
1641
|
assert.deepEqual(setModelCalls, []);
|
|
1810
1642
|
assert.equal(emitted.length, 1);
|
|
1811
1643
|
assert.equal(emitted[0].type, "error");
|
|
1812
|
-
assert.match(emitted[0].error.errorMessage, /
|
|
1644
|
+
assert.match(emitted[0].error.errorMessage, /All provider routes exhausted \(tried live:anthropic\/claude-opus-4-8\)/);
|
|
1813
1645
|
assert.ok(!emitted[0].error.errorMessage.includes("configure a openai ladder"));
|
|
1814
1646
|
});
|
|
1815
1647
|
|
|
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
1648
|
// A cooling provider must settle the turn with a typed retry deadline. Holding
|
|
1833
1649
|
// the stream open here rendered the node as generic `Working...` for five silent
|
|
1834
1650
|
// minutes and prevented the broker's existing scheduled retry path from owning
|
|
@@ -1952,9 +1768,7 @@ test("emits a typed rate-limit cooling error for a long provider deadline", asyn
|
|
|
1952
1768
|
assert.equal(emitted[0].type, "error");
|
|
1953
1769
|
// Rate-limit-classifiable so classify('pi→provider', msg) → rate-limit/auto (not fatal other).
|
|
1954
1770
|
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.
|
|
1771
|
+
// The typed cooling diagnostic carries the exact pool deadline for the stophook.
|
|
1958
1772
|
assert.equal(emitted[0].error.diagnostics?.[0]?.type, "crtr:managed-provider-cooling");
|
|
1959
1773
|
assert.equal(emitted[0].error.diagnostics?.[0]?.details?.rateLimitedUntil, expectedDeadline);
|
|
1960
1774
|
} finally {
|
|
@@ -1988,10 +1802,6 @@ test("an excluded invalid_grant credential does not mask a cooling credential",
|
|
|
1988
1802
|
assert.equal(emitted[0].error.diagnostics?.[0]?.details?.rateLimitedUntil, expectedDeadline);
|
|
1989
1803
|
});
|
|
1990
1804
|
|
|
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
1805
|
test("a same-turn 429 settles immediately instead of starting cooldown continuations", async () => {
|
|
1996
1806
|
await resetRotationState();
|
|
1997
1807
|
writeLadderConfig();
|
|
@@ -2059,54 +1869,6 @@ test("does not requeue a zero Retry-After credential without a later success", a
|
|
|
2059
1869
|
}
|
|
2060
1870
|
});
|
|
2061
1871
|
|
|
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
1872
|
// Fresh-availability re-entry (Fix 3a): a credential that becomes ready AFTER the initial
|
|
2111
1873
|
// `available` snapshot (a concurrent turn's cooldown expiring, or a causally valid success)
|
|
2112
1874
|
// must be consumed by re-entering the SAME provider, not lost to a stale snapshot that would
|
|
@@ -2221,103 +1983,6 @@ test("both pools cooling: emits a rate-limit-classifiable terminal carrying the
|
|
|
2221
1983
|
}
|
|
2222
1984
|
});
|
|
2223
1985
|
|
|
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
1986
|
// A 429 observed during this provider call must author the deadline from the
|
|
2322
1987
|
// freshly-mutated pool, without retrying or sleeping inside the active turn.
|
|
2323
1988
|
test("schedules a sole credential cooldown observed in this turn from fresh pool state", async () => {
|
|
@@ -2417,18 +2082,14 @@ test("treats a headerless Codex usage-limit message as turn-local and falls back
|
|
|
2417
2082
|
}
|
|
2418
2083
|
});
|
|
2419
2084
|
|
|
2420
|
-
test("a
|
|
2085
|
+
test("a concrete model's headerless usage-limit stays turn-local and carries its retry deadline", async () => {
|
|
2421
2086
|
await resetRotationState();
|
|
2422
2087
|
writeLadderConfig();
|
|
2423
2088
|
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
2424
2089
|
await helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
|
|
2425
2090
|
|
|
2426
|
-
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
|
|
2091
|
+
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx(undefined);
|
|
2427
2092
|
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
2093
|
rotationModule.__setStreamForProviderForTest(() => {
|
|
2433
2094
|
return (async function* () {
|
|
2434
2095
|
yield {
|
|
@@ -2564,9 +2225,8 @@ test("a provider with no account: rotation never switches onto it", async () =>
|
|
|
2564
2225
|
assert.deepEqual(setModelCalls, [], "never switches the session model onto an unconfigured provider");
|
|
2565
2226
|
assert.equal(emitted.length, 1);
|
|
2566
2227
|
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/);
|
|
2228
|
+
// An unauthenticated fallback never receives a model switch, while the live route's cooldown remains actionable.
|
|
2229
|
+
assert.match(emitted[0].error.errorMessage, /All provider routes are rate limited/);
|
|
2570
2230
|
});
|
|
2571
2231
|
|
|
2572
2232
|
|