fix: handle structured context overflow errors (#213)

This commit is contained in:
_Kerman 2026-05-29 19:38:24 +08:00 committed by GitHub
parent 54590d3d46
commit 2388f20bb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 100 additions and 23 deletions

View file

@ -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.

View file

@ -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);

View file

@ -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'),

View file

@ -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));

View file

@ -60,6 +60,7 @@ export {
APIStatusError,
APITimeoutError,
ChatProviderError,
isContextOverflowStatusError,
isRetryableGenerateError,
} from './errors';

View file

@ -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)}`,
);

View file

@ -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' }]),