@north-light/crouter 0.3.161 → 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.
Files changed (43) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/modelauth.d.ts +32 -0
  4. package/dist/api/dto/modelauth.js +2 -1
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +55 -395
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +204 -419
  9. package/dist/clients/attach/viewer.js +592 -592
  10. package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
  11. package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
  12. package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
  13. package/dist/core/__tests__/model-pin-durability.test.js +7 -5
  14. package/dist/core/__tests__/model-routes.test.d.ts +1 -0
  15. package/dist/core/__tests__/model-routes.test.js +41 -0
  16. package/dist/core/__tests__/review-model-floor.test.js +2 -2
  17. package/dist/core/auth-file.d.ts +3 -2
  18. package/dist/core/auth-file.js +5 -3
  19. package/dist/core/canvas/types.d.ts +6 -1
  20. package/dist/core/config.d.ts +3 -1
  21. package/dist/core/config.js +88 -2
  22. package/dist/core/model-routes.d.ts +37 -0
  23. package/dist/core/model-routes.js +140 -0
  24. package/dist/core/runtime/broker.d.ts +0 -4
  25. package/dist/core/runtime/broker.js +116 -372
  26. package/dist/core/runtime/launch.d.ts +9 -11
  27. package/dist/core/runtime/launch.js +15 -21
  28. package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
  29. package/dist/core/runtime/managed-provider-cooling.js +0 -19
  30. package/dist/core/runtime/model-swap.js +7 -1
  31. package/dist/core/runtime/promote.js +1 -0
  32. package/dist/core/subscription-state.d.ts +12 -36
  33. package/dist/core/subscription-state.js +19 -203
  34. package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
  35. package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
  36. package/dist/daemon/api/handlers/modelauth.js +51 -2
  37. package/dist/daemon/api/handlers/nodes.js +2 -0
  38. package/dist/types.d.ts +17 -0
  39. package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
  40. package/dist/web-client/index.html +1 -1
  41. package/dist/web-client/sw.js +1 -1
  42. package/package.json +1 -1
  43. package/runtime.lock.json +2 -2
@@ -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 type { FallbackTarget, SubscriptionCredential } from "../../lib/subscription-state.ts";
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(home, ".crouter", "config.json");
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
- helpers.clearPreferredModel();
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("resolves Anthropic to strength-matched OpenAI Codex fallback", async () => {
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
- assert.deepEqual(helpers.resolveFallbackTarget("anthropic", "anthropic/claude-opus-4-8:high"), {
499
- providerId: "openai-codex",
500
- modelId: "gpt-5.6-sol",
501
- label: "openai-codex/gpt-5.6-sol:high",
510
+ const candidates = expandModelCandidates({
511
+ kind: "logical",
512
+ family: "anthropic",
502
513
  strength: "strong",
503
- thinkingLevel: "high",
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
- test("switches exhausted Anthropic sessions to OpenAI Codex fallback", async () => {
1033
- await resetRotationState();
1034
- writeLadderConfig();
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] = provider;
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
- // Live incident: a viewer explicitly selected qualified Sol, which set the
1199
- // broker's provider pin, but the extension ignored that broker state. The first
1200
- // model call could request a tool; on the post-tool continuation a shared Codex
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 pinned Sol session never switches to Anthropic");
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, /explicitly pinned/i);
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, /OpenAI Codex not authenticated.*crtr sys setup/);
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(home, ".crouter", "config.json"), { force: true });
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, /Claude not authenticated.*crtr sys setup/);
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
- // Still carries the actionable fallback hint.
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 pinned provider's headerless usage-limit stays turn-local and carries its retry deadline", async () => {
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
- // The blocker is a missing login, and the error says exactly that instead of wedging a node.
2568
- assert.match(emitted[0].error.errorMessage, /authenticate a fallback provider/);
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