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/presend-guard.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// agent-loop retry keeps re-sending the SAME oversized body — burning quota.
|
|
6
6
|
// This guard runs at the LAST seam before the HTTP call
|
|
7
7
|
// (before_provider_request): it estimates the outgoing payload (chars/4
|
|
8
|
-
// token heuristic) against the model's catalog context window
|
|
9
|
-
//
|
|
10
|
-
// oversized strings (>256KB — base64 blobs, dumped file contents)
|
|
8
|
+
// token heuristic) against the model's catalog context window, and its BYTES
|
|
9
|
+
// against a cap the operator declared; a request DOOMED on either budget has
|
|
10
|
+
// its largest oversized strings (>256KB — base64 blobs, dumped file contents)
|
|
11
11
|
// flushed to disk losslessly and replaced with stubs IN THE REQUEST BODY.
|
|
12
12
|
//
|
|
13
13
|
// Seam note: the runner-level shrink (core's context-emergency offloader,
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
// provider remains the final arbiter, and llm-call's emergency retry
|
|
25
25
|
// owns the aftermath.
|
|
26
26
|
//
|
|
27
|
-
//
|
|
28
|
-
// per request from the same settings.json the
|
|
29
|
-
// models known to the providers catalog
|
|
30
|
-
// an unknown model's context window is
|
|
31
|
-
// no-op or shrink legitimate
|
|
27
|
+
// Settings: braid.context.presendGuard { enabled, maxRequestBytes,
|
|
28
|
+
// reportBytesAbove }, read fresh per request from the same settings.json the
|
|
29
|
+
// ring fallback reads. Only models known to the providers catalog
|
|
30
|
+
// (venice-catalog.ts) are guarded: an unknown model's context window is
|
|
31
|
+
// unknown, and guessing it would either no-op or shrink legitimate requests.
|
|
32
32
|
|
|
33
33
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
34
34
|
import { homedir } from "node:os";
|
|
@@ -79,19 +79,149 @@ function warnNoFlushRoot(): void {
|
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/** Read
|
|
83
|
-
export function
|
|
82
|
+
/** Read the presend guard's policy fresh from settings.json. */
|
|
83
|
+
export function readPresendPolicy(settingsPath?: string): PresendPolicy {
|
|
84
84
|
try {
|
|
85
85
|
const p = settingsPath ?? join(homedir(), ".pi", "agent", "settings.json");
|
|
86
|
-
if (!existsSync(p)) return
|
|
86
|
+
if (!existsSync(p)) return PRESEND_POLICY_DEFAULT;
|
|
87
87
|
const raw = JSON.parse(readFileSync(p, "utf8"));
|
|
88
88
|
const enabled = raw?.braid?.context?.presendGuard?.enabled;
|
|
89
|
-
|
|
89
|
+
// `enabled` stays a direct read of the published expression: the generated
|
|
90
|
+
// settings schema cites that exact text as this key's source, so the block
|
|
91
|
+
// is cloned beside it rather than replacing it
|
|
92
|
+
// (packages/core/scripts/gen-braid-schema.mjs resolves evidence, not a
|
|
93
|
+
// line).
|
|
94
|
+
const block = (raw?.braid?.context?.presendGuard ?? {}) as Record<
|
|
95
|
+
string,
|
|
96
|
+
unknown
|
|
97
|
+
>;
|
|
98
|
+
return {
|
|
99
|
+
enabled:
|
|
100
|
+
typeof enabled === "boolean" ? enabled : PRESGUARD_DEFAULT_ENABLED,
|
|
101
|
+
maxRequestBytes: positiveNumber(block.maxRequestBytes),
|
|
102
|
+
reportBytesAbove: nonNegativeNumber(
|
|
103
|
+
block.reportBytesAbove,
|
|
104
|
+
REPORT_BYTES_ABOVE_DEFAULT,
|
|
105
|
+
),
|
|
106
|
+
};
|
|
90
107
|
} catch {
|
|
91
|
-
return
|
|
108
|
+
return PRESEND_POLICY_DEFAULT;
|
|
92
109
|
}
|
|
93
110
|
}
|
|
94
111
|
|
|
112
|
+
/** Read braid.context.presendGuard.enabled fresh from settings.json. */
|
|
113
|
+
export function isPresendGuardEnabled(settingsPath?: string): boolean {
|
|
114
|
+
return readPresendPolicy(settingsPath).enabled;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Report a request's byte count above this size; `0` disables the line. */
|
|
118
|
+
export const REPORT_BYTES_ABOVE_DEFAULT = 2_000_000;
|
|
119
|
+
|
|
120
|
+
/** One settings consultation's answer: the switch, the declared cap, and the
|
|
121
|
+
* report threshold. `maxRequestBytes` absent means the operator declared no
|
|
122
|
+
* cap, so the byte arm stays out of the way. */
|
|
123
|
+
export interface PresendPolicy {
|
|
124
|
+
enabled: boolean;
|
|
125
|
+
maxRequestBytes?: number;
|
|
126
|
+
reportBytesAbove: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Everything unset: the guard is ON, no byte cap, the default report line. */
|
|
130
|
+
const PRESEND_POLICY_DEFAULT: PresendPolicy = {
|
|
131
|
+
enabled: PRESGUARD_DEFAULT_ENABLED,
|
|
132
|
+
reportBytesAbove: REPORT_BYTES_ABOVE_DEFAULT,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** A declared size, kept only when it is a positive finite number. */
|
|
136
|
+
function positiveNumber(value: unknown): number | undefined {
|
|
137
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
138
|
+
? value
|
|
139
|
+
: undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A size that may legitimately be zero (the off switch); otherwise `fallback`. */
|
|
143
|
+
function nonNegativeNumber(value: unknown, fallback: number): number {
|
|
144
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
145
|
+
? value
|
|
146
|
+
: fallback;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One catalog model's id/window pair, as the catalog declares them. */
|
|
150
|
+
export interface CatalogWindowEntry {
|
|
151
|
+
id?: unknown;
|
|
152
|
+
contextWindow?: unknown;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The guard's window lookup over one catalog. */
|
|
156
|
+
export interface ContextWindowIndex {
|
|
157
|
+
resolve(modelId: string): number | undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Normalized form of a model id: lowercase, dots → dashes.
|
|
162
|
+
*
|
|
163
|
+
* A route may spell a catalog id with dots (`deepseek-v4.1-flash`) while the
|
|
164
|
+
* catalog spells it with dashes (`deepseek-v4-1-flash`); it is the same model
|
|
165
|
+
* with the same window, so the join has to survive the spelling.
|
|
166
|
+
*/
|
|
167
|
+
export function normalizeModelId(modelId: string): string {
|
|
168
|
+
return modelId.toLowerCase().replace(/\./g, "-");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build the model-id → context-window index from one catalog: the exact id
|
|
173
|
+
* (unchanged), plus a normalized key for every catalog id. A dot-form request
|
|
174
|
+
* id resolves into the normalized key of the dash-form catalog id it names.
|
|
175
|
+
*
|
|
176
|
+
* Ambiguity rule: when two catalog models share a normalized key but declare
|
|
177
|
+
* different windows, neither is added to the normalized index. Resolving a
|
|
178
|
+
* collision by picking one would shrink requests for a large-window model
|
|
179
|
+
* against a small window's budget — worse than not shrinking. Exact ids keep
|
|
180
|
+
* resolving, including the two that collided, so a precise spelling is never
|
|
181
|
+
* lost. Lookup order is exact, then normalized; an id neither index knows
|
|
182
|
+
* stays `undefined` and the caller's guard no-ops.
|
|
183
|
+
*/
|
|
184
|
+
export function buildContextWindowIndex(
|
|
185
|
+
models: readonly CatalogWindowEntry[],
|
|
186
|
+
): ContextWindowIndex {
|
|
187
|
+
const exact = new Map<string, number>();
|
|
188
|
+
for (const model of models) {
|
|
189
|
+
const id = model?.id;
|
|
190
|
+
const window = model?.contextWindow;
|
|
191
|
+
if (typeof id !== "string" || id === "") continue;
|
|
192
|
+
if (typeof window !== "number" || !Number.isFinite(window) || window <= 0)
|
|
193
|
+
continue;
|
|
194
|
+
exact.set(id, window);
|
|
195
|
+
}
|
|
196
|
+
const normalized = new Map<string, number>();
|
|
197
|
+
const ambiguous = new Set<string>();
|
|
198
|
+
for (const [id, window] of exact) {
|
|
199
|
+
const key = normalizeModelId(id);
|
|
200
|
+
// Another catalog id is spelled exactly like this key (a dash id, when the
|
|
201
|
+
// id itself is already normalized): the two windows would answer the same
|
|
202
|
+
// lookup. `owner` is this id's own window when the id is its own key.
|
|
203
|
+
const owner = exact.get(key);
|
|
204
|
+
if (owner !== undefined && owner !== window) {
|
|
205
|
+
ambiguous.add(key);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const prior = normalized.get(key);
|
|
209
|
+
if (prior !== undefined && prior !== window) {
|
|
210
|
+
ambiguous.add(key);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Every id gets a normalized entry, including one already in normalized
|
|
214
|
+
// form: that identity entry is what a dot-form request id resolves into.
|
|
215
|
+
normalized.set(key, window);
|
|
216
|
+
}
|
|
217
|
+
for (const key of ambiguous) normalized.delete(key);
|
|
218
|
+
return {
|
|
219
|
+
resolve(modelId: string): number | undefined {
|
|
220
|
+
return exact.get(modelId) ?? normalized.get(normalizeModelId(modelId));
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
95
225
|
/**
|
|
96
226
|
* Estimate the char size of one message content (string or block array).
|
|
97
227
|
* Text blocks count their text; image_url blocks count their URL string
|
|
@@ -154,6 +284,63 @@ export function estimatePayloadChars(payload: unknown): number {
|
|
|
154
284
|
return total;
|
|
155
285
|
}
|
|
156
286
|
|
|
287
|
+
/** The byte arm: a 413 measures SERIALIZED BYTES, so a body far inside the
|
|
288
|
+
* token budget can still be refused, and the token arm can never fire for it.
|
|
289
|
+
* It acts only against a cap the operator declared — inventing a byte number
|
|
290
|
+
* would shrink legitimate large-window requests, a worse failure than a loud
|
|
291
|
+
* 413. Below this share of the cap the char estimate is trusted: a char is
|
|
292
|
+
* one UTF-16 unit while the UTF-8 byte count of the same text is at least as
|
|
293
|
+
* large, so the estimate is a lower bound and no serialization is spent. */
|
|
294
|
+
const BYTE_MEASURE_FRACTION = 0.6;
|
|
295
|
+
|
|
296
|
+
/** Byte-arm candidate floor. The token arm's 256KB floor leaves every mid-size
|
|
297
|
+
* tool result in place, and a megabyte of 20-100KB results is exactly the
|
|
298
|
+
* body a transport refuses. */
|
|
299
|
+
const BYTE_SHRINK_MIN_CHARS = 8_192;
|
|
300
|
+
|
|
301
|
+
/** A payload's size on the wire, and whether it was serialized to find it. */
|
|
302
|
+
interface PayloadSize {
|
|
303
|
+
bytes: number;
|
|
304
|
+
measured: boolean;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Measure a payload in bytes. Above `exactAbove` the payload is serialized
|
|
308
|
+
* once and its UTF-8 JSON length used; below it the char estimate stands as a
|
|
309
|
+
* lower bound and no serialization is spent. */
|
|
310
|
+
function measurePayloadBytes(
|
|
311
|
+
payload: unknown,
|
|
312
|
+
estChars: number,
|
|
313
|
+
exactAbove: number,
|
|
314
|
+
): PayloadSize {
|
|
315
|
+
if (!(estChars > exactAbove)) return { bytes: estChars, measured: false };
|
|
316
|
+
try {
|
|
317
|
+
const json = JSON.stringify(payload);
|
|
318
|
+
if (typeof json !== "string") return { bytes: estChars, measured: false };
|
|
319
|
+
return { bytes: Buffer.byteLength(json, "utf8"), measured: true };
|
|
320
|
+
} catch {
|
|
321
|
+
return { bytes: estChars, measured: false };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The single line a request above the report threshold writes. */
|
|
326
|
+
function reportPayloadBytes(
|
|
327
|
+
modelId: string | undefined,
|
|
328
|
+
size: PayloadSize,
|
|
329
|
+
cap: number | undefined,
|
|
330
|
+
reportAbove: number,
|
|
331
|
+
): void {
|
|
332
|
+
providerLog(
|
|
333
|
+
"warn",
|
|
334
|
+
SCOPE,
|
|
335
|
+
`request payload is ${size.bytes} bytes for ${modelId ?? "(no model id)"} ` +
|
|
336
|
+
`(${size.measured ? "measured as UTF-8 JSON" : "char estimate, a lower bound"}), ` +
|
|
337
|
+
`above the ${reportAbove}-byte report threshold; ` +
|
|
338
|
+
(cap === undefined
|
|
339
|
+
? "no byte cap is declared (braid.context.presendGuard, field maxRequestBytes)"
|
|
340
|
+
: `declared cap is ${cap} bytes`),
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
157
344
|
/** Build the stub text for one shrunk string (brief format, ~500 char head). */
|
|
158
345
|
export function buildPresendStub(
|
|
159
346
|
label: string,
|
|
@@ -175,16 +362,17 @@ interface OversizedString {
|
|
|
175
362
|
kind: "text" | "image-url";
|
|
176
363
|
}
|
|
177
364
|
|
|
178
|
-
|
|
365
|
+
/** Strings of at least `floorChars` in the messages, largest first. */
|
|
366
|
+
function collectOversizedStrings(
|
|
367
|
+
messages: unknown[],
|
|
368
|
+
floorChars: number,
|
|
369
|
+
): OversizedString[] {
|
|
179
370
|
const found: OversizedString[] = [];
|
|
180
371
|
messages.forEach((msg, messageIndex) => {
|
|
181
372
|
if (!msg || typeof msg !== "object") return;
|
|
182
373
|
const content = (msg as Record<string, unknown>).content;
|
|
183
374
|
if (typeof content === "string") {
|
|
184
|
-
if (
|
|
185
|
-
content.length >= SHRINK_MIN_CHARS &&
|
|
186
|
-
!content.startsWith("[offloaded:")
|
|
187
|
-
) {
|
|
375
|
+
if (content.length >= floorChars && !content.startsWith("[offloaded:")) {
|
|
188
376
|
found.push({
|
|
189
377
|
messageIndex,
|
|
190
378
|
blockIndex: -1,
|
|
@@ -203,13 +391,13 @@ function collectOversizedStrings(messages: unknown[]): OversizedString[] {
|
|
|
203
391
|
if (
|
|
204
392
|
b.type === "text" &&
|
|
205
393
|
typeof b.text === "string" &&
|
|
206
|
-
b.text.length >=
|
|
394
|
+
b.text.length >= floorChars
|
|
207
395
|
) {
|
|
208
396
|
found.push({ messageIndex, blockIndex, text: b.text, kind: "text" });
|
|
209
397
|
} else if (
|
|
210
398
|
b.type === "image_url" &&
|
|
211
399
|
typeof b.image_url?.url === "string" &&
|
|
212
|
-
b.image_url.url.length >=
|
|
400
|
+
b.image_url.url.length >= floorChars
|
|
213
401
|
) {
|
|
214
402
|
found.push({
|
|
215
403
|
messageIndex,
|
|
@@ -258,27 +446,41 @@ export interface PresendGuardResult {
|
|
|
258
446
|
payload?: unknown;
|
|
259
447
|
/** Strings flushed + stubbed (0 when the guard no-opped). */
|
|
260
448
|
shrunk: number;
|
|
261
|
-
/** True when
|
|
449
|
+
/** True when a budget was exceeded BEFORE the guard ran. */
|
|
262
450
|
wasDoomed: boolean;
|
|
263
|
-
/** True when the shrunk payload is STILL over budget (honest error logged). */
|
|
451
|
+
/** True when the shrunk payload is STILL over a budget (honest error logged). */
|
|
264
452
|
stillOver: boolean;
|
|
453
|
+
/** Payload size in bytes after the guard ran: re-serialized when a byte cap
|
|
454
|
+
* was declared and something was stubbed, otherwise the estimate minus what
|
|
455
|
+
* the stubs freed. */
|
|
456
|
+
bytes: number;
|
|
457
|
+
/** True when `bytes` came from serializing the payload. */
|
|
458
|
+
measured: boolean;
|
|
265
459
|
/** Human-readable reason for a no-op (diagnostics/tests). */
|
|
266
460
|
reason?: string;
|
|
267
461
|
}
|
|
268
462
|
|
|
269
463
|
/**
|
|
270
464
|
* Guard over one provider payload. `resolveContextWindow` maps the request's
|
|
271
|
-
* model id to its catalog context window; unknown models no
|
|
272
|
-
* when the window is KNOWN — guessing would shrink legitimate
|
|
273
|
-
* requests).
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
465
|
+
* model id to its catalog context window; unknown models get no token arm
|
|
466
|
+
* (only shrink when the window is KNOWN — guessing would shrink legitimate
|
|
467
|
+
* large-window requests). `maxRequestBytes` is the operator's declared byte
|
|
468
|
+
* cap: absent means no byte arm, and the payload path is then unchanged apart
|
|
469
|
+
* from the report line. `reportBytesAbove` (default
|
|
470
|
+
* `REPORT_BYTES_ABOVE_DEFAULT`, `0` disables) writes one warn line per request
|
|
471
|
+
* above it, cap or no cap, and never alters the payload.
|
|
472
|
+
*
|
|
473
|
+
* Flush failure on one string leaves that string untouched (never stub without
|
|
474
|
+
* a verified flush); other candidates still shrink. The input payload object is
|
|
475
|
+
* never mutated — a shallow clone with a new messages array is returned when
|
|
476
|
+
* something shrank.
|
|
277
477
|
*/
|
|
278
478
|
export function shrinkDoomedPayload(
|
|
279
479
|
payload: unknown,
|
|
280
480
|
deps: {
|
|
281
481
|
resolveContextWindow(modelId: string): number | undefined;
|
|
482
|
+
maxRequestBytes?: number;
|
|
483
|
+
reportBytesAbove?: number;
|
|
282
484
|
flush?: (path: string, text: string) => void;
|
|
283
485
|
clock?: () => number;
|
|
284
486
|
},
|
|
@@ -288,50 +490,77 @@ export function shrinkDoomedPayload(
|
|
|
288
490
|
wasDoomed: false,
|
|
289
491
|
stillOver: false,
|
|
290
492
|
shrunk: 0,
|
|
493
|
+
bytes: 0,
|
|
494
|
+
measured: false,
|
|
291
495
|
reason: "payload not an object",
|
|
292
496
|
};
|
|
293
497
|
}
|
|
294
498
|
const rec = payload as Record<string, unknown>;
|
|
295
499
|
const modelId = typeof rec.model === "string" ? rec.model : undefined;
|
|
500
|
+
|
|
501
|
+
// Size first: the report line must land even when the window lookup cannot
|
|
502
|
+
// answer, which is the shape that hid the missing number behind a 413.
|
|
503
|
+
const estChars = estimatePayloadChars(payload);
|
|
504
|
+
const cap = positiveNumber(deps.maxRequestBytes);
|
|
505
|
+
const reportAbove =
|
|
506
|
+
deps.reportBytesAbove === undefined
|
|
507
|
+
? REPORT_BYTES_ABOVE_DEFAULT
|
|
508
|
+
: nonNegativeNumber(deps.reportBytesAbove, REPORT_BYTES_ABOVE_DEFAULT);
|
|
509
|
+
const exactAbove = Math.min(
|
|
510
|
+
cap === undefined ? Number.POSITIVE_INFINITY : cap * BYTE_MEASURE_FRACTION,
|
|
511
|
+
reportAbove > 0 ? reportAbove : Number.POSITIVE_INFINITY,
|
|
512
|
+
);
|
|
513
|
+
const size = measurePayloadBytes(payload, estChars, exactAbove);
|
|
514
|
+
if (reportAbove > 0 && size.bytes > reportAbove) {
|
|
515
|
+
reportPayloadBytes(modelId, size, cap, reportAbove);
|
|
516
|
+
}
|
|
517
|
+
|
|
296
518
|
if (!modelId)
|
|
297
519
|
return {
|
|
298
520
|
wasDoomed: false,
|
|
299
521
|
stillOver: false,
|
|
300
522
|
shrunk: 0,
|
|
523
|
+
bytes: size.bytes,
|
|
524
|
+
measured: size.measured,
|
|
301
525
|
reason: "no model id",
|
|
302
526
|
};
|
|
303
|
-
const contextWindow = deps.resolveContextWindow(modelId);
|
|
304
|
-
if (!contextWindow || contextWindow <= 0) {
|
|
305
|
-
return {
|
|
306
|
-
wasDoomed: false,
|
|
307
|
-
stillOver: false,
|
|
308
|
-
shrunk: 0,
|
|
309
|
-
reason: `unknown context window for ${modelId}`,
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
527
|
const messages = rec.messages;
|
|
313
528
|
if (!Array.isArray(messages)) {
|
|
314
529
|
return {
|
|
315
530
|
wasDoomed: false,
|
|
316
531
|
stillOver: false,
|
|
317
532
|
shrunk: 0,
|
|
533
|
+
bytes: size.bytes,
|
|
534
|
+
measured: size.measured,
|
|
318
535
|
reason: "no messages array",
|
|
319
536
|
};
|
|
320
537
|
}
|
|
321
538
|
|
|
322
|
-
const
|
|
539
|
+
const contextWindow = deps.resolveContextWindow(modelId);
|
|
540
|
+
const tokenBudget =
|
|
541
|
+
typeof contextWindow === "number" && contextWindow > 0
|
|
542
|
+
? Math.floor(contextWindow * CONTEXT_HEADROOM)
|
|
543
|
+
: undefined;
|
|
323
544
|
const estTokens = Math.ceil(estChars / CHARS_PER_TOKEN);
|
|
324
|
-
|
|
325
|
-
|
|
545
|
+
let tokens = estTokens;
|
|
546
|
+
let bytes = size.bytes;
|
|
547
|
+
const tokenOver = tokenBudget !== undefined && tokens > tokenBudget;
|
|
548
|
+
const byteOver = cap !== undefined && bytes > cap;
|
|
549
|
+
if (!tokenOver && !byteOver) {
|
|
326
550
|
return {
|
|
327
551
|
wasDoomed: false,
|
|
328
552
|
stillOver: false,
|
|
329
553
|
shrunk: 0,
|
|
330
|
-
|
|
554
|
+
bytes,
|
|
555
|
+
measured: size.measured,
|
|
556
|
+
reason:
|
|
557
|
+
tokenBudget === undefined
|
|
558
|
+
? `unknown context window for ${modelId}`
|
|
559
|
+
: `estimate ${estTokens}t <= budget ${tokenBudget}t`,
|
|
331
560
|
};
|
|
332
561
|
}
|
|
333
562
|
|
|
334
|
-
// Doomed: stub the largest oversized strings
|
|
563
|
+
// Doomed: stub the largest oversized strings of each exceeded budget.
|
|
335
564
|
const clock = deps.clock ?? (() => Date.now());
|
|
336
565
|
const flush =
|
|
337
566
|
deps.flush ??
|
|
@@ -339,19 +568,6 @@ export function shrinkDoomedPayload(
|
|
|
339
568
|
mkdirSync(join(path, ".."), { recursive: true });
|
|
340
569
|
writeFileSync(path, text, "utf-8");
|
|
341
570
|
});
|
|
342
|
-
|
|
343
|
-
const candidates = collectOversizedStrings(messages as unknown[]);
|
|
344
|
-
if (candidates.length === 0) {
|
|
345
|
-
return {
|
|
346
|
-
wasDoomed: true,
|
|
347
|
-
stillOver: true,
|
|
348
|
-
shrunk: 0,
|
|
349
|
-
reason: `doomed (${estTokens}t > budget ${budgetTokens}t) but nothing oversized to stub`,
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const stamp = clock().toString(36);
|
|
354
|
-
const modelSlug = modelId.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
355
571
|
const offloadDir = presendOffloadDir();
|
|
356
572
|
if (offloadDir === null) {
|
|
357
573
|
warnNoFlushRoot();
|
|
@@ -359,54 +575,109 @@ export function shrinkDoomedPayload(
|
|
|
359
575
|
wasDoomed: true,
|
|
360
576
|
stillOver: true,
|
|
361
577
|
shrunk: 0,
|
|
578
|
+
bytes,
|
|
579
|
+
measured: size.measured,
|
|
362
580
|
reason: "no absolute braid data root to flush to",
|
|
363
581
|
};
|
|
364
582
|
}
|
|
583
|
+
const stamp = clock().toString(36);
|
|
584
|
+
const modelSlug = modelId.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
365
585
|
const newMessages = [...(messages as unknown[])];
|
|
366
|
-
let tokens = estTokens;
|
|
367
586
|
let shrunk = 0;
|
|
368
587
|
let flushedBytes = 0;
|
|
588
|
+
let sawCandidate = false;
|
|
369
589
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
590
|
+
/** Stub candidates above `floorChars`, largest first, until `over()` clears. */
|
|
591
|
+
const shrinkUntilUnder = (over: () => boolean, floorChars: number): void => {
|
|
592
|
+
if (!over()) return;
|
|
593
|
+
for (const cand of collectOversizedStrings(newMessages, floorChars)) {
|
|
594
|
+
sawCandidate = true;
|
|
595
|
+
if (!over()) break;
|
|
596
|
+
const path = join(offloadDir, `${stamp}-${shrunk}-${modelSlug}.txt`);
|
|
597
|
+
try {
|
|
598
|
+
if (!existsSync(path)) flush(path, cand.text);
|
|
599
|
+
} catch {
|
|
600
|
+
continue; // never stub without a verified flush
|
|
601
|
+
}
|
|
602
|
+
const candBytes = Buffer.byteLength(cand.text, "utf-8");
|
|
603
|
+
const stub = buildPresendStub(cand.kind, candBytes, path, cand.text);
|
|
604
|
+
stubMessage(newMessages, cand, stub);
|
|
605
|
+
tokens -= Math.ceil((cand.text.length - stub.length) / CHARS_PER_TOKEN);
|
|
606
|
+
bytes -= Math.max(0, candBytes - Buffer.byteLength(stub, "utf-8"));
|
|
607
|
+
shrunk++;
|
|
608
|
+
flushedBytes += candBytes;
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// The byte arm runs first with its own floor: mid-size results are what add
|
|
613
|
+
// up on the wire, and a token-arm stub of the same message may then be moot.
|
|
614
|
+
shrinkUntilUnder(
|
|
615
|
+
() => cap !== undefined && bytes > cap,
|
|
616
|
+
BYTE_SHRINK_MIN_CHARS,
|
|
617
|
+
);
|
|
618
|
+
shrinkUntilUnder(
|
|
619
|
+
() => tokenBudget !== undefined && tokens > tokenBudget,
|
|
620
|
+
SHRINK_MIN_CHARS,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
// With a declared cap the honest post-shrink number is the serialized one;
|
|
624
|
+
// the per-string decrement above only drove the loop's progress check.
|
|
625
|
+
let measured = size.measured;
|
|
626
|
+
if (cap !== undefined && shrunk > 0) {
|
|
373
627
|
try {
|
|
374
|
-
|
|
628
|
+
const json = JSON.stringify({ ...rec, messages: newMessages });
|
|
629
|
+
if (typeof json === "string") {
|
|
630
|
+
bytes = Buffer.byteLength(json, "utf8");
|
|
631
|
+
measured = true;
|
|
632
|
+
}
|
|
375
633
|
} catch {
|
|
376
|
-
|
|
634
|
+
/* keep the decremented estimate */
|
|
377
635
|
}
|
|
378
|
-
const bytes = Buffer.byteLength(cand.text, "utf-8");
|
|
379
|
-
const stub = buildPresendStub(cand.kind, bytes, path, cand.text);
|
|
380
|
-
stubMessage(newMessages, cand, stub);
|
|
381
|
-
tokens -= Math.ceil((cand.text.length - stub.length) / CHARS_PER_TOKEN);
|
|
382
|
-
shrunk++;
|
|
383
|
-
flushedBytes += bytes;
|
|
384
636
|
}
|
|
385
637
|
|
|
386
|
-
const stillOver =
|
|
638
|
+
const stillOver =
|
|
639
|
+
(tokenBudget !== undefined && tokens > tokenBudget) ||
|
|
640
|
+
(cap !== undefined && bytes > cap);
|
|
641
|
+
const budgets: string[] = [];
|
|
642
|
+
if (tokenOver && tokenBudget !== undefined)
|
|
643
|
+
budgets.push(`${estTokens}t > ${tokenBudget}t`);
|
|
644
|
+
if (byteOver && cap !== undefined)
|
|
645
|
+
budgets.push(`${size.bytes} bytes > ${cap}`);
|
|
387
646
|
if (shrunk > 0) {
|
|
388
647
|
providerLog(
|
|
389
648
|
"info",
|
|
390
649
|
SCOPE,
|
|
391
|
-
`payload was over budget
|
|
650
|
+
`payload was over budget for ${modelId} (${budgets.join("; ")}): ` +
|
|
392
651
|
`stubbed ${shrunk} oversized string(s), ${flushedBytes} bytes flushed to disk; ` +
|
|
393
|
-
`new estimate ~${tokens}t`,
|
|
652
|
+
`new estimate ~${tokens}t / ${bytes} bytes`,
|
|
394
653
|
);
|
|
395
654
|
}
|
|
396
655
|
if (stillOver) {
|
|
397
656
|
providerLog(
|
|
398
657
|
"error",
|
|
399
658
|
SCOPE,
|
|
400
|
-
`payload for ${modelId} is STILL over budget after shrink (~${tokens}t
|
|
401
|
-
`${
|
|
659
|
+
`payload for ${modelId} is STILL over budget after shrink (~${tokens}t, ` +
|
|
660
|
+
`${bytes} bytes) — sending anyway (handler cannot cancel a send); ` +
|
|
402
661
|
`llm-call's emergency retry owns the aftermath`,
|
|
403
662
|
);
|
|
404
663
|
}
|
|
405
|
-
if (shrunk === 0)
|
|
664
|
+
if (shrunk === 0)
|
|
665
|
+
return {
|
|
666
|
+
wasDoomed: true,
|
|
667
|
+
stillOver,
|
|
668
|
+
shrunk: 0,
|
|
669
|
+
bytes,
|
|
670
|
+
measured,
|
|
671
|
+
reason: sawCandidate
|
|
672
|
+
? "every oversized candidate's flush failed"
|
|
673
|
+
: `doomed (${budgets.join("; ")}) but nothing oversized to stub`,
|
|
674
|
+
};
|
|
406
675
|
return {
|
|
407
676
|
payload: { ...rec, messages: newMessages },
|
|
408
677
|
shrunk,
|
|
409
678
|
wasDoomed: true,
|
|
410
679
|
stillOver,
|
|
680
|
+
bytes,
|
|
681
|
+
measured,
|
|
411
682
|
};
|
|
412
683
|
}
|