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/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; when the
9
- // request is DOOMED (estimated tokens > 90% of the window), the largest
10
- // oversized strings (>256KB — base64 blobs, dumped file contents) are
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
- // Setting: braid.context.presendGuard { enabled } — default ON (read fresh
28
- // per request from the same settings.json the ring fallback reads). Only
29
- // models known to the providers catalog (venice-catalog.ts) are guarded:
30
- // an unknown model's context window is unknown, and guessing it would either
31
- // no-op or shrink legitimate large-window requests.
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 braid.context.presendGuard.enabled fresh from settings.json. */
83
- export function isPresendGuardEnabled(settingsPath?: string): boolean {
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 PRESGUARD_DEFAULT_ENABLED;
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
- return typeof enabled === "boolean" ? enabled : PRESGUARD_DEFAULT_ENABLED;
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 PRESGUARD_DEFAULT_ENABLED;
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
- function collectOversizedStrings(messages: unknown[]): OversizedString[] {
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 >= SHRINK_MIN_CHARS
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 >= SHRINK_MIN_CHARS
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 the estimate was over budget BEFORE the guard ran. */
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-op (only shrink
272
- * when the window is KNOWN — guessing would shrink legitimate large-window
273
- * requests). Flush failure on one string leaves that string untouched (never
274
- * stub without a verified flush); other candidates still shrink. The input
275
- * payload object is never mutated — a shallow clone with a new messages
276
- * array is returned when something shrank.
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 estChars = estimatePayloadChars(payload);
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
- const budgetTokens = Math.floor(contextWindow * CONTEXT_HEADROOM);
325
- if (estTokens <= budgetTokens) {
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
- reason: `estimate ${estTokens}t <= budget ${budgetTokens}t`,
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 until under budget
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
- for (const cand of candidates) {
371
- if (tokens <= budgetTokens) break;
372
- const path = join(offloadDir, `${stamp}-${shrunk}-${modelSlug}.txt`);
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
- if (!existsSync(path)) flush(path, cand.text);
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
- continue; // never stub without a verified flush
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 = tokens > budgetTokens;
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 (${estTokens}t > ${budgetTokens}t for ${modelId}): ` +
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
- `${budgetTokens}t) — sending anyway (handler cannot cancel a send); ` +
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) return { wasDoomed: true, stillOver, 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
  }