src/history-budget.js
Rama master · réplica de lectura del repositorio original.
// Outbound conversation-history budget.
//
// WHY THIS EXISTS. Issues #136 / #191 measured the same failure: a 113-turn,
// ~128 KB transcript is accepted, forwarded, and then dies MID-STREAM on the
// upstream's `context deadline exceeded` — the caller has already received
// partial bytes, so it cannot even retry cleanly. Nothing bounded the transcript
// the proxy forwards. `CASCADE_MAX_HISTORY_BYTES` is a prompt-assembly budget
// (it trims the Cascade history suffix and always keeps the newest turn); the
// DEVIN_CONNECT path has no equivalent at all. Neither is an admission check.
//
// This module is that admission check. The shared chat layer measures the
// transcript the caller handed us — turns and payload bytes — and refuses the
// request BEFORE backend selection, account acquisition, the cache, or any
// upstream call. The caller gets one actionable error instead of a truncated
// stream, and the account pool is not charged for a request the upstream cannot
// finish.
//
// UNITS. `turns` is `messages.length` — every entry the encoder will see, system
// turns included, because they all ride the wire. `bytes` counts every field the
// encoder forwards: the `messages` payload fields (content text and non-text
// parts, tool-call ids / names / arguments, reasoning_content,
// reasoning_signature, name, tool_call_id) as UTF-8, plus the JSON-serialized
// `tools[]` schemas — the same unit proto.js writes. Transport framing is not
// counted; the number is a deliberate lower bound on the wire size, not an
// estimate of it. Tool schemas are the largest component of a Claude Code
// request, so measuring `messages` alone could pass exactly the shape the budget
// exists to refuse. A top-level `system` field is NOT counted: no surface
// forwards it (the Messages/Responses translators fold it into `messages`
// before this check, and the OpenAI chat route ignores it), so counting it would
// refuse bytes that never go on the wire.
//
// DEFAULTS. The turn ceiling sits below the measured failure (113 turns) so the
// reported shape is refused locally instead of dying mid-reply; the byte ceiling
// is a safety net under the 10 MB ingress cap, because the reported transcript
// was only 128 KB and a tight byte cap would reject large-but-trimmable Cascade
// transcripts that the history-suffix budget already handles. Both dimensions
// are independent: exceeding EITHER refuses the request. Set a dimension to 0
// to disable it (an explicit operator decision, not an accident).
export const HISTORY_BUDGET_TURNS_ENV = 'WINDSURFAPI_HISTORY_BUDGET_TURNS';
export const HISTORY_BUDGET_BYTES_ENV = 'WINDSURFAPI_HISTORY_BUDGET_BYTES';
export const DEFAULT_HISTORY_BUDGET_TURNS = 100;
export const DEFAULT_HISTORY_BUDGET_BYTES = 8_000_000;
export const HISTORY_BUDGET_ERROR_CODE = 'history_budget_exceeded';
function nonNegativeInt(raw) {
if (raw == null) return null;
const text = String(raw).trim();
if (!text) return null;
const n = Number(text);
if (!Number.isFinite(n) || n < 0) return null;
return Math.floor(n);
}
/**
* Resolve the configured budget. `0` disables a dimension; an absent, empty or
* malformed value falls back to the default (a typo must not silently disable
* the guard).
*/
export function resolveHistoryBudget({ env = process.env } = {}) {
return {
maxTurns: nonNegativeInt(env?.[HISTORY_BUDGET_TURNS_ENV]) ?? DEFAULT_HISTORY_BUDGET_TURNS,
maxBytes: nonNegativeInt(env?.[HISTORY_BUDGET_BYTES_ENV]) ?? DEFAULT_HISTORY_BUDGET_BYTES,
};
}
function textBytes(value) {
return typeof value === 'string' ? Buffer.byteLength(value, 'utf8') : 0;
}
function contentBytes(content) {
if (typeof content === 'string') return textBytes(content);
if (content && typeof content === 'object' && !Array.isArray(content)) {
return textBytes(JSON.stringify(content));
}
if (!Array.isArray(content)) return 0;
let total = 0;
for (const part of content) {
if (part == null) continue;
if (typeof part === 'string') { total += textBytes(part); continue; }
if (typeof part !== 'object') continue;
if (typeof part.text === 'string') total += textBytes(part.text);
else if (typeof part.image_url?.url === 'string') total += textBytes(part.image_url.url);
else if (typeof part.input_audio?.data === 'string') total += textBytes(part.input_audio.data);
else total += textBytes(JSON.stringify(part));
}
return total;
}
function messageBytes(message) {
if (!message || typeof message !== 'object') return 0;
let total = contentBytes(message.content);
total += textBytes(message.reasoning_content);
total += textBytes(message.reasoning_signature);
total += textBytes(message.reasoning);
total += textBytes(message.name);
total += textBytes(message.tool_call_id);
if (Array.isArray(message.tool_calls)) {
for (const call of message.tool_calls) {
total += textBytes(call?.id);
total += textBytes(call?.function?.name);
total += textBytes(call?.function?.arguments);
}
}
return total;
}
/** Tool definitions are forwarded as JSON schemas; count their serialized size. */
function toolsBytes(tools) {
if (Array.isArray(tools)) {
let total = 0;
for (const tool of tools) {
if (tool == null) continue;
if (typeof tool === 'string') { total += textBytes(tool); continue; }
try { total += textBytes(JSON.stringify(tool)); } catch { /* non-serializable tool: count nothing */ }
}
return total;
}
if (tools && typeof tools === 'object') {
try { return textBytes(JSON.stringify(tools)); } catch { return 0; }
}
return 0;
}
/**
* Measure everything the request forwards: `messages` plus `tools[]`. Returns
* the total plus the per-contributor breakdown so a refusal can name the
* dominant one.
*/
export function measureOutboundHistory(messages, { tools = null } = {}) {
const turns = Array.isArray(messages) ? messages.length : 0;
let messagesBytes = 0;
if (Array.isArray(messages)) {
for (const message of messages) messagesBytes += messageBytes(message);
}
const toolsTotal = toolsBytes(tools);
return {
turns,
bytes: messagesBytes + toolsTotal,
breakdown: { messages: messagesBytes, tools: toolsTotal },
};
}
/**
* Admission check. Pure (no logging, no env mutation) so tests and callers can
* drive it deterministically; the chat layer owns the warning and the response.
*
* Returns `{ ok, measured, budget, overTurns, overBytes }`.
*/
export function checkOutboundHistory(messages, { env = process.env, tools = null } = {}) {
const budget = resolveHistoryBudget({ env });
const measured = measureOutboundHistory(messages, { tools });
const overTurns = budget.maxTurns > 0 && measured.turns > budget.maxTurns;
const overBytes = budget.maxBytes > 0 && measured.bytes > budget.maxBytes;
return { ok: !overTurns && !overBytes, measured, budget, overTurns, overBytes };
}
function kb(bytes) {
return `${Math.round(bytes / 1024)} KB`;
}
const CONTRIBUTOR_LABEL = {
messages: 'messages',
tools: 'tool schemas',
};
/** The largest measured contributor, or null when everything is empty. */
export function dominantContributor(breakdown) {
if (!breakdown || typeof breakdown !== 'object') return null;
let best = null;
for (const [name, value] of Object.entries(breakdown)) {
if (!(value > 0)) continue;
if (!best || value > best.bytes) best = { name, bytes: value };
}
return best;
}
/**
* Caller-facing explanation. Names the exceeded dimension(s), the measured
* values, the knobs, and what to do — the point of the check is to replace an
* opaque mid-stream timeout with something the caller can act on. When the
* measurement includes the breakdown, it also names the largest contributor so
* "8 MB of history" is actionable (usually the tool schemas, not the turns).
*/
export function historyBudgetErrorMessage({ measured, budget, overTurns, overBytes }) {
const parts = [];
if (overTurns) parts.push(`${measured.turns} messages (budget ${budget.maxTurns}, ${HISTORY_BUDGET_TURNS_ENV})`);
if (overBytes) parts.push(`${kb(measured.bytes)} of history (budget ${kb(budget.maxBytes)}, ${HISTORY_BUDGET_BYTES_ENV})`);
const dominant = dominantContributor(measured?.breakdown);
const contributor = dominant
? ` Largest contributor: ${CONTRIBUTOR_LABEL[dominant.name] || dominant.name} (${kb(dominant.bytes)} of ${kb(measured.bytes)}).`
: '';
return `Conversation history is too large to forward: ${parts.join('; ')}. `
+ 'The upstream stalls and the stream dies mid-reply on transcripts this large, so the request was refused before it was sent. '
+ 'Start a new session or compact/summarise the transcript. Operators can raise the budget with '
+ `${HISTORY_BUDGET_TURNS_ENV} / ${HISTORY_BUDGET_BYTES_ENV} (0 disables a dimension).${contributor}`;
}