@genesislcap/foundation-ai 15.14.1 → 15.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dts/index.d.ts +2 -0
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/transports/post-with-retry.d.ts.map +1 -1
- package/dist/dts/transports/provider-refused.d.ts +184 -0
- package/dist/dts/transports/provider-refused.d.ts.map +1 -0
- package/dist/dts/transports/retry-hints.d.ts +119 -0
- package/dist/dts/transports/retry-hints.d.ts.map +1 -0
- package/dist/dts/transports/server-openai-transport.d.ts.map +1 -1
- package/dist/dts/types/chat.types.d.ts +38 -2
- package/dist/dts/types/chat.types.d.ts.map +1 -1
- package/dist/esm/index.js +5 -0
- package/dist/esm/transports/post-with-retry.js +66 -8
- package/dist/esm/transports/provider-refused.js +312 -0
- package/dist/esm/transports/retry-hints.js +168 -0
- package/dist/esm/transports/server-openai-transport.js +10 -0
- package/dist/foundation-ai.api.json +360 -4
- package/dist/foundation-ai.d.ts +153 -2
- package/package.json +11 -11
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PROVIDER wall (GENC-1506) — the upstream vendor refusing this account, as opposed to the
|
|
3
|
+
* ai-service proxy refusing this user's own spend budget (`budget-exhausted-error.ts`, GENC-1464).
|
|
4
|
+
*
|
|
5
|
+
* The two are constantly confused, so state the difference once: a
|
|
6
|
+
* {@link BudgetExhaustedError} means *we* declined to spend more on the caller's behalf and an
|
|
7
|
+
* administrator can raise the cap; a {@link ProviderRefusedError} means *Anthropic or Google*
|
|
8
|
+
* declined to serve the request at all, and no amount of budget-raising in our own system changes
|
|
9
|
+
* that. Both are terminal, neither is retryable, and they need different people to fix them.
|
|
10
|
+
*
|
|
11
|
+
* Lives in its own module for the same reason its sibling does: it is raised from
|
|
12
|
+
* `post-with-retry.ts`, which both transports import, so homing it in a transport would close an
|
|
13
|
+
* import cycle the repo's `circular` check rejects.
|
|
14
|
+
*
|
|
15
|
+
* ## Scope: Anthropic only, deliberately
|
|
16
|
+
*
|
|
17
|
+
* Gemini is **not** classified here yet (GENC-1506, Matt's call 2026-08-18). It is not an oversight
|
|
18
|
+
* and it is not because Gemini cannot fail this way — it is because Gemini's failure shapes are
|
|
19
|
+
* genuinely different and only partly observed:
|
|
20
|
+
*
|
|
21
|
+
* - Google's paid tiers refuse a spend cap with `429 RESOURCE_EXHAUSTED`, which is **the same status
|
|
22
|
+
* and the same message text** as an ordinary rate limit, and `429` is in
|
|
23
|
+
* `GeminiTransport.RETRYABLE_STATUSES`. A prose probe there would classify routine rate limiting as
|
|
24
|
+
* terminal and break the retry ladder — the exact inverse of the Anthropic situation below.
|
|
25
|
+
* - The discriminator that *would* work is structural — `details[].QuotaFailure.violations[].quotaId`
|
|
26
|
+
* names the limit's scope (`…PerMinute…` is retryable, per-day or spend-scoped is not) — but only
|
|
27
|
+
* the per-minute free-tier value has been observed. Guessing the others is what this module's
|
|
28
|
+
* history says not to do.
|
|
29
|
+
* - Google's Prepay balance hitting $0 stops every key on the billing account at once, and its error
|
|
30
|
+
* shape is neither documented nor observed.
|
|
31
|
+
*
|
|
32
|
+
* So {@link providerRefusalOf} dispatches on vendor and answers `undefined` for anything that is not
|
|
33
|
+
* Anthropic. Adding Gemini later is a new branch, not a rewrite — which is why the vendor is a
|
|
34
|
+
* parameter rather than an assumption.
|
|
35
|
+
*
|
|
36
|
+
* @packageDocumentation
|
|
37
|
+
*/
|
|
38
|
+
import { VENDOR_LABELS } from './budget-exhausted-error';
|
|
39
|
+
/**
|
|
40
|
+
* The proxy's machine-readable code for "the upstream vendor refused this account".
|
|
41
|
+
*
|
|
42
|
+
* Deliberately distinct from `BUDGET_EXCEEDED`: the ai-service proxy stamps this when it relays a
|
|
43
|
+
* provider refusal, so the client can tell a relayed upstream fault from the proxy's own budget
|
|
44
|
+
* verdict without inspecting the body at all.
|
|
45
|
+
*
|
|
46
|
+
* Exported from the package index — unlike its `BUDGET_EXCEEDED_CODE` sibling, which is internal —
|
|
47
|
+
* because the SERVER side of the contract lives in another repo (genesis-create's ai-service stamps
|
|
48
|
+
* this code onto its proxy responses). A hand-copied string literal there would drift on the first
|
|
49
|
+
* rename, and the drift would be silent: the client would simply stop classifying.
|
|
50
|
+
*
|
|
51
|
+
* @beta
|
|
52
|
+
*/
|
|
53
|
+
export const PROVIDER_REFUSED_CODE = 'PROVIDER_REFUSED';
|
|
54
|
+
/**
|
|
55
|
+
* The single sentence shown to a user whose provider has refused — the transcript bubble the chat
|
|
56
|
+
* driver appends, and the string ai-service reuses for its own generation surfaces.
|
|
57
|
+
*
|
|
58
|
+
* **It names no cause, and that is the whole design.** Three constraints shaped it, and a rewrite
|
|
59
|
+
* has to clear all three:
|
|
60
|
+
*
|
|
61
|
+
* 1. **It must not sound like a cap.** The user can often see their remaining AI spend in the same
|
|
62
|
+
* UI, so *allowance / limit / quota / budget / usage / credit / balance* risk a visible
|
|
63
|
+
* self-contradiction: a message saying there is nothing left, next to a figure showing there is.
|
|
64
|
+
* "isn't related to your account **or your usage**" goes further than avoiding the words — it
|
|
65
|
+
* pre-empts the user checking that figure and concluding the message is wrong.
|
|
66
|
+
* 2. **It must not imply we cannot pay our bills.** *Billing / payment / funds / top up* all read as
|
|
67
|
+
* an unpaid invoice. Naming no cause means there is nothing to be embarrassed by and nothing for
|
|
68
|
+
* the user to contradict.
|
|
69
|
+
* 3. **It must not promise self-healing.** Clearing this needs a human to act out of band, so
|
|
70
|
+
* "currently" rather than "temporarily", and "so it can be restored" positively signals that
|
|
71
|
+
* someone must do something.
|
|
72
|
+
*
|
|
73
|
+
* It is also true of **both** {@link ProviderRefusalKind}s unchanged, which is what makes one
|
|
74
|
+
* sentence for two kinds honest rather than a fudge: for an expired key the provider *is* refusing,
|
|
75
|
+
* it *is* unrelated to the user's own account and usage, they *can* do nothing, and support *can*
|
|
76
|
+
* restore it.
|
|
77
|
+
*
|
|
78
|
+
* Vendor-neutral and deployment-neutral on purpose — the same bundle ships to white-labelled
|
|
79
|
+
* deployments where naming a vendor, or naming Genesis, would be wrong. A host that wants different
|
|
80
|
+
* wording sets `ChatDriverConfig.providerRefusedMessage`.
|
|
81
|
+
*
|
|
82
|
+
* @beta
|
|
83
|
+
*/
|
|
84
|
+
export const DEFAULT_PROVIDER_REFUSED_MESSAGE = "AI requests are currently being refused by the provider. This isn't related to your account or " +
|
|
85
|
+
'your usage and needs no action from you — please contact your support team so it can be restored.';
|
|
86
|
+
/**
|
|
87
|
+
* Thrown when the upstream provider refuses the request because of the state of the **account**
|
|
88
|
+
* rather than anything about the request — see {@link ProviderRefusalKind}.
|
|
89
|
+
*
|
|
90
|
+
* Terminal and non-transient, in the same family as `ResponseTruncatedError` and
|
|
91
|
+
* `BudgetExhaustedError`: retrying cannot clear it, because nothing about the request is wrong. So
|
|
92
|
+
* both the transport retry ladder and the driver's transient-retry catch step aside for it and the
|
|
93
|
+
* failure surfaces immediately as the `'provider-refused'` `TurnFailureReason`.
|
|
94
|
+
*
|
|
95
|
+
* @beta
|
|
96
|
+
*/
|
|
97
|
+
export class ProviderRefusedError extends Error {
|
|
98
|
+
constructor(
|
|
99
|
+
/** Vendor label of the transport that was refused (e.g. `'Anthropic'`). */
|
|
100
|
+
vendorLabel,
|
|
101
|
+
/** Which kind of refusal — drives the log and the operator's fix, never the user's message. */
|
|
102
|
+
kind,
|
|
103
|
+
/**
|
|
104
|
+
* The provider's own HTTP status, when known.
|
|
105
|
+
*
|
|
106
|
+
* Diagnostic only, and deliberately not part of the classification contract: the observed spend
|
|
107
|
+
* refusals arrive as `400` (not the `402 billing_error` the docs describe), so a consumer that
|
|
108
|
+
* branched on this would be encoding one snapshot of provider behaviour.
|
|
109
|
+
*/
|
|
110
|
+
upstreamStatus,
|
|
111
|
+
/** The provider's own `error.type` (e.g. `'invalid_request_error'`, `'authentication_error'`). */
|
|
112
|
+
upstreamType,
|
|
113
|
+
/** The provider's human-readable message, verbatim, for the log. Never shown to the user. */
|
|
114
|
+
detail) {
|
|
115
|
+
super(`${vendorLabel} refused the request (${kind})` +
|
|
116
|
+
(upstreamStatus != null ? ` — HTTP ${upstreamStatus}` : '') +
|
|
117
|
+
(upstreamType ? ` ${upstreamType}` : '') +
|
|
118
|
+
(detail ? `: ${detail}` : '') +
|
|
119
|
+
'. Retrying will not clear this — the provider account must be fixed.');
|
|
120
|
+
this.vendorLabel = vendorLabel;
|
|
121
|
+
this.kind = kind;
|
|
122
|
+
this.upstreamStatus = upstreamStatus;
|
|
123
|
+
this.upstreamType = upstreamType;
|
|
124
|
+
this.detail = detail;
|
|
125
|
+
this.name = 'ProviderRefusedError';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Phrases that identify a spend refusal hiding inside Anthropic's **generic**
|
|
130
|
+
* `invalid_request_error` bucket.
|
|
131
|
+
*
|
|
132
|
+
* Prose matching is not a shortcut here, it is the only handle available, and that is a measured
|
|
133
|
+
* fact rather than an assumption. Both refusals we have captured arrive as `400` +
|
|
134
|
+
* `invalid_request_error` with **no distinct type, no dedicated header and nothing else structural**:
|
|
135
|
+
*
|
|
136
|
+
* | Captured 2026-08-18 | Message |
|
|
137
|
+
* | --- | --- |
|
|
138
|
+
* | Credit balance at zero | `Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.` |
|
|
139
|
+
* | Workspace usage cap | `You have reached your specified workspace API usage limits. You will regain access on 2026-09-01 at 00:00 UTC.` |
|
|
140
|
+
*
|
|
141
|
+
* A classifier keyed only on the documented `402 billing_error` signal would have matched **neither**
|
|
142
|
+
* and shipped a non-fix. That is why this list exists.
|
|
143
|
+
*
|
|
144
|
+
* **Independent signals, not whole sentences.** A sentence list would need a new entry every time a
|
|
145
|
+
* member is reworded, and would silently stop matching in the meantime. `credit balance` and
|
|
146
|
+
* `usage limit` are each confirmed against a capture above; `spend limit` and the `insufficient …`
|
|
147
|
+
* form are unobserved and included as cheap tolerance for the same family.
|
|
148
|
+
*
|
|
149
|
+
* **False positives are near-impossible**, for a reason that is easy to miss: on a refused account
|
|
150
|
+
* the spend check runs *before* request validation (verified — a deliberately malformed request on a
|
|
151
|
+
* dry key still returned the credit message), so a genuine schema `400` is only observable on a
|
|
152
|
+
* healthy account, where its message quotes field paths and matches nothing here.
|
|
153
|
+
*
|
|
154
|
+
* Deletable the day Anthropic gives this family its own `error.type`.
|
|
155
|
+
*/
|
|
156
|
+
const ANTHROPIC_SPEND_PHRASES = Object.freeze([
|
|
157
|
+
/credit balance/i,
|
|
158
|
+
/usage limit/i,
|
|
159
|
+
/spend limit/i,
|
|
160
|
+
/insufficient (credit|funds|balance)/i,
|
|
161
|
+
]);
|
|
162
|
+
/** Anthropic `error.type` values that are an auth refusal, straight from the documented table. */
|
|
163
|
+
const ANTHROPIC_AUTH_TYPES = Object.freeze([
|
|
164
|
+
'authentication_error',
|
|
165
|
+
'permission_error',
|
|
166
|
+
]);
|
|
167
|
+
/**
|
|
168
|
+
* The inner `{ type, message }` of an Anthropic error envelope, from wherever it is on the wire.
|
|
169
|
+
*
|
|
170
|
+
* Two positions, both load-bearing, because the same refusal reaches us in two shapes:
|
|
171
|
+
* - **direct API and legacy JSON proxy** — the envelope IS the body:
|
|
172
|
+
* `{ type: 'error', error: { type, message } }`;
|
|
173
|
+
* - **NDJSON framed proxy** — the envelope is nested under the err frame's `details`, while the
|
|
174
|
+
* frame's own top-level `error` is a *string* (the proxy's summary).
|
|
175
|
+
*
|
|
176
|
+
* So reading only the top level would classify nothing on the browser path, and reading only
|
|
177
|
+
* `details` would classify nothing on the server path. Mirrors how `budgetExhaustedFrom` reads both.
|
|
178
|
+
*/
|
|
179
|
+
function anthropicErrorBody(payload) {
|
|
180
|
+
var _a;
|
|
181
|
+
const outer = typeof payload === 'object' && payload !== null ? payload : {};
|
|
182
|
+
// Framed: `details` holds the upstream envelope. Legacy/direct: the body itself does.
|
|
183
|
+
for (const candidate of [
|
|
184
|
+
outer.error,
|
|
185
|
+
(_a = outer.details) === null || _a === void 0 ? void 0 : _a.error,
|
|
186
|
+
]) {
|
|
187
|
+
if (typeof candidate === 'object' && candidate !== null) {
|
|
188
|
+
const { type, message } = candidate;
|
|
189
|
+
return {
|
|
190
|
+
type: typeof type === 'string' ? type : undefined,
|
|
191
|
+
message: typeof message === 'string' ? message : undefined,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* A `kind` the caller stated explicitly, from either wire position, or `undefined`.
|
|
199
|
+
*
|
|
200
|
+
* Both positions are read for the same reason `anthropicErrorBody` reads two: a JSON body carries its
|
|
201
|
+
* fields at the top level, while an NDJSON err frame nests everything but `error` under `details`.
|
|
202
|
+
*/
|
|
203
|
+
function statedKind(payload) {
|
|
204
|
+
const outer = (typeof payload === 'object' && payload !== null ? payload : {});
|
|
205
|
+
const nested = (typeof outer.details === 'object' && outer.details !== null ? outer.details : {});
|
|
206
|
+
for (const candidate of [outer.kind, nested.kind]) {
|
|
207
|
+
if (candidate === 'auth' || candidate === 'spend')
|
|
208
|
+
return candidate;
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The kind implied by the provider envelope's `type` alone — no prose, no status.
|
|
214
|
+
*
|
|
215
|
+
* Vendor-agnostic on purpose, so the explicit-code path can reuse it: by the time the proxy has
|
|
216
|
+
* stamped `PROVIDER_REFUSED` it has already decided this is a refusal, and all that is left is which
|
|
217
|
+
* kind — a question the relayed envelope answers without any vendor dispatch.
|
|
218
|
+
*/
|
|
219
|
+
function kindFromEnvelopeType(payload) {
|
|
220
|
+
const body = anthropicErrorBody(payload);
|
|
221
|
+
if (!(body === null || body === void 0 ? void 0 : body.type))
|
|
222
|
+
return undefined;
|
|
223
|
+
if (ANTHROPIC_AUTH_TYPES.includes(body.type))
|
|
224
|
+
return 'auth';
|
|
225
|
+
// The documented billing signal. Unobserved by us — every real spend refusal we captured came
|
|
226
|
+
// through the generic bucket — but it is in the errors table, so honour it.
|
|
227
|
+
if (body.type === 'billing_error')
|
|
228
|
+
return 'spend';
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Classify a rejection as a provider refusal, or `undefined` for anything else.
|
|
233
|
+
*
|
|
234
|
+
* **Every trigger is a POSITIVE signal — status alone never classifies.** That is what keeps this
|
|
235
|
+
* from stealing the proxy's own `402 BUDGET_EXCEEDED`: a bare `402` with no provider envelope
|
|
236
|
+
* carries nothing this function recognises, so it falls through to the budget classification exactly
|
|
237
|
+
* as it did before this module existed. A `402` that *does* carry an Anthropic envelope is a relayed
|
|
238
|
+
* upstream fault and is claimed here — which is the collision GENC-1506 set out to close, closed by
|
|
239
|
+
* ordering and positive detection rather than by loosening the budget predicate.
|
|
240
|
+
*
|
|
241
|
+
* @param vendor - which vendor's transport was refused. Anything but `'anthropic'` returns
|
|
242
|
+
* `undefined` today; see this module's header for why Gemini is deliberately absent.
|
|
243
|
+
* @param code - the proxy's machine-readable code, when there is one (`err` frame or JSON body).
|
|
244
|
+
* @param payload - the parsed rejection body, or the parsed `err` frame.
|
|
245
|
+
*/
|
|
246
|
+
export function providerRefusalOf(vendor, code, payload) {
|
|
247
|
+
var _a, _b;
|
|
248
|
+
// The proxy's explicit signal wins over any body inspection, in any status. It is the one route
|
|
249
|
+
// that needs no vendor knowledge at all — the proxy has already done the classifying.
|
|
250
|
+
//
|
|
251
|
+
// The KIND, though, still has to be worked out rather than assumed. Three sources, in descending
|
|
252
|
+
// authority, because a caller that troubled to state the kind knows more than we can infer:
|
|
253
|
+
//
|
|
254
|
+
// 1. a stated `kind`, in EITHER wire position — top level on a JSON body, under `details` on an
|
|
255
|
+
// err frame, since the framed protocol carries rejection metadata there (the same split
|
|
256
|
+
// `anthropicErrorBody` and `budgetExhaustedFrom` already straddle);
|
|
257
|
+
// 2. the provider envelope's own type, which the proxy relays in `details` whether or not it
|
|
258
|
+
// states a kind — this is what our own ai-service proxy actually sends today;
|
|
259
|
+
// 3. `spend`, as the last resort.
|
|
260
|
+
//
|
|
261
|
+
// Reading only position 1 and defaulting the rest was a real mis-attribution rather than a
|
|
262
|
+
// hypothetical: our proxy stamps this code with the Anthropic envelope in `details` and no
|
|
263
|
+
// top-level `kind`, so an `authentication_error` was being reported as `spend` — sending an
|
|
264
|
+
// operator to top up an account when the actual fix is to rotate a credential. The user-facing
|
|
265
|
+
// copy is identical either way, which is exactly why nothing on screen would have caught it.
|
|
266
|
+
if (code === PROVIDER_REFUSED_CODE) {
|
|
267
|
+
return (_b = (_a = statedKind(payload)) !== null && _a !== void 0 ? _a : kindFromEnvelopeType(payload)) !== null && _b !== void 0 ? _b : 'spend';
|
|
268
|
+
}
|
|
269
|
+
if (vendor !== 'anthropic')
|
|
270
|
+
return undefined;
|
|
271
|
+
// Structural first: documented types, no prose, and cheaper than the phrase scan. `401`/`403` are
|
|
272
|
+
// absent from every RETRYABLE_STATUSES, so claiming them changes what the user is told and what the
|
|
273
|
+
// log records — never the retry behaviour.
|
|
274
|
+
const structural = kindFromEnvelopeType(payload);
|
|
275
|
+
if (structural)
|
|
276
|
+
return structural;
|
|
277
|
+
// The generic bucket, gated on the phrase list. Deliberately NOT gated on status as well: the
|
|
278
|
+
// captures were all `400`, and pinning that would encode one snapshot of provider behaviour when
|
|
279
|
+
// the type plus the phrase is already specific enough.
|
|
280
|
+
const body = anthropicErrorBody(payload);
|
|
281
|
+
if ((body === null || body === void 0 ? void 0 : body.type) === 'invalid_request_error' && body.message) {
|
|
282
|
+
const message = body.message;
|
|
283
|
+
if (ANTHROPIC_SPEND_PHRASES.some((phrase) => phrase.test(message)))
|
|
284
|
+
return 'spend';
|
|
285
|
+
}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Build a {@link ProviderRefusedError} from a classified rejection, pulling the provider's own
|
|
290
|
+
* status/type/message across for the log.
|
|
291
|
+
*
|
|
292
|
+
* `upstreamStatus` is passed in rather than read from the payload because the two wire modes carry it
|
|
293
|
+
* differently (HTTP status vs the err frame's `status`), and the caller is the only one that knows
|
|
294
|
+
* which it is looking at.
|
|
295
|
+
*/
|
|
296
|
+
export function providerRefusedFrom(vendorLabel, kind, upstreamStatus, payload, fallbackDetail) {
|
|
297
|
+
var _a;
|
|
298
|
+
const body = anthropicErrorBody(payload);
|
|
299
|
+
return new ProviderRefusedError(vendorLabel, kind, upstreamStatus, body === null || body === void 0 ? void 0 : body.type, (_a = body === null || body === void 0 ? void 0 : body.message) !== null && _a !== void 0 ? _a : fallbackDetail);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The {@link AIProviderType} a vendor label belongs to, for the classification call above.
|
|
303
|
+
*
|
|
304
|
+
* A thin wrapper over the label map rather than a reuse of `vendorTypeOfLabel`: this one is called
|
|
305
|
+
* with the transport's OWN label, which is always a literal from {@link VENDOR_LABELS}, so an exact
|
|
306
|
+
* match is correct and the case-insensitive, whitespace-tolerant lookup that the wire-borne label
|
|
307
|
+
* needs would be misleading precision here.
|
|
308
|
+
*/
|
|
309
|
+
export function vendorOfTransportLabel(vendorLabel) {
|
|
310
|
+
const entry = Object.entries(VENDOR_LABELS).find(([, label]) => label === vendorLabel);
|
|
311
|
+
return entry === null || entry === void 0 ? void 0 : entry[0];
|
|
312
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-supplied retry hints for the shared `postWithRetry` ladder (GENC-1506).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* The ladder was `base * 2^attempt` — 1s, 2s, 4s, 8s, 16s, **31s in total** — and it read neither the
|
|
7
|
+
* HTTP `Retry-After` header nor Google's in-body `RetryInfo`. Measured against a real rate-limited
|
|
8
|
+
* Gemini key (2026-08-18), the provider asked for **53s**:
|
|
9
|
+
*
|
|
10
|
+
* ```jsonc
|
|
11
|
+
* {"error":{"code":429,"status":"RESOURCE_EXHAUSTED",
|
|
12
|
+
* "message":"… Please retry in 53.59713199s.",
|
|
13
|
+
* "details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"53s"}]}}
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* So a **legitimate** rate limit burned all five retries inside the window the provider had told us to
|
|
17
|
+
* wait, then failed opaquely — every attempt after the first guaranteed to fail, and the user waiting
|
|
18
|
+
* 31s to be told nothing useful. Honouring the hint turns five doomed requests into one that works.
|
|
19
|
+
*
|
|
20
|
+
* ## Scope note
|
|
21
|
+
*
|
|
22
|
+
* This is deliberately vendor-neutral and applies to **healthy traffic**, not just refusals. It is the
|
|
23
|
+
* one part of GENC-1506 that changes timing on a path that is working: some failures that used to give
|
|
24
|
+
* up after 31s will now wait longer before succeeding or failing. That is the correct behaviour and it
|
|
25
|
+
* is what the server asked for, but it is why the ladder keeps its own tests and its own line in the
|
|
26
|
+
* release notes rather than riding along silently inside a fix about error messages.
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Ceiling on a server-requested wait, per attempt.
|
|
32
|
+
*
|
|
33
|
+
* A hint is *advice from a remote party*, so it needs a bound: without one, a mistaken or hostile
|
|
34
|
+
* `Retry-After: 86400` parks a turn for a day with no way out but a caller abort. 60s sits comfortably
|
|
35
|
+
* above the largest value observed (53s) while keeping the worst case survivable.
|
|
36
|
+
*
|
|
37
|
+
* This caps each wait, and it is the ONLY per-attempt bound — the earlier claim that the exponential
|
|
38
|
+
* ladder bounds a pathological sequence was simply wrong, because `resolveBackoffMs` takes the LARGER
|
|
39
|
+
* of the two and the ladder tops out at `baseMs * 2 ** 4` (16s at the 1s base every transport uses). A
|
|
40
|
+
* 60s hint therefore wins on every attempt and the ladder never contributes. That is what
|
|
41
|
+
* {@link MAX_TOTAL_SERVER_REQUESTED_BACKOFF_MS} is for.
|
|
42
|
+
*/
|
|
43
|
+
export const MAX_SERVER_REQUESTED_BACKOFF_MS = 60000;
|
|
44
|
+
/**
|
|
45
|
+
* Ceiling on the server-requested wait across a WHOLE retry sequence.
|
|
46
|
+
*
|
|
47
|
+
* Without it the per-attempt cap is no real bound: five capped waits is five minutes, and nothing is
|
|
48
|
+
* watching during them — the attempt's own timeout is cleared before each backoff, so a provider (or a
|
|
49
|
+
* proxy in front of one) answering `Retry-After: 3600` five times parks the turn for the full five
|
|
50
|
+
* minutes with a caller abort as the only way out.
|
|
51
|
+
*
|
|
52
|
+
* 120s is chosen so the case this module was written for still works untouched: the measured Gemini
|
|
53
|
+
* hint was 53s, and two of those fit inside the budget with headroom. Past it we stop honouring hints
|
|
54
|
+
* and fall back to the ladder, so a sequence of maximal 60s hints now totals ~151s (120s of honoured
|
|
55
|
+
* excess plus the ladder's own 31s) rather than 300s.
|
|
56
|
+
*
|
|
57
|
+
* What it budgets is the EXCESS over the wait the ladder would have taken anyway — see
|
|
58
|
+
* {@link serverBackoffSpentMs}. Budgeting the raw hint instead would let the ladder's own growth eat
|
|
59
|
+
* the allowance late in a sequence, declining hints on the grounds of time we were always going to
|
|
60
|
+
* spend.
|
|
61
|
+
*/
|
|
62
|
+
export const MAX_TOTAL_SERVER_REQUESTED_BACKOFF_MS = 120000;
|
|
63
|
+
/** Anthropic's explicit retry verdict, which its own SDK honours. */
|
|
64
|
+
const SHOULD_RETRY_HEADER = 'x-should-retry';
|
|
65
|
+
/** Both hint formats are expressed in seconds; every internal wait is in ms. */
|
|
66
|
+
const MS_PER_SECOND = 1000;
|
|
67
|
+
/**
|
|
68
|
+
* `true` only when the server has **explicitly** said not to retry.
|
|
69
|
+
*
|
|
70
|
+
* Absent means "no opinion" and must never be read as `true` — that asymmetry is measured, not
|
|
71
|
+
* cautious: the captured Anthropic `401` carries no `x-should-retry` at all, while the captured
|
|
72
|
+
* spend-refusal `400` carries `false`. Treating absence as a refusal to retry would silently disable
|
|
73
|
+
* the ladder for every provider that does not send the header, Google included.
|
|
74
|
+
*/
|
|
75
|
+
export function shouldNotRetry(headers) {
|
|
76
|
+
var _a;
|
|
77
|
+
const value = (_a = headers === null || headers === void 0 ? void 0 : headers.get) === null || _a === void 0 ? void 0 : _a.call(headers, SHOULD_RETRY_HEADER);
|
|
78
|
+
return typeof value === 'string' && value.trim().toLowerCase() === 'false';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The `Retry-After` header in ms, or `undefined`.
|
|
82
|
+
*
|
|
83
|
+
* Both documented forms are accepted — delta-seconds (`Retry-After: 53`) and an HTTP-date
|
|
84
|
+
* (`Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`) — because a proxy or CDN in front of the provider may
|
|
85
|
+
* rewrite one into the other, and a silently unparsed hint is indistinguishable from no hint at all.
|
|
86
|
+
* A date already in the past clamps to 0 rather than going negative.
|
|
87
|
+
*/
|
|
88
|
+
export function retryAfterMs(headers) {
|
|
89
|
+
var _a;
|
|
90
|
+
const raw = (_a = headers === null || headers === void 0 ? void 0 : headers.get) === null || _a === void 0 ? void 0 : _a.call(headers, 'retry-after');
|
|
91
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
92
|
+
return undefined;
|
|
93
|
+
const seconds = Number(raw.trim());
|
|
94
|
+
if (Number.isFinite(seconds))
|
|
95
|
+
return Math.max(0, seconds * MS_PER_SECOND);
|
|
96
|
+
const at = Date.parse(raw);
|
|
97
|
+
if (Number.isNaN(at))
|
|
98
|
+
return undefined;
|
|
99
|
+
return Math.max(0, at - Date.now());
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Google's `google.rpc.RetryInfo.retryDelay` in ms, or `undefined`.
|
|
103
|
+
*
|
|
104
|
+
* Read from **two positions**, for the same reason the refusal classifier reads two: on the direct API
|
|
105
|
+
* the body is Google's own (`{ error: { details: [...] } }`), while through the NDJSON framed proxy that
|
|
106
|
+
* body is nested under the err frame's `details`. Reading one position only would honour the hint on
|
|
107
|
+
* exactly one of the two paths.
|
|
108
|
+
*
|
|
109
|
+
* Matched on an `@type` **suffix** rather than the full URL: the type prefix is a Google-owned constant
|
|
110
|
+
* that has no reason to change, but suffix-matching costs nothing and survives it if it does.
|
|
111
|
+
*/
|
|
112
|
+
export function retryInfoMs(payload) {
|
|
113
|
+
const outer = typeof payload === 'object' && payload !== null ? payload : {};
|
|
114
|
+
const framed = typeof outer.details === 'object' && outer.details !== null
|
|
115
|
+
? outer.details
|
|
116
|
+
: undefined;
|
|
117
|
+
for (const holder of [outer.error, framed === null || framed === void 0 ? void 0 : framed.error]) {
|
|
118
|
+
const details = holder === null || holder === void 0 ? void 0 : holder.details;
|
|
119
|
+
if (!Array.isArray(details))
|
|
120
|
+
continue;
|
|
121
|
+
for (const entry of details) {
|
|
122
|
+
const type = entry === null || entry === void 0 ? void 0 : entry['@type'];
|
|
123
|
+
if (typeof type !== 'string' || !type.endsWith('google.rpc.RetryInfo'))
|
|
124
|
+
continue;
|
|
125
|
+
const delay = entry === null || entry === void 0 ? void 0 : entry.retryDelay;
|
|
126
|
+
// Protobuf Duration as JSON: a decimal number of seconds with an `s` suffix ("53s", "1.5s").
|
|
127
|
+
if (typeof delay !== 'string')
|
|
128
|
+
continue;
|
|
129
|
+
const seconds = Number(delay.replace(/s$/, ''));
|
|
130
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
131
|
+
return seconds * MS_PER_SECOND;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* How long to wait before the next attempt: never less than our own ladder, never less than the
|
|
138
|
+
* server asked, never more than the ceiling.
|
|
139
|
+
*
|
|
140
|
+
* `max` rather than "prefer the server's value" is the point — a server asking for *less* than our
|
|
141
|
+
* backoff must not be able to talk us into hammering it, which a naive "honour the hint" would do on
|
|
142
|
+
* `Retry-After: 0`.
|
|
143
|
+
*
|
|
144
|
+
* @param attempt - zero-based attempt index, as the ladder counts them.
|
|
145
|
+
* @param baseMs - the transport's backoff base; the ladder is `baseMs * 2^attempt`.
|
|
146
|
+
* @param serverMs - the server's requested delay, when it supplied one.
|
|
147
|
+
*/
|
|
148
|
+
export function resolveBackoffMs(attempt, baseMs, serverMs, serverAllowanceMs = Number.POSITIVE_INFINITY) {
|
|
149
|
+
const ladder = baseMs * Math.pow(2, attempt);
|
|
150
|
+
if (serverMs == null || !Number.isFinite(serverMs))
|
|
151
|
+
return ladder;
|
|
152
|
+
// Three ceilings, all of which have to hold: what the server asked, the per-attempt cap, and
|
|
153
|
+
// whatever is left of the sequence budget. An exhausted allowance clamps to 0, so the `max` below
|
|
154
|
+
// returns the bare ladder and the sequence degrades to its pre-GENC-1506 behaviour rather than
|
|
155
|
+
// stopping — a hint we decline to honour is not a reason to stop retrying.
|
|
156
|
+
const honoured = Math.min(serverMs, MAX_SERVER_REQUESTED_BACKOFF_MS, Math.max(0, serverAllowanceMs));
|
|
157
|
+
return Math.max(ladder, honoured);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* How much of the sequence budget a resolved wait consumed.
|
|
161
|
+
*
|
|
162
|
+
* Only the part attributable to the server counts: a wait the ladder would have taken anyway is not
|
|
163
|
+
* the server's doing, so it must not eat the allowance — otherwise a long ladder late in a sequence
|
|
164
|
+
* would exhaust the budget without a single hint having been honoured.
|
|
165
|
+
*/
|
|
166
|
+
export function serverBackoffSpentMs(resolvedMs, ladderMs) {
|
|
167
|
+
return Math.max(0, resolvedMs - ladderMs);
|
|
168
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { __awaiter } from "tslib";
|
|
2
2
|
import { budgetExhaustedFrom, codeOf, isBudgetRejection, parseJsonOrUndefined, VENDOR_LABELS, } from './budget-exhausted-error';
|
|
3
|
+
import { providerRefusalOf, providerRefusedFrom } from './provider-refused';
|
|
3
4
|
const AI_SERVER_PATH = '/gwf/ai-service/chat-completions';
|
|
4
5
|
const DEFAULT_MODEL = 'gpt-4o-mini';
|
|
5
6
|
const DEFAULT_TIMEOUT = 30000;
|
|
@@ -72,6 +73,15 @@ export class ServerOpenAITransport {
|
|
|
72
73
|
// the assistant's `blocked` state, before or after this change. The typed
|
|
73
74
|
// throw improves the log line and any host that catches it directly. See
|
|
74
75
|
// `docs/migration-GENC-1464.md` §"Transport coverage".
|
|
76
|
+
// The provider wall (GENC-1506), classified before the budget wall for the same
|
|
77
|
+
// ordering reason `postWithRetry` has: a relayed upstream `402` must not be
|
|
78
|
+
// reported as the caller's own spend cap. Same honest caveat as the budget
|
|
79
|
+
// classification below — this transport's only caller catches everything and
|
|
80
|
+
// returns null, so the typed throw improves the log line and nothing else.
|
|
81
|
+
const refusal = providerRefusalOf('openai', codeOf(parsed), parsed);
|
|
82
|
+
if (refusal) {
|
|
83
|
+
throw providerRefusedFrom(VENDOR_LABELS.openai, refusal, response.status, parsed, err || undefined);
|
|
84
|
+
}
|
|
75
85
|
if (isBudgetRejection(response.status, codeOf(parsed))) {
|
|
76
86
|
throw budgetExhaustedFrom(VENDOR_LABELS.openai, parsed, err || undefined);
|
|
77
87
|
}
|