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/README.md +68 -217
- package/build-info.json +4 -4
- package/index.ts +25 -16
- package/package.json +12 -11
- package/presend-guard.ts +344 -73
- package/ring-lifecycle.ts +355 -26
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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
|
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,
|
|
1219
|
-
*
|
|
1220
|
-
* (inert: nothing to serve, no
|
|
1221
|
-
*
|
|
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`
|
|
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,
|
|
2114
|
-
*
|
|
2115
|
-
*
|
|
2116
|
-
* re-serve the SAME id over
|
|
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
|
-
* - `
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
2467
|
-
*
|
|
2468
|
-
*
|
|
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
|
-
|
|
2472
|
-
|
|
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 };
|