Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
1586f59
fix: clamp the output-token reservation against the context window
anandgupta42 Aug 30, 2026
8c289cb
fix: mark the clamped-params return line in the upstream-shared reque…
anandgupta42 Aug 30, 2026
1f1adba
Merge remote-tracking branch 'origin/main' into codex/pr-1196-merge-r…
anandgupta42 Aug 30, 2026
ac3d782
fix(provider): harden output-token budgeting
anandgupta42 Aug 30, 2026
01e13a9
Merge remote-tracking branch 'origin/main' into codex/pr-1196-merge-r…
anandgupta42 Aug 30, 2026
12180d1
chore(provider): align upstream markers after main sync
anandgupta42 Aug 30, 2026
b058876
chore(provider): protect message option integration
anandgupta42 Aug 30, 2026
b06207a
fix: count what is actually sent when clamping the output reservation
anandgupta42 Aug 30, 2026
3b01975
fix: close the gaps the second review pass found in the clamp
anandgupta42 Aug 30, 2026
4304de8
fix(provider): close output budget media bypasses
anandgupta42 Aug 30, 2026
8ee9544
Merge updated PR source branch
anandgupta42 Aug 30, 2026
9039a17
fix(provider): preserve small-model output budgets
anandgupta42 Aug 30, 2026
ab7c907
fix: close final output budget review gaps
anandgupta42 Aug 30, 2026
e37b7a9
fix: bound document budget estimation
anandgupta42 Aug 30, 2026
ac7346e
fix: reject untrusted PDF metadata amplification
anandgupta42 Aug 30, 2026
858c7b1
fix: parse PDF page counts safely
anandgupta42 Aug 30, 2026
0eba8f8
Revert "fix: parse PDF page counts safely"
anandgupta42 Aug 31, 2026
908be9c
fix: keep PDF budgeting parser-free
anandgupta42 Aug 31, 2026
5e04c78
docs: record PR 1196 final review
anandgupta42 Aug 31, 2026
c78e1a6
fix: align budgeting with final request shape
anandgupta42 Aug 31, 2026
071f4dc
fix: count duplicate instruction fields
anandgupta42 Aug 31, 2026
a279303
docs: finalize PR 1196 review record
anandgupta42 Aug 31, 2026
aab3dac
fix: enforce final edge-case budgets
anandgupta42 Aug 31, 2026
e3ca597
docs: record final PR 1196 edge-case review
anandgupta42 Aug 31, 2026
56dbc7e
fix: scale safety margins for small limits
anandgupta42 Aug 31, 2026
d663c49
docs: record final small-limit review
anandgupta42 Aug 31, 2026
b7cfd65
test: keep processor fixture within request budget
anandgupta42 Aug 31, 2026
cedd33a
docs: record final CI fixture review
anandgupta42 Aug 31, 2026
d13f784
fix: count system message framing
anandgupta42 Aug 31, 2026
a0d7a4a
docs: record final system-framing review
anandgupta42 Aug 31, 2026
b2c3a34
Merge remote-tracking branch 'origin/main' into codex/pr-1196-merge-r…
anandgupta42 Sep 1, 2026
e475a2d
fix: harden text and media token estimates
anandgupta42 Sep 1, 2026
dc24ed0
fix: preserve dense token floors across chunks
anandgupta42 Sep 1, 2026
800e3b1
docs: record post-merge review fixes
anandgupta42 Sep 1, 2026
856f428
fix: calibrate semantic media estimates
anandgupta42 Sep 1, 2026
f8552be
docs: record final media calibration review
anandgupta42 Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
532 changes: 532 additions & 0 deletions packages/opencode/src/provider/output-token-budget.ts

Large diffs are not rendered by default.

13 changes: 8 additions & 5 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 34 additions & 5 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,21 +334,26 @@ 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,
text: "ERROR: Image file is empty or corrupted. Please provide a valid image.",
}
}
}
// 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

Expand All @@ -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 =
Comment thread
anandgupta42 marked this conversation as resolved.
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[],
Expand Down
89 changes: 66 additions & 23 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Comment thread
anandgupta42 marked this conversation as resolved.
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),
Comment thread
anandgupta42 marked this conversation as resolved.
tools,
instructions: params.options.instructions,
}),
})
const requestOptions = clampReasoningBudget(params.options, maxOutputTokens)
// altimate_change end

return streamText({
onError(error) {
l.error("stream error", {
Expand Down Expand Up @@ -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(
Expand All @@ -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
},
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/session/llm/native-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -153,10 +156,15 @@ const requireBaseURL = (model: Provider.Model, url: string | undefined) => {
export const model = (input: Provider.Model | RequestInput, headers?: Record<string, string>) => {
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,
Expand Down
15 changes: 6 additions & 9 deletions packages/opencode/src/session/llm/native-runtime.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -152,13 +156,6 @@ function providerFetch(input: Pick<StreamInput, "provider" | "auth">): typeof gl
return value as typeof globalThis.fetch
}

function providerHeaders(value: unknown): Record<string, string> | 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")
Expand Down
87 changes: 66 additions & 21 deletions packages/opencode/src/session/llm/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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: () =>
Comment thread
anandgupta42 marked this conversation as resolved.
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
}
})

Expand Down
Loading
Loading