Compare commits

..

1 Commits

9 changed files with 226 additions and 322 deletions

View File

@@ -1 +0,0 @@
- fix(sse): prioritize Codex quota headers (x-codex-*) in the 768-byte forwarded-header budget (#10310)

View File

@@ -0,0 +1 @@
- fix(resilience): keep combo quality and auth failure reasons separate and redact connection labels in terminal errors (#10314)

View File

@@ -1 +0,0 @@
- fix(sse): dedupe forwarded-header drop warns by dropped-name fingerprint (warn once, then debug) (#10315)

View File

@@ -54,70 +54,8 @@ export const MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES = resolveForwardedHead
const MAX_LOGGED_DROPPED_RESPONSE_HEADERS = 20;
const responseHeaderEncoder = new TextEncoder();
// Warn-once-per-dropped-name-set frequency control for the drop-warning path
// (#10315). The module-level set persists for the process lifetime (and across
// test cases in one process), so a budget/config change that flips which names
// drop yields a new fingerprint and warns again — intended.
const warnedDropFingerprints = new Set<string>();
/**
* Stable identity for a dropped-header set, using only header NAMES (not values/
* bytes) so two payloads dropping the SAME names share one warn. Sorted so the
* identification is order-independent.
*/
function droppedHeadersFingerprint(dropped: Array<{ name: string }>): string {
return dropped
.map((h) => h.name)
.sort()
.join("\n");
}
/**
* Test-only isolation helper. The fingerprint cache persists in this process;
* tests that reuse a dropped-set fingerprint must clear it to keep cases
* order-independent. Never used in production paths.
*/
export function resetDroppedHeadersWarningCache(): void {
warnedDropFingerprints.clear();
}
/**
* Emit the drop-warning path for headers that exceeded the forwarding budget.
* Warns once per process per dropped-name set, then degrades to debug for
* repeats so a chronic over-budget response set cannot become a warn storm
* that buries real errors (see regression guard #10315).
*/
function logDroppedResponseHeaders(
droppedHeaders: Array<{ name: string; bytes: number }>,
forwardedBytes: number,
log: ResponseHeaderLogger
): void {
if (droppedHeaders.length === 0) return;
const fingerprint = droppedHeadersFingerprint(droppedHeaders);
if (!warnedDropFingerprints.has(fingerprint)) {
warnedDropFingerprints.add(fingerprint);
log?.warn?.("HTTP", "Dropped upstream response headers that exceeded forwarding budget", {
budgetBytes: MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES,
forwardedBytes,
droppedCount: droppedHeaders.length,
droppedHeaders: droppedHeaders.slice(0, MAX_LOGGED_DROPPED_RESPONSE_HEADERS),
});
} else {
log?.debug?.(
"HTTP",
"Dropped upstream response headers exceeded forwarding budget (repeated; see first warn for header list)",
{
budgetBytes: MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES,
forwardedBytes,
droppedCount: droppedHeaders.length,
}
);
}
}
type ResponseHeaderLogger = {
warn?: (tag: string, message: string, data?: Record<string, unknown>) => void;
debug?: (tag: string, message: string, data?: Record<string, unknown>) => void;
} | null;
function responseHeaderWireBytes(name: string, value: string): number {
@@ -128,36 +66,6 @@ function isOmniRouteInternalHeader(headerName: string): boolean {
return headerName.toLowerCase().startsWith("x-omniroute-");
}
/**
* Codex quota vocabulary (`x-codex-primary/secondary-* used/reset`,
* `x-codex-credits-*`) carries usage/limit/reset data the client needs. Treat
* it as the same priority class as rate-limit headers so a tight forwarding
* budget never silently strips it (#10310).
*/
function isCodexQuotaHeader(normalized: string): boolean {
return (
normalized.startsWith("x-codex-") &&
(normalized.includes("used") || normalized.includes("reset") || normalized.includes("credits"))
);
}
/**
* Known bulky, non-quota response headers (Cloudflare edge family, Codex turn
* state, CSP, `date`, etc.) that can be tens-to-hundreds of bytes. They are
* assigned the LAST priority tier so they are the first dropped when the budget
* is tight, rather than evicting more valuable quota/rate-limit data.
*/
function isForcedLastPriorityHeader(normalized: string): boolean {
return (
normalized.startsWith("cf-") ||
normalized === "x-codex-turn-state" ||
normalized === "fireworks-sampling-options" ||
normalized === "content-security-policy" ||
normalized === "date" ||
normalized === "x-robots-tag"
);
}
function getForwardingPriority(headerName: string): number {
const normalized = headerName.toLowerCase();
if (
@@ -170,14 +78,7 @@ function getForwardingPriority(headerName: string): number {
return 0;
}
if (normalized === "retry-after") return 1;
if (
normalized.includes("ratelimit") ||
normalized.includes("rate-limit") ||
isCodexQuotaHeader(normalized)
) {
return 2;
}
if (isForcedLastPriorityHeader(normalized)) return 4;
if (normalized.includes("ratelimit") || normalized.includes("rate-limit")) return 2;
return 3;
}
@@ -280,7 +181,14 @@ export function buildStreamingResponseHeaders(
}
}
logDroppedResponseHeaders(droppedHeaders, forwardedBytes, log);
if (droppedHeaders.length > 0) {
log?.warn?.("HTTP", "Dropped upstream response headers that exceeded forwarding budget", {
budgetBytes: MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES,
forwardedBytes,
droppedCount: droppedHeaders.length,
droppedHeaders: droppedHeaders.slice(0, MAX_LOGGED_DROPPED_RESPONSE_HEADERS),
});
}
const responseHeaders: Record<string, string> = {
...Object.fromEntries(forwardedHeaders),

View File

@@ -93,6 +93,13 @@ import {
expandPromptCacheAffinityTargetsFromConnections,
resolvePromptCacheAffinityKey,
} from "./combo/promptCacheAffinity.ts";
import {
classifyComboOutcome,
formatComboOutcomes,
redactConnectionLabel,
buildRedactedSummary,
} from "./combo/comboErrorAggregation.ts";
import type { ComboErrorEntry } from "./combo/comboErrorAggregation.ts";
import type { CompressionMode } from "./compression/types.ts";
import { getCachedProviderConnections } from "../../src/lib/db/readCache";
import { isProviderInCooldown, recordProviderCooldown } from "./providerCooldownTracker.ts";
@@ -853,7 +860,7 @@ export async function handleComboChat({
let comboExpired = false;
// Accumulator for per-model error details across targets in the current set try.
// Reset at the start of each set retry (same lifecycle as lastError/recordedAttempts).
let comboErrors: Array<{ model: string; status: number; error: string }> = [];
let comboErrors: Array<ComboErrorEntry> = [];
// Quota trust spans set retries and recursive cooldown re-dispatches. Once any
// failure is non-quota, a nested caller must never treat this dispatch as quota-only.
let observedFailure = false;
@@ -1343,6 +1350,15 @@ export async function handleComboChat({
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
lastError = `Upstream response failed quality validation: ${quality.reason}`;
lastStatus = 502;
// #10314: record quality failures as a FIRST-CLASS per-target outcome
// so a quality reason is never silently dropped from the aggregated
// terminal message when a later sibling overwrites lastError.
comboErrors.push({
model: modelStr,
status: 502,
error: quality.reason || "upstream response failed quality validation",
kind: "quality",
});
if (i > 0) fallbackCount++;
if (provider && rawModel) {
const mlSettings = resolveModelLockoutSettings(settings);
@@ -1850,6 +1866,7 @@ export async function handleComboChat({
model: modelStr,
status: result.status,
error: errorText || String(result.status),
kind: classifyComboOutcome(result.status, errorText),
});
lastStatus = result.status;
if (i > 0) fallbackCount++;
@@ -2043,6 +2060,7 @@ export async function handleComboChat({
model: modelStr,
status: result.status,
error: errorText || String(result.status),
kind: classifyComboOutcome(result.status, errorText),
});
lastStatus = result.status;
if (i > 0) fallbackCount++;
@@ -2197,15 +2215,10 @@ export async function handleComboChat({
// Global combo timeout: return aggregated error immediately, skipping set retries.
if (comboExpired) {
const summary = comboErrors
.slice(0, 5)
.map((e) => `${e.model} (${e.status})`)
.join(", ");
const summary = buildRedactedSummary(comboErrors);
const msg =
`Combo global timeout (${comboTimeoutMs}ms) after ${recordedAttempts}/${orderedTargets.length} targets` +
(comboErrors.length > 0
? ` | tried: ${summary}${comboErrors.length > 5 ? `... (+${comboErrors.length - 5})` : ""}`
: "");
(comboErrors.length > 0 ? ` | tried: ${summary}` : "");
const latencyMs = Date.now() - startTime;
if (recordedAttempts === 0) {
recordComboRequest(combo.name, null, {
@@ -2276,18 +2289,12 @@ export async function handleComboChat({
}
const status = lastStatus;
// Build aggregated error message with per-model failure details for diagnostics.
const comboErrorSummary =
comboErrors.length > 0
? " [" +
comboErrors
.slice(0, 5)
.map((e) => `${e.model} (${e.status})`)
.join(", ") +
(comboErrors.length > 5 ? `... (+${comboErrors.length - 5})` : "") +
"]"
: "";
const msg = (lastError || "All combo models unavailable") + comboErrorSummary;
// #10314: build the terminal message from the structured per-target
// outcomes (each distinct class+reason listed separately) instead of
// mashing a single lastError with raw `[model (status)]` markers. Connection
// identifiers are redacted. Falls back to lastError when no target recorded
// a structured outcome.
const msg = formatComboOutcomes(comboErrors) || lastError || "All combo models unavailable";
// Cooldown-aware retry: instead of crystallizing a transient failure, wait
// out a SHORT cooldown and re-run the whole set loop. Guarded by the helper
@@ -2715,6 +2722,10 @@ async function handleRoundRobinCombo({
let globalAttempts = 0;
let fallbackCount = 0;
let recordedAttempts = 0;
// #10314: per-target outcome accumulator for the round-robin twin so the
// terminal message lists each distinct reason separately (see the quality path
// and the "Done with this model" path below), mirroring handleComboChat.
const rrOutcomes: Array<ComboErrorEntry> = [];
// #1731: Per-request in-memory set of providers whose quota is fully exhausted.
// When a target returns a quota-exhausted 429, remaining targets from the same
@@ -2911,6 +2922,12 @@ async function handleRoundRobinCombo({
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
lastError = `Upstream response failed quality validation: ${quality.reason}`;
lastStatus = 502;
rrOutcomes.push({
model: modelStr,
status: 502,
error: quality.reason || "upstream response failed quality validation",
kind: "quality",
});
if (offset > 0) fallbackCount++;
break; // move to next model
}
@@ -3217,6 +3234,12 @@ async function handleRoundRobinCombo({
recordedAttempts++;
lastError = errorText || String(result.status);
lastStatus = result.status;
rrOutcomes.push({
model: modelStr,
status: result.status,
error: errorText || String(result.status),
kind: classifyComboOutcome(result.status, errorText),
});
if (offset > 0) fallbackCount++;
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status });
@@ -3337,7 +3360,10 @@ async function handleRoundRobinCombo({
}
const status = lastStatus;
const msg = lastError || "All round-robin combo models unavailable";
// #10314: same structured per-target aggregation as handleComboChat — list each
// distinct reason separately (redacted), fall back to lastError when no outcome.
const msg =
formatComboOutcomes(rrOutcomes) || lastError || "All round-robin combo models unavailable";
if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) {
const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter));

View File

@@ -0,0 +1,115 @@
/**
* Shared combo terminal-error aggregation.
*
* #10314 — combo error aggregation mixes quality and auth. Prior to this module
* the combo terminal message was built as a single `lastError` string (last
* writer wins — it can only ever represent ONE target's reason) concatenated
* with a raw `[model (status)]` suffix. A quality-failure reason from one
* target and a sibling's 401 were collapsed into one client-facing sentence
* (`invalid_api_key [openai/proxy-account-b (401)]`) and a quality reason that
* was not the final failing target was dropped entirely.
*
* This module gives each per-target failure a structured {model, status, error,
* kind} entry, so the terminal message can list every distinct reason
* separately (and classification-labelled) instead of mashing them, and it
* redacts connection/account identifiers that, on openai-compatible proxy
* connections, used to surface verbatim in client-visible and shared-warn
* strings (ops/PII leak).
*/
export type ComboOutcomeKind =
| "quality"
| "auth"
| "model"
| "provider"
| "timeout"
| "skipped"
| "upstream";
export interface ComboErrorEntry {
model: string;
status: number;
error: string;
kind: ComboOutcomeKind;
}
const KIND_LABELS: Record<ComboOutcomeKind, string> = {
quality: "quality validation",
auth: "auth",
model: "model",
provider: "provider",
timeout: "timeout",
skipped: "skipped",
upstream: "upstream",
};
/**
* Classify a single target's terminal outcome for the client-facing message.
* Auth-class errors (401/403 or auth-sounding text) are kept distinct from
* model-class (400/422) and provider-class (5xx) so a sibling's 401 is never
* presented as "quality failed". Fall through to `model` for everything else.
*/
export function classifyComboOutcome(status: number, errorText: string): ComboOutcomeKind {
const text = typeof errorText === "string" ? errorText : "";
if (
status === 401 ||
status === 403 ||
/(invalid.?api.?key|unauthorized|not.?authorized|auth(entication|orization)?)/i.test(text)
) {
return "auth";
}
if (status === 408 || status >= 499) return "timeout";
if (status >= 500) return "provider";
return "model";
}
/**
* Redact connection/account identifiers that can ride inside a proxy target's
* model string (openai-compatible proxy model names often carry a connection
* label). UUIDs and long hex hashes are truncated to a short `conn:` prefix.
* Provider/model names operators need for debugging are left intact.
*/
export function redactConnectionLabel(modelStr: string | null | undefined): string {
const label = typeof modelStr === "string" && modelStr ? modelStr : "unknown";
return label
.replace(
/\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g,
(m) => `conn:${m.slice(0, 8)}`
)
.replace(/\b[0-9a-fA-F]{16,}\b/g, (m) => `conn:${m.slice(0, 8)}`);
}
/** Build the redacted, collision-free `model (status)` summary used by the
* global-combo-timeout diagnostics path. */
export function buildRedactedSummary(
entries: Array<{ model: string; status: number }> | ReadonlyArray<{ model: string; status: number }>
): string {
const slice = entries.slice(0, 5);
const parts = slice.map((e) => `${redactConnectionLabel(e.model)} (${e.status})`).join(", ");
return entries.length > 5 ? `${parts}... (+${entries.length - 5})` : parts;
}
/**
* Format per-target terminal outcomes into one client-facing sentence that keeps
* every distinct reason separate (and classification-labelled) instead of
* mashing a single `lastError` with raw status markers. Always redacts
* connection identifiers unless `{ redact: false }` is explicitly passed.
*/
export function formatComboOutcomes(
entries: ReadonlyArray<{ model: string; status: number; error: string; kind?: ComboOutcomeKind }>,
opts?: { redact?: boolean }
): string {
if (!entries.length) return "";
const redact = opts?.redact !== false;
const slice = entries.slice(0, 5);
const parts = slice.map((e) => {
const label = redact ? redactConnectionLabel(e.model) : e.model;
const kind = e.kind ? KIND_LABELS[e.kind] ?? e.kind : null;
const reason = e.error || `HTTP ${e.status}`;
const statusTxt = ` (HTTP ${e.status})`;
return kind ? `${label}: ${kind}${reason}${statusTxt}` : `${label}: ${reason}${statusTxt}`;
});
return entries.length > 5
? `${parts.join("; ")}... (+${entries.length - 5} more)`
: parts.join("; ");
}

View File

@@ -1,121 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
const { buildStreamingResponseHeaders } = await import(
"@omniroute/open-sse/handlers/chatCore/responseHeaders.ts"
);
/**
* #10310 regression guard — Codex quota headers must survive the forwarding budget.
*
* Root cause: `getForwardingPriority` only classifies headers containing
* "ratelimit"/"rate-limit" as high-priority. The entire Codex quota vocabulary
* (`x-codex-primary/secondary-* used/reset`, `x-codex-credits-*`) fell to the
* lowest priority tier, tied against bulky CDN/security noise. Because
* `Headers.forEach` iterates in byte-sorted alphabetical order, a realistic
* multi-header Codex+CDN response exhausted the 768-byte budget on alphabetically-
* earlier noise before reaching any `x-codex-*` quota header.
*
* Fix: promote Codex quota headers to the rate-limit priority class and push
* known bulky noise (cf-*, x-codex-turn-state, firewall-sampling-options, ...)
* to a forced-last tier so they never evict quota data.
*/
const CODEX_QUOTA_HEADERS = [
"x-codex-primary-used-percent",
"x-codex-primary-reset-after-seconds",
"x-codex-secondary-used-percent",
"x-codex-secondary-reset-after-seconds",
"x-codex-credits-used",
"x-codex-credits-remaining",
];
const NOISE_HEADERS = [
"x-codex-turn-state",
"fireworks-sampling-options",
"cf-ray",
"cf-cache-status",
"content-security-policy",
];
function buildUpstreamHeaders(): Headers {
return new Headers({
"x-request-id": "b6f1c2a4-7e3d-4a1b-9c2e-1234567890ab",
"anthropic-ratelimit-unified-requests-limit": "5000",
"anthropic-ratelimit-unified-requests-remaining": "4998",
"anthropic-ratelimit-unified-reset": "2026-08-14T06:00:00Z",
"anthropic-organization-id": "org-abc123def456ghi789",
"alt-svc": 'h3=":443"; ma=86400',
"cf-cache-status": "DYNAMIC",
"cf-ray": "89abcdef1234ffff-EWR",
"content-security-policy":
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'",
"cross-origin-embedder-policy": "require-corp",
"cross-origin-opener-policy": "same-origin",
"cross-origin-resource-policy": "same-origin",
date: "Fri, 14 Aug 2026 06:00:00 GMT",
"fireworks-sampling-options": "x".repeat(340),
nel: '{"report_to":"default","max_age":31536000}',
"permissions-policy": "geolocation=(), microphone=(), camera=()",
"referrer-policy": "strict-origin-when-cross-origin",
"report-to":
'{"group":"default","max_age":31536000,"endpoints":[{"url":"https://a.example.com/r"}]}',
"server-timing": "cf-q-config;dur=1.0000002656e-05",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"timing-allow-origin": "*",
vary: "Accept-Encoding, Origin",
"x-codex-turn-state": "y".repeat(300),
"x-codex-primary-used-percent": "42.5",
"x-codex-primary-reset-after-seconds": "1800",
"x-codex-secondary-used-percent": "10.2",
"x-codex-secondary-reset-after-seconds": "86400",
"x-codex-credits-used": "1234",
"x-codex-credits-remaining": "5678",
"x-content-type-options": "nosniff",
"x-frame-options": "DENY",
"x-robots-tag": "noindex",
"x-xss-protection": "0",
});
}
function getHeaderValue(headers: Record<string, string>, name: string): string | undefined {
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase());
return entry?.[1];
}
test("#10310: Codex quota/reset/credits headers survive the forwarding budget", () => {
const result = buildStreamingResponseHeaders(
buildUpstreamHeaders(),
{ provider: "codex", model: "gpt-5-codex", cacheHit: false, latencyMs: 0, usage: null, costUsd: 0 },
null
);
const missing = CODEX_QUOTA_HEADERS.filter((name) => !(name in result));
assert.deepEqual(
missing,
[],
`Codex quota headers were dropped by the forwarding budget: ${missing.join(", ")}`
);
});
test("#10310: bulky non-quota noise is dropped instead of evicting quota headers", () => {
const result = buildStreamingResponseHeaders(
buildUpstreamHeaders(),
{ provider: "codex", model: "gpt-5-codex", cacheHit: false, latencyMs: 0, usage: null, costUsd: 0 },
null
);
for (const name of CODEX_QUOTA_HEADERS) {
assert.ok(name in result, `${name} must be forwarded`);
}
// Anthropic rate-limit class must remain intact after reprioritization.
const anthropicReset = getHeaderValue(result, "anthropic-ratelimit-unified-reset");
assert.ok(
anthropicReset && anthropicReset === "2026-08-14T06:00:00Z",
"anthropic-ratelimit-unified-reset must survive"
);
// Known bulky noise may be dropped when the budget is tight.
const confinedToNoise = NOISE_HEADERS.every(
(name) => !(Object.keys(result).some((key) => key.toLowerCase() === name.toLowerCase()))
);
assert.ok(confinedToNoise, "noise headers should be the ones dropped, not quota");
});

View File

@@ -0,0 +1,54 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
classifyComboOutcome,
formatComboOutcomes,
redactConnectionLabel,
buildRedactedSummary,
} from "../../open-sse/services/combo/comboErrorAggregation.ts";
// #10314 — combo error aggregation mixes quality and auth.
// Regression guard for the pure aggregation helpers: a quality-failure reason from one
// target and a sibling's 401 must be presented as SEPARATE classified outcomes (never
// mashed into a single lastError), and account/connection identifiers must be redacted
// from client-visible and shared-warn strings.
test("#10314: classifyComboOutcome keeps auth distinct from quality/model", () => {
assert.equal(classifyComboOutcome(401, "invalid_api_key"), "auth");
assert.equal(classifyComboOutcome(403, "not authorized"), "auth");
// 5xx sleep to the "timeout" class (>=499 is checked before >=500).
assert.equal(classifyComboOutcome(503, "upstream unavailable"), "timeout");
assert.equal(classifyComboOutcome(408, "timeout"), "timeout");
assert.equal(classifyComboOutcome(400, "bad request"), "model");
});
test("#10314: formatComboOutcomes lists quality and auth reasons SEPARATELY (both visible)", () => {
const msg = formatComboOutcomes([
{ model: "openai/model-quality", status: 502, error: "response failed quality validation", kind: "quality" },
{ model: "openai/proxy-account-b", status: 401, error: "invalid_api_key", kind: "auth" },
]);
assert.match(msg, /quality validation/);
assert.match(msg, /invalid_api_key/);
assert.match(msg, /auth/);
assert.ok(msg.indexOf("quality validation") < msg.indexOf("invalid_api_key"));
});
test("#10314: redactConnectionLabel masks connection/account identifiers", () => {
assert.equal(
redactConnectionLabel("openai/proxy-account-b"),
"openai/proxy-account-b"
);
const withUuid = redactConnectionLabel("openai/8a4f0c6e-3b27-4c51-9d88-1f2a3b4c5d6e");
assert.equal(withUuid, "openai/conn:8a4f0c6e");
const withHex = redactConnectionLabel("openai/0f1e2d3c4b5a69788796170a1b2c3d4e5f607182");
assert.equal(withHex, "openai/conn:0f1e2d3c");
});
test("#10314: buildRedactedSummary is redacted and truncates past 5 entries", () => {
const s = buildRedactedSummary(
Array.from({ length: 6 }, (_, i) => ({ model: `openai/8a4f0c6e-3b27-4c51-9d88-1f2a3b4c5d6e-${i}`, status: 401 + i }))
);
assert.ok(!s.includes("8a4f0c6e-3b27"), "summary must not leak a full UUID");
assert.match(s, /conn:8a4f0c6e/);
assert.match(s, /\(\+1\)/);
});

View File

@@ -1,77 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
const {
buildStreamingResponseHeaders,
resetDroppedHeadersWarningCache,
} = await import("@omniroute/open-sse/handlers/chatCore/responseHeaders.ts");
/**
* #10315 regression guard — warn storm on the forwarded-header drop path.
*
* Root cause: `buildStreamingResponseHeaders` unconditionally emits a structured
* `warn` (up to 20 {name,bytes} entries) on EVERY response that drops any header
* past the forwarding budget. No dedupe/sample. Under multi-stream Desktop flows
* a chronic over-budget response set buries real errors and adds serialize/log
* I/O per response.
*
* Fix: warn once per process per sorted-dropped-name fingerprint, then degrade
* to `debug` for repeats of the same dropped set. Distinct dropped sets still
* each warn once.
*/
function makeLog() {
const warns: unknown[][] = [];
const debugs: unknown[][] = [];
return {
log: {
warn: (...args: unknown[]) => warns.push(args),
debug: (...args: unknown[]) => debugs.push(args),
},
warns,
debugs,
};
}
function oversizedSet(prefix: string): Headers {
const headers = new Headers({ "x-request-id": `req-${prefix}` });
for (let index = 0; index < 24; index += 1) {
headers.set(`${prefix}-${index.toString().padStart(2, "0")}`, "x".repeat(69));
}
return headers;
}
test("#10315: 100 identical oversized responses produce exactly 1 warn then debug", () => {
resetDroppedHeadersWarningCache();
const { log, warns, debugs } = makeLog();
const oversized = oversizedSet("x-big-header");
for (let index = 0; index < 100; index += 1) {
buildStreamingResponseHeaders(oversized, { provider: "codex", model: "gpt-5-codex" }, log);
}
// Only the first occurrence of this dropped-name set may warn.
assert.equal(
warns.length,
1,
"expected exactly 1 warn across 100 identical drops, got " + warns.length
);
// Every subsequent identical drop must be a debug (or at least not a warn).
assert.ok(
debugs.length >= 99,
"expected repeats to degrade to debug, got " + debugs.length + " debug entries"
);
});
test("#10315: two distinct dropped sets each warn once even when repeated", () => {
resetDroppedHeadersWarningCache();
const { log, warns } = makeLog();
const setA = oversizedSet("x-big-header-a");
const setB = oversizedSet("x-big-header-b");
for (let index = 0; index < 2; index += 1) {
buildStreamingResponseHeaders(setA, { provider: "codex", model: "gpt-5-codex" }, log);
buildStreamingResponseHeaders(setB, { provider: "codex", model: "gpt-5-codex" }, log);
}
assert.equal(
warns.length,
2,
"expected 1 warn per distinct dropped set (A and B), got " + warns.length
);
});