From 2388f20bb3d039e89caefca159801059b90dc64a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 29 May 2026 19:38:24 +0800 Subject: [PATCH] fix: handle structured context overflow errors (#213) --- .changeset/context-overflow-responses.md | 7 +++ packages/agent-core/src/agent/turn/index.ts | 14 +---- packages/agent-core/test/agent/turn.test.ts | 11 ++++ packages/kosong/src/errors.ts | 6 ++- packages/kosong/src/index.ts | 1 + .../kosong/src/providers/openai-responses.ts | 53 +++++++++++++++---- packages/kosong/test/openai-responses.test.ts | 31 ++++++++++- 7 files changed, 100 insertions(+), 23 deletions(-) create mode 100644 .changeset/context-overflow-responses.md diff --git a/.changeset/context-overflow-responses.md b/.changeset/context-overflow-responses.md new file mode 100644 index 000000000..ccefcfaa5 --- /dev/null +++ b/.changeset/context-overflow-responses.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kosong": patch +--- + +Handle context overflow errors consistently across provider responses. diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 102b7f5de..068d5626c 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -7,6 +7,7 @@ import { APIStatusError, APITimeoutError, inputTotal, + isContextOverflowStatusError, type ContentPart, type TokenUsage, } from '@moonshot-ai/kosong'; @@ -738,7 +739,7 @@ function classifyApiError(error: unknown, summary: KimiErrorPayload): ApiErrorCl if (statusCode === 429) return { errorType: 'rate_limit', statusCode }; if (statusCode === 401 || statusCode === 403) return { errorType: 'auth', statusCode }; if (statusCode >= 500) return { errorType: '5xx_server', statusCode }; - if (isContextOverflowMessage(summary.message)) { + if (isContextOverflowStatusError(statusCode, summary.message)) { return { errorType: 'context_overflow', statusCode }; } if (statusCode >= 400) return { errorType: '4xx_client', statusCode }; @@ -787,17 +788,6 @@ function isApiEmptyResponseError(error: unknown, summary: KimiErrorPayload): boo return error instanceof APIEmptyResponseError || summary.name === 'APIEmptyResponseError'; } -function isContextOverflowMessage(message: string): boolean { - const lower = message.toLowerCase(); - return ( - lower.includes('context length') || - lower.includes('context_length') || - lower.includes('max tokens') || - lower.includes('maximum context') || - lower.includes('too many tokens') - ); -} - function currentTurnInputTokens(usage: TokenUsage | undefined): number | undefined { if (usage === undefined) return undefined; return inputTotal(usage); diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index b39731e0b..094e06fcb 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -1056,6 +1056,17 @@ describe('Agent turn flow', () => { errorType: 'context_overflow', statusCode: 422, }, + { + name: 'context overflow token count status', + createError: () => + new APIStatusError( + 400, + 'input token count 131072 exceeds the maximum number of tokens allowed', + 'req-token-count', + ), + errorType: 'context_overflow', + statusCode: 400, + }, { name: 'connection error', createError: () => new APIConnectionError('socket hang up'), diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index df2ae6adb..6804fe7ea 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -85,6 +85,10 @@ const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [ /request.*exceed(?:ed|s|ing)?.*model token limit/, ] as const; +export function isContextOverflowErrorCode(code: string | null | undefined): boolean { + return code === 'context_length_exceeded'; +} + export function normalizeAPIStatusError( statusCode: number, message: string, @@ -96,7 +100,7 @@ export function normalizeAPIStatusError( return new APIStatusError(statusCode, message, requestId); } -function isContextOverflowStatusError(statusCode: number, message: string): boolean { +export function isContextOverflowStatusError(statusCode: number, message: string): boolean { if (statusCode !== 400 && statusCode !== 413 && statusCode !== 422) return false; const lowerMessage = message.toLowerCase(); return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index 35401dc79..3404bf36d 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -60,6 +60,7 @@ export { APIStatusError, APITimeoutError, ChatProviderError, + isContextOverflowStatusError, isRetryableGenerateError, } from './errors'; diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 7a9147e8d..4c1677b88 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -1,5 +1,5 @@ import type { ModelCapability } from '#/capability'; -import { ChatProviderError } from '#/errors'; +import { APIContextOverflowError, ChatProviderError, isContextOverflowErrorCode } from '#/errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { extractText } from '#/message'; import type { @@ -217,12 +217,39 @@ function formatResponsesErrorEvent( return `${codeText}: ${message}${paramText}`; } -function formatResponsesFailedResponse(response: RawObject): string { +function errorFromOpenAIResponsesEvent( + prefix: string, + code: string | null, + message: string, + param: string | null, +): ChatProviderError { + const formatted = formatResponsesErrorEvent(code, message, param); + const fullMessage = `${prefix}: ${formatted}`; + if (isContextOverflowErrorCode(code)) { + return new APIContextOverflowError(400, fullMessage); + } + return new ChatProviderError(fullMessage); +} + +function readResponsesFailedResponseError(response: RawObject): + | { + code: string | null; + message: string; + } + | undefined { const error = readObjectField(response, 'error'); if (error !== undefined) { const code = readNullableStringField(error, 'code') ?? 'unknown'; const message = readStringField(error, 'message') ?? 'no message'; - return `${code}: ${message}`; + return { code, message }; + } + return undefined; +} + +function formatResponsesFailedResponse(response: RawObject): string { + const error = readResponsesFailedResponseError(response); + if (error !== undefined) { + return formatResponsesErrorEvent(error.code, error.message, null); } const incompleteDetails = readObjectField(response, 'incomplete_details'); @@ -777,16 +804,24 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { } case 'error': { const message = requireStringField(chunk, 'message', type); - throw new ChatProviderError( - `OpenAI Responses stream error: ${formatResponsesErrorEvent( - readNullableStringField(chunk, 'code') ?? null, - message, - readNullableStringField(chunk, 'param') ?? null, - )}`, + throw errorFromOpenAIResponsesEvent( + 'OpenAI Responses stream error', + readNullableStringField(chunk, 'code') ?? null, + message, + readNullableStringField(chunk, 'param') ?? null, ); } case 'response.failed': { const responseObject = requireObjectField(chunk, 'response', type); + const error = readResponsesFailedResponseError(responseObject); + if (error !== undefined) { + throw errorFromOpenAIResponsesEvent( + 'OpenAI Responses response.failed', + error.code, + error.message, + null, + ); + } throw new ChatProviderError( `OpenAI Responses response.failed: ${formatResponsesFailedResponse(responseObject)}`, ); diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 266f7c776..93e27b7d8 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -1,4 +1,4 @@ -import { ChatProviderError } from '#/errors'; +import { APIContextOverflowError, ChatProviderError } from '#/errors'; import { generate } from '#/generate'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { @@ -1635,6 +1635,35 @@ describe('OpenAIResponsesChatProvider', () => { ); }); + it('normalizes response.failed context overflow events', async () => { + const events = [ + { + type: 'response.failed', + response: { + id: 'resp_context_overflow', + status: 'failed', + error: { + code: 'context_length_exceeded', + message: + 'Your input exceeds the context window of this model. Please adjust your input and try again.', + }, + }, + }, + ]; + const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true); + + let caughtError: unknown; + try { + await collectStreamParts(stream); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(APIContextOverflowError); + expect((caughtError as APIContextOverflowError).statusCode).toBe(400); + expect((caughtError as Error).message).toMatch(/context_length_exceeded/); + }); + it('throws when a known stream event is missing a required field', async () => { const stream = new OpenAIResponsesStreamedMessage( makeAsyncIterable([{ type: 'response.output_text.delta' }]),