Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 46 additions & 0 deletions src/services/compact/autoCompact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test'
import {
getEffectiveContextWindowSize,
getAutoCompactThreshold,
} from './autoCompact.ts'

describe('getEffectiveContextWindowSize', () => {
test('returns positive value for known models with large context windows', () => {
// claude-sonnet-4 has 200k context
const effective = getEffectiveContextWindowSize('claude-sonnet-4')
expect(effective).toBeGreaterThan(0)
})

test('never returns negative even for unknown 3P models (issue #635)', () => {
// Previously, unknown 3P models got 8k context → effective context was
// 8k minus 20k summary reservation = -12k, causing infinite auto-compact.
// Now the fallback is 128k and there's a floor, so effective is always
// at least reservedTokensForSummary + buffer.
process.env.CLAUDE_CODE_USE_OPENAI = '1'
try {
const effective = getEffectiveContextWindowSize('some-unknown-3p-model')
expect(effective).toBeGreaterThan(0)
// Must be at least summary reservation (20k) + buffer (13k) = 33k
expect(effective).toBeGreaterThanOrEqual(33_000)
} finally {
delete process.env.CLAUDE_CODE_USE_OPENAI
}
})
})

describe('getAutoCompactThreshold', () => {
test('returns positive threshold for known models', () => {
const threshold = getAutoCompactThreshold('claude-sonnet-4')
expect(threshold).toBeGreaterThan(0)
})

test('never returns negative threshold even for unknown 3P models (issue #635)', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
try {
const threshold = getAutoCompactThreshold('some-unknown-3p-model')
expect(threshold).toBeGreaterThan(0)
} finally {
delete process.env.CLAUDE_CODE_USE_OPENAI
}
})
})
7 changes: 6 additions & 1 deletion src/services/compact/autoCompact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ export function getEffectiveContextWindowSize(model: string): number {
}
}

return contextWindow - reservedTokensForSummary
// Floor: effective context must be at least the summary reservation plus a
// usable buffer. If it goes lower, the auto-compact threshold becomes
// negative and fires on every message (issue #635).
const autocompactBuffer = 13_000 // must match AUTOCOMPACT_BUFFER_TOKENS
const effectiveContext = contextWindow - reservedTokensForSummary
return Math.max(effectiveContext, reservedTokensForSummary + autocompactBuffer)
}

export type AutoCompactTrackingState = {
Expand Down
18 changes: 16 additions & 2 deletions src/utils/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,23 @@ test('MiniMax-M2.7 uses explicit provider-specific context and output caps', ()
expect(getMaxOutputTokensForModel('MiniMax-M2.7')).toBe(131_072)
})

test('unknown openai-compatible models still use the conservative fallback window', () => {
test('unknown openai-compatible models use the 128k fallback window (not 8k, see #635)', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS

expect(getContextWindowForModel('some-unknown-3p-model')).toBe(8_000)
expect(getContextWindowForModel('some-unknown-3p-model')).toBe(128_000)
})

test('MiniMax-M2.5 and M2.1 use explicit provider-specific context and output caps', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS

expect(getContextWindowForModel('MiniMax-M2.5')).toBe(204_800)
expect(getContextWindowForModel('MiniMax-M2.5-highspeed')).toBe(204_800)
expect(getContextWindowForModel('MiniMax-M2.1')).toBe(204_800)
expect(getContextWindowForModel('MiniMax-M2.1-highspeed')).toBe(204_800)
expect(getModelMaxOutputTokens('MiniMax-M2.5')).toEqual({
default: 131_072,
upperLimit: 131_072,
})
})
14 changes: 10 additions & 4 deletions src/utils/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { getOpenAIContextWindow, getOpenAIMaxOutputTokens } from './model/openai
// Model context window size (200k tokens for all models right now)
export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000

// Fallback context window for unknown 3P models. Must be large enough that
// the effective context (this minus output token reservation) stays positive,
// otherwise auto-compact fires on every message (issue #635).
export const OPENAI_FALLBACK_CONTEXT_WINDOW = 128_000

// Maximum output tokens for compact operations
export const COMPACT_MAX_OUTPUT_TOKENS = 20_000

Expand Down Expand Up @@ -73,8 +78,9 @@ export function getContextWindowForModel(
}

// OpenAI-compatible provider — use known context windows for the model.
// Unknown models get a conservative 8k default so auto-compact triggers
// before hitting a hard context_window_exceeded error.
// Unknown models get a conservative 128k default. This was previously 8k,
// but that caused auto-compact to fire on every turn because the effective
// context (8k minus output reservation) became negative (issue #635).
const isOpenAIProvider =
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
Expand All @@ -86,10 +92,10 @@ export function getContextWindowForModel(
return openaiWindow
}
console.error(
`[context] Warning: model "${model}" not in context window table — using conservative 8k default. ` +
`[context] Warning: model "${model}" not in context window table — using conservative 128k default. ` +
'Add it to src/utils/model/openaiContextWindows.ts for accurate compaction.',
)
return 8_000
return OPENAI_FALLBACK_CONTEXT_WINDOW
}

const cap = getModelCapability(model)
Expand Down
24 changes: 22 additions & 2 deletions src/utils/model/openaiContextWindows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,19 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
'devstral-latest': 256_000,
'ministral-3b-latest': 256_000,

// MiniMax
// MiniMax (all M2.x variants share 204,800 context, 131,072 max output)
'MiniMax-M2.7': 204_800,
'MiniMax-M2.7-highspeed': 204_800,
'MiniMax-M2.5': 204_800,
'MiniMax-M2.5-highspeed': 204_800,
'MiniMax-M2.1': 204_800,
'MiniMax-M2.1-highspeed': 204_800,
'minimax-m2.7': 204_800,
'minimax-m2.7-highspeed': 204_800,
'minimax-m2.5': 204_800,
'minimax-m2.5-highspeed': 204_800,
'minimax-m2.1': 204_800,
'minimax-m2.1-highspeed': 204_800,

// Google (via OpenRouter)
'google/gemini-2.0-flash':1_048_576,
Expand Down Expand Up @@ -223,9 +233,19 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
'mistral-large-latest': 32_768,
'mistral-small-latest': 32_768,

// MiniMax
// MiniMax (all M2.x variants share 131,072 max output)
'MiniMax-M2.7': 131_072,
'MiniMax-M2.7-highspeed': 131_072,
'MiniMax-M2.5': 131_072,
'MiniMax-M2.5-highspeed': 131_072,
'MiniMax-M2.1': 131_072,
'MiniMax-M2.1-highspeed': 131_072,
'minimax-m2.7': 131_072,
'minimax-m2.7-highspeed': 131_072,
'minimax-m2.5': 131_072,
'minimax-m2.5-highspeed': 131_072,
'minimax-m2.1': 131_072,
'minimax-m2.1-highspeed': 131_072,

// Google (via OpenRouter)
'google/gemini-2.0-flash': 8_192,
Expand Down
Loading