pi-braid-providers 1.0.6 → 1.0.7

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/ring-lifecycle.ts CHANGED
@@ -33,6 +33,12 @@ import { join } from "node:path";
33
33
  import { createKeyRing } from "./key-ring";
34
34
  import { providerLog, createThrottledLogger } from "./log";
35
35
  import { readRouteState } from "./route-state";
36
+ // The transport predicate lives with the connectivity watchdog, which imports
37
+ // this module's WAF and key predicates in turn. The two modules therefore read
38
+ // each other's declarations, and neither one calls the other while loading. One
39
+ // definition of "this failure is transport" is what keeps the walk and the
40
+ // breaker from disagreeing about the same text.
41
+ import { classifyConnectivityFailure } from "./outage-watch";
36
42
 
37
43
  // Retry reclassification for self-healing
38
44
  // pi-core classifies provider quota/auth/403 errors as NON-retryable (fail-fast)
@@ -80,14 +86,34 @@ export function shapeRetryMarker(status: number | null): string {
80
86
  }
81
87
 
82
88
  /**
83
- * Fatal account-level tokens pi-ai will not retry
84
- * (dist/utils/retry.js NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN), listed
85
- * verbatim and lowercased. pi-ai joins these same literals with "|" and tests
86
- * case-insensitively, so a literal substring test agrees with its regex for
87
- * this token set.
89
+ * Capacity-refusal retry marker. Same pi-core retry-flip phrase as the markers
90
+ * above, and the same claim discipline: the cell serving the model id is full,
91
+ * so the SAME logical model moves to another route. No key is named because
92
+ * none was at fault, and the status is interpolated only when the refusal
93
+ * carried one (the same sentence arrives with no status at all from some
94
+ * gateways).
95
+ */
96
+ export const CAPACITY_RETRY_MARKER =
97
+ " — [pi-braid-providers] Provider returned error; the serving cell is at capacity";
98
+
99
+ export function capacityRetryMarker(status: number | null): string {
100
+ return (
101
+ `${CAPACITY_RETRY_MARKER}${status === null ? "" : ` (HTTP ${status})`}, ` +
102
+ "no key exhausted — rotated to another route serving the same model, " +
103
+ "retrying the turn there."
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Fatal account-level failures pi-ai will not retry
109
+ * (dist/utils/retry.js NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN). pi-ai joins
110
+ * its entries with "|" and tests case-insensitively. The literal entries below
111
+ * are listed verbatim and lowercased, so a literal substring test agrees with
112
+ * its regex; the one entry pi-ai declares as a pattern is tested as a pattern,
113
+ * in NON_RETRYABLE_ERROR_PATTERN.
88
114
  *
89
115
  * Pinned by test/key-ring-selfheal.test.mjs, which parses the installed pi-ai
90
- * module's pattern source and requires every token it declares to be refused
116
+ * module's pattern source and requires every entry it declares to be refused
91
117
  * below: an upstream addition turns that test red instead of silently
92
118
  * re-enabling the rewrite.
93
119
  */
@@ -101,8 +127,17 @@ const NON_RETRYABLE_ERROR_TOKENS = [
101
127
  "quota exceeded",
102
128
  "billing",
103
129
  "subscription_sharing_usage_limit_exceeded",
130
+ "subscription_sharing_workplace_individual_usage_limit_exceeded",
104
131
  ];
105
132
 
133
+ /**
134
+ * The fatal entry pi-ai declares as a pattern rather than a literal: Zhipu/Z.ai
135
+ * plan-limit code 1308, matched as the JSON body it arrives in. The digit guard
136
+ * keeps a longer number from matching a 1308 prefix, which is also why it
137
+ * cannot be flattened into the literal list above.
138
+ */
139
+ const NON_RETRYABLE_ERROR_PATTERN = /"code"\s*:\s*"?1308(?![0-9])/i;
140
+
106
141
  /**
107
142
  * True when appending SELF_HEAL_RETRY_MARKER can flip pi-core's retry
108
143
  * classification. If the raw error already carries one of the fatal
@@ -112,6 +147,7 @@ const NON_RETRYABLE_ERROR_TOKENS = [
112
147
  export function canReclassifyForTurnRetry(errorMessage: unknown): boolean {
113
148
  const text = String(errorMessage ?? "");
114
149
  if (!text.trim()) return false;
150
+ if (NON_RETRYABLE_ERROR_PATTERN.test(text)) return false;
115
151
  const lower = text.toLowerCase();
116
152
  return !NON_RETRYABLE_ERROR_TOKENS.some((tok) => lower.includes(tok));
117
153
  }
@@ -1163,6 +1199,16 @@ const shapeSkipLogs = new Map<
1163
1199
  ReturnType<typeof createThrottledLogger>
1164
1200
  >();
1165
1201
 
1202
+ /**
1203
+ * The capacity-skip channel, throttled per route for the same reason: a walk
1204
+ * that passes the same route over on every turn of a long request would
1205
+ * otherwise repeat the line each time.
1206
+ */
1207
+ const capacitySkipLogs = new Map<
1208
+ string,
1209
+ ReturnType<typeof createThrottledLogger>
1210
+ >();
1211
+
1166
1212
  /**
1167
1213
  * Whether a recorded death says this route refused the SAME request: a shape
1168
1214
  * death for the same model id and the same HTTP status, younger than the bound.
@@ -1210,16 +1256,62 @@ function reportShapeSkip(
1210
1256
  );
1211
1257
  }
1212
1258
 
1259
+ /**
1260
+ * Whether a recorded death says this route could not serve the SAME model id for
1261
+ * capacity, inside the class's own (short) bound. A death of another class,
1262
+ * another model id, no record at all, or one past the bound leaves the route as
1263
+ * eligible as before. No status component: the same refusal arrives 429, 403 or
1264
+ * 503 depending on the edge, so requiring a status match would let a route be
1265
+ * asked again the moment the gateway changed its spelling.
1266
+ */
1267
+ function isFreshCapacityDeath(
1268
+ death: RouteDeathVerdict | undefined,
1269
+ at: number,
1270
+ freshnessMs: number,
1271
+ modelId: string,
1272
+ ): death is RouteDeathVerdict {
1273
+ if (freshnessMs <= 0) return false; // the same explicit off switch
1274
+ if (!death || death.class !== MODEL_CAPACITY_DEATH_CLASS) return false;
1275
+ if (death.modelId !== modelId) return false;
1276
+ return at - death.at < freshnessMs;
1277
+ }
1278
+
1279
+ /**
1280
+ * Report one capacity-death skip through the same throttled channel as the
1281
+ * other skips: names the route, the model id the cell refused and the bound it
1282
+ * is held to, and repeats inside the window are silent.
1283
+ */
1284
+ function reportCapacitySkip(
1285
+ routeId: string,
1286
+ death: RouteDeathVerdict,
1287
+ at: number,
1288
+ freshnessMs: number,
1289
+ ): void {
1290
+ let log = capacitySkipLogs.get(routeId);
1291
+ if (!log) {
1292
+ log = createThrottledLogger();
1293
+ capacitySkipLogs.set(routeId, log);
1294
+ }
1295
+ log.occurrence(
1296
+ "info",
1297
+ "providers/provider-rotation",
1298
+ `[pi-braid-providers] ${routeId} passed over for ${death.modelId ?? "this model"}: ` +
1299
+ `its cell was at capacity ${at - death.at}ms ago and that verdict is still ` +
1300
+ `fresh (bound ${freshnessMs}ms). A death for another model id, one older ` +
1301
+ `than the bound, and a route with no record stay eligible.`,
1302
+ );
1303
+ }
1304
+
1213
1305
  export interface RotationEngine {
1214
1306
  /**
1215
1307
  * One-shot engage decision for a route death. Walks `order` and engages the
1216
1308
  * first route that is not the dead route, is not the route that opened the
1217
1309
  * open episode, has a usable key, can serve modelId, is not currently measured
1218
- * unable by a fresh probe verdict, and has not recently rejected the same
1219
- * request. Returns the retry marker to append (engage succeeded), or null
1220
- * (inert: nothing to serve, no route available for this failure class, an
1221
- * ineligible death while an episode is open, or every candidate failed).
1222
- * Async when setModel is async.
1310
+ * unable by a fresh probe verdict, has not recently rejected the same request,
1311
+ * and has not recently refused that model id for capacity. Returns the retry
1312
+ * marker to append (engage succeeded), or null (inert: nothing to serve, no
1313
+ * route available for this failure class, an ineligible death while an
1314
+ * episode is open, or every candidate failed). Async when setModel is async.
1223
1315
  */
1224
1316
  maybeEngage(
1225
1317
  providerId: string,
@@ -1274,6 +1366,8 @@ interface RotationPlan {
1274
1366
  probeSkipped: string[];
1275
1367
  /** Routes passed over because they rejected this same request recently. */
1276
1368
  shapeSkipped: string[];
1369
+ /** Routes passed over because they refused this model id for capacity. */
1370
+ capacitySkipped: string[];
1277
1371
  /** Candidates that were attempted and whose engage returned false. */
1278
1372
  failed: string[];
1279
1373
  /** The candidate the engage selected (set by apply, read by onEngaged). */
@@ -1283,7 +1377,8 @@ interface RotationPlan {
1283
1377
  /**
1284
1378
  * One recorded route death, handed to the walk as DATA (the composition layer
1285
1379
  * reads the store; this module never touches a file). `class` is the caller's
1286
- * name for the death; only `SHAPE_DEATH_CLASS` records are consulted.
1380
+ * name for the death; only `SHAPE_DEATH_CLASS` and
1381
+ * `MODEL_CAPACITY_DEATH_CLASS` records are consulted.
1287
1382
  */
1288
1383
  export interface RouteDeathVerdict {
1289
1384
  class: string;
@@ -1342,10 +1437,21 @@ export function createRotationEngine(deps: {
1342
1437
  routeDeaths?: () => ReadonlyMap<string, RouteDeathVerdict>;
1343
1438
  /** The route-death store's writer; absent ⇒ nothing is recorded. */
1344
1439
  recordRouteDeath?: (providerId: string, death: WalkDeathRecord) => void;
1440
+ /**
1441
+ * How long a `model-capacity` death keeps a route out of the walk for that
1442
+ * model id; defaults to `MODEL_CAPACITY_FRESHNESS_MS`. Separate from
1443
+ * `probeFreshnessMs` on purpose: a full cell recovers in minutes, so it is
1444
+ * held to minutes.
1445
+ */
1446
+ modelCapacityFreshnessMs?: number;
1345
1447
  }): RotationEngine {
1346
1448
  const order = deps.order;
1347
1449
  const now = deps.now ?? Date.now;
1348
1450
  const probeFreshnessMs = deps.probeFreshnessMs ?? DEFAULT_PROBE_FRESHNESS_MS;
1451
+ const modelCapacityFreshnessMs =
1452
+ deps.modelCapacityFreshnessMs ?? MODEL_CAPACITY_FRESHNESS_MS;
1453
+ /** The bound as the diagnostics state it. */
1454
+ const capacityMinutes = `${Math.max(1, Math.round(modelCapacityFreshnessMs / 60_000))}`;
1349
1455
 
1350
1456
  let lastProbeAt = 0;
1351
1457
 
@@ -1364,10 +1470,17 @@ export function createRotationEngine(deps: {
1364
1470
  episode: EngageEpisode | null,
1365
1471
  ): RotationPlan | null => {
1366
1472
  if (!providerId || !modelId) return null; // nothing to serve / look up
1473
+ // A transport failure reaches the walk only as a repeated failure, and it
1474
+ // is not a route decision: the route never answered, so no plan is built,
1475
+ // nothing rotates and no notice (engaged or inert) claims the route refused
1476
+ // the request. Silence is the answer here; the connectivity watchdog owns
1477
+ // the route pause and pi-core owns the turn retry.
1478
+ if (signal.class === "transport") return null;
1367
1479
  const candidates: RotationCandidate[] = [];
1368
1480
  const skipped: string[] = [];
1369
1481
  const probeSkipped: string[] = [];
1370
1482
  const shapeSkipped: string[] = [];
1483
+ const capacitySkipped: string[] = [];
1371
1484
  // One verdict snapshot per engage, one clock reading: the whole walk judges
1372
1485
  // every candidate against the same instant.
1373
1486
  const at = now();
@@ -1406,6 +1519,29 @@ export function createRotationEngine(deps: {
1406
1519
  skipped.push(routeId); // cannot serve this model id
1407
1520
  continue;
1408
1521
  }
1522
+ // The route is otherwise eligible; skip it only when its cell refused
1523
+ // THIS model id recently. Scoped to the id on purpose: the same route may
1524
+ // still serve another model id from its map, and a death past the (short)
1525
+ // capacity bound says the cell has had time to recover.
1526
+ const capacityDeath = deaths?.get(routeId);
1527
+ if (
1528
+ signal.class === "capacity" &&
1529
+ isFreshCapacityDeath(
1530
+ capacityDeath,
1531
+ at,
1532
+ modelCapacityFreshnessMs,
1533
+ modelId,
1534
+ )
1535
+ ) {
1536
+ capacitySkipped.push(routeId);
1537
+ reportCapacitySkip(
1538
+ routeId,
1539
+ capacityDeath,
1540
+ at,
1541
+ modelCapacityFreshnessMs,
1542
+ );
1543
+ continue;
1544
+ }
1409
1545
  // The route is otherwise eligible; skip it only when it recently refused
1410
1546
  // this same request. Recorded so the no-route diagnostic still fires
1411
1547
  // when this leaves nothing to engage.
@@ -1436,7 +1572,8 @@ export function createRotationEngine(deps: {
1436
1572
  candidates.length === 0 &&
1437
1573
  skipped.length === 0 &&
1438
1574
  probeSkipped.length === 0 &&
1439
- shapeSkipped.length === 0
1575
+ shapeSkipped.length === 0 &&
1576
+ capacitySkipped.length === 0
1440
1577
  )
1441
1578
  return null;
1442
1579
  return {
@@ -1446,6 +1583,7 @@ export function createRotationEngine(deps: {
1446
1583
  skipped,
1447
1584
  probeSkipped,
1448
1585
  shapeSkipped,
1586
+ capacitySkipped,
1449
1587
  failed: [],
1450
1588
  };
1451
1589
  };
@@ -1499,6 +1637,19 @@ export function createRotationEngine(deps: {
1499
1637
  );
1500
1638
  return shapeRetryMarker(signal.status);
1501
1639
  }
1640
+ if (signal.class === "capacity") {
1641
+ // The cell, not the credential: the same logical model id moves, and
1642
+ // the key ring is named as untouched so a later reader does not read
1643
+ // this notice as a quota event.
1644
+ engage.notify(
1645
+ `[pi-braid-providers] ${plan.from} refused ${plan.modelId}: the serving cell is at capacity ` +
1646
+ `(no key exhausted) — rotated to ${target.routeId} serving ${plan.modelId}${mapped}. ` +
1647
+ `The failed turn is retrying there; ${plan.from}'s key ring is untouched, and ` +
1648
+ `${plan.from} is passed over for ${plan.modelId} for ${capacityMinutes} minute(s) ` +
1649
+ `after a refusal.`,
1650
+ );
1651
+ return capacityRetryMarker(signal.status);
1652
+ }
1502
1653
  // The classifier saw only the error text, never the reason behind it: a
1503
1654
  // spent subscription allowance and a dead key both arrive here, so the
1504
1655
  // notice states the fact the ring reports (this route has no usable key),
@@ -1522,6 +1673,9 @@ export function createRotationEngine(deps: {
1522
1673
  plan.shapeSkipped.length > 0
1523
1674
  ? `rejected this request (fresh): ${plan.shapeSkipped.join(", ")}`
1524
1675
  : "",
1676
+ plan.capacitySkipped.length > 0
1677
+ ? `cell at capacity for this model (fresh): ${plan.capacitySkipped.join(", ")}`
1678
+ : "",
1525
1679
  plan.failed.length > 0
1526
1680
  ? `engage failed: ${plan.failed.join(", ")}`
1527
1681
  : "",
@@ -1546,6 +1700,21 @@ export function createRotationEngine(deps: {
1546
1700
  );
1547
1701
  return;
1548
1702
  }
1703
+ if (signal.class === "capacity") {
1704
+ // The truth, in the order an operator needs it: what happened (a full
1705
+ // cell), what it means (this model id, for this long), and that the
1706
+ // credential is not the thing to change. No route could take the id,
1707
+ // so the recorded model death is what a later turn reads.
1708
+ const at = signal.status === null ? "" : ` (HTTP ${signal.status})`;
1709
+ engage.notify(
1710
+ `[pi-braid-providers] ${plan.from} refused ${plan.modelId}: the serving cell is at capacity` +
1711
+ `${at}, no key exhausted, and no configured route serves that model id` +
1712
+ `${detail ? ` (${detail})` : ""}. The cell refused this model for the next ` +
1713
+ `${capacityMinutes} minutes and the key ring is untouched — move ${plan.modelId} to a ` +
1714
+ `route that serves it, or wait out the cell.`,
1715
+ );
1716
+ return;
1717
+ }
1549
1718
  engage.notify(
1550
1719
  `[pi-braid-providers] ${plan.from} route ring exhausted but no configured route could serve ` +
1551
1720
  `${plan.modelId}${detail ? ` (${detail})` : ""}. ${plan.from} requests keep failing; ` +
@@ -1577,6 +1746,18 @@ export function createRotationEngine(deps: {
1577
1746
  status: signal.status,
1578
1747
  });
1579
1748
  }
1749
+ // A capacity death is the same kind of fact — this route cannot serve
1750
+ // that model id right now — remembered before the walk decides so a later
1751
+ // turn does not ask the same full cell again inside the short bound. No
1752
+ // key is touched anywhere on this path: nothing is exhausted.
1753
+ if (signal.class === "capacity" && modelId) {
1754
+ deps.recordRouteDeath?.(providerId, {
1755
+ class: MODEL_CAPACITY_DEATH_CLASS,
1756
+ at: now(),
1757
+ modelId,
1758
+ status: signal.status,
1759
+ });
1760
+ }
1580
1761
  return scaffold.attempt(providerId, engage, modelId, signal);
1581
1762
  },
1582
1763
  maybeFailBack(
@@ -2110,10 +2291,11 @@ export interface MessageEndHooks {
2110
2291
  rotation?: {
2111
2292
  /**
2112
2293
  * Called for a death the walk is consulted for: any key-rotation error
2113
- * whose ring reports zero usable keys, and any shape-class (HTTP 4xx)
2114
- * rejection, which does not touch the ring at all. providerId is the route
2115
- * that died; modelId is the dead turn's own model id, so the engine can
2116
- * re-serve the SAME id over another route.
2294
+ * whose ring reports zero usable keys, any shape-class (HTTP 4xx) rejection,
2295
+ * and any capacity-class refusal (a full serving cell) — the last two do not
2296
+ * touch the ring at all. providerId is the route that died; modelId is the
2297
+ * dead turn's own model id, so the engine can re-serve the SAME id over
2298
+ * another route.
2117
2299
  */
2118
2300
  onRingExhausted(
2119
2301
  providerId: string,
@@ -2281,6 +2463,24 @@ export function handleMessageEnd(
2281
2463
  return consultWalk(hooks.rotation);
2282
2464
  }
2283
2465
 
2466
+ // Capacity refusal: the cell serving this model id is full. Nothing about the
2467
+ // key ring is written — the same predicate backs reportKeyRingFailure, so the
2468
+ // direct-call path leaves the key healthy too — and the walk moves the SAME
2469
+ // logical model id onto another route's model surface, or stops and names the
2470
+ // recorded model death. Skipped entirely without an engine: byte-identical to
2471
+ // a build without the feature.
2472
+ if (failure.class === "capacity") {
2473
+ if (!hooks?.rotation) return;
2474
+ return consultWalk(hooks.rotation);
2475
+ }
2476
+
2477
+ // Transport: the route never answered, so neither the ring nor the walk has
2478
+ // anything to decide. The turn keeps its real error, no marker claims a
2479
+ // refusal, and pi-core's turn retry plus the connectivity watchdog own the
2480
+ // failure. Stated here rather than left to the `key` guard below so a later
2481
+ // edit to that guard cannot absorb this class.
2482
+ if (failure.class === "transport") return;
2483
+
2284
2484
  // Key class only from here on. Same predicate the direct-call feed-back path
2285
2485
  // uses (reportKeyRingFailure) — one classification governs both paths;
2286
2486
  // payload (context-window) and unrecognized failures keep their own paths.
@@ -2364,11 +2564,37 @@ export function handleMessageEnd(
2364
2564
  * a quota denial: neither a fresh key nor a backoff helps, only rotating the
2365
2565
  * route — see isSpentAllowanceError for the allowance vocabulary.
2366
2566
  */
2567
+ /**
2568
+ * Serving-capacity idioms: the cell cannot take this request for this model id
2569
+ * right now. Every string here is one a live route answered with
2570
+ * (`prefill_cap_refused` / "the cell is at capacity"), plus the bare idiom the
2571
+ * outage vocabulary already reads as an availability statement.
2572
+ *
2573
+ * Lowercases its own input, unlike the predicates beside it: this one is
2574
+ * reached from both the class test and the key predicate, and a caller that
2575
+ * forgets the lowercase contract would silently lose the class.
2576
+ */
2577
+ const CAPACITY_PATTERNS = [
2578
+ /prefill_cap/,
2579
+ /cell is at capacity/,
2580
+ /\bat capacity\b/,
2581
+ ];
2582
+
2583
+ export function isCapacityRefusal(errText: string): boolean {
2584
+ if (!errText) return false;
2585
+ const text = errText.toLowerCase();
2586
+ return CAPACITY_PATTERNS.some((pattern) => pattern.test(text));
2587
+ }
2588
+
2367
2589
  export function isKeyRotationError(errText: string): boolean {
2368
2590
  // WAF / infra-block pages are NEVER key-rotation — they carry a stray
2369
2591
  // 403/402 status on an HTML edge page and must not exhaust a healthy key.
2370
2592
  // Check first so the `includes("403")` guard below can't fire on an edge page.
2371
2593
  if (isWafBlockError(errText)) return false;
2594
+ // A capacity refusal is a statement about the serving cell. No key rotation
2595
+ // changes how full it is, so whatever status (`403`, `402`) or quota idiom
2596
+ // rides along with the sentence, the key is innocent.
2597
+ if (isCapacityRefusal(errText)) return false;
2372
2598
  const isQuota =
2373
2599
  errText.includes("402") ||
2374
2600
  /no quota|quota left|insufficient.{0,24}(credit|balance)|billing|out of budget|quota|balance/.test(
@@ -2429,16 +2655,39 @@ function isSpentAllowanceError(errText: string): boolean {
2429
2655
  * - `waf`: an edge/WAF block page. The key is not at fault and the block is
2430
2656
  * a fact about the (route, request) pair, so the class exists to let the
2431
2657
  * walk speak about it without ever claiming a credential problem.
2432
- * - `shape`: an HTTP 4xx that is neither of the above and not a WAF page —
2658
+ * - `capacity`: the serving cell refused this model id for lack of capacity
2659
+ * ("prefill_cap_refused", "the cell is at capacity"). Neither a credential
2660
+ * fact nor a fact about the request's size: the key ring is untouched, and
2661
+ * the recovery is to serve the SAME logical model id through another
2662
+ * route's model surface. Tested before `key`, so no status or quota idiom
2663
+ * riding on the sentence can turn it into a credential death.
2664
+ * - `transport`: the route never answered. The connectivity predicate in
2665
+ * outage-watch.ts owns this class (timeouts, socket and DNS idioms, an
2666
+ * HTTP 5xx from any hop), and it is consulted, not copied. No key is at
2667
+ * fault, no other route is asked to serve the turn, and the turn is handed
2668
+ * no marker: pi-core's own turn retry and the connectivity watchdog own
2669
+ * this failure. Tested after `capacity` on purpose, because the transport
2670
+ * vocabulary reads "at capacity" as availability and would otherwise
2671
+ * swallow a capacity refusal.
2672
+ * - `shape`: an HTTP 4xx that is neither of the above and not a WAF page:
2433
2673
  * the route refused THIS request. A rate-limit `429` is deliberately NOT
2434
2674
  * shape: the callers' own cooldown bounds it, and moving the request
2435
2675
  * elsewhere would spend the walk's budget on a transient.
2436
- * - `none`: everything else (5xx/transient, unrecognized text).
2676
+ * - `none`: everything else (unrecognized text, and the plain `429` the
2677
+ * callers' cooldowns bound).
2437
2678
  *
2438
2679
  * Evaluation order is the order of the tests below and matches
2439
- * handleMessageEnd: WAF, then payload, then key, then shape.
2680
+ * handleMessageEnd: WAF, then payload, then capacity, then transport, then
2681
+ * key, then the `429` carve-out, then shape.
2440
2682
  */
2441
- export type FailureClass = "key" | "payload" | "shape" | "waf" | "none";
2683
+ export type FailureClass =
2684
+ | "key"
2685
+ | "payload"
2686
+ | "capacity"
2687
+ | "transport"
2688
+ | "shape"
2689
+ | "waf"
2690
+ | "none";
2442
2691
 
2443
2692
  export interface FailureClassification {
2444
2693
  class: FailureClass;
@@ -2454,6 +2703,30 @@ export interface FailureClassification {
2454
2703
  */
2455
2704
  export const SHAPE_DEATH_CLASS = "request-shape";
2456
2705
 
2706
+ /**
2707
+ * Death-class name the walk records when a route refused a model id for lack of
2708
+ * serving capacity. Scoped to the model id by construction: the fact is about
2709
+ * the id (this cell cannot take it now), not about the route's credential, and
2710
+ * the record carries no key hash.
2711
+ */
2712
+ export const MODEL_CAPACITY_DEATH_CLASS = "model-capacity";
2713
+
2714
+ /**
2715
+ * How long a recorded model-capacity death keeps a route out of the walk for
2716
+ * that one model id.
2717
+ *
2718
+ * Measured from the cell's own refusal: every `prefill_cap_refused` answer
2719
+ * states the delay it wants ("retry shortly (after N s)"). Across the answers
2720
+ * this seat's routes have recorded, N ran 2 s to 59 s in 235 observations, with
2721
+ * 30 s the mode and 59 s the largest (5 s and 14 s the next most common). Three
2722
+ * minutes is just over three times the largest delay the cell ever asked for and
2723
+ * six times the modal one, and short enough that a cell which has recovered is
2724
+ * tried again within a turn boundary's worth of work. Every fresh refusal
2725
+ * re-records the death, so a cell that stays full keeps its own entry alive;
2726
+ * only a cell that has been quiet for the whole bound is asked again.
2727
+ */
2728
+ export const MODEL_CAPACITY_FRESHNESS_MS = 180_000;
2729
+
2457
2730
  const CONTEXT_WINDOW_PATTERNS = [
2458
2731
  /413/,
2459
2732
  /context[_ ]length[_ ]exceeded/i,
@@ -2463,13 +2736,54 @@ const CONTEXT_WINDOW_PATTERNS = [
2463
2736
  ];
2464
2737
 
2465
2738
  /**
2466
- * The first HTTP status the text names, or null. Bounded to 4xx/5xx three-digit
2467
- * runs so a model id or a token count cannot be read as a status; the walk
2468
- * needs the status CLASS, not a parsed response.
2739
+ * How a text frames an HTTP status. Each pattern's digits are its last
2740
+ * element, so a match's digit position is `length - group.length`.
2741
+ *
2742
+ * - the opening token of the text ("400 Bad Request", "429: {...}");
2743
+ * - the opening token of a bracketed clause, an HTML tag boundary
2744
+ * included, which is how an edge page carries its status
2745
+ * ("<body>403 ...");
2746
+ * - named as a status: "HTTP 429", "HTTP/1.1 502", "status 403",
2747
+ * "StatusCode: 400", "code 400", "response 503" (at most eight filler
2748
+ * characters between the marker and the digits).
2749
+ *
2750
+ * The frames are the ones the producers actually print: pi-ai's
2751
+ * `formatProviderError` renders `"<prefix> (<status>): <body>"` or
2752
+ * `"<status>: <body>"`, core's `llm-error` prefixes `"<status> <message>"`,
2753
+ * and the vendor SDKs append `"... status code 400"` or `"StatusCode: 400"`.
2754
+ *
2755
+ * A run that no frame introduces is not a status. `host:443`, `//host:443`,
2756
+ * the octet of a dotted address and the fraction of a decimal are the shapes
2757
+ * a transport failure names where a status would sit, and the old unbounded
2758
+ * `\b(4\d{2}|5\d{2})\b` read the port as the status: a connect timeout was
2759
+ * classified as a request the route had refused, and the walk rotated on it.
2760
+ */
2761
+ const STATUS_FRAMES: readonly RegExp[] = [
2762
+ /^\s*(4\d{2}|5\d{2})(?![\d.])/,
2763
+ /[([{<>]\s*(4\d{2}|5\d{2})(?![\d.])/,
2764
+ /\bhttp(?:\/\d(?:\.\d)?)?\s*[:=]?\s*(4\d{2}|5\d{2})(?![\d.])/,
2765
+ /\b(?:status[_ ]?code|status|code|response)\b[^\d\n]{0,8}?(4\d{2}|5\d{2})(?![\d.])/,
2766
+ ];
2767
+
2768
+ /**
2769
+ * The HTTP status the text presents as one, or null. Bounded to 4xx/5xx
2770
+ * three-digit runs, so a model id or a token count cannot be read as a status,
2771
+ * and bounded to the framings above, so a port or an address cannot either.
2772
+ * The earliest framing wins: a status the text already gave is not outranked by
2773
+ * a port printed later. The walk needs the status CLASS, not a parsed response,
2774
+ * so an unframed run answers null rather than guessing.
2469
2775
  */
2470
2776
  function httpStatusOf(errText: string): number | null {
2471
- const match = /\b(4\d{2}|5\d{2})\b/.exec(errText);
2472
- return match ? Number(match[1]) : null;
2777
+ let framed: { at: number; status: number } | null = null;
2778
+ for (const frame of STATUS_FRAMES) {
2779
+ const match = frame.exec(errText);
2780
+ if (!match) continue;
2781
+ const at = match.index + (match[0].length - match[1].length);
2782
+ if (framed === null || at < framed.at) {
2783
+ framed = { at, status: Number(match[1]) };
2784
+ }
2785
+ }
2786
+ return framed === null ? null : framed.status;
2473
2787
  }
2474
2788
 
2475
2789
  export function classifyFailure(errText: unknown): FailureClassification {
@@ -2493,6 +2807,21 @@ export function classifyFailure(errText: unknown): FailureClassification {
2493
2807
  if (CONTEXT_WINDOW_PATTERNS.some((pattern) => pattern.test(text))) {
2494
2808
  return { class: "payload", status };
2495
2809
  }
2810
+ // Capacity is tested BEFORE key for the reason payload is: the sentence can
2811
+ // arrive behind a status the key predicate claims (a gateway answers it 429,
2812
+ // 403 or 503 depending on the edge in front of it) or beside a quota idiom.
2813
+ // Exhausting a healthy key over a full cell burns the credential on a turn
2814
+ // the next route would refuse identically; the model id is what moves.
2815
+ if (isCapacityRefusal(text)) return { class: "capacity", status };
2816
+ // Transport is tested AFTER payload and capacity, both of which own their
2817
+ // recovery, and BEFORE key and shape, whose verdicts a host:port would
2818
+ // otherwise forge: the connectivity predicate is the single definition of
2819
+ // "the route never answered" (timeouts, socket and DNS idioms, an HTTP 5xx
2820
+ // from any hop). A transport failure is not a route decision, so it is a
2821
+ // class of its own: no key is exhausted, the walk rotates nothing and the
2822
+ // turn is handed no marker. The clause that makes the order load-bearing is
2823
+ // "at capacity", which the connectivity vocabulary reads as availability.
2824
+ if (classifyConnectivityFailure(text)) return { class: "transport", status };
2496
2825
  if (isKeyRotationError(text)) return { class: "key", status };
2497
2826
  if (status === 429) return { class: "none", status };
2498
2827
  if (status !== null && status < 500) return { class: "shape", status };