From 83808d9dc28fcb66831f92de308d556ebbbc31a2 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 8 Jul 2026 22:18:50 +0800 Subject: [PATCH 01/10] feat(kosong): classify HTTP 413 request-body-too-large as a dedicated error type --- .changeset/request-too-large-error.md | 5 +++ packages/kosong/src/errors.ts | 44 ++++++++++++++++++++ packages/kosong/src/index.ts | 2 + packages/kosong/test/errors.test.ts | 59 +++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 .changeset/request-too-large-error.md diff --git a/.changeset/request-too-large-error.md b/.changeset/request-too-large-error.md new file mode 100644 index 0000000000..b64ccc04ee --- /dev/null +++ b/.changeset/request-too-large-error.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Classify provider "request body too large" rejections (HTTP 413) as a dedicated error type, distinguishing them from token context overflow so oversized-media failures can be handled separately. diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index cd7076d834..4036950b50 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -56,6 +56,19 @@ export class APIContextOverflowError extends APIStatusError { } } +/** + * HTTP 413 that specifically means the serialized request body exceeded the + * provider's byte ceiling (e.g. accumulated base64 images), as opposed to a + * token-count overflow. Token overflow is recoverable by compaction; a body + * size rejection is not — it needs media to be dropped or shrunk. + */ +export class APIRequestTooLargeError extends APIStatusError { + constructor(statusCode: number, message: string, requestId?: string | null) { + super(statusCode, message, requestId); + this.name = 'APIRequestTooLargeError'; + } +} + /** * HTTP status error that specifically means the provider rate-limited the * request. @@ -146,6 +159,26 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ /rate-limited/, ] as const; +// Wordings that mean the serialized request BODY was too big, matched against +// the lowercased message of a 413. Kept separate from the context-overflow +// patterns above: those describe token counts, these describe bytes. A 413 +// whose message matches neither family stays a plain `APIStatusError` — +// Vertex phrases prompt-too-long as a 413, so the status alone is not proof +// of a body-size rejection. +const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ + // Moonshot / Kimi: "Request exceeds the maximum size". + /request exceeds the maximum size/, + // Reverse proxies (nginx-style HTML body): "413 Request Entity Too Large". + /request entity too large/, + // Anthropic: error type `request_too_large`, message "Request exceeds the + // maximum allowed number of bytes". + /request_too_large/, + /exceeds? the maximum allowed number of bytes/, + // RFC 9110 reason phrase (both the pre-2022 and current names). + /payload too large/, + /content too large/, +] as const; + export function isContextOverflowErrorCode(code: string | null | undefined): boolean { return code === 'context_length_exceeded'; } @@ -158,9 +191,14 @@ export function normalizeAPIStatusError( if (statusCode === 429) { return new APIProviderRateLimitError(message, requestId); } + // Context overflow first: Vertex returns prompt-too-long as a 413, and a + // token overflow must keep routing to compaction even on that status. if (isContextOverflowStatusError(statusCode, message)) { return new APIContextOverflowError(statusCode, message, requestId); } + if (isRequestTooLargeStatusError(statusCode, message)) { + return new APIRequestTooLargeError(statusCode, message, requestId); + } return new APIStatusError(statusCode, message, requestId); } @@ -170,6 +208,12 @@ export function isContextOverflowStatusError(statusCode: number, message: string return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } +export function isRequestTooLargeStatusError(statusCode: number, message: string): boolean { + if (statusCode !== 413) return false; + const lowerMessage = message.toLowerCase(); + return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + // Strict providers reject a request whose assistant `tool_use`/`tool_calls` and // `tool_result`/`tool` blocks are not correctly paired and adjacent — a missing // result, a stray result with no matching call, or a result that does not diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index 01662bcbc7..97909a16a3 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -63,12 +63,14 @@ export { APIContextOverflowError, APIEmptyResponseError, APIProviderRateLimitError, + APIRequestTooLargeError, APIStatusError, APITimeoutError, ChatProviderError, isContextOverflowStatusError, isProviderRateLimitError, isRecoverableRequestStructureError, + isRequestTooLargeStatusError, isRetryableGenerateError, isToolExchangeAdjacencyError, } from './errors'; diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index 7cd9253349..754ee5080d 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -3,6 +3,7 @@ import { APIContextOverflowError, APIEmptyResponseError, APIProviderRateLimitError, + APIRequestTooLargeError, APIStatusError, APITimeoutError, ChatProviderError, @@ -112,6 +113,23 @@ describe('APIProviderRateLimitError', () => { }); }); +describe('APIRequestTooLargeError', () => { + it('extends APIStatusError and preserves HTTP details', () => { + const err = new APIRequestTooLargeError(413, 'Request exceeds the maximum size.', 'req-large'); + expect(err).toBeInstanceOf(APIStatusError); + expect(err).toBeInstanceOf(ChatProviderError); + expect(err.name).toBe('APIRequestTooLargeError'); + expect(err.statusCode).toBe(413); + expect(err.requestId).toBe('req-large'); + }); + + it('is not retryable', () => { + expect( + isRetryableGenerateError(new APIRequestTooLargeError(413, 'Request exceeds the maximum size.')), + ).toBe(false); + }); +}); + describe('isRetryableGenerateError', () => { it('matches transient provider errors and empty generate responses', () => { expect(isRetryableGenerateError(new APIConnectionError('conn'))).toBe(true); @@ -209,6 +227,47 @@ describe('normalizeAPIStatusError', () => { expect(error).toBeInstanceOf(APIStatusError); expect(error).not.toBeInstanceOf(APIContextOverflowError); }); + + it.each([ + // Moonshot / Kimi 413 observed in the field when accumulated media pushed + // the request body over the provider's byte ceiling. + [413, 'Request exceeds the maximum size'], + // Reverse-proxy (nginx-style) 413 with an HTML body. + [413, '413 413 Request Entity Too Large'], + // Anthropic request_too_large: body over the 32 MB API ceiling. + [413, 'request_too_large: Request exceeds the maximum allowed number of bytes'], + // RFC 9110 reason phrase / Node-style wording. + [413, 'Payload Too Large'], + [413, 'Content Too Large'], + ])('normalizes %i "%s" to APIRequestTooLargeError', (statusCode, message) => { + const error = normalizeAPIStatusError(statusCode, message, 'req-large'); + expect(error).toBeInstanceOf(APIRequestTooLargeError); + expect(error.statusCode).toBe(statusCode); + expect(error.requestId).toBe('req-large'); + }); + + it('keeps a 413 with token-overflow wording as APIContextOverflowError', () => { + // Vertex phrases prompt-too-long as a 413; that is a token problem + // (recoverable by compaction), not a request-body-size problem. + const error = normalizeAPIStatusError(413, 'prompt is too long: 210000 tokens > 200000 maximum'); + expect(error).toBeInstanceOf(APIContextOverflowError); + expect(error).not.toBeInstanceOf(APIRequestTooLargeError); + }); + + it.each([ + // A bare 413 with unrecognized wording stays unclassified: Vertex abuses + // 413 for prompt-too-long, so the status alone is not proof of a + // body-size rejection. + [413, 'Request failed'], + // Size wording without the 413 status is not classified either. + [400, 'Payload too large'], + [422, 'Request entity too large'], + ])('keeps %i "%s" as plain APIStatusError', (statusCode, message) => { + const error = normalizeAPIStatusError(statusCode, message); + expect(error).toBeInstanceOf(APIStatusError); + expect(error).not.toBeInstanceOf(APIRequestTooLargeError); + expect(error).not.toBeInstanceOf(APIContextOverflowError); + }); }); describe('isToolExchangeAdjacencyError', () => { From ed67a15f0748aca63ed3dbc9af2a3163fb96691a Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 8 Jul 2026 22:18:50 +0800 Subject: [PATCH 02/10] feat(agent-core): lower default image downscale cap to 2000px and make it configurable --- .changeset/image-max-edge-config.md | 5 + .../editor-keyboard-image-paste.test.ts | 6 +- docs/en/configuration/config-files.md | 13 ++- docs/en/configuration/env-vars.md | 1 + docs/zh/configuration/config-files.md | 13 ++- docs/zh/configuration/env-vars.md | 1 + packages/agent-core/src/config/schema.ts | 14 +++ packages/agent-core/src/config/toml.ts | 12 ++ packages/agent-core/src/rpc/core-impl.ts | 3 + .../src/tools/support/image-compress.ts | 60 ++++++++-- .../agent-core/test/config/configs.test.ts | 20 ++++ .../test/tools/image-compress.test.ts | 105 +++++++++++++++--- .../agent-core/test/tools/read-media.test.ts | 6 +- 13 files changed, 225 insertions(+), 34 deletions(-) create mode 100644 .changeset/image-max-edge-config.md diff --git a/.changeset/image-max-edge-config.md b/.changeset/image-max-edge-config.md new file mode 100644 index 0000000000..5537c64d95 --- /dev/null +++ b/.changeset/image-max-edge-config.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Lower the default image downscale cap from 3000px back to 2000px so multi-image request bodies stay within provider size limits, and make it adjustable via `[image] max_edge_px` in config.toml or the `KIMI_IMAGE_MAX_EDGE_PX` environment variable. diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts index 0fe14bc09e..73e05b7ef1 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -141,14 +141,14 @@ describe('clipboard image paste compression', () => { if (att?.kind !== 'image') throw new Error('expected image attachment'); // Stored metadata reflects the compressed size. - expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(3000); - expect(att.placeholder).toContain('3000×1500'); + expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000); + expect(att.placeholder).toContain('2000×1000'); // The stored bytes decode to the compressed dimensions — the thumbnail and // the submitted image both read from these bytes, so they cannot diverge. const dims = parseImageMeta(att.bytes); expect(dims).not.toBeNull(); - expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(3000); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000); }); it('records and persists the pre-compression original for an oversized paste', async () => { diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index f7e8935554..e862965abb 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -87,11 +87,12 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) | | `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) | | `background` | `table` | — | Background task runtime parameters → [`background`](#background) | +| `image` | `table` | — | Image compression parameters → [`image`](#image) | | `services` | `table` | — | Built-in external service configuration → [`services`](#services) | | `permission` | `table` | — | Initial permission rules → [`permission`](#permission) | | `hooks` | `array` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) | -The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `services`, and `permission`. +The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `image`, `services`, and `permission`. ## `providers` @@ -202,6 +203,16 @@ You can also switch models temporarily without touching the config file — by s In print mode (`kimi -p ""`), Kimi Code runs a single non-interactive turn and exits as soon as the main agent finishes. If you launch background tasks (for example, concurrent subagents via `Agent(run_in_background=true)`) and need them to run to completion, set `keep_alive_on_exit = true`: the process then waits for every background task to reach a terminal state before exiting, bounded by `print_wait_ceiling_s`. Without it, the single turn ending tears background tasks down with the process. +## `image` + +`image` controls how images are compressed before being sent to the model, across every ingestion point (pasted images, `ReadMediaFile` reads, images in MCP tool results, and so on). + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `max_edge_px` | `integer` | `2000` | Longest-edge ceiling in pixels. Larger images are scaled down proportionally to fit; raising it preserves more detail at the cost of larger request bodies | + +`max_edge_px` can be overridden by the `KIMI_IMAGE_MAX_EDGE_PX` environment variable, which takes higher priority than `config.toml`. +