@crouton-kit/crouter 0.3.65 → 0.3.66

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.
@@ -1074,10 +1074,10 @@ test("treats a request timeout as transient: retries the SAME provider in place,
1074
1074
  }
1075
1075
  });
1076
1076
 
1077
- // Regression: when a transient blip never recovers, the turn must end with a clear,
1078
- // actionable error instead of spinning forever or silently rotating a healthy credential
1079
- // away to a fallback provider that was never the actual problem.
1080
- test("ends the turn without rotating once the transient retry budget is exhausted", async () => {
1077
+ // Once the in-place transient retry budget is exhausted, the failure is surfaced to the
1078
+ // user AND the credential is rotated away: the struggling credential cools down briefly and
1079
+ // the turn continues on the fallback provider, rather than hard-failing.
1080
+ test("surfaces the transient error and rotates to the fallback once the in-place retry budget is exhausted", async () => {
1081
1081
  resetRotationState();
1082
1082
  writeLadderConfig();
1083
1083
  helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
@@ -1085,6 +1085,10 @@ test("ends the turn without rotating once the transient retry budget is exhauste
1085
1085
 
1086
1086
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
1087
1087
  ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
1088
+ const notifications: Array<{ message: string; level: string }> = [];
1089
+ ctx.ui.notify = (message: string, level: string) => {
1090
+ notifications.push({ message, level });
1091
+ };
1088
1092
  const streamedModels: any[] = [];
1089
1093
  const sleepCalls: number[] = [];
1090
1094
  rotationModule.__setSleepForTest(async (ms) => {
@@ -1093,8 +1097,12 @@ test("ends the turn without rotating once the transient retry budget is exhauste
1093
1097
  rotationModule.__setStreamForProviderForTest((model) => {
1094
1098
  streamedModels.push(model);
1095
1099
  return (async function* () {
1096
- // 503 with no retry-after header is transient by STATUS, not just by message.
1097
- yield { type: "error", error: Object.assign(new Error("Service Unavailable"), { status: 503 }) };
1100
+ if (model.provider === "anthropic") {
1101
+ // 503 with no retry-after header is transient by STATUS, not just by message.
1102
+ yield { type: "error", error: Object.assign(new Error("Service Unavailable"), { status: 503 }) };
1103
+ return;
1104
+ }
1105
+ yield { type: "text_delta", text: "hello from codex fallback" };
1098
1106
  })() as any;
1099
1107
  });
1100
1108
 
@@ -1108,13 +1116,17 @@ test("ends the turn without rotating once the transient retry budget is exhauste
1108
1116
  const emitted = [] as any[];
1109
1117
  for await (const event of stream) emitted.push(event);
1110
1118
 
1111
- assert.deepEqual(setModelCalls, []);
1112
- assert.deepEqual(streamedModels.map((m) => m.provider), ["anthropic", "anthropic", "anthropic"]);
1119
+ // Retried the same anthropic credential in place (3 attempts) then rotated to codex.
1120
+ assert.equal(streamedModels.filter((m) => m.provider === "anthropic").length, 3);
1113
1121
  assert.deepEqual(sleepCalls, [250, 750]);
1114
- assert.equal(emitted.length, 1);
1115
- assert.equal(emitted[0].type, "error");
1116
- assert.match(emitted[0].error.errorMessage, /transient error after 3 attempts/);
1117
- assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 0);
1122
+ assert.deepEqual(setModelCalls.map((m) => m.provider), ["openai-codex"]);
1123
+ // The turn was served on the fallback, never failed with a terminal error.
1124
+ assert.ok(emitted.some((e) => e.type === "text_delta"));
1125
+ assert.ok(!emitted.some((e) => e.type === "error"));
1126
+ // The user was told about the transient failure AND that it rotated.
1127
+ assert.ok(notifications.some((n) => /transient error after 3 attempts/.test(n.message) && /rotating/i.test(n.message)));
1128
+ // The struggling anthropic credential was cooled down so rotation moved on.
1129
+ assert.ok(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil > Date.now());
1118
1130
  } finally {
1119
1131
  rotationModule.__setStreamForProviderForTest(undefined);
1120
1132
  rotationModule.__setSleepForTest(undefined);
@@ -47,17 +47,23 @@ const INVALID_REFRESH_TOKEN_BACKOFF_MS = 30 * 24 * 60 * 60 * 1000;
47
47
  // not a wait. Cap at the same window as our own default rate-limit backoff.
48
48
  const SOLE_PROVIDER_MAX_WAIT_MS = DEFAULT_RATE_LIMIT_BACKOFF_MS;
49
49
  // Short in-place retry budget for a transient network/5xx blip on the SAME credential
50
- // (#122): a couple of quick retries, no cooldown, no rotation to another credential or
51
- // provider. Once this budget is exhausted the turn ends with an actionable error.
50
+ // (#122): a couple of quick retries, no cooldown. Once this budget is exhausted we no
51
+ // longer hard-fail the turn -- we surface the error and rotate (see TRANSIENT_ROTATE below).
52
52
  const TRANSIENT_RETRY_BACKOFFS_MS = [250, 750];
53
+ // After the in-place transient retry budget is exhausted, rotate instead of failing: the
54
+ // credential itself is probably healthy (this is a provider-side blip like "overloaded"),
55
+ // so cool it down only briefly -- long enough to rotate to another credential / fallback
56
+ // provider, short enough that it comes back quickly if it was the only option.
57
+ const TRANSIENT_ROTATE_COOLDOWN_MS = 60 * 1000;
53
58
 
54
59
  let runtimeContext: ExtensionContext | undefined;
55
60
  let extensionAPI: ExtensionAPI | undefined;
56
61
 
57
62
  type AttemptResult = {
58
- result: "success" | "rate_limited" | "fatal";
63
+ result: "success" | "rate_limited" | "transient_exhausted" | "fatal";
59
64
  retryAfterMs?: number;
60
65
  attemptAt: number;
66
+ attempts?: number;
61
67
  classification?: ProviderErrorClassification;
62
68
  };
63
69
 
@@ -466,21 +472,16 @@ async function streamProviderAttempt(
466
472
  return { result: "rate_limited", retryAfterMs: requestedRetryAfterMs, attemptAt, classification };
467
473
  }
468
474
 
469
- // Transient: retry the SAME credential/provider in place -- no cooldown, no rotation.
475
+ // Transient: retry the SAME credential/provider in place first -- no cooldown for the
476
+ // quick in-place budget.
470
477
  const backoffMs = TRANSIENT_RETRY_BACKOFFS_MS[attempt - 1];
471
478
  if (backoffMs === undefined) {
472
- // Exhausted the short in-place retry budget: surface a clear terminal error rather
473
- // than spinning forever or silently rotating a healthy credential away.
474
- bufferedEvents.push({
475
- type: "error",
476
- reason: "error",
477
- error: toAssistantError(
478
- model,
479
- `${getProviderLabel(providerId)} "${credential.label}" hit a transient error after ${attempt} attempts (${classification.reason})`,
480
- ),
481
- });
482
- flush();
483
- return { result: "fatal", attemptAt, classification };
479
+ // In-place retry budget exhausted. Rather than hard-failing the turn, hand back a
480
+ // transient-exhausted result so the caller surfaces the error to the user AND rotates
481
+ // to another credential / fallback provider. Nothing is emitted into the stream here:
482
+ // the buffered (pre-content) events are discarded exactly like the rate-limit path, so
483
+ // the rotation target streams a clean turn.
484
+ return { result: "transient_exhausted", attemptAt, classification, attempts: attempt };
484
485
  }
485
486
  logRotation(
486
487
  `${getProviderLabel(providerId)} subscription "${credential.label}" hit a transient error (${classification.reason}, status=${classification.status ?? "n/a"}); retrying same credential in ${backoffMs}ms (attempt ${attempt + 1})`,
@@ -599,6 +600,30 @@ async function runManagedProvider(
599
600
  logRotation(
600
601
  `${getProviderLabel(providerId)} subscription "${freshCredential.label}" rate-limited (status=${result.classification?.status ?? "n/a"}, reason=${result.classification?.reason ?? "unclassified"}, ${retryAfterPart}); cooling down for ${Math.ceil(cooldownMs / 1000)}s, trying next credential`,
601
602
  );
603
+ // Surface the rotation to the user too (parity with the transient-exhausted path), so a
604
+ // rate-limit-driven credential/provider switch is visible rather than silent.
605
+ runtimeContext?.ui.notify?.(
606
+ `${getProviderLabel(providerId)} "${freshCredential.label}" rate-limited (cooling down ${Math.ceil(cooldownMs / 1000)}s); rotating to another credential/provider.`,
607
+ "warn",
608
+ );
609
+ continue;
610
+ }
611
+
612
+ if (result.result === "transient_exhausted") {
613
+ // The credential is likely healthy -- this was a provider-side transient blip (e.g.
614
+ // "overloaded") that survived the in-place retries. Surface it to the user and rotate:
615
+ // cool the credential down briefly so this loop (and the pool-exhaustion logic below)
616
+ // moves on to the next credential / fallback provider instead of failing the turn.
617
+ const reason = result.classification?.reason ?? "transient error";
618
+ markSubscriptionRateLimited(providerId, freshCredential.label, TRANSIENT_ROTATE_COOLDOWN_MS, result.attemptAt);
619
+ runtimeContext?.ui.notify?.(
620
+ `${getProviderLabel(providerId)} "${freshCredential.label}" hit a transient error after ${result.attempts ?? 1} attempts (${reason}); rotating to another credential/provider.`,
621
+ "warn",
622
+ );
623
+ logRotation(
624
+ `${getProviderLabel(providerId)} subscription "${freshCredential.label}" transient error exhausted (reason=${reason}, status=${result.classification?.status ?? "n/a"}); cooling down ${Math.ceil(TRANSIENT_ROTATE_COOLDOWN_MS / 1000)}s and rotating`,
625
+ );
626
+ setStatus(formatStatusLine(providerId));
602
627
  continue;
603
628
  }
604
629