@genesislcap/foundation-ai 15.6.2 → 15.7.1
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 +1 -0
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/transports/anthropic-transport.d.ts.map +1 -1
- package/dist/dts/transports/budget-exhausted-error.d.ts +254 -0
- package/dist/dts/transports/budget-exhausted-error.d.ts.map +1 -0
- package/dist/dts/transports/gemini-transport.d.ts.map +1 -1
- package/dist/dts/transports/post-with-retry.d.ts +6 -1
- package/dist/dts/transports/post-with-retry.d.ts.map +1 -1
- package/dist/dts/transports/server-openai-transport.d.ts.map +1 -1
- package/dist/dts/types/chat.types.d.ts +74 -2
- package/dist/dts/types/chat.types.d.ts.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/transports/anthropic-transport.js +2 -1
- package/dist/esm/transports/budget-exhausted-error.js +277 -0
- package/dist/esm/transports/gemini-transport.js +2 -1
- package/dist/esm/transports/post-with-retry.js +47 -8
- package/dist/esm/transports/server-openai-transport.js +19 -0
- package/dist/foundation-ai.api.json +533 -6
- package/dist/foundation-ai.d.ts +247 -2
- package/package.json +11 -11
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI-spend budget wall (GENC-1464 workstream C/D).
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module rather than beside `ResponseTruncatedError` in
|
|
5
|
+
* `anthropic-transport.ts` because it is raised from `post-with-retry.ts`, which
|
|
6
|
+
* both transports import — homing it in a transport would close an import cycle
|
|
7
|
+
* (transport → post-with-retry → transport) that the repo's `circular` check
|
|
8
|
+
* rejects.
|
|
9
|
+
*/
|
|
10
|
+
/** The proxy's machine-readable rejection code for an exhausted AI budget. */
|
|
11
|
+
export const BUDGET_EXCEEDED_CODE = 'BUDGET_EXCEEDED';
|
|
12
|
+
/**
|
|
13
|
+
* Display name for each concrete AI vendor: the **single source** every
|
|
14
|
+
* transport's `vendorLabel` is read from, and the only place a vendor's name is
|
|
15
|
+
* spelled for a user.
|
|
16
|
+
*
|
|
17
|
+
* This map is the hinge of the per-vendor budget model (GENC-1464). A wall is
|
|
18
|
+
* attributed to a vendor by reverse-looking-up the label the refused transport
|
|
19
|
+
* stamped on its {@link BudgetExhaustedError} — so a label that drifts from this
|
|
20
|
+
* map (a literal typo, a casing change, a new vendor added with a hardcoded
|
|
21
|
+
* string) silently degrades every wall from that transport to "unattributable"
|
|
22
|
+
* and re-locks the whole session instead of just that vendor. It fails safe, but
|
|
23
|
+
* the feature quietly stops working, which is why the transports read their label
|
|
24
|
+
* from here rather than restating it.
|
|
25
|
+
*
|
|
26
|
+
* The map has **two kinds of entry**, and the difference matters:
|
|
27
|
+
*
|
|
28
|
+
* - The {@link BUDGETED_VENDORS} — `anthropic` and `gemini` — reach the model
|
|
29
|
+
* through the ai-service proxy, are metered by it, and stamp their label on a
|
|
30
|
+
* {@link BudgetExhaustedError}. For these the label is both an attribution key
|
|
31
|
+
* and display copy.
|
|
32
|
+
* - `chrome` and `openai` are **display-only**. `ChromeProvider` talks to the
|
|
33
|
+
* on-device Prompt API and the proxy refuses `openai` outright, so neither can
|
|
34
|
+
* be refused for budget or stamp a label — but both report a `provider` from
|
|
35
|
+
* `getStatus()`, which puts them in the assistant's reachable set, which is
|
|
36
|
+
* what the blocked banner names when it tells a walled user where they can
|
|
37
|
+
* still go ("Switch to Chrome to keep going."). Dropping either entry would
|
|
38
|
+
* print the raw type there.
|
|
39
|
+
*
|
|
40
|
+
* `'none'` is excluded deliberately: it is the "no provider configured" sentinel,
|
|
41
|
+
* not a vendor, and nothing can be refused by it — nor switched to.
|
|
42
|
+
*
|
|
43
|
+
* @beta
|
|
44
|
+
*/
|
|
45
|
+
export const VENDOR_LABELS = Object.freeze({
|
|
46
|
+
anthropic: 'Anthropic',
|
|
47
|
+
gemini: 'Gemini',
|
|
48
|
+
openai: 'OpenAI',
|
|
49
|
+
chrome: 'Chrome',
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* The vendors whose spend the ai-service proxy meters — i.e. the only vendors
|
|
53
|
+
* that can raise a {@link BudgetExhaustedError}, and therefore the only ones a
|
|
54
|
+
* 402's `otherVendorAvailable: false` is a statement about.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately narrower than the keys of {@link VENDOR_LABELS}, and it must stay
|
|
57
|
+
* exactly the proxy's own list — `VENDORS` in ai-service's `utils/aiVendor.js`.
|
|
58
|
+
* A vendor listed here that the proxy does not meter gets walled by the
|
|
59
|
+
* `otherVendorAvailable: false` sweep on a verdict that says nothing about it,
|
|
60
|
+
* and if it is reachable, `blocked` then derives true and locks a composer with
|
|
61
|
+
* headroom left.
|
|
62
|
+
*
|
|
63
|
+
* Two vendors are excluded for that reason:
|
|
64
|
+
* - `chrome` runs on-device — no pot to exhaust, and free.
|
|
65
|
+
* - `openai` is **not budgeted by the proxy**. ai-service rejects it up front
|
|
66
|
+
* with `400 UNSUPPORTED_PROVIDER` (`SUPPORTED_PROVIDERS` in `aiVendor.js`), no
|
|
67
|
+
* `MODEL_PRICING` row names it, and `otherVendorAvailable` is computed over
|
|
68
|
+
* Anthropic and Gemini alone. So it can never raise a 402, and a 402 is never
|
|
69
|
+
* a statement about it.
|
|
70
|
+
*
|
|
71
|
+
* @beta
|
|
72
|
+
*/
|
|
73
|
+
export const BUDGETED_VENDORS = Object.freeze([
|
|
74
|
+
'anthropic',
|
|
75
|
+
'gemini',
|
|
76
|
+
]);
|
|
77
|
+
/**
|
|
78
|
+
* Whether this vendor's spend is metered by the proxy — see
|
|
79
|
+
* {@link BUDGETED_VENDORS}.
|
|
80
|
+
*
|
|
81
|
+
* A HOST-FACING helper with, deliberately, no internal caller: the library's
|
|
82
|
+
* own consumers iterate {@link BUDGETED_VENDORS} directly (structural, cannot
|
|
83
|
+
* drift), while a host writing its own budget pre-flight or status surface
|
|
84
|
+
* needs the membership test in predicate form. Kept exported for that use —
|
|
85
|
+
* genesis-create's pre-flight is the shape of consumer it exists for.
|
|
86
|
+
*
|
|
87
|
+
* @beta
|
|
88
|
+
*/
|
|
89
|
+
export const isBudgetedVendor = (vendor) => vendor != null && BUDGETED_VENDORS.includes(vendor);
|
|
90
|
+
/** Reverse index of {@link VENDOR_LABELS}, lower-cased, built once. */
|
|
91
|
+
const VENDOR_TYPE_BY_LABEL = new Map(Object.entries(VENDOR_LABELS).map(([type, label]) => [
|
|
92
|
+
label.toLowerCase(),
|
|
93
|
+
type,
|
|
94
|
+
]));
|
|
95
|
+
/**
|
|
96
|
+
* The {@link AIProviderType} behind a vendor label, or `undefined` for a label
|
|
97
|
+
* no vendor claims.
|
|
98
|
+
*
|
|
99
|
+
* Case-insensitive and whitespace-tolerant, because the label travels as free
|
|
100
|
+
* text on the wire (the driver contract carries `vendorLabel`, not the type) and
|
|
101
|
+
* an unrecognised label must degrade to "unattributable" rather than to a wrong
|
|
102
|
+
* attribution — blocking the wrong vendor is worse than blocking none.
|
|
103
|
+
*
|
|
104
|
+
* @beta
|
|
105
|
+
*/
|
|
106
|
+
export function vendorTypeOfLabel(label) {
|
|
107
|
+
if (typeof label !== 'string')
|
|
108
|
+
return undefined;
|
|
109
|
+
return VENDOR_TYPE_BY_LABEL.get(label.trim().toLowerCase());
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* HTTP status the ai-service proxy rejects an over-budget request with, in both
|
|
113
|
+
* the legacy JSON mode (the response status) and the NDJSON framed mode (the
|
|
114
|
+
* `status` on the terminal `err` frame).
|
|
115
|
+
*
|
|
116
|
+
* Deliberately **absent** from every transport's `RETRYABLE_STATUSES`: the budget
|
|
117
|
+
* does not refill on a backoff ladder, so retrying only delays the inevitable
|
|
118
|
+
* five times over.
|
|
119
|
+
*/
|
|
120
|
+
export const BUDGET_EXCEEDED_STATUS = 402;
|
|
121
|
+
/**
|
|
122
|
+
* The single copy shown to a user whose AI budget is gone — the transcript bubble
|
|
123
|
+
* the chat driver appends, and the default text of the assistant's blocked banner.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately **vendor-neutral**: the same bundle ships to white-labelled
|
|
126
|
+
* deployments where "contact Genesis" is simply wrong. A host that wants
|
|
127
|
+
* branded wording overrides both surfaces — the banner via
|
|
128
|
+
* `FoundationAiAssistant.setBlocked(true, reason)`, the transcript bubble via
|
|
129
|
+
* `ChatDriverConfig.budgetExhaustedMessage` — so neither is stuck with this
|
|
130
|
+
* default while the other is customised.
|
|
131
|
+
*
|
|
132
|
+
* @beta
|
|
133
|
+
*/
|
|
134
|
+
export const DEFAULT_BUDGET_EXHAUSTED_MESSAGE = "You've reached your AI usage limit. Contact your administrator to raise it.";
|
|
135
|
+
/**
|
|
136
|
+
* Thrown when the AI-spend budget for the caller (user, tenant, or project) is
|
|
137
|
+
* exhausted and the ai-service proxy refuses the request outright — HTTP `402`
|
|
138
|
+
* with `code: 'BUDGET_EXCEEDED'`, in either the legacy JSON or the NDJSON framed
|
|
139
|
+
* mode.
|
|
140
|
+
*
|
|
141
|
+
* This is a **terminal, non-transient** condition, in the same family as
|
|
142
|
+
* `ResponseTruncatedError`: no amount of retrying clears it, because nothing
|
|
143
|
+
* about the request is wrong. The budget has to be raised out-of-band before any
|
|
144
|
+
* further call can succeed, so both the transport retry ladder and the driver's
|
|
145
|
+
* transient-retry catch step aside for it and the failure surfaces immediately
|
|
146
|
+
* as the `'budget-exhausted'` `TurnFailureReason`.
|
|
147
|
+
*
|
|
148
|
+
* `budgetUsd`/`spentUsd` are populated from the rejection body when the proxy
|
|
149
|
+
* supplies them (the workstream-C contract does); they are optional because a
|
|
150
|
+
* plain-text or truncated 402 from an older proxy still has to classify.
|
|
151
|
+
*
|
|
152
|
+
* @beta
|
|
153
|
+
*/
|
|
154
|
+
export class BudgetExhaustedError extends Error {
|
|
155
|
+
constructor(
|
|
156
|
+
/** Vendor label of the transport that was refused (e.g. `'Anthropic'`). */
|
|
157
|
+
vendorLabel,
|
|
158
|
+
/** The configured spend cap in USD, when the proxy reported one. */
|
|
159
|
+
budgetUsd,
|
|
160
|
+
/** Spend already booked against that cap in USD, when the proxy reported it. */
|
|
161
|
+
spentUsd,
|
|
162
|
+
/** The proxy's human-readable rejection message, when present. */
|
|
163
|
+
detail,
|
|
164
|
+
/**
|
|
165
|
+
* The fields the proxy reports about the wider budget picture. An object
|
|
166
|
+
* rather than two more positional arguments: the four above are already the
|
|
167
|
+
* limit of what reads at a call site, and these two are set together or not
|
|
168
|
+
* at all.
|
|
169
|
+
*/
|
|
170
|
+
extra) {
|
|
171
|
+
super(`${vendorLabel} request refused: the AI usage budget is exhausted` +
|
|
172
|
+
(budgetUsd != null
|
|
173
|
+
? ` (spent ${spentUsd != null ? `$${spentUsd}` : 'an unknown amount'} of a $${budgetUsd} budget)`
|
|
174
|
+
: '') +
|
|
175
|
+
(detail ? `: ${detail}` : '') +
|
|
176
|
+
'. Retrying will not clear this — the budget must be raised.');
|
|
177
|
+
this.vendorLabel = vendorLabel;
|
|
178
|
+
this.budgetUsd = budgetUsd;
|
|
179
|
+
this.spentUsd = spentUsd;
|
|
180
|
+
this.detail = detail;
|
|
181
|
+
this.name = 'BudgetExhaustedError';
|
|
182
|
+
this.otherVendorAvailable = extra === null || extra === void 0 ? void 0 : extra.otherVendorAvailable;
|
|
183
|
+
this.serverVendor = extra === null || extra === void 0 ? void 0 : extra.serverVendor;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* True for a budget rejection in either wire mode: the contracted `402`, or the
|
|
188
|
+
* `BUDGET_EXCEEDED` code under **any** status.
|
|
189
|
+
*
|
|
190
|
+
* Both keys are load-bearing, and they are not redundant:
|
|
191
|
+
*
|
|
192
|
+
* - `status` is the key the workstream-C contract guarantees ("402 always"), and
|
|
193
|
+
* the only one a plain-text or unparseable rejection carries.
|
|
194
|
+
* - `code` is the cheap defence against that contract drifting. Nothing stops a
|
|
195
|
+
* proxy from mapping the refusal onto `403` or `429` — and `429` is in *both*
|
|
196
|
+
* transports' `RETRYABLE_STATUSES`, so a status-only check would hand the user
|
|
197
|
+
* the exact bug GENC-1464 exists to fix: five doomed requests and ~31s of backoff
|
|
198
|
+
* before an opaque failure. A well-formed `{ t: 'err', status: 429, code:
|
|
199
|
+
* 'BUDGET_EXCEEDED' }` frame is accepted by `readFramedBody` (it only rejects a
|
|
200
|
+
* non-numeric status), so this is a reachable shape, not a hypothetical one.
|
|
201
|
+
*
|
|
202
|
+
* The false-positive cost is a proxy sending `code: 'BUDGET_EXCEEDED'` on a
|
|
203
|
+
* genuinely retryable failure — a shape the contract does not permit.
|
|
204
|
+
*
|
|
205
|
+
* Internal to the transports (unlike `BudgetExhaustedError`, which hosts branch
|
|
206
|
+
* on): deliberately **not** re-exported from the package index.
|
|
207
|
+
*
|
|
208
|
+
* @internal
|
|
209
|
+
*/
|
|
210
|
+
export const isBudgetRejection = (status, code) => status === BUDGET_EXCEEDED_STATUS || code === BUDGET_EXCEEDED_CODE;
|
|
211
|
+
/**
|
|
212
|
+
* Best-effort JSON parse of an error body — `undefined` rather than a throw.
|
|
213
|
+
* Shared by every caller that has to classify a rejection body before it knows
|
|
214
|
+
* whether the body is JSON at all.
|
|
215
|
+
*
|
|
216
|
+
* @internal
|
|
217
|
+
*/
|
|
218
|
+
export const parseJsonOrUndefined = (text) => {
|
|
219
|
+
try {
|
|
220
|
+
return JSON.parse(text);
|
|
221
|
+
}
|
|
222
|
+
catch (_a) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Read the `code` field off an already-parsed rejection body, ignoring anything
|
|
228
|
+
* that is not a string. Pairs with {@link isBudgetRejection} at the legacy call
|
|
229
|
+
* sites, which have a parsed body rather than a typed frame.
|
|
230
|
+
*
|
|
231
|
+
* @internal
|
|
232
|
+
*/
|
|
233
|
+
export const codeOf = (payload) => {
|
|
234
|
+
const code = payload === null || payload === void 0 ? void 0 : payload.code;
|
|
235
|
+
return typeof code === 'string' ? code : undefined;
|
|
236
|
+
};
|
|
237
|
+
/** Narrow an unknown JSON value to a number, ignoring anything else. */
|
|
238
|
+
const numberOrUndefined = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
239
|
+
/** Narrow an unknown JSON value to a boolean, ignoring anything else. */
|
|
240
|
+
const booleanOrUndefined = (value) => typeof value === 'boolean' ? value : undefined;
|
|
241
|
+
/** Narrow an unknown JSON value to a non-empty string, ignoring anything else. */
|
|
242
|
+
const stringOrUndefined = (value) => typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
243
|
+
/**
|
|
244
|
+
* Build a {@link BudgetExhaustedError} from a rejection payload of either shape:
|
|
245
|
+
* the legacy body `{ error, code, budgetUsd, spentUsd, vendor,
|
|
246
|
+
* otherVendorAvailable }` or the framed err frame's `details` (the same fields
|
|
247
|
+
* minus `error`) plus its top-level `error`.
|
|
248
|
+
*
|
|
249
|
+
* The fields read off the wire, and why each is here:
|
|
250
|
+
*
|
|
251
|
+
* | field | used for |
|
|
252
|
+
* | ---------------------- | ----------------------------------------------------- |
|
|
253
|
+
* | `budgetUsd` | the cap in the banner's figures |
|
|
254
|
+
* | `spentUsd` | the spend in the banner's figures |
|
|
255
|
+
* | `error` | the proxy's human-readable detail |
|
|
256
|
+
* | `vendor` | attribution when the transport's own label is unclaimed |
|
|
257
|
+
* | `otherVendorAvailable` | whether "switch vendor" is honest advice at all |
|
|
258
|
+
*
|
|
259
|
+
* Tolerant by design — a `402` classifies even when the payload is missing,
|
|
260
|
+
* unparseable, or carries none of these. Each field is narrowed independently,
|
|
261
|
+
* so a proxy that sends `null` for a figure it does not know (which the
|
|
262
|
+
* ai-service side now does, in preference to a misleading `0`) still yields the
|
|
263
|
+
* vendor and the availability verdict.
|
|
264
|
+
*/
|
|
265
|
+
export function budgetExhaustedFrom(vendorLabel, payload, fallbackDetail) {
|
|
266
|
+
var _a, _b, _c, _d;
|
|
267
|
+
const body = (typeof payload === 'object' && payload !== null ? payload : {});
|
|
268
|
+
// The framed shape nests everything but `error` under `details`; the legacy
|
|
269
|
+
// body has it all at the top level. Read both so one helper serves both call
|
|
270
|
+
// sites.
|
|
271
|
+
const details = (typeof body.details === 'object' && body.details !== null ? body.details : {});
|
|
272
|
+
const detail = typeof body.error === 'string' ? body.error : fallbackDetail;
|
|
273
|
+
return new BudgetExhaustedError(vendorLabel, (_a = numberOrUndefined(body.budgetUsd)) !== null && _a !== void 0 ? _a : numberOrUndefined(details.budgetUsd), (_b = numberOrUndefined(body.spentUsd)) !== null && _b !== void 0 ? _b : numberOrUndefined(details.spentUsd), detail, {
|
|
274
|
+
otherVendorAvailable: (_c = booleanOrUndefined(body.otherVendorAvailable)) !== null && _c !== void 0 ? _c : booleanOrUndefined(details.otherVendorAvailable),
|
|
275
|
+
serverVendor: (_d = stringOrUndefined(body.vendor)) !== null && _d !== void 0 ? _d : stringOrUndefined(details.vendor),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
@@ -3,6 +3,7 @@ import { SUPPORTED_GEMINI_MODEL_IDS, } from '../types';
|
|
|
3
3
|
import { logger } from '../utils/logger';
|
|
4
4
|
import { scaleTemperature } from '../utils/temperature';
|
|
5
5
|
import { ResponseTruncatedError } from './anthropic-transport';
|
|
6
|
+
import { VENDOR_LABELS } from './budget-exhausted-error';
|
|
6
7
|
import { repairMalformedFunctionCall } from './gemini-malformed-call';
|
|
7
8
|
import { postWithRetry } from './post-with-retry';
|
|
8
9
|
const GEMINI_DIRECT_URL = (model) => `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
|
|
@@ -604,7 +605,7 @@ export class GeminiTransport {
|
|
|
604
605
|
headers,
|
|
605
606
|
body: payload,
|
|
606
607
|
credentials,
|
|
607
|
-
vendorLabel:
|
|
608
|
+
vendorLabel: VENDOR_LABELS.gemini,
|
|
608
609
|
retryableStatuses: GeminiTransport.RETRYABLE_STATUSES,
|
|
609
610
|
timeout: this.timeout,
|
|
610
611
|
stallTimeout: this.stallTimeout,
|
|
@@ -3,6 +3,7 @@ import { authoritativeAbortReason } from '../utils/abort-reason';
|
|
|
3
3
|
import { abortableDelay } from '../utils/abortable-delay';
|
|
4
4
|
import { combineSignals } from '../utils/combine-signals';
|
|
5
5
|
import { logger } from '../utils/logger';
|
|
6
|
+
import { budgetExhaustedFrom, codeOf, isBudgetRejection, parseJsonOrUndefined, } from './budget-exhausted-error';
|
|
6
7
|
import { readFramedBody } from './ndjson-frames';
|
|
7
8
|
const MAX_RETRIES = 5;
|
|
8
9
|
/**
|
|
@@ -56,15 +57,44 @@ export function postWithRetry(options) {
|
|
|
56
57
|
signal: combinedSignal,
|
|
57
58
|
credentials,
|
|
58
59
|
});
|
|
59
|
-
if (retryableStatuses.includes(response.status) && attempt < MAX_RETRIES) {
|
|
60
|
-
logger.warn(`${vendorLabel}Transport: retryable status ${response.status}, retrying (attempt ${attempt + 1}/${MAX_RETRIES})`);
|
|
61
|
-
clearTimeout(timeoutId); // this attempt is over — don't let its timer tick through the backoff
|
|
62
|
-
yield backoff(attempt);
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
60
|
if (!response.ok) {
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
// One read of the error body per failing attempt — it feeds BOTH the
|
|
62
|
+
// budget classification and the thrown message, and the response is
|
|
63
|
+
// discarded either way. Note this means a retryable attempt now reads
|
|
64
|
+
// the (small, proxy-authored) error body before backing off. That is the
|
|
65
|
+
// price of the classification winning over the ladder in this branch as
|
|
66
|
+
// well as the framed one; the attempt's wall-clock timer is still armed
|
|
67
|
+
// and the read runs on the fetch's combined signal, so a pathological
|
|
68
|
+
// non-terminating body times out rather than hanging.
|
|
69
|
+
//
|
|
70
|
+
// That timeout is a real behaviour change for a RETRYABLE status, and the
|
|
71
|
+
// accepted cost of the symmetry: a 503 used to reach the ladder without
|
|
72
|
+
// the body being touched, so a non-terminating one still backed off and
|
|
73
|
+
// retried. Now the abort propagates out of `postWithRetry` instead.
|
|
74
|
+
// Acceptable because these bodies are proxy-authored and small — if that
|
|
75
|
+
// ever stops being true, read the body only when the status is
|
|
76
|
+
// non-retryable or already budget-shaped.
|
|
77
|
+
const errText = yield response.text();
|
|
78
|
+
const parsed = parseJsonOrUndefined(errText);
|
|
79
|
+
// The budget wall (GENC-1464) wins over ANY status-based retry — the same
|
|
80
|
+
// rule the framed branch below applies, now symmetric. It must be
|
|
81
|
+
// classified BEFORE the ladder, not after it: 402 is absent from every
|
|
82
|
+
// RETRYABLE_STATUSES today, but a proxy that maps the refusal onto a
|
|
83
|
+
// retryable status (or a future set that includes 402) would otherwise
|
|
84
|
+
// cost five doomed requests and ~31s of backoff before an opaque error —
|
|
85
|
+
// exactly the bug this classification exists to remove. Parsing is
|
|
86
|
+
// best-effort: an unparseable or plain-text 402 still classifies, just
|
|
87
|
+
// without the budget/spent figures.
|
|
88
|
+
if (isBudgetRejection(response.status, codeOf(parsed))) {
|
|
89
|
+
throw budgetExhaustedFrom(vendorLabel, parsed, errText || undefined);
|
|
90
|
+
}
|
|
91
|
+
if (retryableStatuses.includes(response.status) && attempt < MAX_RETRIES) {
|
|
92
|
+
logger.warn(`${vendorLabel}Transport: retryable status ${response.status}, retrying (attempt ${attempt + 1}/${MAX_RETRIES})`);
|
|
93
|
+
clearTimeout(timeoutId); // this attempt is over — don't let its timer tick through the backoff
|
|
94
|
+
yield backoff(attempt);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`${vendorLabel} request error ${response.status}: ${errText}`);
|
|
68
98
|
}
|
|
69
99
|
// The proxy streams NDJSON heartbeat frames when it supports them; an old
|
|
70
100
|
// proxy ignores our Accept header and returns plain JSON, so branch on
|
|
@@ -83,6 +113,15 @@ export function postWithRetry(options) {
|
|
|
83
113
|
// local stall, so the driver's TimeoutError branch handles both.
|
|
84
114
|
throw new DOMException(`${vendorLabel} request stalled: ${(_c = frame.error) !== null && _c !== void 0 ? _c : 'no upstream data'}`, 'TimeoutError');
|
|
85
115
|
}
|
|
116
|
+
// The budget wall, framed (GENC-1464) — the in-band twin of the 402 above,
|
|
117
|
+
// through the same predicate, keyed on status OR code. Classified before
|
|
118
|
+
// the retryable ladder for the same reason UPSTREAM_STALLED is: the
|
|
119
|
+
// classification must win over any status-based retry. That ordering is
|
|
120
|
+
// no longer unique to this branch — the legacy branch above now applies
|
|
121
|
+
// the identical rule, so the two read as one policy rather than two.
|
|
122
|
+
if (isBudgetRejection(frame.status, frame.code)) {
|
|
123
|
+
throw budgetExhaustedFrom(vendorLabel, frame);
|
|
124
|
+
}
|
|
86
125
|
// An in-band retryable status joins the same attempt/backoff ladder as
|
|
87
126
|
// its HTTP-status equivalent (shared attempt counter).
|
|
88
127
|
if (retryableStatuses.includes(frame.status) && attempt < MAX_RETRIES) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { __awaiter } from "tslib";
|
|
2
|
+
import { budgetExhaustedFrom, codeOf, isBudgetRejection, parseJsonOrUndefined, VENDOR_LABELS, } from './budget-exhausted-error';
|
|
2
3
|
const AI_SERVER_PATH = '/gwf/ai-service/chat-completions';
|
|
3
4
|
const DEFAULT_MODEL = 'gpt-4o-mini';
|
|
4
5
|
const DEFAULT_TIMEOUT = 30000;
|
|
@@ -56,6 +57,24 @@ export class ServerOpenAITransport {
|
|
|
56
57
|
clearTimeout(timeoutId);
|
|
57
58
|
if (!response.ok) {
|
|
58
59
|
const err = yield response.text();
|
|
60
|
+
const parsed = parseJsonOrUndefined(err);
|
|
61
|
+
// This transport does its own bare `fetch` rather than going through
|
|
62
|
+
// `postWithRetry` (it has no retry ladder, no stall timer and no framed
|
|
63
|
+
// mode), so it would otherwise be the one proxy call where a 402 stays an
|
|
64
|
+
// opaque `AI proxy error 402`. Classify it through the same shared
|
|
65
|
+
// predicate the envelope uses, so the typed error is uniform across
|
|
66
|
+
// transports even though the envelope is not.
|
|
67
|
+
//
|
|
68
|
+
// Scope, stated plainly: this transport serves only `sendStructuredPrompt`
|
|
69
|
+
// — it has no `chat()` — and its single caller
|
|
70
|
+
// (`OpenAIProvider.interpretCriteria`) catches everything and returns
|
|
71
|
+
// null. So a 402 here degrades smart-search silently and can never latch
|
|
72
|
+
// the assistant's `blocked` state, before or after this change. The typed
|
|
73
|
+
// throw improves the log line and any host that catches it directly. See
|
|
74
|
+
// `docs/migration-GENC-1464.md` §"Transport coverage".
|
|
75
|
+
if (isBudgetRejection(response.status, codeOf(parsed))) {
|
|
76
|
+
throw budgetExhaustedFrom(VENDOR_LABELS.openai, parsed, err || undefined);
|
|
77
|
+
}
|
|
59
78
|
throw new Error(`AI proxy error ${response.status}: ${err}`);
|
|
60
79
|
}
|
|
61
80
|
const data = (yield response.json());
|