diff --git a/packages/opencode/src/provider/output-token-budget.ts b/packages/opencode/src/provider/output-token-budget.ts new file mode 100644 index 0000000000..24700e9731 --- /dev/null +++ b/packages/opencode/src/provider/output-token-budget.ts @@ -0,0 +1,532 @@ +// altimate_change start — keep output reservations inside the provider context window +import type { Provider } from "./provider" +import { Log } from "@/util/log" +import { Token } from "@/util/token" + +const log = Log.create({ service: "provider.output-token-budget" }) + +/** Smallest completion budget worth sending for an agent turn. */ +export const OUTPUT_TOKEN_FLOOR = 1_024 + +const CLAMP_MARGIN_FRACTION = 0.02 +const CLAMP_MARGIN_MIN = 512 +const ESTIMATE_CHUNK_SIZE = 400 +const MEDIA_TOKEN_ALLOWANCE = 2_048 +const FILE_TOKEN_ALLOWANCE = 16_384 +const PDF_TOKEN_ALLOWANCE = 32_768 +const AUDIO_TOKEN_ALLOWANCE = 8_192 +const VIDEO_TOKEN_ALLOWANCE = 8_192 +const SEMANTIC_MEDIA_BYTES_PER_TOKEN = 64 +const DATA_URL_HEADER_LIMIT = 1_024 +const MIN_REASONING_BUDGET = 1_024 +const EMOJI = /\p{Extended_Pictographic}/u +const DENSE_ASCII_CHARACTER = /[A-Za-z0-9+/_=-]/ +const DENSE_ASCII_MIN_LENGTH = 32 +const DENSE_ASCII_MIN_UNIQUE = 6 +const DENSE_ASCII_EXTRA_FRACTION = 0.75 +const REASONING_BUDGET_KEYS = new Set(["budgetTokens", "thinkingBudget", "budget_tokens"]) +const CONTEXT_WINDOW_BETAS = new Map([["context-1m-2025-08-07", 1_000_000]]) +const MEDIA_PART_TYPES = new Set([ + "image", + "file", + "media", + "audio", + "video", + "file-data", + "file-url", + "file-id", + "image-data", + "image-url", + "image-file-id", +]) +const MEDIA_PAYLOAD_KEYS = new Set(["data", "image", "file", "audio", "video", "url", "fileId"]) +const FILE_PART_TYPES = new Set(["file", "media", "file-data", "file-url", "file-id"]) + +type JsonRecord = Record + +/** Numbers captured when a prompt cannot leave a usable completion budget. */ +export type OutputTokenBudgetInfo = { + readonly modelID: string + readonly providerID: string + readonly inputTokens: number + readonly requested: number + readonly context: number + readonly floor: number +} + +/** Numbers captured when a prompt exceeds a model's dedicated input ceiling. */ +export type InputTokenBudgetInfo = { + readonly modelID: string + readonly providerID: string + readonly inputTokens: number + readonly inputLimit: number + readonly margin: number +} + +/** Thrown before transport when the prompt leaves no usable completion budget. */ +export class OutputTokenBudgetError extends Error { + constructor(readonly info: OutputTokenBudgetInfo) { + super( + [ + "Context budget exceeded before the request was sent.", + `${info.providerID}/${info.modelID} declares a ${info.context}-token context window,`, + `the prompt is ~${info.inputTokens} tokens, and ${info.requested} tokens are reserved for`, + `the completion — ${info.inputTokens + info.requested} in total.`, + `Even after clamping, fewer than ${info.floor} tokens would remain for the response.`, + "Reduce the system prompt (fewer instructions, skills, or AGENTS.md content), lower the", + "output reservation via OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX, or use a model with a", + "larger context window.", + ].join(" "), + ) + this.name = "OutputTokenBudgetError" + } +} + +/** Thrown before transport when the estimated prompt exceeds a dedicated input limit. */ +export class InputTokenBudgetError extends Error { + constructor(readonly info: InputTokenBudgetInfo) { + super( + [ + "Input budget exceeded before the request was sent.", + `${info.providerID}/${info.modelID} declares a ${info.inputLimit}-token input limit,`, + `the prompt is ~${info.inputTokens} tokens, and ${info.margin} safety tokens are required.`, + "Reduce the prompt or use a model with a larger input limit.", + ].join(" "), + ) + this.name = "InputTokenBudgetError" + } +} + +/** Thrown when preserving enabled reasoning would leave no useful visible response. */ +export class ReasoningTokenBudgetError extends Error { + constructor( + readonly info: { + readonly path: string + readonly configured: number + readonly maxOutputTokens: number + }, + ) { + super( + [ + "The context-window clamp cannot preserve the configured reasoning budget.", + `${info.path} is ${info.configured} tokens while maxOutputTokens is ${info.maxOutputTokens},`, + `which cannot leave ${OUTPUT_TOKEN_FLOOR} tokens for the visible response.`, + "Use a larger-context model, shorten the prompt, or select a lower reasoning effort.", + ].join(" "), + ) + this.name = "ReasoningTokenBudgetError" + } +} + +/** Return true for plain record-like values used in request payloads. */ +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +/** Merge outgoing request headers with HTTP's case-insensitive last-source precedence. */ +export function mergeRequestHeaders(...sources: readonly unknown[]): Record { + const result: Record = {} + const set = (name: unknown, value: unknown) => { + if (typeof name !== "string" || typeof value !== "string") return + result[name.toLowerCase()] = value + } + for (const source of sources) { + if (!source) continue + if (source instanceof Headers) { + source.forEach((value, name) => set(name, value)) + continue + } + if (Array.isArray(source)) { + for (const entry of source) { + if (Array.isArray(entry)) set(entry[0], entry[1]) + } + continue + } + if (!isRecord(source)) continue + for (const [name, value] of Object.entries(source)) set(name, value) + } + return result +} + +/** Count opaque ASCII runs in one bounded-memory pass, including across estimator chunks. */ +function denseAsciiCharacters(input: string): number { + let total = 0 + let length = 0 + const unique = new Set() + const flush = () => { + if (length >= DENSE_ASCII_MIN_LENGTH && unique.size >= DENSE_ASCII_MIN_UNIQUE) total += length + length = 0 + unique.clear() + } + for (const character of input) { + if (!DENSE_ASCII_CHARACTER.test(character)) { + flush() + continue + } + length++ + if (unique.size < DENSE_ASCII_MIN_UNIQUE) unique.add(character) + } + flush() + return total +} + +/** Estimate heterogeneous text in small chunks and conservatively count dense or non-ASCII text. */ +function estimateTextTokens(input: string): number { + let total = 0 + for (let offset = 0; offset < input.length; offset += ESTIMATE_CHUNK_SIZE) { + const chunk = input.slice(offset, offset + ESTIMATE_CHUNK_SIZE) + let ascii = "" + let nonAscii = 0 + let emoji = 0 + for (const character of chunk) { + if (character.codePointAt(0)! <= 0x7f) { + ascii += character + } else { + nonAscii++ + if (EMOJI.test(character)) emoji++ + } + } + const multilingualFloor = Token.estimate(ascii) + nonAscii + emoji + total += Math.max(Token.estimate(chunk), multilingualFloor) + } + // Token.estimate already charges at least one token per 3.7 characters. Adding three quarters + // of each dense run establishes a conservative one-token-per-character floor. + return total + Math.ceil(denseAsciiCharacters(input) * DENSE_ASCII_EXTRA_FRACTION) +} + +/** Return the first transport payload carried by a semantic media part. */ +function mediaPayload(part: JsonRecord): unknown { + for (const key of MEDIA_PAYLOAD_KEYS) { + if (part[key] !== undefined && part[key] !== null) return part[key] + } + return undefined +} + +/** Resolve a media part's MIME type from its declared type, data URL, or URL suffix. */ +function mediaType(part: JsonRecord, payload: unknown): string | undefined { + const declared = + typeof part.mediaType === "string" ? part.mediaType : typeof part.mime === "string" ? part.mime : undefined + if (declared) return declared.split(";", 1)[0].trim().toLowerCase() + + const value = payload instanceof URL ? payload.href : typeof payload === "string" ? payload : undefined + const prefix = value?.slice(0, DATA_URL_HEADER_LIMIT) + const dataType = prefix?.match(/^data:([^;,]+)/i)?.[1] + if (dataType) return dataType.toLowerCase() + if (value && /\.pdf(?:[?#]|$)/i.test(value.slice(-DATA_URL_HEADER_LIMIT))) return "application/pdf" + if (String(part.type).startsWith("image")) return "image/*" + return undefined +} + +/** Return an inline payload's decoded byte size without allocating its encoded contents. */ +function inlinePayloadSize(payload: unknown): number | undefined { + if (ArrayBuffer.isView(payload)) return payload.byteLength + if (payload instanceof ArrayBuffer) return payload.byteLength + const value = payload instanceof URL ? payload.href : typeof payload === "string" ? payload : undefined + if (!value || /^https?:/i.test(value)) return undefined + + const dataURL = /^data:/i.test(value) + const prefix = dataURL ? value.slice(0, DATA_URL_HEADER_LIMIT) : "" + const comma = dataURL ? prefix.indexOf(",") : -1 + // A delimiter outside the bounded header prefix is malformed for admission purposes. Charge + // its complete encoded length without scanning or copying the attacker-controlled payload. + if (dataURL && comma === -1) return value.length + const bodyOffset = comma === -1 ? 0 : comma + 1 + const bodyLength = value.length - bodyOffset + if (comma !== -1 && !/;base64(?:;|$)/i.test(prefix.slice(0, comma))) return bodyLength + + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0 + return Math.max(0, Math.floor((bodyLength * 3) / 4) - padding) +} + +/** Apply a bounded, parser-free PDF heuristic without trusting document metadata. */ +function pdfTokenAllowance(payload: unknown): number { + const bytes = inlinePayloadSize(payload) ?? 0 + // Page expansion cannot be derived safely from raw bytes: lexical page markers are spoofable, + // while structural parsing adds decompression and traversal risks to the request path. Keep the + // local estimate best-effort and monotonic; the configured provider remains authoritative for + // unusually compact or dense documents. + return Math.max(PDF_TOKEN_ALLOWANCE, bytes) +} + +/** Keep semantic media usable on small contexts while making very large inline payloads monotonic. */ +function semanticMediaTokenAllowance(payload: unknown, baseline: number): number { + const bytes = inlinePayloadSize(payload) ?? 0 + // Encoded bytes do not map directly to provider tokens, but a coarse size floor prevents an + // arbitrarily large inline recording from receiving the same allowance as a tiny or remote one. + return Math.max(baseline, Math.ceil(bytes / SEMANTIC_MEDIA_BYTES_PER_TOKEN)) +} + +/** Assign an allowance that matches the semantic media kind rather than charging every byte. */ +function mediaTokenAllowance(part: JsonRecord): number { + const payload = mediaPayload(part) + const mime = mediaType(part, payload) + const type = String(part.type) + if (mime?.startsWith("image/") || type.startsWith("image")) return MEDIA_TOKEN_ALLOWANCE + if (mime === "application/pdf") return pdfTokenAllowance(payload) + if (mime?.startsWith("audio/") || type === "audio") { + return semanticMediaTokenAllowance(payload, AUDIO_TOKEN_ALLOWANCE) + } + if (mime?.startsWith("video/") || type === "video") { + return semanticMediaTokenAllowance(payload, VIDEO_TOKEN_ALLOWANCE) + } + if (FILE_PART_TYPES.has(type)) { + return Math.max(FILE_TOKEN_ALLOWANCE, inlinePayloadSize(payload) ?? 0) + } + return MEDIA_TOKEN_ALLOWANCE +} + +/** Mark only actual ModelMessage content parts whose payload is provider media. */ +function messageMediaAllowances(messages: readonly unknown[]): WeakMap { + const result = new WeakMap() + const visited = new WeakSet() + + const visitContent = (content: unknown) => { + if (!Array.isArray(content) || visited.has(content)) return + visited.add(content) + for (const part of content) { + if (!isRecord(part)) continue + if (MEDIA_PART_TYPES.has(String(part.type))) result.set(part, mediaTokenAllowance(part)) + + // Tool-result media is nested in the AI SDK's typed content output. + if (part.type !== "tool-result" || !isRecord(part.output)) continue + if (part.output.type === "content") visitContent(part.output.value) + } + } + + for (const message of messages) { + if (isRecord(message)) visitContent(message.content) + } + return result +} + +/** Serialize request structures without expanding semantic media payloads into fake text tokens. */ +function serializeForEstimate( + value: unknown, + mediaContainers?: WeakMap, +): { readonly text: string; readonly mediaTokens: number } { + let mediaTokens = 0 + const ancestors: object[] = [] + const text = + JSON.stringify(value, function (key, child) { + while (ancestors.length > 0 && ancestors.at(-1) !== this) ancestors.pop() + + const mediaField = + typeof this === "object" && this !== null && mediaContainers?.has(this) && MEDIA_PAYLOAD_KEYS.has(key) + if (mediaField && child !== undefined && child !== null) { + return "[media omitted]" + } + if (typeof child === "object" && child !== null) { + if (ancestors.includes(child)) return "[circular value omitted]" + // Count transport occurrences, not object identities: JSON duplicates shared aliases. + mediaTokens += mediaContainers?.get(child) ?? 0 + ancestors.push(child) + } + return child + }) ?? "" + return { text, mediaTokens } +} + +/** Collect Anthropic beta values only from header-shaped records. */ +function anthropicBetaValues(source: unknown, depth = 0): string[] { + if (!isRecord(source) || depth > 4) return [] + const result: string[] = [] + for (const [key, value] of Object.entries(source)) { + const normalized = key.toLowerCase() + if (normalized === "anthropic-beta") { + if (typeof value === "string") result.push(value) + if (Array.isArray(value)) result.push(...value.filter((item): item is string => typeof item === "string")) + continue + } + if (normalized === "headers" || normalized.endsWith("headers")) { + result.push(...anthropicBetaValues(value, depth + 1)) + } + } + return result +} + +/** Resolve catalog context limits with known provider beta headers applied. */ +export function effectiveContextWindow(input: { + readonly model: Provider.Model + readonly headerSources?: readonly unknown[] +}): number { + let context = input.model.limit.context + let finalValues: string[] = [] + for (const source of input.headerSources ?? []) { + const values = anthropicBetaValues(source) + if (values.length > 0) finalValues = values + } + for (const value of finalValues) { + for (const beta of value.split(/[\s,]+/)) { + context = Math.max(context, CONTEXT_WINDOW_BETAS.get(beta) ?? 0) + } + } + return context +} + +/** Estimate the text, finalized tools, instructions, and media allowance sent in one request. */ +export function estimateInputTokens(input: { + readonly system: readonly string[] + readonly messages: readonly unknown[] + readonly tools?: Readonly> + readonly instructions?: unknown +}): number { + let total = 0 + if (input.system.length > 0) { + const system = serializeForEstimate(input.system.map((content) => ({ role: "system", content }))) + total += estimateTextTokens(system.text) + } + + const messages = serializeForEstimate(input.messages, messageMediaAllowances(input.messages)) + total += estimateTextTokens(messages.text) + messages.mediaTokens + + if (input.tools !== undefined) { + const tools = serializeForEstimate(input.tools) + total += estimateTextTokens(tools.text) + } + + if (input.instructions !== undefined) { + const serialized = serializeForEstimate(input.instructions) + total += estimateTextTokens(serialized.text) + serialized.mediaTokens + } + return total +} + +/** Resolve a direct or lazy input estimate after cheap no-op checks have passed. */ +function resolveInputTokens(value: number | (() => number)): number { + return typeof value === "function" ? value() : value +} + +/** Keep estimator drift proportional when a credible limit is smaller than the default margin. */ +function safetyMargin(inputTokens: number, limit: number): number { + const proportionalMinimum = Math.max(1, Math.ceil(limit * CLAMP_MARGIN_FRACTION)) + return Math.max(Math.ceil(inputTokens * CLAMP_MARGIN_FRACTION), Math.min(CLAMP_MARGIN_MIN, proportionalMinimum)) +} + +/** Clamp a completion reservation so estimated input, margin, and output fit the effective window. */ +export function clampOutputTokens(input: { + readonly model: Provider.Model + readonly requested: number | undefined + readonly inputTokens: number | (() => number) + readonly context?: number +}): number | undefined { + const requested = input.requested + if (requested === undefined) return undefined + + const context = input.context ?? input.model.limit.context + const inputLimit = input.model.limit.input + if ((!context || context <= 0) && (!inputLimit || inputLimit <= 0)) return requested + + const inputTokens = resolveInputTokens(input.inputTokens) + if (!Number.isFinite(inputTokens) || inputTokens <= 0) return requested + + if (inputLimit && inputLimit > 0) { + const margin = safetyMargin(inputTokens, inputLimit) + if (inputTokens + margin > inputLimit) { + throw new InputTokenBudgetError({ + modelID: input.model.id, + providerID: input.model.providerID, + inputTokens, + inputLimit, + margin, + }) + } + } + if (!context || context <= 0) return requested + const margin = safetyMargin(inputTokens, context) + if (inputTokens + requested + margin <= context) return requested + + // Do not reject a model for failing to reach a floor above its own reservation. + const floor = Math.min(OUTPUT_TOKEN_FLOOR, requested) + const clamped = Math.floor(context - inputTokens - margin) + if (clamped < floor) { + throw new OutputTokenBudgetError({ + modelID: input.model.id, + providerID: input.model.providerID, + inputTokens, + requested, + context, + floor, + }) + } + log.warn("clamped output token reservation to fit context window", { + providerID: input.model.providerID, + modelID: input.model.id, + context, + inputTokens, + requested, + clamped, + }) + return clamped +} + +/** Recursively copy and clamp explicit reasoning-token fields in provider options. */ +function transformReasoningBudgets( + value: JsonRecord, + ceiling: number, + maxOutputTokens: number, + path?: readonly string[], +): JsonRecord +function transformReasoningBudgets( + value: unknown, + ceiling: number, + maxOutputTokens: number, + path?: readonly string[], +): unknown +function transformReasoningBudgets( + value: unknown, + ceiling: number, + maxOutputTokens: number, + path: readonly string[] = [], +): unknown { + if (Array.isArray(value)) { + let changed = false + const result = value.map((item, index) => { + const next = transformReasoningBudgets(item, ceiling, maxOutputTokens, [...path, String(index)]) + changed ||= next !== item + return next + }) + return changed ? result : value + } + if (!isRecord(value)) return value + + let result = value + for (const [key, child] of Object.entries(value)) { + let next = child + if (REASONING_BUDGET_KEYS.has(key) && typeof child === "number" && child > 0 && child > ceiling) { + if (ceiling < MIN_REASONING_BUDGET) { + throw new ReasoningTokenBudgetError({ + path: [...path, key].join("."), + configured: child, + maxOutputTokens, + }) + } + next = ceiling + log.warn("clamped reasoning token budget with output reservation", { + path: [...path, key].join("."), + configured: child, + clamped: ceiling, + maxOutputTokens, + }) + } else { + next = transformReasoningBudgets(child, ceiling, maxOutputTokens, [...path, key]) + } + if (next !== child) { + if (result === value) result = { ...value } + result[key] = next + } + } + return result +} + +/** Clamp explicit thinking budgets while preserving room for a visible response. */ +export function clampReasoningBudget( + options: Record, + maxOutputTokens: number | undefined, +): Record { + if (maxOutputTokens === undefined) return options + const ceiling = Math.floor(maxOutputTokens - OUTPUT_TOKEN_FLOOR) + return transformReasoningBudgets(options, ceiling, maxOutputTokens) +} + +export * as OutputTokenBudget from "./output-token-budget" +// altimate_change end diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 5850bb82f3..a956a38559 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -58,6 +58,9 @@ import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "gitlab-ai-prov import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { GoogleAuth } from "google-auth-library" import { ProviderTransform } from "./transform" +// altimate_change start — make provider/model headers obey HTTP's case-insensitive precedence +import { mergeRequestHeaders } from "./output-token-budget" +// altimate_change end // altimate_change start — provider fetch timeout errors use typed ProviderError classes import { ProviderError } from "./error" // altimate_change end @@ -1817,11 +1820,11 @@ export namespace Provider { if (baseURL !== undefined) options["baseURL"] = baseURL if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key - if (model.headers) - options["headers"] = { - ...options["headers"], - ...model.headers, - } + // altimate_change start — canonical names ensure later per-request headers replace provider defaults + if (options["headers"] !== undefined || model.headers) { + options["headers"] = mergeRequestHeaders(options["headers"], model.headers) + } + // altimate_change end const key = Hash.fast(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options })) const existing = s.sdk.get(key) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 446b1a5036..dc3c6e7863 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -334,9 +334,11 @@ export namespace ProviderTransform { // Check for empty base64 image data if (part.type === "image") { - const imageStr = part.image.toString() - if (imageStr.startsWith("data:")) { - const match = imageStr.match(/^data:([^;]+);base64,(.*)$/) + // altimate_change start — support every valid image payload form and case + const imageStr = + typeof part.image === "string" ? part.image : part.image instanceof URL ? part.image.href : undefined + if (imageStr && /^data:/i.test(imageStr)) { + const match = imageStr.match(/^data:([^;]+);base64,(.*)$/i) if (match && (!match[2] || match[2].length === 0)) { return { type: "text" as const, @@ -344,11 +346,14 @@ export namespace ProviderTransform { } } } + // altimate_change end } - const mime = part.type === "image" ? part.image.toString().split(";")[0].replace("data:", "") : part.mediaType + // altimate_change start — classify semantic images independently of their payload representation const filename = part.type === "file" ? part.filename : undefined - const modality = mimeToModality(mime) + const modality = + part.type === "image" ? "image" : mimeToModality(part.mediaType.split(";", 1)[0]!.trim().toLowerCase()) + // altimate_change end if (!modality) return part if (model.capabilities.input[modality]) return part @@ -363,6 +368,30 @@ export namespace ProviderTransform { }) } + // altimate_change start — expose the pure request projection used before input-budget estimation + export function messagesForInputEstimate(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { + const projected = unsupportedParts(msgs, model) + const mistral = + model.providerID === "mistral" || + model.api.id.toLowerCase().includes("mistral") || + model.api.id.toLowerCase().includes("devstral") + if (!mistral) return projected + + // normalizeMessages inserts this bridge before transport because Mistral rejects a tool + // message followed directly by a user message. Estimate the same synthetic messages without + // mutating the history that the real transform will process later. + const result: ModelMessage[] = [] + for (let index = 0; index < projected.length; index++) { + const message = projected[index] + result.push(message) + if (message.role === "tool" && projected[index + 1]?.role === "user") { + result.push({ role: "assistant", content: [{ type: "text", text: "Done." }] }) + } + } + return result + } + // altimate_change end + // altimate_change start — shared providerOptions transform used before request signing function mapProviderOptions( msgs: ModelMessage[], diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a4c826d5f5..69b72088b5 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -13,6 +13,15 @@ import { } from "ai" import { mergeDeep, pipe } from "remeda" import { ProviderTransform } from "@/provider/transform" +// altimate_change start — size and clamp the finalized provider request +import { + clampOutputTokens, + clampReasoningBudget, + effectiveContextWindow, + estimateInputTokens, + mergeRequestHeaders, +} from "@/provider/output-token-budget" +// altimate_change end // altimate_change start — tool retrieval import { Retrieval } from "@/tool/retrieval" // altimate_change end @@ -73,7 +82,9 @@ export namespace LLM { ]) const isCodex = provider.id === "openai" && auth?.type === "oauth" - const system = [] + // altimate_change start — keep the request-budget input typed before the first push + const system: string[] = [] + // altimate_change end system.push( [ // use agent prompt otherwise provider prompt @@ -164,6 +175,25 @@ export namespace LLM { }, ) + // altimate_change start — canonicalize the exact outgoing header precedence before budgeting + const requestHeaders = mergeRequestHeaders( + input.model.providerID.startsWith("opencode") + ? { + "x-opencode-project": Instance.project.id, + "x-opencode-session": input.sessionID, + "x-opencode-request": input.user.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, + } + : input.model.providerID !== "anthropic" + ? { + "User-Agent": `altimate-code/${Installation.VERSION}`, + } + : undefined, + input.model.headers, + headers, + ) + // altimate_change end + const tools = await resolveTools(input) // altimate_change start — ensure tool definitions exist for all tool_use blocks in history @@ -199,6 +229,30 @@ export namespace LLM { } // altimate_change end + // altimate_change start — clamp after every context-affecting request field is finalized. + // Tool schemas and provider instructions consume the shared context window, while encoded + // media bytes do not count as literal text. The estimator runs lazily so providers that omit + // maxOutputTokens pay no serialization cost. Known context beta headers widen the catalog + // limit before the clamp. Fixed reasoning budgets are reconciled with the final reservation. + const maxOutputTokens = clampOutputTokens({ + model: input.model, + requested: params.maxOutputTokens, + context: effectiveContextWindow({ + model: input.model, + // Provider defaults are lower precedence than the exact case-normalized outgoing record. + headerSources: [provider.options, requestHeaders], + }), + inputTokens: () => + estimateInputTokens({ + system, + messages: ProviderTransform.messagesForInputEstimate(input.messages, input.model), + tools, + instructions: params.options.instructions, + }), + }) + const requestOptions = clampReasoningBudget(params.options, maxOutputTokens) + // altimate_change end + return streamText({ onError(error) { l.error("stream error", { @@ -233,32 +287,19 @@ export namespace LLM { temperature: params.temperature, topP: params.topP, topK: params.topK, - providerOptions: ProviderTransform.providerOptions(input.model, params.options), + // altimate_change start — use the reasoning options reconciled with the final output reservation + providerOptions: ProviderTransform.providerOptions(input.model, requestOptions), + // altimate_change end activeTools: Object.keys(tools).filter((x) => x !== "invalid"), tools, toolChoice: input.toolChoice, - // altimate_change start — read maxOutputTokens from params (now plumbed through chat.params hook) - maxOutputTokens: params.maxOutputTokens, + // altimate_change start — use the plugin-selected reservation after context clamping + maxOutputTokens, // altimate_change end abortSignal: input.abort, - headers: { - ...(input.model.providerID.startsWith("opencode") - ? { - "x-opencode-project": Instance.project.id, - "x-opencode-session": input.sessionID, - "x-opencode-request": input.user.id, - "x-opencode-client": Flag.OPENCODE_CLIENT, - } - : input.model.providerID !== "anthropic" - ? { - // altimate_change start — upstream_fix: UA brand - "User-Agent": `altimate-code/${Installation.VERSION}`, - // altimate_change end - } - : undefined), - ...input.model.headers, - ...headers, - }, + // altimate_change start — send the canonical headers used by the budget estimator + headers: requestHeaders, + // altimate_change end maxRetries: input.retries ?? 0, messages: [ ...system.map( @@ -276,8 +317,10 @@ export namespace LLM { specificationVersion: "v3", async transformParams(args) { if (args.type === "stream") { + // altimate_change start — transform messages with the reconciled reasoning options // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) + args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, requestOptions) + // altimate_change end } return args.params }, diff --git a/packages/opencode/src/session/llm/native-request.ts b/packages/opencode/src/session/llm/native-request.ts index b7f30e24c3..7e649ded89 100644 --- a/packages/opencode/src/session/llm/native-request.ts +++ b/packages/opencode/src/session/llm/native-request.ts @@ -11,6 +11,9 @@ import { } from "@opencode-ai/llm/providers" import type { ModelMessage } from "ai" import type { Provider } from "@/provider/provider" +// altimate_change start — preserve canonical header precedence through the native route adapter +import { mergeRequestHeaders } from "@/provider/output-token-budget" +// altimate_change end import { isRecord } from "@/util/record" type ToolInput = { @@ -153,10 +156,15 @@ const requireBaseURL = (model: Provider.Model, url: string | undefined) => { export const model = (input: Provider.Model | RequestInput, headers?: Record) => { const model = "model" in input ? input.model : input const url = baseURL(input) + // altimate_change start — avoid recreating differently-cased duplicates after request canonicalization + const requestHeaders = mergeRequestHeaders(model.headers, headers) + // altimate_change end const options = { ...("model" in input && input.apiKey ? { apiKey: input.apiKey } : {}), ...(url ? { baseURL: url } : {}), - headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers }, + // altimate_change start — the later request value wins case-insensitively at the final native boundary + headers: Object.keys(requestHeaders).length === 0 ? undefined : requestHeaders, + // altimate_change end limits: { context: model.limit.context, output: model.limit.output, diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index bac385c591..f67c73cb6f 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -1,8 +1,10 @@ import type { Auth } from "@/auth" import type { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" +// altimate_change start — share case-insensitive provider-to-request header precedence +import { mergeRequestHeaders } from "@/provider/output-token-budget" +// altimate_change end import { errorMessage } from "@/util/error" -import { isRecord } from "@/util/record" import { asSchema, type ModelMessage, type Tool } from "ai" import { Cause, Effect, FiberSet, Queue } from "effect" import * as Stream from "effect/Stream" @@ -98,7 +100,9 @@ export function stream(input: StreamInput): StreamResult { topK: input.topK, maxOutputTokens: input.maxOutputTokens, providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}), - headers: { ...providerHeaders(input.provider.options.headers), ...input.headers }, + // altimate_change start — canonical names keep request headers authoritative regardless of casing + headers: mergeRequestHeaders(input.provider.options.headers, input.headers), + // altimate_change end }) const stream = Stream.scoped( Stream.unwrap( @@ -152,13 +156,6 @@ function providerFetch(input: Pick): typeof gl return value as typeof globalThis.fetch } -function providerHeaders(value: unknown): Record | undefined { - if (!isRecord(value)) return undefined - return Object.fromEntries( - Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"), - ) -} - function nativeSchema(value: unknown): JsonSchema { if (!value || typeof value !== "object") return { type: "object", properties: {} } if ("jsonSchema" in value && value.jsonSchema && typeof value.jsonSchema === "object") diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 2785d98526..992a4ef1ac 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -8,6 +8,15 @@ import type { Agent } from "@/agent/agent" import type { MessageV2 } from "../message-v2" import type { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" +// altimate_change start — size and clamp the finalized provider request +import { + clampOutputTokens, + clampReasoningBudget, + effectiveContextWindow, + estimateInputTokens, + mergeRequestHeaders, +} from "@/provider/output-token-budget" +// altimate_change end import { SystemPrompt } from "../system" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect, Record } from "effect" @@ -168,30 +177,66 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre ? (yield* InstanceState.context).project.id : undefined + // altimate_change start — canonicalize the exact outgoing header precedence before budgeting + const requestHeaders = mergeRequestHeaders( + input.model.providerID.startsWith("opencode") + ? { + ...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}), + "x-opencode-session": input.sessionID, + "x-opencode-request": input.user.id, + "x-opencode-client": input.flags.client, + "User-Agent": USER_AGENT, + } + : { + "x-session-affinity": input.sessionID, + "X-Session-Id": input.sessionID, + ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), + "User-Agent": USER_AGENT, + }, + input.model.headers, + headers, + ) + // altimate_change end + + // altimate_change start — clamp after tools, headers, and plugin options are finalized. + const sortedTools = Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))) + const maxOutputTokens = clampOutputTokens({ + model: input.model, + requested: params.maxOutputTokens, + context: effectiveContextWindow({ + model: input.model, + // Provider defaults are lower precedence than the exact case-normalized outgoing record. + headerSources: [input.provider.options, requestHeaders], + }), + inputTokens: () => + estimateInputTokens({ + // OAuth carries this prompt in instructions; workflows deliberately omit it. Count the + // generated system prompt only when this request prepends it to the outgoing messages. + system: isOpenaiOauth || input.isWorkflow ? [] : system, + messages: ProviderTransform.messagesForInputEstimate(input.messages, input.model), + tools: sortedTools, + instructions: params.options.instructions, + }), + }) + const requestOptions = clampReasoningBudget(params.options, maxOutputTokens) + const clampedParams = { + ...params, + maxOutputTokens, + options: requestOptions, + } + // altimate_change end + return { system, messages, - tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))), - params, - messageTransformOptions: options, - headers: { - ...(input.model.providerID.startsWith("opencode") - ? { - ...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}), - "x-opencode-session": input.sessionID, - "x-opencode-request": input.user.id, - "x-opencode-client": input.flags.client, - "User-Agent": USER_AGENT, - } - : { - "x-session-affinity": input.sessionID, - "X-Session-Id": input.sessionID, - ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), - "User-Agent": USER_AGENT, - }), - ...input.model.headers, - ...headers, - }, + // altimate_change start — return the finalized tools and context-window-clamped request values + tools: sortedTools, + params: clampedParams, + messageTransformOptions: requestOptions, + // altimate_change end + // altimate_change start — return the canonical headers used by the budget estimator + headers: requestHeaders, + // altimate_change end } }) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 001aab94f3..c4dce5d413 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1,6 +1,16 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" +import { jsonSchema, tool, type ModelMessage, type Tool } from "ai" import { ProviderTransform } from "@/provider/transform" +import { + clampOutputTokens, + clampReasoningBudget, + effectiveContextWindow, + estimateInputTokens, + InputTokenBudgetError, + OUTPUT_TOKEN_FLOOR, + OutputTokenBudgetError, +} from "@/provider/output-token-budget" import { LLMRequestPrep } from "@/session/llm/request" // ProviderTransform.message expects a Provider.Model with the fork's ModelID/ProviderID brands. import { ModelID, ProviderID } from "@/provider/schema" @@ -1927,6 +1937,22 @@ describe("ProviderTransform.message - empty image handling", () => { }) }) + test("should replace an empty base64 image wrapped in a URL object", () => { + const msgs = [ + { + role: "user", + content: [{ type: "image", image: new URL("data:image/png;base64,") }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel, {}) + + expect(result[0].content[0]).toEqual({ + type: "text", + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + }) + }) + test("should keep valid base64 images unchanged", () => { const validBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" @@ -4650,3 +4676,898 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) }) }) + +// Regression suite for the output-token reservation exceeding the context window. +// +// Reported failure: with a large system prompt on a model declaring a 65,536-token window, the +// shipped 16,384-token reservation produced a hard provider 400 before any model work — +// "You requested a total of 68564 tokens: 52180 tokens from the input messages and 16384 tokens +// for the completion". The reservation was applied without ever being compared to the input size. +describe("output token budget", () => { + const createWindowModel = (limit: { context: number; input?: number; output: number }) => + ({ + id: "large-window-model", + providerID: "openai-compatible", + api: { id: "large-window-model", url: "https://example.invalid/v1", npm: "@ai-sdk/openai-compatible" }, + name: "Large Window Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + limit, + status: "active", + options: {}, + headers: {}, + }) as any + + // The exact reported case. + const REPORTED = { inputTokens: 52_180, requested: 16_384, context: 65_536 } + + test("the reported case is clamped instead of being sent as-is", () => { + const model = createWindowModel({ context: REPORTED.context, output: 16_384 }) + const result = clampOutputTokens({ + model, + requested: REPORTED.requested, + inputTokens: REPORTED.inputTokens, + })! + + // Unclamped, this is exactly the request the provider rejected. + expect(REPORTED.inputTokens + REPORTED.requested).toBeGreaterThan(REPORTED.context) + expect(result).toBeLessThan(REPORTED.requested) + // The clamped request fits, with the estimator margin (2%, min 512) still spare. + expect(REPORTED.inputTokens + result).toBeLessThanOrEqual(REPORTED.context) + // 65536 - 52180 - ceil(52180 * 0.02) = 12312 + expect(result).toBe(12_312) + // Still a usable completion budget, not a stub. + expect(result).toBeGreaterThanOrEqual(OUTPUT_TOKEN_FLOOR) + }) + + test("throws with the actual numbers when even the floor does not fit", () => { + const model = createWindowModel({ context: 65_536, output: 16_384 }) + let thrown: unknown + try { + clampOutputTokens({ model, requested: 16_384, inputTokens: 65_000 }) + } catch (e) { + thrown = e + } + expect(thrown).toBeInstanceOf(OutputTokenBudgetError) + const message = (thrown as Error).message + // The message must name input tokens, the requested reservation and the window. + expect(message).toContain("65000") + expect(message).toContain("16384") + expect(message).toContain("65536") + expect(message).toContain("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX") + }) + + test("throws rather than clamping to an unusable budget just above zero", () => { + const model = createWindowModel({ context: 65_536, output: 16_384 }) + // 65536 - 65000 = 536 would "fit" arithmetically but is below the floor. + expect(536).toBeLessThan(OUTPUT_TOKEN_FLOOR) + expect(() => clampOutputTokens({ model, requested: 16_384, inputTokens: 65_000 })).toThrow() + }) + + test("leaves a config that already fits completely unchanged", () => { + const model = createWindowModel({ context: 65_536, output: 16_384 }) + expect(clampOutputTokens({ model, requested: 16_384, inputTokens: 10_000 })).toBe(16_384) + // An exact estimated fit still needs room for estimator drift. + expect(clampOutputTokens({ model, requested: 16_384, inputTokens: 49_152 })).toBe(15_400) + }) + + test("leaves large-window models unchanged", () => { + const model = createWindowModel({ context: 200_000, output: 8_192 }) + expect(clampOutputTokens({ model, requested: 8_192, inputTokens: 150_000 })).toBe(8_192) + }) + + test("still clamps models that also declare an input ceiling", () => { + // limit.input is an input ceiling, not evidence that completion tokens use a separate window. + const model = createWindowModel({ context: 65_536, input: 65_536, output: 16_384 }) + expect(clampOutputTokens({ model, requested: 16_384, inputTokens: 60_000 })).toBe(4_336) + }) + + test("rejects a prompt that exceeds a dedicated input ceiling", () => { + const model = createWindowModel({ context: 200_000, input: 65_536, output: 16_384 }) + expect(() => clampOutputTokens({ model, requested: 16_384, inputTokens: 70_000 })).toThrow(InputTokenBudgetError) + }) + + test("does not clamp when the model declares no context window", () => { + const model = createWindowModel({ context: 0, output: 16_384 }) + expect(clampOutputTokens({ model, requested: 16_384, inputTokens: 60_000 })).toBe(16_384) + }) + + test("never demands more headroom than the model's own output reservation", () => { + const model = createWindowModel({ context: 8_192, output: 512 }) + expect(clampOutputTokens({ model, requested: 512, inputTokens: 7_516 })).toBe(512) + // One token more would require discarding the estimator margin. Refuse instead of sending an + // exact-fill request that is likely to reproduce the provider context error. + expect(() => clampOutputTokens({ model, requested: 512, inputTokens: 7_517 })).toThrow(OutputTokenBudgetError) + expect(() => clampOutputTokens({ model, requested: 512, inputTokens: 8_000 })).toThrow(OutputTokenBudgetError) + }) + + test("enforces real context windows at or below the default output floor", () => { + const model = createWindowModel({ context: 512, output: 512 }) + expect(clampOutputTokens({ model, requested: 1, inputTokens: 1 })).toBe(1) + expect(() => clampOutputTokens({ model, requested: 512, inputTokens: 1 })).toThrow(OutputTokenBudgetError) + }) + + test("scales the safety margin for a small dedicated input ceiling", () => { + const model = createWindowModel({ context: 200_000, input: 512, output: 1 }) + expect(clampOutputTokens({ model, requested: 1, inputTokens: 501 })).toBe(1) + expect(() => clampOutputTokens({ model, requested: 1, inputTokens: 502 })).toThrow(InputTokenBudgetError) + }) + + test("passes an omitted reservation through untouched", () => { + // Codex and GitHub Copilot deliberately send no maxOutputTokens. + const model = createWindowModel({ context: 65_536, output: 16_384 }) + expect(clampOutputTokens({ model, requested: undefined, inputTokens: 60_000 })).toBeUndefined() + }) + + test("does not evaluate a lazy estimate when the reservation is omitted", () => { + const model = createWindowModel({ context: 65_536, output: 16_384 }) + let evaluated = false + expect( + clampOutputTokens({ + model, + requested: undefined, + inputTokens: () => { + evaluated = true + return 60_000 + }, + }), + ).toBeUndefined() + expect(evaluated).toBeFalse() + }) + + test("counts tool schemas and provider instructions", () => { + const base = estimateInputTokens({ system: ["system"], messages: [{ role: "user", content: "hello" }] }) + const complete = estimateInputTokens({ + system: ["system"], + messages: [{ role: "user", content: "hello" }], + instructions: "provider instruction ".repeat(400), + tools: { + search: { + description: "search fields ".repeat(400), + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + }, + }) + expect(complete).toBeGreaterThan(base + 1_000) + }) + + test("counts identical system and instructions as separate wire occurrences", () => { + const prompt = "same provider instruction ".repeat(1_000) + const systemOnly = estimateInputTokens({ system: [prompt], messages: [] }) + const both = estimateInputTokens({ system: [prompt], messages: [], instructions: prompt }) + + expect(both).toBeGreaterThan(systemOnly * 1.8) + }) + + test("counts framing for every separately transmitted system message", () => { + const entries = Array.from({ length: 2_000 }, () => "x") + const flattened = estimateInputTokens({ system: [entries.join("\n")], messages: [] }) + const framed = estimateInputTokens({ system: entries, messages: [] }) + + expect(framed).toBeGreaterThan(flattened + entries.length) + }) + + test("charges dense high-entropy ASCII more conservatively than repetitive text", () => { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + let state = 0x12345678 + const dense = Array.from({ length: 8_192 }, () => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0 + return alphabet[state & 63] + }).join("") + const repetitive = "x".repeat(dense.length) + const estimate = (content: string) => estimateInputTokens({ system: [], messages: [{ role: "user", content }] }) + + expect(estimate(dense)).toBeGreaterThan(8_000) + expect(estimate(dense)).toBeGreaterThan(estimate(repetitive) * 3) + + const shortDense = alphabet.slice(0, 32) + for (let padding = 0; padding < 400; padding++) { + const prefix = " ".repeat(padding) + expect(estimate(prefix + shortDense)).toBeGreaterThan(estimate(prefix + "x".repeat(32)) + 15) + } + }) + + test("does not tokenize encoded media bytes as literal prompt text", () => { + const estimated = estimateInputTokens({ + system: [], + messages: [ + { + role: "user", + content: [{ type: "image", image: `data:image/png;base64,${"A".repeat(1_048_576)}` }], + }, + ], + }) + expect(estimated).toBeGreaterThan(2_000) + expect(estimated).toBeLessThan(10_000) + }) + + test("counts data-URL-shaped text in every textual request field", () => { + const prefixes = [ + "data:image/png;base64,", + "data:audio/wav;base64,", + "data:video/mp4;base64,", + "data:application/pdf;base64,", + ] + for (const prefix of prefixes) { + const text = prefix + "漢".repeat(70_000) + expect( + estimateInputTokens({ + system: [], + messages: [{ role: "user", content: [{ type: "text", text }] }], + }), + ).toBeGreaterThan(70_000) + } + + const text = prefixes[0] + "漢".repeat(70_000) + const estimates = [ + estimateInputTokens({ system: [], messages: [{ role: "user", content: text }] }), + estimateInputTokens({ system: [], messages: [], instructions: text }), + estimateInputTokens({ + system: [], + messages: [], + tools: { inspect: { description: text, inputSchema: { type: "object" } } }, + }), + estimateInputTokens({ + system: [], + messages: [ + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "call-1", toolName: "inspect", input: { type: "file", data: text } }, + ], + }, + ], + }), + estimateInputTokens({ + system: [], + messages: [ + { + role: "user", + content: [{ type: "image", image: "data:image/png;base64,AQ==", filename: text }], + }, + ], + }), + ] + for (const estimated of estimates) expect(estimated).toBeGreaterThan(70_000) + + const model = createWindowModel({ context: 65_536, output: 16_384 }) + expect(() => clampOutputTokens({ model, requested: 16_384, inputTokens: estimates[0] })).toThrow( + OutputTokenBudgetError, + ) + }) + + test("charges the same fixed allowance for URL-backed media", () => { + const base = estimateInputTokens({ system: [], messages: [{ role: "user", content: "show this" }] }) + const estimated = estimateInputTokens({ + system: [], + messages: [ + { + role: "user", + content: [{ type: "image", image: new URL("https://example.com/tiny.png") }], + }, + ], + }) + expect(estimated).toBeGreaterThan(base + 2_000) + expect(estimated).toBeLessThan(base + 3_000) + }) + + test("keeps binary images bounded while scaling PDF estimates with document size", () => { + const binaryImage = estimateInputTokens({ + system: [], + messages: [ + { + role: "user", + content: [{ type: "image", image: new Uint8Array(1_048_576) }], + }, + ], + }) + const pdfToolResult = estimateInputTokens({ + system: [], + messages: [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "read", + output: { + type: "content", + value: [{ type: "media", mediaType: "application/pdf", data: "A".repeat(1_048_576) }], + }, + }, + ], + }, + ], + }) + expect(binaryImage).toBeGreaterThan(2_000) + expect(binaryImage).toBeLessThan(10_000) + expect(pdfToolResult).toBeGreaterThan(100_000) + }) + + test("does not trust unparsed PDF metadata as page-count evidence", () => { + const marker = "/Type /Pages /Count 600" + const variants = [ + ["%PDF-1.4", `% ${marker}`, "1 0 obj << /Type /Pages /Count 1 >> endobj", "%%EOF"].join("\n"), + ["%PDF-1.4", `1 0 obj << /Note (${marker}) >> endobj`, "%%EOF"].join("\n"), + ["%PDF-1.4", "1 0 obj << /Length 24 >> stream", marker, "endstream endobj", "%%EOF"].join("\n"), + ["%PDF-1.4", `99 0 obj << ${marker} >> endobj`, "%%EOF"].join("\n"), + ] + const estimate = (data: string | Uint8Array | ArrayBuffer) => + estimateInputTokens({ + system: [], + messages: [{ role: "user", content: [{ type: "file", mediaType: "application/pdf", data }] }], + }) + + for (const pdf of variants) { + const bytes = new Uint8Array(Buffer.from(pdf, "latin1")) + const payloads = [ + Buffer.from(bytes).toString("base64"), + `data:application/pdf;base64,${Buffer.from(bytes).toString("base64")}`, + bytes, + bytes.buffer, + ] + for (const payload of payloads) { + const estimated = estimate(payload) + expect(estimated).toBeGreaterThan(32_000) + expect(estimated).toBeLessThan(40_000) + expect( + clampOutputTokens({ + model: createWindowModel({ context: 200_000, input: 180_000, output: 16_384 }), + requested: 16_384, + inputTokens: estimated, + }), + ).toBe(16_384) + } + } + }) + + test("never lets a page marker suppress the PDF byte-size floor", () => { + const pdf = ["%PDF-1.7", "1 0 obj << /Type /Page >> endobj", "x".repeat(200_000), "%%EOF"].join("\n") + const estimated = estimateInputTokens({ + system: [], + messages: [ + { + role: "user", + content: [{ type: "file", mediaType: "application/pdf", data: Buffer.from(pdf).toString("base64") }], + }, + ], + }) + expect(estimated).toBeGreaterThan(200_000) + }) + + test("bounds malformed data-URL header inspection and keeps its size floor", () => { + const lateDelimiter = [ + "data:application/pdf", + "x".repeat(70 * 1_024), + ",%PDF-1.7 << /Type /Pages /Count 600 >> %%EOF", + ].join("") + const lateEstimated = estimateInputTokens({ + system: [], + messages: [ + { + role: "user", + content: [{ type: "file", mediaType: "application/pdf", data: lateDelimiter }], + }, + ], + }) + // The comma is outside the bounded data-URL header prefix. The full string still establishes + // a conservative size floor without inspecting attacker-controlled PDF metadata. + expect(lateEstimated).toBeGreaterThan(70_000) + expect(lateEstimated).toBeLessThan(100_000) + + const missingDelimiter = `data:application/pdf${"A".repeat(100_000)}` + const missingEstimated = estimateInputTokens({ + system: [], + messages: [ + { + role: "user", + content: [{ type: "file", mediaType: "application/pdf", data: missingDelimiter }], + }, + ], + }) + expect(missingEstimated).toBeGreaterThan(100_000) + }) + + test("scales inline non-PDF files with decoded payload size", () => { + const text = "漢".repeat(50_000) + const estimated = estimateInputTokens({ + system: [], + messages: [ + { + role: "user", + content: [{ type: "file", mediaType: "text/plain", data: Buffer.from(text, "utf8").toString("base64") }], + }, + ], + }) + expect(estimated).toBeGreaterThanOrEqual(150_000) + }) + + test("uses semantic baselines plus a coarse size floor for audio and video", () => { + const estimate = (mediaType: string, data: string) => + estimateInputTokens({ + system: [], + messages: [ + { + role: "user", + content: [{ type: "file", mediaType, data }], + }, + ], + }) + + const tinyAudio = estimate("audio/wav", "AQ==") + const tinyVideo = estimate("video/mp4", "AQ==") + expect(tinyAudio).toBeGreaterThan(8_000) + expect(tinyAudio).toBeLessThan(10_000) + expect(tinyVideo).toBeGreaterThan(8_000) + expect(tinyVideo).toBeLessThan(10_000) + + const smallContext = createWindowModel({ context: 16_384, output: 8_192 }) + expect(clampOutputTokens({ model: smallContext, requested: 8_192, inputTokens: tinyVideo })).toBeGreaterThan( + OUTPUT_TOKEN_FLOOR, + ) + + const oneMiB = "A".repeat(1_398_104) + expect(estimate("audio/wav", oneMiB)).toBeGreaterThan(16_000) + expect(estimate("audio/wav", oneMiB)).toBeLessThan(20_000) + expect(estimate("video/mp4", oneMiB)).toBeGreaterThan(16_000) + expect(estimate("video/mp4", oneMiB)).toBeLessThan(20_000) + + const fourMiB = "A".repeat(5_592_408) + expect(estimate("audio/wav", fourMiB)).toBeGreaterThan(65_000) + expect(estimate("video/mp4", fourMiB)).toBeGreaterThan(65_000) + }) + + test("uses a fixed parser-free fallback for remote PDFs", () => { + const estimated = estimateInputTokens({ + system: [], + messages: [ + { + role: "user", + content: [ + { type: "file", mediaType: "application/pdf", data: new URL("https://example.invalid/report.pdf") }, + ], + }, + ], + }) + expect(estimated).toBeGreaterThan(32_000) + expect(estimated).toBeLessThan(40_000) + }) + + test("counts every AI SDK v6 tool-result media variant once per transport occurrence", () => { + const payload = "A".repeat(1_048_576) + const variants = [ + { type: "media" as const, mediaType: "application/pdf", data: payload }, + { type: "file-data" as const, mediaType: "application/pdf", data: payload }, + { type: "file-url" as const, url: `https://example.invalid/${payload}` }, + { type: "file-id" as const, fileId: { openai: payload } }, + { type: "image-data" as const, mediaType: "image/png", data: payload }, + { type: "image-url" as const, url: `https://example.invalid/${payload}` }, + { type: "image-file-id" as const, fileId: { openai: payload } }, + ] + + for (const variant of variants) { + const estimated = estimateInputTokens({ + system: [], + messages: [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "read", + output: { type: "content", value: [variant] }, + }, + ], + }, + ], + }) + if (variant.type.startsWith("image")) { + expect(estimated).toBeGreaterThan(2_000) + expect(estimated).toBeLessThan(10_000) + } else { + expect(estimated).toBeGreaterThan(16_000) + } + } + + const shared = { type: "image-data" as const, mediaType: "image/png", data: "AQ==" } + const repeated = estimateInputTokens({ + system: [], + messages: [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "read", + output: { type: "content", value: Array.from({ length: 64 }, () => shared) }, + }, + ], + }, + ], + }) + expect(repeated).toBeGreaterThan(64 * 2_000) + }) + + test("projects unsupported media before estimation without discounting supported media", () => { + const unsupported = createWindowModel({ context: 65_536, output: 16_384 }) + const messages = [ + { + role: "user", + content: Array.from({ length: 64 }, () => ({ + type: "image" as const, + image: "data:image/png;base64,AQ==", + })), + }, + ] satisfies ModelMessage[] + + const projected = ProviderTransform.messagesForInputEstimate(messages, unsupported) + expect((messages[0].content[0] as { type: string }).type).toBe("image") + expect((projected[0]!.content[0] as { type: string }).type).toBe("text") + const projectedEstimate = estimateInputTokens({ system: [], messages: projected }) + expect(projectedEstimate).toBeLessThan(10_000) + expect(clampOutputTokens({ model: unsupported, requested: 16_384, inputTokens: projectedEstimate })).toBe(16_384) + + const supported = { + ...unsupported, + capabilities: { + ...unsupported.capabilities, + input: { ...unsupported.capabilities.input, image: true }, + }, + } + const preserved = ProviderTransform.messagesForInputEstimate(messages, supported) + expect((preserved[0]!.content[0] as { type: string }).type).toBe("image") + expect(estimateInputTokens({ system: [], messages: preserved })).toBeGreaterThan(64 * 2_000) + }) + + test("projects every valid unsupported image payload and case-normalized file media type", () => { + const unsupported = createWindowModel({ context: 65_536, output: 16_384 }) + const messages = [ + { + role: "user", + content: [ + { type: "image" as const, image: new URL("https://example.invalid/image.png") }, + { type: "image" as const, image: "AQ==" }, + { type: "image" as const, image: new Uint8Array([1]) }, + { type: "image" as const, image: new Uint8Array([1]).buffer }, + { type: "image" as const, image: "DATA:IMAGE/PNG;BASE64,AQ==" }, + { type: "file" as const, data: "AQ==", mediaType: "IMAGE/PNG" }, + { type: "file" as const, data: "AQ==", mediaType: "APPLICATION/PDF; VERSION=1.7" }, + ], + }, + ] satisfies ModelMessage[] + + const projected = ProviderTransform.messagesForInputEstimate(messages, unsupported) + expect((projected[0]!.content as Array<{ type: string }>).every((part) => part.type === "text")).toBeTrue() + expect(estimateInputTokens({ system: [], messages: projected })).toBeLessThan(10_000) + expect((messages[0].content as Array<{ type: string }>).every((part) => part.type !== "text")).toBeTrue() + }) + + test("projects Mistral's synthetic tool-to-user bridge before estimation", () => { + const model = { + ...createWindowModel({ context: 65_536, output: 16_384 }), + providerID: "mistral", + api: { id: "mistral-large", url: "https://example.invalid/v1", npm: "@ai-sdk/mistral" }, + } + const messages = Array.from({ length: 64 }, (_, index) => [ + { role: "tool" as const, content: [] }, + { role: "user" as const, content: `continue ${index}` }, + ]).flat() as ModelMessage[] + + const projected = ProviderTransform.messagesForInputEstimate(messages, model) + const normalized = ProviderTransform.message(structuredClone(messages), model, {}) + + expect(projected).toEqual(normalized) + expect(projected).toHaveLength(messages.length + 64) + expect(estimateInputTokens({ system: [], messages: projected })).toBeGreaterThan( + estimateInputTokens({ system: [], messages }) + 1_000, + ) + }) + + test("counts repeated shared tool objects while terminating true cycles", () => { + const sharedTool = tool({ + description: "shared schema documentation ".repeat(1_200), + inputSchema: jsonSchema({ + type: "object", + properties: { query: { type: "string", description: "query details ".repeat(1_200) } }, + }), + }) + const once = estimateInputTokens({ system: [], messages: [], tools: { first: sharedTool } }) + const twice = estimateInputTokens({ system: [], messages: [], tools: { first: sharedTool, second: sharedTool } }) + expect(twice).toBeGreaterThan(once * 1.8) + + const circular: Record = { text: "still counted" } + circular.self = circular + expect(estimateInputTokens({ system: [], messages: [circular] })).toBeGreaterThan(0) + }) + + test("uses a conservative multilingual floor instead of the ASCII ratio", () => { + const text = "漢".repeat(10_000) + expect(estimateInputTokens({ system: [text], messages: [] })).toBeGreaterThanOrEqual(10_000) + }) + + test("honors the known one-million-token Anthropic beta header", () => { + const model = createWindowModel({ context: 200_000, output: 16_384 }) + expect( + effectiveContextWindow({ + model, + headerSources: [{ aiGatewayHeaders: { "anthropic-beta": "context-1m-2025-08-07" } }], + }), + ).toBe(1_000_000) + expect( + effectiveContextWindow({ + model, + headerSources: [{ "Anthropic-Beta": "context-1m-2025-08-07" }], + }), + ).toBe(1_000_000) + expect( + effectiveContextWindow({ + model, + headerSources: [{ aiGatewayHeaders: { "anthropic-beta": "interleaved-thinking-2025-05-14" } }], + }), + ).toBe(200_000) + }) + + test("uses the final outgoing Anthropic beta header value", () => { + const model = createWindowModel({ context: 200_000, output: 16_384 }) + expect( + effectiveContextWindow({ + model, + headerSources: [ + { "anthropic-beta": "context-1m-2025-08-07" }, + { "Anthropic-Beta": "interleaved-thinking-2025-05-14" }, + ], + }), + ).toBe(200_000) + expect( + effectiveContextWindow({ + model, + headerSources: [ + { "anthropic-beta": "interleaved-thinking-2025-05-14" }, + { aiGatewayHeaders: { "anthropic-beta": "context-1m-2025-08-07" } }, + ], + }), + ).toBe(1_000_000) + }) + + test("clamps fixed reasoning budgets with the output reservation without mutating inputs", () => { + const options = { + thinking: { type: "enabled", budgetTokens: 16_000 }, + thinkingConfig: { thinkingBudget: 16_000 }, + reasoningConfig: { budgetTokens: 31_999 }, + } + const result = clampReasoningBudget(options, 12_312) + expect(result.thinking.budgetTokens).toBe(11_288) + expect(result.thinkingConfig.thinkingBudget).toBe(11_288) + expect(result.reasoningConfig.budgetTokens).toBe(11_288) + expect(options.thinking.budgetTokens).toBe(16_000) + }) + + test("preserves non-JSON provider options while lowering reasoning budgets", () => { + const callback = () => undefined + const options = { + thinking: { budgetTokens: 31_999 }, + keepUndefined: undefined, + keepFunction: callback, + keepBigInt: 10n, + nested: [{ thinkingBudget: 31_999 }, { untouched: "value" }], + } + const result = clampReasoningBudget(options, 8_192) + expect(result.thinking.budgetTokens).toBe(7_168) + expect(result.nested[0]!.thinkingBudget).toBe(7_168) + expect(result.nested[1]!.untouched).toBe("value") + expect("keepUndefined" in result).toBeTrue() + expect(result.keepFunction).toBe(callback) + expect(result.keepBigInt).toBe(10n) + expect(options.thinking.budgetTokens).toBe(31_999) + + const unchanged = { thinking: { budgetTokens: 512 } } + expect(clampReasoningBudget(unchanged, 8_192)).toBe(unchanged) + }) + + test("fails clearly when reasoning and visible output cannot both fit", () => { + expect(() => clampReasoningBudget({ thinking: { budgetTokens: 16_000 } }, 1_500)).toThrow( + /cannot preserve the configured reasoning budget/, + ) + }) +}) + +describe("LLMRequestPrep.prepare - output token reservation", () => { + const sessionID = "ses_clamp-test" + + const model = { + id: "large-window-model", + providerID: "openai-compatible", + api: { id: "large-window-model", url: "https://example.invalid/v1", npm: "@ai-sdk/openai-compatible" }, + name: "Large Window Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + limit: { context: 65_536, output: 16_384 }, + status: "active", + options: {}, + headers: {}, + } as any + + const messages: ModelMessage[] = [{ role: "user", content: "Hello" }] + + const run = ( + systemPrompt: string, + overrides: { + readonly tools?: Record + readonly agentOptions?: Record + readonly outputTokenMax?: number + readonly messages?: ModelMessage[] + readonly providerOptions?: Record + readonly modelHeaders?: Record + readonly chatHeaders?: Record + readonly isWorkflow?: boolean + } = {}, + ) => + Effect.runPromise( + LLMRequestPrep.prepare({ + user: { + id: "msg_user-clamp", + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: "openai-compatible", modelID: "large-window-model" }, + } as any, + sessionID, + model: { ...model, headers: overrides.modelHeaders ?? model.headers }, + agent: { + name: "test", + mode: "primary", + options: overrides.agentOptions ?? {}, + permission: [], + prompt: systemPrompt, + } as any, + system: [], + messages: overrides.messages ?? messages, + tools: overrides.tools ?? {}, + provider: { id: "openai-compatible", options: overrides.providerOptions ?? {} } as any, + auth: undefined, + plugin: { + trigger: (name: string, _input: unknown, output: unknown) => { + if (name === "chat.params" && overrides.outputTokenMax !== undefined) { + return Effect.succeed({ + ...(output as Record), + maxOutputTokens: overrides.outputTokenMax, + }) + } + if (name === "chat.headers" && overrides.chatHeaders) { + return Effect.succeed({ headers: overrides.chatHeaders }) + } + return Effect.succeed(output) + }, + list: () => Effect.succeed([]), + init: () => Effect.void, + } as any, + flags: { outputTokenMax: 32_000, client: "test" } as any, + isWorkflow: overrides.isWorkflow ?? false, + }), + ) + + // Prose with no code or JSON characters, so Token.estimate uses its default 3.7 chars/token. + const PROSE = "the quick brown fox jumps over the lazy dog " + const largePrompt = PROSE.repeat(Math.ceil((52_180 * 3.7) / PROSE.length)) + + test("a ~52K-token system prompt does not produce an unclamped request", async () => { + const estimated = estimateInputTokens({ system: [largePrompt], messages }) + // Sized to reproduce the reported 52,180-token prompt. + expect(estimated).toBeGreaterThan(51_500) + expect(estimated).toBeLessThan(53_500) + // Unclamped this is the request the provider rejected: 52,180 + 16,384 > 65,536. + expect(estimated + 16_384).toBeGreaterThan(65_536) + + const result = await run(largePrompt) + const maxOutputTokens = result.params.maxOutputTokens! + expect(maxOutputTokens).toBeLessThan(16_384) + expect(estimated + maxOutputTokens).toBeLessThanOrEqual(65_536) + expect(maxOutputTokens).toBeGreaterThanOrEqual(OUTPUT_TOKEN_FLOOR) + }) + + test("a small system prompt keeps the full model reservation", async () => { + const result = await run("You are a helpful assistant.") + expect(result.params.maxOutputTokens).toBe(16_384) + }) + + test("does not budget a generated system prompt omitted from workflow requests", async () => { + const result = await run(largePrompt, { isWorkflow: true }) + expect(result.messages).toEqual(messages) + expect(result.params.maxOutputTokens).toBe(16_384) + }) + + test("uses provider then model then chat header precedence at the request boundary", async () => { + const beta = "context-1m-2025-08-07" + const disabled = "interleaved-thinking-2025-05-14" + const clamped = await run(largePrompt, { + providerOptions: { headers: { "Anthropic-Beta": beta } }, + modelHeaders: { "Anthropic-Beta": beta }, + chatHeaders: { "anthropic-beta": disabled }, + }) + expect(clamped.params.maxOutputTokens).toBeLessThan(16_384) + expect(new Headers(clamped.headers).get("anthropic-beta")).toBe(disabled) + expect(Object.keys(clamped.headers).filter((key) => key.toLowerCase() === "anthropic-beta")).toHaveLength(1) + + const widened = await run(largePrompt, { + providerOptions: { headers: { "Anthropic-Beta": disabled } }, + modelHeaders: { "Anthropic-Beta": disabled }, + chatHeaders: { "anthropic-beta": beta }, + }) + expect(widened.params.maxOutputTokens).toBe(16_384) + expect(new Headers(widened.headers).get("anthropic-beta")).toBe(beta) + expect(Object.keys(widened.headers).filter((key) => key.toLowerCase() === "anthropic-beta")).toHaveLength(1) + }) + + test("unsupported media is normalized before the request-builder estimate", async () => { + const result = await run("You are a helpful assistant.", { + messages: [ + { + role: "user", + content: Array.from({ length: 64 }, () => ({ + type: "image" as const, + image: "data:image/png;base64,AQ==", + })), + }, + ], + }) + expect(result.params.maxOutputTokens).toBe(16_384) + }) + + test("large finalized tool schemas participate in the request-builder clamp", async () => { + const mediumPrompt = PROSE.repeat(Math.ceil((47_500 * 3.7) / PROSE.length)) + expect((await run(mediumPrompt)).params.maxOutputTokens).toBe(16_384) + + const schemaHeavyTool = tool({ + description: "search parameter documentation ".repeat(1_200), + inputSchema: jsonSchema({ + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }), + }) + const result = await run(mediumPrompt, { tools: { schema_heavy: schemaHeavyTool } }) + expect(result.params.maxOutputTokens).toBeLessThan(16_384) + }) + + test("a clamped request also clamps a fixed reasoning budget", async () => { + const result = await run(largePrompt, { + agentOptions: { thinking: { type: "enabled", budgetTokens: 16_000 } }, + }) + expect(result.params.options.thinking.budgetTokens).toBeLessThan(result.params.maxOutputTokens!) + expect(result.params.options.thinking.budgetTokens + OUTPUT_TOKEN_FLOOR).toBe(result.params.maxOutputTokens!) + }) + + test("a plugin-selected output budget always reconciles fixed reasoning", async () => { + const result = await run("You are a helpful assistant.", { + outputTokenMax: 8_192, + agentOptions: { thinking: { type: "enabled", budgetTokens: 16_000 } }, + }) + expect(result.params.maxOutputTokens).toBe(8_192) + expect(result.params.options.thinking.budgetTokens).toBe(8_192 - OUTPUT_TOKEN_FLOOR) + }) + + test("a system prompt that leaves no usable budget fails before the request is built", async () => { + const hugePrompt = PROSE.repeat(Math.ceil((65_000 * 3.7) / PROSE.length)) + await expect(run(hugePrompt)).rejects.toThrow(/Context budget exceeded/) + }) +}) diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 54fd097ec1..17ee289a42 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -344,6 +344,26 @@ describe("session.llm-native.request", () => { expect(anthropic.route.id).toBe("anthropic-messages") expect(anthropic.route.endpoint.baseURL).toBe("https://api.anthropic.com/v1") + const disabledBeta = "interleaved-thinking-2025-05-14" + const anthropicWithCanonicalHeaders = LLMNative.model( + { + model: { + ...baseModel, + api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" }, + headers: { "Anthropic-Beta": "context-1m-2025-08-07" }, + }, + apiKey: "test-key", + messages: [], + }, + { "anthropic-beta": disabledBeta }, + ) + expect(anthropicWithCanonicalHeaders.route.defaults.headers?.["anthropic-beta"]).toBe(disabledBeta) + expect( + Object.keys(anthropicWithCanonicalHeaders.route.defaults.headers ?? {}).filter( + (key) => key.toLowerCase() === "anthropic-beta", + ), + ).toHaveLength(1) + const google = LLMNative.model({ model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } }, apiKey: "test-key", diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 6f01766a71..4e340e11a3 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" -import type { ModelMessage, Tool } from "ai" +import { jsonSchema, tool, type ModelMessage, type Tool } from "ai" import { LLM } from "../../src/session/llm" import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" @@ -708,4 +708,203 @@ describe("session.llm.stream", () => { }, }) }, 30_000) + + test("clamps finalized tools and reasoning in the Google stream request", async () => { + const server = state.server + if (!server) throw new Error("Server not initialized") + + const providerID = "google" + const modelID = "gemini-2.5-flash" + const fixture = await loadFixture(providerID, modelID) + const pathSuffix = `/v1beta/models/${fixture.model.id}:streamGenerateContent` + const request = waitRequest( + pathSuffix, + createEventResponse([ + { + candidates: [{ content: { parts: [{ text: "Hello" }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, + }, + ]), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://altimate.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-google-key", + baseURL: `${server.url.origin}/v1beta`, + headers: { "Anthropic-Beta": "context-1m-2025-08-07" }, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(fixture.model.id)) + const budgeted = { + ...resolved, + headers: { "anthropic-beta": "interleaved-thinking-2025-05-14" }, + limit: { ...resolved.limit, context: 65_536, output: 16_384 }, + } + const sessionID = SessionID.make("session-budget-stream") + const agent = { + name: "test", + mode: "primary", + options: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("msg_user_budget_stream"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(providerID), modelID: budgeted.id }, + } satisfies MessageV2.User + const prose = "the quick brown fox jumps over the lazy dog " + const largeSystem = prose.repeat(Math.ceil((45_000 * 3.7) / prose.length)) + const schemaMarker = "finalized-tool-schema-marker" + const tools = { + schema_heavy: tool({ + description: `${schemaMarker} ${"search parameter documentation ".repeat(1_000)}`, + inputSchema: jsonSchema({ + type: "object", + properties: { + query: { type: "string", description: "query details ".repeat(1_000) }, + }, + required: ["query"], + }), + }), + } + + const stream = await LLM.stream({ + user, + sessionID, + model: budgeted, + agent, + system: [largeSystem], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools, + }) + for await (const _ of stream.fullStream) { + } + + const capture = await request + const config = capture.body.generationConfig as + | { maxOutputTokens?: number; thinkingConfig?: { thinkingBudget?: number } } + | undefined + const maxOutputTokens = config?.maxOutputTokens + expect(maxOutputTokens).toBeDefined() + expect(maxOutputTokens!).toBeLessThan(16_384) + expect(maxOutputTokens!).toBeGreaterThanOrEqual(1_024) + expect(config?.thinkingConfig?.thinkingBudget).toBe(maxOutputTokens! - 1_024) + expect(JSON.stringify(capture.body.tools)).toContain(schemaMarker) + expect(capture.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14") + }, + }) + }, 30_000) + + test("normalizes unsupported media before the Google stream budget is enforced", async () => { + const server = state.server + if (!server) throw new Error("Server not initialized") + + const providerID = "google" + const modelID = "gemini-2.5-flash" + const fixture = await loadFixture(providerID, modelID) + const pathSuffix = `/v1beta/models/${fixture.model.id}:streamGenerateContent` + const request = waitRequest( + pathSuffix, + createEventResponse([ + { + candidates: [{ content: { parts: [{ text: "Hello" }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, + }, + ]), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://altimate.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { apiKey: "test-google-key", baseURL: `${server.url.origin}/v1beta` }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(fixture.model.id)) + const textOnly = { + ...resolved, + capabilities: { + ...resolved.capabilities, + input: { ...resolved.capabilities.input, image: false }, + }, + limit: { ...resolved.limit, context: 65_536, output: 16_384 }, + } + const sessionID = SessionID.make("session-budget-unsupported-media") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("msg_user_budget_unsupported_media"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(providerID), modelID: textOnly.id }, + } satisfies MessageV2.User + + const stream = await LLM.stream({ + user, + sessionID, + model: textOnly, + agent, + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [ + { + role: "user", + content: Array.from({ length: 64 }, () => ({ + type: "image" as const, + image: "data:image/png;base64,AQ==", + })), + }, + ], + tools: {}, + }) + for await (const _ of stream.fullStream) { + } + + const capture = await request + const config = capture.body.generationConfig as { maxOutputTokens?: number } | undefined + expect(config?.maxOutputTokens).toBe(16_384) + expect(JSON.stringify(capture.body.contents)).toContain("Cannot read image") + }, + }) + }, 30_000) }) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 8922baf247..bb68e31364 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -392,8 +392,8 @@ it.live("session.processor effect tests preserve text start time", () => { config: (url) => providerCfg(url) }, ), ) -// ACCEPT: the fork's overflow guard intentionally leaves this context:20 fixture -// below the compaction threshold, so processor.process should continue. +// ACCEPT: the fork's overflow guard intentionally leaves this context:20_000 fixture +// at the compaction threshold, so processor.process should continue. it.live("session.processor effect tests continue when guarded token fixture does not request compaction", () => provideTmpdirServerLegacy( ({ dir, llm }) => @@ -407,7 +407,7 @@ it.live("session.processor effect tests continue when guarded token fixture does const parent = yield* user(chat.id, "compact") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) const base = yield* refModel(provider) - const mdl = { ...base, limit: { context: 20, output: 10 } } + const mdl = { ...base, limit: { context: 20_000, output: 10 } } const controller = new AbortController() const handle = yield* processors.create({ assistantMessage: msg as unknown as MessageV2.Assistant, diff --git a/packages/opencode/test/upstream/bridge-merge-e2e.test.ts b/packages/opencode/test/upstream/bridge-merge-e2e.test.ts index 5c971bc6c4..6cc3b51e25 100644 --- a/packages/opencode/test/upstream/bridge-merge-e2e.test.ts +++ b/packages/opencode/test/upstream/bridge-merge-e2e.test.ts @@ -524,10 +524,13 @@ describe("E2E: chat.params maxOutputTokens hook (cycle 6)", () => { expect(hookBlock).toMatch(/maxOutputTokens/) }) - test("session/llm.ts reads params.maxOutputTokens (not the local var) for streamText", async () => { + test("session/llm.ts routes params.maxOutputTokens through the context clamp", async () => { const content = readFileSync(path.join(srcDir, "session", "llm.ts"), "utf-8") - // streamText config must reference params.maxOutputTokens - expect(content).toMatch(/maxOutputTokens:\s*params\.maxOutputTokens/) + // altimate_change start — the plugin result is now clamped before streamText receives it + expect(content).toMatch(/requested:\s*params\.maxOutputTokens/) + expect(content).toMatch(/const maxOutputTokens = clampOutputTokens/) + expect(content).toMatch(/return streamText\([\s\S]*?(? { diff --git a/research/code-reviews/PR 1196 Consensus Review.md b/research/code-reviews/PR 1196 Consensus Review.md new file mode 100644 index 0000000000..fc5302e80c --- /dev/null +++ b/research/code-reviews/PR 1196 Consensus Review.md @@ -0,0 +1,185 @@ +# PR 1196 Consensus Review + +- Repository: `AltimateAI/altimate-code` +- Pull request: [#1196](https://github.com/AltimateAI/altimate-code/pull/1196) +- Review dates: 2026-08-30 through 2026-08-31 +- Final code candidate: `856f428981ddc970880cb525e82aee4f974e5e5f` +- Mode: full Council review plus final independent remediation pass +- Local verdict: **PASS** +- Remote gate: the final candidate must be pushed and fresh CI/bot review must finish + +## Decision + +PR #1196 is locally merge-ready under the selected product scope. + +The feature prevents avoidable provider failures by reserving output only after the real request shape is known. The final code budgets system text, messages, finalized tool schemas, provider instructions, semantic media, dedicated input ceilings, context-expansion headers, and fixed reasoning options at both production request boundaries. + +PDF accounting is intentionally approximate. The user selected a parser-free policy: + +```text +PDF allowance = max(32,768, decoded inline payload bytes) +``` + +Remote references and provider file IDs receive the fixed allowance when the part is identifiable as a PDF. An untyped provider file ID cannot be classified locally and receives the generic 16,384-token file allowance instead. Exact page expansion and tokenization remain provider-authoritative. The Council did not require exact PDF parsing because adding a parser would create a new document-processing/resource boundary without making provider token accounting exact. + +## Original Council gate + +The first three-round Council unanimously rejected the pre-repair candidate and required five concrete fixes: + +1. Enforce `model.limit.input` as a hard estimated-input ceiling. +2. Count every semantic media occurrence conservatively, including URL-backed and AI SDK v6 tool-result variants. +3. Reconcile fixed reasoning budgets with every defined final output reservation. +4. Distinguish repeated aliases from true cycles so repeated schemas/media are counted per transport occurrence. +5. Add behavioral proof that both AI SDK and native request paths send finalized tools, clamped output, and reconciled reasoning together. + +All five are present in the final branch with focused regressions. + +## Additional review findings + +Post-Council review found and repaired: + +- request-header precedence drift around the Anthropic 1M context flag; +- unsupported-media estimation before canonical projection; +- media-looking ordinary text being discounted as binary content; +- small-model output floors above the model's own reservation; +- safety-margin loss near the context boundary; +- non-JSON provider-option loss during reasoning reconciliation; +- raw PDF page-marker trust; +- provider-normalized Mistral/Devstral bridge messages missing from the estimate; +- generated system prompts counted even when workflows omit them or OAuth routes them through instructions; +- identical system and instruction values being deduplicated despite occupying two wire fields; +- a static bridge assertion that could match the unclamped property-access form; +- empty base64 image data wrapped in a `URL` object escaping unsupported-media projection; +- positive sub-floor context windows being treated as placeholder metadata; +- the fixed 512-token safety margin consuming an entire small but valid context or input limit; +- separately transmitted system messages being flattened before estimation, omitting each entry's wire framing. +- high-entropy ASCII being estimated like repetitive prose, which could leave too much output reserved for opaque identifiers or encoded text; +- audio and video payloads being charged by decoded bytes rather than a semantic media allowance; and +- a first-pass dense-text detector resetting at 400-character estimator boundaries, allowing short opaque runs to evade the conservative floor at particular alignments. + +Each repair was centralized in the shared output-budget module or the existing pure media projection, then exercised through both request boundaries. + +## Main reconciliation and final bot-comment repairs + +Current `main` (`7f07b7d3b6`) was merged into the PR branch in `b2c3a3480c`. The only textual conflict was the import in `packages/opencode/test/session/llm.test.ts`; the resolution preserves the PR's `jsonSchema` and `tool` runtime imports and `main`'s `Tool` type import. The focused file passes with 14 tests and 2 intentional skips. + +The two remaining bot comments were addressed in `e475a2d1df` and `dc24ed0555`: + +- Dense ASCII runs of at least 32 characters and six distinct opaque characters receive an added conservative floor. Detection is one forward pass with scalar counters and a `Set` capped at six entries, so it is linear time, constant auxiliary memory, and independent of the estimator's 400-character chunk boundaries. +- Audio and video use the crude hybrid `max(8,192, ceil(decoded inline bytes / 64))`. Tiny and remote media remain usable on 16K contexts, while very large inline payloads grow monotonically instead of receiving an unbounded fixed estimate. Generic files still scale one-for-one with decoded payload size, images retain the existing fixed allowance, and PDF remains parser-free at `max(32,768, decoded inline bytes)`. +- A regression sweeps all 400 possible chunk alignments for a 32-character dense token. Both independent reviewers reproduced the same conservative delta at every offset. + +After the first push, fresh Cubic and Codex reviews exposed opposing problems with the initial fixed media constants: 131,072 tokens rejected even tiny video on supported 16K/32K models, while any fixed constant could undercount arbitrarily large inline media. Commit `856f428981` replaces those constants with the hybrid above without parsing codecs, duration, frames, or PDF structure. + +## Why the PDF parser experiment was rejected + +An intermediate branch attempted structural page counting with `pdf-lib`. Independent reviewers found two blockers: + +- the fixed 100-page/500 KB policy still undercounted valid many-page requests on supported 1M-context models; +- compressed object streams could expand substantially in-process before request admission, while the input gate bounded only compressed bytes. + +The parser experiment was reverted. The dependency and transitive lock entries are absent from the final tree, estimator APIs are synchronous again, and tests describe the fixed allowance as parser-free. + +Batching the same parser would not remove its decompression/traversal boundary. A subagent is useful for development review, but it is not a runtime memory, CPU, or trust boundary. The smallest safe scope is therefore the crude local estimate plus provider enforcement. + +## Final independent remediation review + +Two independent live Council seats re-reviewed the request-shape correction at `c78e1a61b6`, the small-limit production delta through `56dbc7e9b1`, the test-fixture correction through `b7cfd659fa`, and the final system-framing correction at exact production head `d13f784786`. The last correction serializes non-empty system entries as the same array of `{ role: "system", content }` records sent by both applicable request paths. Empty arrays remain free, while OAuth and workflow paths continue to omit generated-system framing. None of these changes alter PDF behavior. + +After reconciling `main`, the same two seats reviewed the intermediate range `a0d7a4aed2..dc24ed0555`. Both returned **PASS**. Feynman independently measured a 1 MiB dense-text pass at roughly 38 ms and confirmed all 400 alignments. Musashi independently observed the same +24-token delta at every alignment and verified that equal-size generic/PDF payloads still scaled by bytes while audio/video used the intermediate fixed allowances. + +Both seats then reviewed `800e3b102f..856f428981` and returned **PASS** on the final hybrid media curve. They independently reproduced 8,221 tokens for tiny/remote media, 16,413 for 1 MiB, and 65,565 for 4 MiB; verified usable output remains on a 16K context; and confirmed bounded header inspection with no decoding, parsing, copying, dependency, or PDF/generic-file change. Both explicitly classified codec-dependent over/underestimation as the documented heuristic limitation rather than a blocker. + +### Feynman seat — PASS + +- Re-reviewed post-main head `dc24ed0555` and final media-calibration head `856f428981`. +- Verified exact final production code head `d13f784786`. +- Confirmed `pdf-lib`, async parsing, page regex/policy, and transitive lock entries are absent. +- Confirmed lazy estimation is not evaluated when `maxOutputTokens` is omitted. +- Confirmed Mistral/Devstral projection matches the transport normalizer without mutating history. +- Confirmed workflow/OAuth prompt routing matches the fields actually sent. +- Confirmed identical system/instruction values are counted as two wire occurrences while omitted system fields remain excluded. +- Confirmed the tightened bridge regex rejects `params.maxOutputTokens` and accepts the clamped shorthand. +- Confirmed empty base64 images wrapped in `URL` objects become explanatory text while valid strings, byte buffers, and ordinary URLs remain unchanged. +- Confirmed undefined and zero context limits bypass lazily, while every positive limit—including 1, 512, and 1,024—is enforced. +- Confirmed the final margin is the larger of 2% of estimated input and the limit-scaled minimum: 2% of the authoritative limit capped at 512 tokens, with a one-token floor. +- Confirmed the exact 8,192-token context boundary: input 7,516 fits with output 512 and margin 164, while input 7,517 is rejected. +- Confirmed a one-token input/output request fits a 512-token context, while reserving the full 512-token output does not. +- Confirmed a 512-token dedicated input ceiling accepts input 501 with margin 11 and rejects input 502. +- Confirmed the processor fixture now uses context 20,000, exactly its default compaction headroom, while the former context 20 still raises `OutputTokenBudgetError`. +- Confirmed each system entry receives its own role/content framing in both the AI SDK and native request shapes, while OAuth/workflow callers still pass an empty system array. +- Reproduced the framing regression with 2,000 entries: the old flattened shape estimated 1,629 tokens and the corrected framed shape estimated 17,298 tokens, with linear bounded runtime. +- Re-ran provider, typecheck, diff, and bridge checks successfully. + +### Musashi seat — PASS + +- Re-reviewed post-main head `dc24ed0555` and final media-calibration head `856f428981`. +- Verified exact final production code head `d13f784786`. +- Confirmed the parser experiment remains cleanly reverted and no PDF dependency returned. +- Confirmed synchronous lazy estimation, linear Mistral projection, and parity across both callers. +- Confirmed identical wire fields are counted separately while OAuth/workflow omissions are not double-counted. +- Confirmed the fixed-width bridge lookbehind runs correctly under Bun. +- Confirmed `URL.href` inspection matches the AI SDK URL contract without changing binary media handling. +- Confirmed every positive context window is enforced while absent/non-positive contexts retain lazy bypass behavior. +- Confirmed the exact 8,192-token context boundary: input 7,516 plus output 512 plus margin 164 fits, while one additional input token is rejected. +- Confirmed the exact 512-token input-limit boundary: input 501 plus margin 11 fits, while input 502 is rejected. +- Confirmed context and dedicated input limits receive independent margins while normal-window behavior remains unchanged. +- Confirmed the processor-fixture delta changes only that fixture and comment, preserves the intended `base <= headroom` compaction guard, and does not mask the separate small-context admission regressions. +- Confirmed discrete system-entry framing matches both request lowerings, empty arrays remain free, and OAuth/workflow caller projections are unchanged. +- Reproduced a 15,669-token increase over flattened text for 2,000 tiny entries in roughly 2.4 ms. +- Confirmed focused tests, typecheck, and diff checks pass. + +### Degraded seat + +The original chairman seat was rejected twice by the service safety filter because its retained conversation context included the earlier parser stress case. It produced no contrary code finding on the final head. The thread limit prevented replacing that retained seat with a new fourth thread. + +Consensus therefore rests on two independent live PASS votes on exact final production head `856f428981`, the primary review, the complete changed-file inspection, and sealed zero-finding Codex Security scans through the final production change. The degraded seat is disclosed rather than silently counted as agreement. + +## Final verification + +- Focused provider, AI-SDK stream, native request, processor, and upstream bridge suites: **435 passed, 11 skipped, 7 existing todos, 0 failed**. +- Repository typecheck: **13/13 successful**. +- Strict changed-file marker validation: passed. +- Required-marker inventory: **35/35**. +- Frozen lockfile install: **1,295 installs across 1,390 packages, no changes**. +- Targeted oxlint on the final supplemental production and provider-test files: **161 warnings, 0 errors**; warnings are existing test-file debt. +- Prettier: the final supplemental files and resolved `llm.test.ts` pass. +- `git diff --check`: passed. +- Post-main, dense-boundary, and final media-calibration Codex Security scans `86b98822-70df-446e-bd56-47d7169ef98a`, `37afdb50-204e-462a-85ab-7deeafdbb2ab`, and `5b969965-638d-42a6-bc38-d2f5d23b7dc2`: complete coverage, **0 findings**. Earlier request-shape and edge-case scans remain sealed with zero findings. + +## Residual limitations + +- Compact or dense PDFs can be underestimated locally and rejected by the provider. +- Exact tokenizer parity across providers is not claimed. +- No live request was made against every provider/context combination. +- Broader tokenizer calibration and document-ingestion architecture belong in follow-up work, not this PR. + +## Merge gate + +The local recommendation is **merge after remote completion**, provided: + +1. the final commits are pushed without overwriting concurrent remote work; +2. every review thread is answered or resolved against the new head; +3. fresh required CI and bot reviews are green; and +4. no new critical finding appears on the pushed head. + +Do not merge merely on the strength of checks attached to an earlier head; fresh checks must complete after the final documentation commit is pushed. + +## Execution reliability + +- Intended panel seats: 3 +- Final live PASS seats: 2 +- Degraded seats: 1 (service filter) +- Offline seats: 0 +- Final retries of degraded seat: 1 +- Fallback: primary reviewer completed the full diff and security reconciliation +- Code-discovery source: repository knowledge graph plus exact immutable Git diffs + +--- + +- `schema_version: 1` +- `mode: full` +- `panel_size: 3` +- `final_live_votes: 2` +- `final_pass_votes: 2` +- `degraded_seats: 1` diff --git a/research/security-reviews/PR 1196 Security Review.md b/research/security-reviews/PR 1196 Security Review.md new file mode 100644 index 0000000000..26e2ff6dfd --- /dev/null +++ b/research/security-reviews/PR 1196 Security Review.md @@ -0,0 +1,123 @@ +# PR 1196 Security Review + +- Repository: `AltimateAI/altimate-code` +- Pull request: [#1196](https://github.com/AltimateAI/altimate-code/pull/1196) +- Review dates: 2026-08-30 through 2026-08-31 +- Final code candidate: `856f428981ddc970880cb525e82aee4f974e5e5f` +- Scan mode: chained immutable branch-diff reviews +- Final coverage: complete +- Findings remaining on the final candidate: **0** + +## Outcome + +The final candidate removes local PDF parsing entirely. PDF media now uses a deliberately crude, parser-free estimate: + +```text +max(32,768 tokens, decoded inline payload bytes) +``` + +Remote references and provider file IDs receive the fixed 32,768-token allowance when the part is identifiable as a PDF. An untyped provider file ID cannot be classified locally and receives the generic 16,384-token file allowance instead. The estimate is monotonic in locally observable payload size, ignores untrusted page metadata, and does not decompress or traverse PDF structure. The configured provider remains authoritative for exact tokenization and for unusually compact or dense documents. + +This is the selected product boundary, not an attempt at exact PDF accounting. A compact or dense PDF can still be underestimated locally and rejected by the provider. That residual is an acknowledged reliability limitation; it is not a local parser, authorization, confidentiality, integrity, or shared-service vulnerability. + +## Scan chain + +The review used immutable ranges so every material repair was independently reconciled. + +| Stage | Immutable range/candidate | Result | +| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------ | +| Initial PR review | current `main` through remote PR head `9039a178c0` | Complete coverage; no remaining finding in that candidate | +| Media hardening supplement | `9039a178c0..e37b7a974d` | One validated Low finding: raw PDF page-marker amplification | +| Marker fix | `ac7346e767` | Removed lexical page-marker trust | +| Structural parser experiment | `ac7346e767..858c7b1dab` | Two validated Low findings; experiment rejected | +| Parser-free remediation | `858c7b1dab..908be9cabb` | Complete coverage; **0 findings** | +| Final request-shape fixes | `5e04c7885d..c78e1a61b6` | Complete coverage; **0 findings** | +| Instruction occurrence fix | `c78e1a61b6..071f4dc782` | Complete coverage; **0 findings** | +| Final edge-case hardening | `a279303720..aab3dac850` | Complete coverage; **0 findings** | +| Small-limit margin fix | `e3ca59741a..56dbc7e9b1` | Complete coverage; **0 findings** | +| Final fixture compatibility | `d663c49b74..b7cfd659fa` | Test-only; no production attack-surface change | +| System-message framing fix | `cedd33a866..d13f784786` | Complete coverage; **0 findings** | +| Post-main estimator repairs | `b2c3a3480c..e475a2d1df` | Complete coverage; **0 findings** | +| Dense-boundary hardening | `e475a2d1df..dc24ed0555` | Complete coverage; **0 findings** | +| Final media calibration | `800e3b102f..856f428981` | Complete coverage; **0 findings** | + +The parser-free remediation scan was sealed once as scan `80591880-0a17-454c-b312-92c32a38f5ff`. The final request-shape and edge-case scans were sealed once each as `1dcbce9e-587e-4495-94ff-6d3291e5e1d6`, `bfa1cc4a-7d87-463d-8fc9-822e1624f8b1`, `19f139b0-8c4c-48cc-adca-a60d41920664`, `5111b689-e4ad-4243-a7cb-86845b30ca6d`, and `33ec5c89-fce8-434d-b8a6-2baba941ff27`. The post-main bot-comment repair, chunk-boundary hardening, and final media calibration were sealed once each as `86b98822-70df-446e-bd56-47d7169ef98a`, `37afdb50-204e-462a-85ab-7deeafdbb2ab`, and `5b969965-638d-42a6-bc38-d2f5d23b7dc2`. Their authoritative results contain no deferred work, no open question, and zero findings. + +## Findings discovered and resolved + +### 1. Raw page markers could poison local admission + +An intermediate estimator scanned PDF bytes for lexical `/Type /Pages /Count` text. A marker in a comment, literal string, stream, or unreachable object could therefore inflate the local estimate and reject a valid user turn before transport. + +The fix removed page-marker scanning. Regression tests cover comment, string, stream, and unreachable-object variants across string, base64/data URL, `Uint8Array`, and `ArrayBuffer` payload shapes. + +### 2. In-process parsing could amplify compressed object streams + +The structural-parser experiment passed attacker-influenced PDF bytes to `pdf-lib` inside the synchronous request path. A bounded reproduction used a 51,430-byte PDF whose unreachable compressed object stream expanded to 50 MiB while loading and increased process RSS by roughly 88 MiB. The byte gate limited compressed input, not decompressed output, traversal work, or memory. + +The final candidate removes `pdf-lib`, `PDFDocument.load`, all structural page traversal, and the parser's transitive lockfile entries. Graph-augmented source search found no remaining runtime parser reference. + +### 3. The parser policy still disagreed with supported long-context requests + +The experiment coupled a 100-page fallback to a 500 KB parser ceiling. A valid 600-page PDF on a 1M-context request could sit just above that byte ceiling, receive only a byte-sized estimate, and preserve a large output reservation. Structural parsing therefore did not make the local estimate authoritative; it merely added a new resource boundary. + +The final policy removes the 100-page claim instead of replacing it with a larger parser. Exact page expansion is explicitly delegated to the provider. + +### 4. Final boundary cases were made conservative + +The final supplemental review found that an empty base64 image represented as a `URL` object could escape unsupported-media projection, and that positive context windows at or below 1,024 tokens were treated as placeholder metadata. The final candidate inspects `URL.href` using the same data-URL rule as strings and enforces every positive context limit. Absent and non-positive context metadata still bypasses lazily, so estimates are not evaluated when no credible limit exists. + +### 5. Small authoritative limits retain usable capacity + +A follow-up review correctly found that enforcing every positive limit with an unconditional 512-token minimum margin would consume an entire 512-token window. The final candidate retains the 512-token minimum for normal windows but caps that minimum at 2% of each smaller authoritative limit, never below one token. Context and dedicated input ceilings receive separate margins. This preserves local enforcement without rejecting every otherwise valid request on small models. + +The subsequent branch-head delta changes only an artificial processor test context from 20 to 20,000 tokens. That value exactly equals the existing default compaction headroom and preserves the fixture's intended guard path; the separate production admission regressions continue to exercise 1-, 512-, and 1,024-token contexts. + +### 6. System-message framing matches the request transport + +A final bot review found that the estimator joined separately transmitted system strings with newlines. Both applicable request paths instead lower each entry to its own `{ role: "system", content }` record, so a plugin emitting many short entries could omit substantial role/content and JSON framing from the estimate. + +The final candidate serializes the exact framed array before token estimation. Empty arrays still contribute zero, and OAuth/workflow paths still pass an empty system array while counting their provider instructions separately. A 2,000-entry regression fails under the old flattening and confirms linear, bounded execution under the corrected shape. The exact production delta was sealed as a complete zero-finding security scan. + +### 7. Dense ASCII and semantic media accounting + +Final bot review identified two estimator mismatches. High-entropy ASCII was receiving the same low character-ratio floor as repetitive prose, while audio and video were charged according to decoded bytes even though provider accounting is duration/semantic based. + +Dense ASCII classification now makes one forward pass over the complete serialized text, preserving run state across the 400-character token-estimator chunks. It uses fixed counters and a unique-character `Set` capped at six entries. The final security review found no superlinear scan, unbounded allocation, backtracking, execution, logging, or chunk-boundary bypass. A regression exercises every possible chunk alignment. + +The first repair gave audio and video fixed semantic allowances of 32,768 and 131,072 tokens. Fresh review then found that 131,072 unconditionally rejects tiny video on supported 16K/32K contexts, while a constant can undercount arbitrarily large inline media. + +The final candidate uses `max(8,192, ceil(decoded inline bytes / 64))` for both modalities. Tiny and remote media therefore retain an 8,192-token baseline; a 16K context still leaves a usable clamped output budget. One MiB grows to roughly 16K tokens and four MiB to roughly 65K. The calculation reuses bounded payload-size inspection and introduces no decoding, codec/duration/frame parsing, traversal, proportional allocation, fetch, logging, or dependency. Generic files retain one-token-per-decoded-byte scaling, and PDF remains the selected parser-free `max(32,768, decoded inline bytes)` policy. + +## Final trust and data flow + +Both production request paths use the same sequence: + +1. Finalize messages, tools, provider instructions, headers, and plugin-selected output reservation. +2. If no output reservation or credible limit exists, return without serializing the prompt. +3. Lazily estimate text, schemas, semantic media allowances, decoded inline payload size, each separately framed system entry, and every provider-instruction wire occurrence. +4. Enforce a dedicated input limit when declared. +5. Clamp the output reservation against the shared context window with a safety margin. +6. Reconcile fixed reasoning budgets with the final reservation. +7. Send through the AI SDK or native transport. + +Codebase graph tracing found exactly two production callers of the centralized clamp: `session/llm.ts` and `session/llm/request.ts`. + +## Verification + +- Focused provider, AI-SDK stream, native request, processor, and upstream bridge suites: **435 passed, 11 skipped, 7 existing todos, 0 failed**. +- Repository typecheck: **13/13 tasks successful**. +- Strict changed-file marker validation: passed. +- Required-marker inventory: **35/35**. +- Frozen lockfile install: **1,295 installs across 1,390 packages, no changes**. +- Targeted oxlint: **0 errors**; warnings remain repository debt. +- Prettier: final supplemental files and the resolved merge-conflict test pass. +- `git diff --check`: passed. +- Final post-main scans through `856f428981`: complete coverage, **0 findings**. + +## Operational caveats + +- TAC advisory was attempted once earlier in the PR workflow and was unavailable; it was not retried. +- No live provider request or full interactive UI replay was performed. +- Provider-side rejection remains possible for compact or unusually dense PDFs because the local estimate is intentionally crude. +- The remote PR must receive the final commits, reconcile every current review thread, and pass fresh CI/bot review before merge.