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
7 changes: 7 additions & 0 deletions .changeset/context-overflow-responses.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 2 additions & 12 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
APIStatusError,
APITimeoutError,
inputTotal,
isContextOverflowStatusError,
type ContentPart,
type TokenUsage,
} from '@moonshot-ai/kosong';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions packages/agent-core/test/agent/turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
6 changes: 5 additions & 1 deletion packages/kosong/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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));
Expand Down
1 change: 1 addition & 0 deletions packages/kosong/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export {
APIStatusError,
APITimeoutError,
ChatProviderError,
isContextOverflowStatusError,
isRetryableGenerateError,
} from './errors';

Expand Down
53 changes: 44 additions & 9 deletions packages/kosong/src/providers/openai-responses.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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)}`,
);
Expand Down
31 changes: 30 additions & 1 deletion packages/kosong/test/openai-responses.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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' }]),
Expand Down
Loading