3.5 Token Budget Management
Claude Code maintains fine-grained token budget tracking:
Output Token Reservation (by model)
getModelMaxOutputTokens() (src/utils/context.ts) returns a default and an upper limit per model:
| Model | Default max_output_tokens | Upper limit |
|---|---|---|
| Opus 4.6 | 64,000 | 128,000 |
| Sonnet 4.6 | 32,000 | 128,000 |
| Opus 4.5 / Sonnet 4.x / Haiku 4 | 32,000 | 64,000 |
| Opus 4 / 4.1 | 32,000 | 32,000 |
| Claude 3.7 Sonnet | 32,000 | 64,000 |
| Claude 3 Sonnet, 3.5 Sonnet/Haiku | 8,192 | 8,192 |
| Claude 3 Opus / Haiku | 4,096 | 4,096 |
Models that don't match fall back to MAX_OUTPUT_TOKENS_DEFAULT; can be overridden via the CLAUDE_CODE_MAX_OUTPUT_TOKENS environment variable. The thinking token budget is no longer a fixed table: getMaxThinkingTokensForModel() is marked deprecated and simply returns upperLimit - 1; newer models use adaptive thinking rather than a strict thinking budget.
Token Estimation Algorithm
tokenCountWithEstimation() (src/utils/tokens.ts) is the core function for context size estimation. Its design principle is to never call the API — avoiding network latency's impact on compression decisions.
The core idea can be understood through an analogy: Suppose you weighed yourself this morning at 75 kg, and since then you've had lunch. You don't need to step on the scale again — estimating 75.5 kg is good enough. The "scale" for tokenCountWithEstimation() is the usage data returned by the API (server-side precisely calculated token count), and the "lunch" is the small number of new messages added since then.
Algorithm logic:
export function tokenCountWithEstimation(messages: readonly Message[]): number {
// 1. Search backward from the end of messages, find the most recent message with API usage data
let i = messages.length - 1
while (i >= 0) {
const usage = getTokenUsage(messages[i])
if (usage) {
// 2. Skip backward past split records from the same API response (same message.id)
// Parallel tool calls may split one response into multiple messages
const responseId = getAssistantMessageId(messages[i])
if (responseId) {
let j = i - 1
while (j >= 0) {
if (getAssistantMessageId(messages[j]) === responseId) {
i = j // Anchor to the earliest record of the same response
} else if (getAssistantMessageId(messages[j]) !== undefined) {
break // Encountered a different API response, stop
}
j--
}
}
// 3. Use the server-reported token count as anchor, plus rough estimation for subsequent messages
return getTokenCountFromUsage(usage) +
roughTokenCountEstimationForMessages(messages.slice(i + 1))
}
i--
}
// 4. If there's no usage data at all (e.g., session just started), rely entirely on string length estimation
return roughTokenCountEstimationForMessages(messages)
}Key insight: Every API response comes with usage data (containing input_tokens, output_tokens, cache tokens), which is the precise result calculated on the server side. tokenCountWithEstimation() uses this precise value as an anchor, only making rough estimates for new messages after the anchor (usually just a few tool results) (character count ÷ 4, i.e. roughly 4 bytes per token; JSON-like content uses a more conservative ÷ 2 ratio because dense JSON has many single-character tokens).
This is far more precise than relying entirely on client-side estimation (error drops from potentially 30%+ to typically <5%), while requiring no additional API calls.
Task Budget Carryover Across Compression
Before each compression, finalContextTokensFromLastResponse() is captured, and after compression, it's deducted from the remaining amount. This ensures token budget continuity across compressions — compression "replaces" messages, but the server only sees the post-compression summary and doesn't know how large the pre-compression context was. taskBudgetRemaining tells the server: these tokens have already been "spent".
// query.ts — Loop-level remaining tracking
let taskBudgetRemaining: number | undefined = undefined
// On each compact:
// taskBudgetRemaining -= finalContextTokensFromLastResponse(messages)