@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.
@@ -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
  }