feat: add OpenAI context window error handling (#6967)

* feat: add OpenAI context window error handling

- Add comprehensive context window error detection for OpenAI, OpenRouter, Anthropic, and Cerebras
- Implement automatic retry with aggressive context truncation (25% reduction)
- Use proper profile settings for condensing operations
- Add robust error handling with try-catch blocks

Based on PR #5479 from cline/cline repository

* fix: address PR review comments

- Improved type safety by using Record<string, any> instead of direct any casts
- Enhanced Anthropic error detection with message pattern matching
- Added comprehensive unit tests for context-error-handling module
- Added named constant FORCED_CONTEXT_REDUCTION_PERCENT
- Added MAX_CONTEXT_WINDOW_RETRIES limit to prevent infinite loops
- Added logging for context window exceeded errors
- Extracted getCurrentProfileId helper method to reduce duplication
- All tests passing (3438 tests)

* fix: address PR review comments for context window error handling

- Improve Anthropic error detection with more specific patterns and error codes
- Add comprehensive unit tests for context-error-handling module
- Add logging for context window errors with detailed information
- Fix comment for FORCED_CONTEXT_REDUCTION_PERCENT constant
- Fix TypeScript error for untyped error parameter
- Maintain existing getCurrentProfileId helper method

---------

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Daniel 2025-08-21 04:20:49 -05:00 committed by GitHub
parent 007b58db77
commit a5b143bb44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 529 additions and 4 deletions

View file

@ -0,0 +1,329 @@
import { describe, it, expect, vi } from "vitest"
import { APIError } from "openai"
import { checkContextWindowExceededError } from "../context-error-handling"
describe("checkContextWindowExceededError", () => {
describe("OpenAI errors", () => {
it("should detect OpenAI context window error with APIError instance", () => {
const error = Object.create(APIError.prototype)
Object.assign(error, {
status: 400,
code: "400",
message: "This model's maximum context length is 4096 tokens",
error: {
message: "This model's maximum context length is 4096 tokens",
type: "invalid_request_error",
param: null,
code: "context_length_exceeded",
},
})
expect(checkContextWindowExceededError(error)).toBe(true)
})
it("should detect OpenAI LengthFinishReasonError", () => {
const error = {
name: "LengthFinishReasonError",
message: "The response was cut off due to length",
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
it("should not detect non-context OpenAI errors", () => {
const error = Object.create(APIError.prototype)
Object.assign(error, {
status: 400,
code: "400",
message: "Invalid API key",
error: {
message: "Invalid API key",
type: "invalid_request_error",
param: null,
code: "invalid_api_key",
},
})
expect(checkContextWindowExceededError(error)).toBe(false)
})
})
describe("OpenRouter errors", () => {
it("should detect OpenRouter context window error with status 400", () => {
const error = {
status: 400,
message: "Request exceeds maximum context length of 8192 tokens",
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
it("should detect OpenRouter error with nested error structure", () => {
const error = {
error: {
status: 400,
message: "Input tokens exceed model limit",
},
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
it("should detect OpenRouter error with response status", () => {
const error = {
response: {
status: 400,
},
message: "Too many tokens in the request",
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
it("should detect various context error patterns", () => {
const patterns = [
"context length exceeded",
"maximum context window",
"input tokens exceed limit",
"too many tokens",
]
patterns.forEach((pattern) => {
const error = {
status: 400,
message: pattern,
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
})
it("should not detect non-context 400 errors", () => {
const error = {
status: 400,
message: "Invalid request format",
}
expect(checkContextWindowExceededError(error)).toBe(false)
})
it("should not detect errors with different status codes", () => {
const error = {
status: 500,
message: "context length exceeded",
}
expect(checkContextWindowExceededError(error)).toBe(false)
})
})
describe("Anthropic errors", () => {
it("should detect Anthropic context window error", () => {
const error = {
error: {
error: {
type: "invalid_request_error",
message: "prompt is too long: 150000 tokens > 100000 maximum",
},
},
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
it("should detect Anthropic error with context_length_exceeded code", () => {
const error = {
error: {
error: {
type: "invalid_request_error",
code: "context_length_exceeded",
message: "The request exceeds the maximum context window",
},
},
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
it("should detect various Anthropic context error patterns", () => {
const patterns = [
"prompt is too long",
"maximum 200000 tokens",
"context is too long",
"exceeds the context window",
"token limit exceeded",
]
patterns.forEach((pattern) => {
const error = {
error: {
error: {
type: "invalid_request_error",
message: pattern,
},
},
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
})
it("should not detect non-context Anthropic errors", () => {
const error = {
error: {
error: {
type: "invalid_request_error",
message: "Invalid model specified",
},
},
}
expect(checkContextWindowExceededError(error)).toBe(false)
})
it("should not detect errors with different error types", () => {
const error = {
error: {
error: {
type: "authentication_error",
message: "prompt is too long",
},
},
}
expect(checkContextWindowExceededError(error)).toBe(false)
})
})
describe("Cerebras errors", () => {
it("should detect Cerebras context window error", () => {
const error = {
status: 400,
message: "Please reduce the length of the messages or completion",
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
it("should detect Cerebras error with nested structure", () => {
const error = {
error: {
status: 400,
message: "Please reduce the length of the messages or completion",
},
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
it("should not detect non-context Cerebras errors", () => {
const error = {
status: 400,
message: "Invalid request parameters",
}
expect(checkContextWindowExceededError(error)).toBe(false)
})
})
describe("Edge cases", () => {
it("should handle null input", () => {
expect(checkContextWindowExceededError(null)).toBe(false)
})
it("should handle undefined input", () => {
expect(checkContextWindowExceededError(undefined)).toBe(false)
})
it("should handle empty object", () => {
expect(checkContextWindowExceededError({})).toBe(false)
})
it("should handle string input", () => {
expect(checkContextWindowExceededError("error")).toBe(false)
})
it("should handle number input", () => {
expect(checkContextWindowExceededError(123)).toBe(false)
})
it("should handle array input", () => {
expect(checkContextWindowExceededError([])).toBe(false)
})
it("should handle errors with circular references", () => {
const error: any = { status: 400, message: "context length exceeded" }
error.self = error // Create circular reference
expect(checkContextWindowExceededError(error)).toBe(true)
})
it("should handle errors with deeply nested undefined values", () => {
const error = {
error: {
error: {
type: undefined,
message: undefined,
},
},
}
expect(checkContextWindowExceededError(error)).toBe(false)
})
it("should handle errors that throw during property access", () => {
const error = {
get status() {
throw new Error("Property access error")
},
message: "context length exceeded",
}
expect(checkContextWindowExceededError(error)).toBe(false)
})
it("should handle mixed provider error structures", () => {
// Error that could match multiple providers
const error = {
status: 400,
code: "400",
message: "context length exceeded",
error: {
error: {
type: "invalid_request_error",
message: "prompt is too long",
},
},
}
expect(checkContextWindowExceededError(error)).toBe(true)
})
})
describe("Multiple provider detection", () => {
it("should detect error if any provider check returns true", () => {
// This error should be detected by OpenRouter check
const error1 = {
status: 400,
message: "context window exceeded",
}
expect(checkContextWindowExceededError(error1)).toBe(true)
// This error should be detected by Anthropic check
const error2 = {
error: {
error: {
type: "invalid_request_error",
message: "prompt is too long",
},
},
}
expect(checkContextWindowExceededError(error2)).toBe(true)
// This error should be detected by Cerebras check
const error3 = {
status: 400,
message: "Please reduce the length of the messages or completion",
}
expect(checkContextWindowExceededError(error3)).toBe(true)
})
})
})

View file

@ -0,0 +1,114 @@
import { APIError } from "openai"
export function checkContextWindowExceededError(error: unknown): boolean {
return (
checkIsOpenAIContextWindowError(error) ||
checkIsOpenRouterContextWindowError(error) ||
checkIsAnthropicContextWindowError(error) ||
checkIsCerebrasContextWindowError(error)
)
}
function checkIsOpenRouterContextWindowError(error: unknown): boolean {
try {
if (!error || typeof error !== "object") {
return false
}
// Use Record<string, any> for proper type narrowing
const err = error as Record<string, any>
const status = err.status ?? err.code ?? err.error?.status ?? err.response?.status
const message: string = String(err.message || err.error?.message || "")
// Known OpenAI/OpenRouter-style signal (code 400 and message includes "context length")
const CONTEXT_ERROR_PATTERNS = [
/\bcontext\s*(?:length|window)\b/i,
/\bmaximum\s*context\b/i,
/\b(?:input\s*)?tokens?\s*exceed/i,
/\btoo\s*many\s*tokens?\b/i,
] as const
return String(status) === "400" && CONTEXT_ERROR_PATTERNS.some((pattern) => pattern.test(message))
} catch {
return false
}
}
// Docs: https://platform.openai.com/docs/guides/error-codes/api-errors
function checkIsOpenAIContextWindowError(error: unknown): boolean {
try {
// Check for LengthFinishReasonError
if (error && typeof error === "object" && "name" in error && error.name === "LengthFinishReasonError") {
return true
}
const KNOWN_CONTEXT_ERROR_SUBSTRINGS = ["token", "context length"] as const
return (
Boolean(error) &&
error instanceof APIError &&
error.code?.toString() === "400" &&
KNOWN_CONTEXT_ERROR_SUBSTRINGS.some((substring) => error.message.includes(substring))
)
} catch {
return false
}
}
function checkIsAnthropicContextWindowError(response: unknown): boolean {
try {
// Type guard to safely access properties
if (!response || typeof response !== "object") {
return false
}
// Use type assertions with proper checks
const res = response as Record<string, any>
// Check for Anthropic-specific error structure with more specific validation
if (res.error?.error?.type === "invalid_request_error") {
const message: string = String(res.error?.error?.message || "")
// More specific patterns for context window errors
const contextWindowPatterns = [
/prompt is too long/i,
/maximum.*tokens/i,
/context.*too.*long/i,
/exceeds.*context/i,
/token.*limit/i,
/context_length_exceeded/i,
/max_tokens_to_sample/i,
]
// Additional check for Anthropic-specific error codes
const errorCode = res.error?.error?.code
if (errorCode === "context_length_exceeded" || errorCode === "invalid_request_error") {
return contextWindowPatterns.some((pattern) => pattern.test(message))
}
return contextWindowPatterns.some((pattern) => pattern.test(message))
}
return false
} catch {
return false
}
}
function checkIsCerebrasContextWindowError(response: unknown): boolean {
try {
// Type guard to safely access properties
if (!response || typeof response !== "object") {
return false
}
// Use type assertions with proper checks
const res = response as Record<string, any>
const status = res.status ?? res.code ?? res.error?.status ?? res.response?.status
const message: string = String(res.message || res.error?.message || "")
return String(status) === "400" && message.includes("Please reduce the length of the messages or completion")
} catch {
return false
}
}

View file

@ -88,6 +88,7 @@ import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-
import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace"
import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "../task-persistence"
import { getEnvironmentDetails } from "../environment/getEnvironmentDetails"
import { checkContextWindowExceededError } from "../context/context-management/context-error-handling"
import {
type CheckpointDiffOptions,
type CheckpointRestoreOptions,
@ -105,6 +106,8 @@ import { AutoApprovalHandler } from "./AutoApprovalHandler"
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors
export type TaskOptions = {
provider: ClineProvider
@ -1387,7 +1390,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (this.bridgeService) {
this.bridgeService
.unsubscribeFromTask(this.taskId)
.catch((error) => console.error("Error unsubscribing from task bridge:", error))
.catch((error: unknown) => console.error("Error unsubscribing from task bridge:", error))
this.bridgeService = null
}
@ -2232,6 +2235,71 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
})()
}
private getCurrentProfileId(state: any): string {
return (
state?.listApiConfigMeta?.find((profile: any) => profile.name === state?.currentApiConfigName)?.id ??
"default"
)
}
private async handleContextWindowExceededError(): Promise<void> {
const state = await this.providerRef.deref()?.getState()
const { profileThresholds = {} } = state ?? {}
const { contextTokens } = this.getTokenUsage()
const modelInfo = this.api.getModel().info
const maxTokens = getModelMaxOutputTokens({
modelId: this.api.getModel().id,
model: modelInfo,
settings: this.apiConfiguration,
})
const contextWindow = modelInfo.contextWindow
// Get the current profile ID using the helper method
const currentProfileId = this.getCurrentProfileId(state)
// Log the context window error for debugging
console.warn(
`[Task#${this.taskId}] Context window exceeded for model ${this.api.getModel().id}. ` +
`Current tokens: ${contextTokens}, Context window: ${contextWindow}. ` +
`Forcing truncation to ${FORCED_CONTEXT_REDUCTION_PERCENT}% of current context.`,
)
// Force aggressive truncation by keeping only 75% of the conversation history
const truncateResult = await truncateConversationIfNeeded({
messages: this.apiConversationHistory,
totalTokens: contextTokens || 0,
maxTokens,
contextWindow,
apiHandler: this.api,
autoCondenseContext: true,
autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT,
systemPrompt: await this.getSystemPrompt(),
taskId: this.taskId,
profileThresholds,
currentProfileId,
})
if (truncateResult.messages !== this.apiConversationHistory) {
await this.overwriteApiConversationHistory(truncateResult.messages)
}
if (truncateResult.summary) {
const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
await this.say(
"condense_context",
undefined /* text */,
undefined /* images */,
false /* partial */,
undefined /* checkpoint */,
undefined /* progressStatus */,
{ isNonInteractive: true } /* options */,
contextCondense,
)
}
}
public async *attemptApiRequest(retryAttempt: number = 0): ApiStream {
const state = await this.providerRef.deref()?.getState()
@ -2310,9 +2378,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const contextWindow = modelInfo.contextWindow
const currentProfileId =
state?.listApiConfigMeta.find((profile) => profile.name === state?.currentApiConfigName)?.id ??
"default"
// Get the current profile ID using the helper method
const currentProfileId = this.getCurrentProfileId(state)
const truncateResult = await truncateConversationIfNeeded({
messages: this.apiConversationHistory,
@ -2419,6 +2486,21 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.isWaitingForFirstChunk = false
} catch (error) {
this.isWaitingForFirstChunk = false
const isContextWindowExceededError = checkContextWindowExceededError(error)
// If it's a context window error and we haven't exceeded max retries for this error type
if (isContextWindowExceededError && retryAttempt < MAX_CONTEXT_WINDOW_RETRIES) {
console.warn(
`[Task#${this.taskId}] Context window exceeded for model ${this.api.getModel().id}. ` +
`Retry attempt ${retryAttempt + 1}/${MAX_CONTEXT_WINDOW_RETRIES}. ` +
`Attempting automatic truncation...`,
)
await this.handleContextWindowExceededError()
// Retry the request after handling the context window error
yield* this.attemptApiRequest(retryAttempt + 1)
return
}
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
if (autoApprovalEnabled && alwaysApproveResubmit) {
let errorMsg