From 5a90d9d7f110337ee0f7f3899aec3bd25fe4b92d Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 10 Jul 2026 00:08:25 +0800 Subject: [PATCH 1/4] chore(agents): place Polish before Bug Fixes in changelog sections (#1527) --- .agents/skills/sync-changelog/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md index c913812cc..25e542d4b 100644 --- a/.agents/skills/sync-changelog/SKILL.md +++ b/.agents/skills/sync-changelog/SKILL.md @@ -148,8 +148,8 @@ The docs changelog uses five section types: | English section | Chinese section | Meaning | |---|---|---| | `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before | -| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | | `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities | +| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | | `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames | | `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts | @@ -176,7 +176,7 @@ Keyword hints: Within each version, section order is: ```text -Features → Bug Fixes → Polish → Refactors → Other +Features → Polish → Bug Fixes → Refactors → Other ``` Omit empty sections. Within each section, order entries by reader value, not upstream order: From 0ad568436aa4ee34460e746844ba27062c6a91b4 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 10 Jul 2026 00:48:47 +0800 Subject: [PATCH 2/4] docs(changelog): sync 0.23.4 from apps/kimi-code/CHANGELOG.md (#1528) --- docs/en/release-notes/changelog.md | 20 ++++++++++++++++++++ docs/zh/release-notes/changelog.md | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index d0549df1c..9b8d8ab93 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,26 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.23.4 (2026-07-10) + +### Features + +- web: Add notifications when a tool needs approval, and improve notification reliability. + +### Polish + +- web: Polish the chat UI with Inter typography, localized labels, and tighter composer and menu styling. +- web: Polish the session sidebar layout, colors, icons, and typography. +- Display the Extra Usage (fuel pack) balance in the `/usage` and `/status` commands. +- Add a Kimi WebBridge entry to the Official tab of the `/plugins` panel that opens the WebBridge install page in your browser. + +### Bug Fixes + +- Keep image-heavy sessions within provider request-size limits: oversized images (model-read and pasted, including WebP) are downscaled and compressed, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and an HTTP 413 request-too-large now recovers automatically — the request and `/compact` retry with older media replaced by text markers. The limits are configurable via `[image]` in `config.toml` (or `KIMI_IMAGE_*` env vars), and each core keeps its own settings so reloading one client's config no longer changes another client's compression. +- Fix resuming sessions whose original working directory no longer exists. +- Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts. +- web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent. + ## 0.23.3 (2026-07-08) ### Bug Fixes diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 444699fdc..98ff34285 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,26 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.23.4(2026-07-10) + +### 新功能 + +- web: 新增工具需要审批时的通知,并提升通知的可靠性。 + +### 优化 + +- web: 优化聊天界面,采用 Inter 字体、本地化标签与更紧凑的输入框和菜单样式。 +- web: 优化会话侧边栏的布局、配色、图标与字体。 +- `/usage` 和 `/status` 命令现显示 Extra Usage(加油包)余额。 +- `/plugins` 面板的 Official 标签页新增 Kimi WebBridge 入口,可在浏览器中打开 WebBridge 安装页。 + +### 修复 + +- 控制图片较多会话的请求体积:超大体量的模型读取与粘贴图片(含 WebP)会自动压缩、缩小;HEIC/HEIF 图片会给出对应平台的转换命令,而非污染会话;HTTP 413 请求过大现可自动恢复——请求和 `/compact` 会用文本标记替换旧媒体后重试。相关限制可通过 `config.toml` 的 `[image]`(或 `KIMI_IMAGE_*` 环境变量)配置,且每个 core 独立保存设置,重新加载某客户端的配置不再影响其他客户端的图片压缩。 +- 修复原工作目录已不存在的会话无法恢复的问题。 +- 修复 prompt 模式目标未运行至完成的问题,并在发送 prompt 前校验并提示无效的目标命令。 +- web: 修复新对话发送首条消息时偶发的 “another turn is active” 错误,并在发送过程中显示启动状态。 + ## 0.23.3(2026-07-08) ### 修复 From 9f66ec416cc658842dc1414b79b6447d1b4cc7f9 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Fri, 10 Jul 2026 15:47:12 +0800 Subject: [PATCH 3/4] feat: harden LLM API fault tolerance against 429 and overload (#1530) * feat(retry): harden LLM API fault tolerance against 429/overload - retry more transient errors: 408/409/429/5xx/529, an embedded upstream status_code=429 in OpenAI Responses stream errors, and unclassified provider errors as a last-resort fallback - honor server Retry-After (parsed into APIStatusError.retryAfterMs by the OpenAI and Anthropic providers); chatWithRetry prefers it over its backoff - align app-level backoff with claude-code (500ms base, 32s cap, factor 2, up to 25% jitter) so high-attempt configs ride out multi-minute overload - emit a turn.step.retrying meta line in -p --output-format stream-json --- .changeset/retry-fault-tolerance.md | 5 ++ apps/kimi-code/src/cli/run-prompt.ts | 41 ++++++++- apps/kimi-code/test/cli/run-prompt.test.ts | 53 ++++++++++++ packages/agent-core/src/loop/retry.ts | 45 +++++++--- packages/agent-core/test/loop/retry.test.ts | 77 ++++++++++++++++- packages/kosong/src/errors.ts | 83 ++++++++++++++++--- packages/kosong/src/providers/anthropic.ts | 8 +- .../kosong/src/providers/openai-common.ts | 8 +- .../kosong/src/providers/openai-responses.ts | 9 +- packages/kosong/test/anthropic-errors.test.ts | 34 +++++++- packages/kosong/test/errors.test.ts | 28 ++++++- .../kosong/test/openai-common-errors.test.ts | 33 +++++++- packages/kosong/test/openai-responses.test.ts | 27 ++++++ 13 files changed, 417 insertions(+), 34 deletions(-) create mode 100644 .changeset/retry-fault-tolerance.md diff --git a/.changeset/retry-fault-tolerance.md b/.changeset/retry-fault-tolerance.md new file mode 100644 index 000000000..8be58774c --- /dev/null +++ b/.changeset/retry-fault-tolerance.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Retry provider 429, overload, and other transient errors more reliably, honoring the server Retry-After delay, and surface retries in `-p --output-format stream-json`. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 3bb3a705f..bcce14136 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -501,6 +501,7 @@ function runPromptTurn( return; case 'turn.step.retrying': outputWriter.discardAssistant(); + outputWriter.writeRetrying(event); return; case 'assistant.delta': outputWriter.writeAssistantDelta(event.delta); @@ -612,6 +613,7 @@ interface PromptTurnWriter { argumentsPart: string | undefined, ): void; writeToolResult(toolCallId: string, output: unknown): void; + writeRetrying(event: Extract): void; flushAssistant(): void; discardAssistant(): void; finish(): void; @@ -648,6 +650,11 @@ class PromptTranscriptWriter implements PromptTurnWriter { writeToolResult(): void {} + // Text `-p` keeps retries silent: only the failed attempt's partial assistant + // text is discarded (handled by the caller). No human-readable retry line is + // emitted, matching the prior behavior. + writeRetrying(): void {} + flushAssistant(): void { this.assistantWriter.finish(); } @@ -689,6 +696,18 @@ interface PromptJsonResumeMetaMessage { content: string; } +interface PromptJsonRetryMetaMessage { + role: 'meta'; + type: 'turn.step.retrying'; + failed_attempt: number; + next_attempt: number; + max_attempts: number; + delay_ms: number; + error_name: string; + error_message: string; + status_code?: number; +} + function writeResumeHint( sessionId: string, outputFormat: PromptOutputFormat, @@ -787,6 +806,24 @@ class PromptJsonWriter implements PromptTurnWriter { this.toolCalls.length = 0; } + writeRetrying(event: Extract): void { + // Emit a machine-readable meta line so stream-json consumers can observe + // provider retries. The failed attempt's partial assistant text was already + // discarded by the caller, so no half-formed assistant message leaks. + const message: PromptJsonRetryMetaMessage = { + role: 'meta', + type: 'turn.step.retrying', + failed_attempt: event.failedAttempt, + next_attempt: event.nextAttempt, + max_attempts: event.maxAttempts, + delay_ms: event.delayMs, + error_name: event.errorName, + error_message: event.errorMessage, + status_code: event.statusCode, + }; + this.writeJsonLine(message); + } + finish(): void { this.flushAssistant(); } @@ -806,7 +843,9 @@ class PromptJsonWriter implements PromptTurnWriter { return toolCall; } - private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void { + private writeJsonLine( + message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage, + ): void { this.stdout.write(`${JSON.stringify(message)}\n`); } } diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index aafb99af9..cee7c2146 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -666,6 +666,59 @@ describe('runPrompt', () => { ); }); + it('emits a stream-json meta line on retry and discards the failed attempt output', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'partial attempt' })); + handler( + mocks.mainEvent({ + type: 'turn.step.retrying', + turnId: 10, + step: 1, + stepId: 'step-uuid', + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 3, + delayMs: 300, + errorName: 'APIProviderRateLimitError', + errorMessage: 'llmproxy/openai/responses/resp_abc.json status_code=429', + statusCode: 429, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'final answer' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr }); + + const retryMeta = JSON.stringify({ + role: 'meta', + type: 'turn.step.retrying', + failed_attempt: 1, + next_attempt: 2, + max_attempts: 3, + delay_ms: 300, + error_name: 'APIProviderRateLimitError', + error_message: 'llmproxy/openai/responses/resp_abc.json status_code=429', + status_code: 429, + }); + expect(stdout.text()).toBe( + [ + retryMeta, + '{"role":"assistant","content":"final answer"}', + '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}', + '', + ].join('\n'), + ); + // The failed attempt's partial text must not leak as an assistant line. + expect(stdout.text()).not.toContain('partial attempt'); + expect(stderr.text()).toBe(''); + }); + it('flushes stream-json assistant output before waiting for background tasks', async () => { let releaseWait: () => void = () => {}; const waitGate = new Promise((resolve) => { diff --git a/packages/agent-core/src/loop/retry.ts b/packages/agent-core/src/loop/retry.ts index 0d26781d4..863e67ea8 100644 --- a/packages/agent-core/src/loop/retry.ts +++ b/packages/agent-core/src/loop/retry.ts @@ -1,5 +1,4 @@ import { sleep } from '@antfu/utils'; -import * as retry from 'retry'; import type { Logger } from '#/logging/types'; @@ -10,9 +9,16 @@ import type { LLM, LLMChatParams, LLMChatResponse } from './llm'; export const DEFAULT_MAX_RETRY_ATTEMPTS = 3; -const RETRY_MIN_TIMEOUT_MS = 300; -const RETRY_MAX_TIMEOUT_MS = 5000; +const BASE_DELAY_MS = 500; +// Per-attempt backoff cap (32s). With the default 3 attempts the ramp +// (0.5s, 1s) never reaches the cap, so interactive runs are unaffected; it +// only matters for high-attempt configs (e.g. eval harnesses with +// `max_retries_per_step = 10`), where it lets retries ride out multi-minute +// provider overload instead of giving up after a few seconds of backoff. +const MAX_DELAY_MS = 32_000; const RETRY_FACTOR = 2; +// Up to 25% jitter on top of the exponential base to avoid herd retries. +const JITTER_FACTOR = 0.25; export interface ChatWithRetryInput { readonly llm: LLM; @@ -49,7 +55,10 @@ export async function chatWithRetry(input: ChatWithRetryInput): Promise 0 ? value : null; } export async function sleepForRetry(delayMs: number, signal: AbortSignal): Promise { diff --git a/packages/agent-core/test/loop/retry.test.ts b/packages/agent-core/test/loop/retry.test.ts index 21570935d..aedf0dda6 100644 --- a/packages/agent-core/test/loop/retry.test.ts +++ b/packages/agent-core/test/loop/retry.test.ts @@ -1,10 +1,15 @@ -import { APIConnectionError, emptyUsage, isRetryableGenerateError } from '@moonshot-ai/kosong'; +import { + APIConnectionError, + APIProviderRateLimitError, + emptyUsage, + isRetryableGenerateError, +} from '@moonshot-ai/kosong'; import { describe, expect, it } from 'vitest'; import type { KimiConfig } from '#/config'; import { ErrorCodes, KimiError } from '#/errors'; import type { LLM, LLMChatParams, LLMChatResponse } from '#/loop/llm'; -import { chatWithRetry } from '#/loop/retry'; +import { chatWithRetry, retryBackoffDelays } from '#/loop/retry'; import { ProviderManager } from '#/session/provider-manager'; function okResponse(): LLMChatResponse { @@ -137,6 +142,74 @@ describe('chatWithRetry: terminated stream drops', () => { }); }); +describe('retryBackoffDelays', () => { + it('uses a 500ms base, factor-2 ramp, 32s cap, and up to +25% jitter', () => { + const delays = retryBackoffDelays(10); + expect(delays).toHaveLength(9); + // Max possible delay is the capped base (32s) plus 25% jitter = 40s. + for (const d of delays) { + expect(d).toBeGreaterThan(0); + expect(d).toBeLessThanOrEqual(40_000); + } + // First attempt base is 500ms (plus up to 25% jitter) -> within [500, 625]. + expect(delays[0]).toBeGreaterThanOrEqual(500); + expect(delays[0]).toBeLessThanOrEqual(625); + }); + + it('reaches the 32s cap for high-attempt configs (overload ride-out)', () => { + // The ramp hits 32s by attempt 7 (500 * 2^6); across many draws the peak + // approaches the cap (32s..40s with jitter), well above the old 5s cap. + let maxSeen = 0; + for (let i = 0; i < 50; i += 1) { + for (const d of retryBackoffDelays(12)) { + maxSeen = Math.max(maxSeen, d); + } + } + expect(maxSeen).toBeGreaterThan(30_000); + }); + + it('keeps default-attempt retries quick so interactive runs are not slowed', () => { + // 3 attempts -> 2 delays at the bottom of the ramp (~0.5s / ~1s before + // jitter); their sum stays small. + const delays = retryBackoffDelays(3); + expect(delays).toHaveLength(2); + expect(delays.reduce((a, b) => a + b, 0)).toBeLessThan(3_000); + }); +}); + +describe('chatWithRetry: honors server retry-after', () => { + it('uses the error retryAfterMs as the retry delay instead of the backoff', async () => { + let calls = 0; + const captured: Array<{ type: string; delayMs?: number }> = []; + const llm: LLM = { + systemPrompt: '', + modelName: 'mock', + isRetryableError: (e) => isRetryableGenerateError(e), + async chat(): Promise { + calls += 1; + if (calls === 1) { + // 429 carrying a server `retry-after` of 42ms. Kept tiny so the test + // sleeps only briefly, while still being distinguishable from the + // attempt-1 backoff (500..625ms) it must override. + throw new APIProviderRateLimitError('rate limited', null, 42); + } + return okResponse(); + }, + }; + const input = makeInput(llm, new AbortController().signal); + await chatWithRetry({ + ...input, + dispatchEvent: async (event) => { + captured.push(event as { type: string; delayMs?: number }); + }, + }); + + expect(calls).toBe(2); + const retrying = captured.find((e) => e.type === 'step.retrying'); + expect(retrying?.delayMs).toBe(42); + }); +}); + function oauthConfig(): KimiConfig { return { defaultModel: 'kimi-code/kimi-for-coding', diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index d78ebbab6..818b903df 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -36,12 +36,25 @@ export class APITimeoutError extends ChatProviderError { export class APIStatusError extends ChatProviderError { readonly statusCode: number; readonly requestId: string | null; + /** + * Server-requested backoff from the `retry-after` response header, in + * milliseconds. When present, the retry loop honors it instead of its own + * computed backoff — a server `Retry-After` directive overrides the local + * exponential delay. + */ + readonly retryAfterMs: number | null; - constructor(statusCode: number, message: string, requestId?: string | null) { + constructor( + statusCode: number, + message: string, + requestId?: string | null, + retryAfterMs?: number | null, + ) { super(message); this.name = 'APIStatusError'; this.statusCode = statusCode; this.requestId = requestId ?? null; + this.retryAfterMs = retryAfterMs ?? null; } } @@ -50,8 +63,13 @@ export class APIStatusError extends ChatProviderError { * context window. */ export class APIContextOverflowError extends APIStatusError { - constructor(statusCode: number, message: string, requestId?: string | null) { - super(statusCode, message, requestId); + constructor( + statusCode: number, + message: string, + requestId?: string | null, + retryAfterMs?: number | null, + ) { + super(statusCode, message, requestId, retryAfterMs); this.name = 'APIContextOverflowError'; } } @@ -63,8 +81,13 @@ export class APIContextOverflowError extends APIStatusError { * 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); + constructor( + statusCode: number, + message: string, + requestId?: string | null, + retryAfterMs?: number | null, + ) { + super(statusCode, message, requestId, retryAfterMs); this.name = 'APIRequestTooLargeError'; } } @@ -74,8 +97,8 @@ export class APIRequestTooLargeError extends APIStatusError { * request. */ export class APIProviderRateLimitError extends APIStatusError { - constructor(message: string, requestId?: string | null) { - super(429, message, requestId); + constructor(message: string, requestId?: string | null, retryAfterMs?: number | null) { + super(429, message, requestId, retryAfterMs); this.name = 'APIProviderRateLimitError'; } } @@ -108,7 +131,22 @@ export function isRetryableGenerateError(error: unknown): boolean { if (error instanceof APIEmptyResponseError) { return true; } - return error instanceof APIStatusError && [429, 500, 502, 503, 504].includes(error.statusCode); + if (error instanceof APIStatusError) { + // Transient statuses worth retrying: 408 (request timeout), 409 + // (lock/conflict timeout), 429 (rate limit), 5xx (server errors) and 529 + // (provider overloaded — the "engine is currently overloaded" case). + return [408, 409, 429, 500, 502, 503, 504, 529].includes(error.statusCode); + } + // Fallback safety net: an unclassified provider failure — typically an + // upstream gateway that forwards the original error only as text, with no + // usable HTTP status (e.g. llmproxy embedding `status_code=429` in the + // message) — lands here as a base `ChatProviderError`. Retrying beats + // failing the run on the first transient blip. Typed `APIStatusError` + // instances are deliberately excluded above: deterministic 4xx + // (400/401/403/404/422) and the recovery-owned context-overflow / + // request-too-large subclasses keep their dedicated handling instead of + // burning retries first. + return error instanceof ChatProviderError; } // `terminated` is the undici signature for an SSE/HTTP body stream that is @@ -190,19 +228,40 @@ export function normalizeAPIStatusError( statusCode: number, message: string, requestId?: string | null, + retryAfterMs?: number | null, ): APIStatusError { if (statusCode === 429) { - return new APIProviderRateLimitError(message, requestId); + return new APIProviderRateLimitError(message, requestId, retryAfterMs); } // 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); + return new APIContextOverflowError(statusCode, message, requestId, retryAfterMs); } if (isRequestTooLargeStatusError(statusCode, message)) { - return new APIRequestTooLargeError(statusCode, message, requestId); + return new APIRequestTooLargeError(statusCode, message, requestId, retryAfterMs); } - return new APIStatusError(statusCode, message, requestId); + return new APIStatusError(statusCode, message, requestId, retryAfterMs); +} + +/** + * Parse a `retry-after` response header into milliseconds. Only integer + * seconds is honored; an HTTP-date (or any non-integer / missing value) + * returns null and the caller falls back to its computed backoff. Shared by + * the provider error converters so every backend honors the same server + * backoff directive. + */ +export function parseRetryAfterMs(headers: unknown): number | null { + const raw = + headers !== null && + typeof headers === 'object' && + typeof (headers as { get?: unknown }).get === 'function' + ? (headers as { get(name: string): string | null }).get('retry-after') + : null; + if (raw === null || raw === undefined) return null; + const seconds = Number.parseInt(raw, 10); + if (!Number.isFinite(seconds) || seconds < 0) return null; + return seconds * 1000; } export function isContextOverflowStatusError(statusCode: number, message: string): boolean { diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 43e653989..54733d060 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -4,6 +4,7 @@ import { ChatProviderError, classifyBaseApiError, normalizeAPIStatusError, + parseRetryAfterMs, } from '#/errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { isToolDeclarationOnlyMessage } from '#/message'; @@ -693,7 +694,12 @@ export function convertAnthropicError(error: unknown): ChatProviderError { // APIError with a status code => status error if (error instanceof AnthropicAPIError && typeof error.status === 'number') { const reqId = error.requestID ?? null; - return normalizeAPIStatusError(error.status, error.message, reqId); + return normalizeAPIStatusError( + error.status, + error.message, + reqId, + parseRetryAfterMs(error.headers), + ); } if (error instanceof AnthropicError) { return new ChatProviderError(`Anthropic error: ${error.message}`); diff --git a/packages/kosong/src/providers/openai-common.ts b/packages/kosong/src/providers/openai-common.ts index b83377e4e..2541b11e5 100644 --- a/packages/kosong/src/providers/openai-common.ts +++ b/packages/kosong/src/providers/openai-common.ts @@ -4,6 +4,7 @@ import { ChatProviderError, classifyBaseApiError, normalizeAPIStatusError, + parseRetryAfterMs, } from '#/errors'; import { extractText } from '#/message'; import type { ContentPart, Message } from '#/message'; @@ -103,7 +104,12 @@ export function convertOpenAIError(error: unknown): ChatProviderError { // APIError with a status code => status error if (error instanceof OpenAIAPIError && typeof error.status === 'number') { const reqId = error.requestID ?? null; - return normalizeAPIStatusError(error.status, error.message, reqId); + return normalizeAPIStatusError( + error.status, + error.message, + reqId, + parseRetryAfterMs(error.headers), + ); } // Base APIError with no status and no body => transport-layer failure. // When the error has a body (e.g. SSE error events from the server), diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 389d28d72..78bda2931 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -235,6 +235,13 @@ function formatResponsesErrorEvent( return `${codeText}: ${message}${paramText}`; } +const EMBEDDED_STATUS_CODE_RE = /\bstatus_code\s*[:=]\s*(\d{3})\b/; + +function readEmbeddedStatusCode(message: string): number | undefined { + const match = EMBEDDED_STATUS_CODE_RE.exec(message); + return match === null ? undefined : Number(match[1]); +} + function errorFromOpenAIResponsesEvent( prefix: string, code: string | null, @@ -246,7 +253,7 @@ function errorFromOpenAIResponsesEvent( if (isContextOverflowErrorCode(code)) { return new APIContextOverflowError(400, fullMessage); } - if (code === 'rate_limit_exceeded') { + if (code === 'rate_limit_exceeded' || readEmbeddedStatusCode(message) === 429) { return new APIProviderRateLimitError(fullMessage); } return new ChatProviderError(fullMessage); diff --git a/packages/kosong/test/anthropic-errors.test.ts b/packages/kosong/test/anthropic-errors.test.ts index 700ba00ff..6d59ac416 100644 --- a/packages/kosong/test/anthropic-errors.test.ts +++ b/packages/kosong/test/anthropic-errors.test.ts @@ -86,6 +86,30 @@ describe('convertAnthropicError', () => { expect((result as APIProviderRateLimitError).statusCode).toBe(429); }); + it('reads an integer retry-after header (seconds) onto the rate-limit error', () => { + const err = AnthropicAPIError.generate( + 429, + { type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }, + 'rate limited', + new Headers({ 'retry-after': '7' }), + ); + const result = convertAnthropicError(err); + expect(result).toBeInstanceOf(APIProviderRateLimitError); + expect((result as APIProviderRateLimitError).retryAfterMs).toBe(7_000); + }); + + it('ignores a non-integer (HTTP-date) retry-after header, leaving retryAfterMs null', () => { + const err = AnthropicAPIError.generate( + 429, + { type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }, + 'rate limited', + new Headers({ 'retry-after': 'Wed, 21 Oct 2026 07:28:00 GMT' }), + ); + const result = convertAnthropicError(err); + expect(result).toBeInstanceOf(APIProviderRateLimitError); + expect((result as APIProviderRateLimitError).retryAfterMs).toBeNull(); + }); + it('generic AnthropicError -> ChatProviderError', () => { const err = new AnthropicError('something went wrong'); const result = convertAnthropicError(err); @@ -120,11 +144,17 @@ describe('convertAnthropicError', () => { expect(isRetryableGenerateError(result)).toBe(true); }); - it('still wraps an unrelated raw Error as a non-retryable ChatProviderError', () => { + it('still wraps an unrelated raw Error as a base ChatProviderError, now retryable via fallback', () => { + // An unrelated raw Error is NOT an Anthropic SDK error and carries no + // usable HTTP status, so convertAnthropicError wraps it as a base + // ChatProviderError (constructor check guards that typing). The fallback + // safety net in isRetryableGenerateError then treats such unclassified + // provider failures as transient — retry beats failing the run on the + // first blip. const result = convertAnthropicError(new Error('something completely unrelated')); expect(result.constructor).toBe(ChatProviderError); - expect(isRetryableGenerateError(result)).toBe(false); + expect(isRetryableGenerateError(result)).toBe(true); }); }); describe('non-stream error propagation', () => { diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index 044dc07a9..5fa195f88 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -137,7 +137,7 @@ describe('isRetryableGenerateError', () => { expect(isRetryableGenerateError(new APIEmptyResponseError('empty'))).toBe(true); }); - it.each([429, 500, 502, 503, 504])('treats HTTP %i as retryable', (statusCode) => { + it.each([408, 409, 429, 500, 502, 503, 504, 529])('treats HTTP %i as retryable', (statusCode) => { expect(isRetryableGenerateError(new APIStatusError(statusCode, 'retryable'))).toBe(true); }); @@ -145,6 +145,21 @@ describe('isRetryableGenerateError', () => { expect(isRetryableGenerateError(new APIStatusError(statusCode, 'non-retryable'))).toBe(false); }); + it('propagates retryAfterMs through normalizeAPIStatusError onto the typed error', () => { + const rateLimited = normalizeAPIStatusError(429, 'rate limited', 'req-1', 12_500); + expect(rateLimited).toBeInstanceOf(APIProviderRateLimitError); + expect(rateLimited.retryAfterMs).toBe(12_500); + + const generic = normalizeAPIStatusError(503, 'bad gateway', null, 3_000); + expect(generic).toBeInstanceOf(APIStatusError); + expect(generic.retryAfterMs).toBe(3_000); + }); + + it('defaults retryAfterMs to null when no retry-after header is present', () => { + expect(new APIStatusError(429, 'x').retryAfterMs).toBeNull(); + expect(normalizeAPIStatusError(429, 'x').retryAfterMs).toBeNull(); + }); + it('does not retry context overflow or unknown errors', () => { expect( isRetryableGenerateError(new APIContextOverflowError(400, 'Context length exceeded')), @@ -152,6 +167,17 @@ describe('isRetryableGenerateError', () => { expect(isRetryableGenerateError(new Error('boom'))).toBe(false); expect(isRetryableGenerateError('boom')).toBe(false); }); + + it('retries an unclassified base ChatProviderError as a transient fallback', () => { + // An upstream gateway that forwards the original failure only as text (no + // usable HTTP status) surfaces as a base ChatProviderError. It must be + // retried rather than failing the run on the first blip — while typed + // 4xx / context-overflow / request-too-large (all APIStatusError) stay + // non-retryable on their dedicated recovery paths. + expect(isRetryableGenerateError(new ChatProviderError('unclassified upstream failure'))).toBe( + true, + ); + }); }); describe('error hierarchy instanceof checks', () => { diff --git a/packages/kosong/test/openai-common-errors.test.ts b/packages/kosong/test/openai-common-errors.test.ts index 5279fa6bb..7f4501ad7 100644 --- a/packages/kosong/test/openai-common-errors.test.ts +++ b/packages/kosong/test/openai-common-errors.test.ts @@ -118,6 +118,30 @@ describe('convertOpenAIError: provider rate limit', () => { expect(result).toBeInstanceOf(APIProviderRateLimitError); expect((result as APIProviderRateLimitError).statusCode).toBe(429); }); + + it('reads an integer retry-after header (seconds) onto the rate-limit error', () => { + const err = new OpenAIAPIError( + 429, + undefined, + 'Too many requests', + new Headers({ 'retry-after': '12' }), + ); + const result = convertOpenAIError(err); + expect(result).toBeInstanceOf(APIProviderRateLimitError); + expect((result as APIProviderRateLimitError).retryAfterMs).toBe(12_000); + }); + + it('ignores a non-integer (HTTP-date) retry-after header, leaving retryAfterMs null', () => { + const err = new OpenAIAPIError( + 429, + undefined, + 'Too many requests', + new Headers({ 'retry-after': 'Wed, 21 Oct 2026 07:28:00 GMT' }), + ); + const result = convertOpenAIError(err); + expect(result).toBeInstanceOf(APIProviderRateLimitError); + expect((result as APIProviderRateLimitError).retryAfterMs).toBeNull(); + }); }); describe('convertOpenAIError: subclass errors still match first', () => { it('APIConnectionError matches its own case', () => { @@ -211,11 +235,16 @@ describe('convertOpenAIError: raw transport-layer stream errors', () => { expect(isRetryableGenerateError(result)).toBe(true); }); - it('still wraps an unrelated raw Error as a non-retryable ChatProviderError', () => { + it('still wraps an unrelated raw Error as a base ChatProviderError, now retryable via fallback', () => { + // An unrelated raw Error is NOT an OpenAI SDK error and carries no usable + // HTTP status, so convertOpenAIError wraps it as a base ChatProviderError + // (constructor check guards that typing). The fallback safety net in + // isRetryableGenerateError then treats such unclassified provider failures + // as transient — retry beats failing the run on the first blip. const result = convertOpenAIError(new Error('something completely unrelated')); expect(result.constructor).toBe(ChatProviderError); - expect(isRetryableGenerateError(result)).toBe(false); + expect(isRetryableGenerateError(result)).toBe(true); }); }); describe('OpenAI streaming: undici terminated mid-stream', () => { diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 7421c8fdf..7b13edcb5 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -1911,6 +1911,33 @@ describe('OpenAIResponsesChatProvider', () => { expect((caughtError as Error).message).not.toContain('stream event.type must be a string'); }); + it('promotes an embedded upstream status_code=429 to a retryable rate limit', async () => { + // llmproxy forwards the original provider 429 as text inside the message + // (`.../responses/.json status_code=429`) while the Responses API + // `code` is not `rate_limit_exceeded`. It must still surface as an + // APIProviderRateLimitError so chatWithRetry recovers it. + const events = [ + { + type: 'error', + code: 'upstream_error', + message: 'llmproxy/openai/responses/resp_abc.json status_code=429', + param: null, + }, + ]; + const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true); + + let caughtError: unknown; + try { + await collectStreamParts(stream); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(APIProviderRateLimitError); + expect((caughtError as APIProviderRateLimitError).statusCode).toBe(429); + expect((caughtError as Error).message).toContain('status_code=429'); + }); + it('rejects malformed stream events with a non-string type even when message is present', async () => { const events = [ { From 04041eb998b6798898fa5df97f7587b3aa119b27 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 10 Jul 2026 17:02:08 +0800 Subject: [PATCH 4/4] fix(web): hide injected system asides in user message bubbles (#1535) * fix(web): hide injected system asides in user message bubbles * fix(web): preserve literal tags in user prompts * chore: fold duplicate web changeset into caption-hiding entry --- .changeset/hide-web-system-asides.md | 5 ++ .../src/composables/messagesToTurns.ts | 22 ++++- apps/kimi-web/test/turn-logic.test.ts | 88 +++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 .changeset/hide-web-system-asides.md diff --git a/.changeset/hide-web-system-asides.md b/.changeset/hide-web-system-asides.md new file mode 100644 index 000000000..d8a520a41 --- /dev/null +++ b/.changeset/hide-web-system-asides.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Hide the internal image-compression note so it no longer renders as user message text. diff --git a/apps/kimi-web/src/composables/messagesToTurns.ts b/apps/kimi-web/src/composables/messagesToTurns.ts index 8d2993a22..cddd47a63 100644 --- a/apps/kimi-web/src/composables/messagesToTurns.ts +++ b/apps/kimi-web/src/composables/messagesToTurns.ts @@ -25,6 +25,24 @@ const USER_MEDIA_PATH_TAG_RE = /^<(image|video|audio)\s+path="([^"]+)">(?:<\/\1> const SYSTEM_MIME_RE = /Mime type:\s*([^.\s]+)/i; const SYSTEM_SIZE_RE = /Size:\s*(\d+)\s*bytes/i; const SYSTEM_DIMENSIONS_RE = /Original dimensions:\s*(\d+)x(\d+)\s*pixels/i; +// agent-core inlines a single model-facing `` caption next to a +// compressed image upload (buildImageCompressionCaption), which rides along as +// a text part of the persisted user message. That one caption is harness +// metadata, not something the user typed, and its raw markup must never reach +// the bubble (or the edit/preview text derived from `turn.text`). The TUI and +// agent-core strip ONLY that caption — anchored on its fixed opening +// `Image compressed to fit model limits:` (see +// extractImageCompressionCaptions in agent-core) — and reroute it through the +// hidden system-reminder injection. Mirror that narrow targeting here: a +// literal `` the user pasted themselves (e.g. an XML / prompt +// example) is their own text, not harness metadata, so it survives untouched. +const CAPTION_OPENING = 'Image compressed to fit model limits:'; +const CAPTION_PATTERN = /Image compressed to fit model limits:[\s\S]*?<\/system>/g; + +function stripImageCompressionCaptions(text: string): string { + if (!text.includes(CAPTION_OPENING)) return text; + return text.replace(CAPTION_PATTERN, ''); +} function unescapeAttr(value: string): string { // & last so a doubly-escaped value isn't decoded twice. @@ -698,7 +716,9 @@ export function messagesToTurns( continue; } } - textParts.push(c.text); + const stripped = stripImageCompressionCaptions(c.text); + if (stripped !== c.text && stripped.trim().length === 0) continue; + textParts.push(stripped); } } const media = resolveMediaUrl(c); diff --git a/apps/kimi-web/test/turn-logic.test.ts b/apps/kimi-web/test/turn-logic.test.ts index 07b09bae7..62498e2b3 100644 --- a/apps/kimi-web/test/turn-logic.test.ts +++ b/apps/kimi-web/test/turn-logic.test.ts @@ -206,6 +206,94 @@ describe('messagesToTurns', () => { expect(turns[0]).toMatchObject({ role: 'user', text: tag }); expect(turns[0]?.images).toBeUndefined(); }); + + it('strips the hidden image-compression caption from a user bubble', () => { + // The server persists this `` note as its own text part next to a + // compressed upload (buildImageCompressionCaption). It is model-facing + // harness metadata and must never render as user-typed text. + const caption = + 'Image compressed to fit model limits: original 3024x1834 image/png (934 KB) -> ' + + 'sent 2000x1213 image/png (518 KB). Fine detail may be lost. The uncompressed original ' + + 'is saved at "/Users/me/.kimi-code/files/f_0000000000000000000000000"; if you need fine ' + + 'detail, call ReadMediaFile on that path with the region parameter to view a crop at full ' + + 'fidelity.'; + const turns = messagesToTurns( + [ + message('u1', 'user', [ + { type: 'text', text: 'look at this' }, + { type: 'text', text: caption }, + ]), + ], + [], + undefined, + false, + ); + + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ role: 'user', text: 'look at this' }); + expect(turns[0]?.text).not.toContain(''); + }); + + it('drops a caption-only text part and strips captions merged into prose', () => { + const caption = + 'Image compressed to fit model limits: original 100x100 image/png (1 KB) -> ' + + 'sent 100x100 image/png (1 KB). Fine detail may be lost.'; + + // Image-only upload: the caption is the sole text part, so nothing + // user-typed remains and the bubble text is empty (the image still renders). + const captionOnly = messagesToTurns( + [message('u1', 'user', [{ type: 'text', text: caption }])], + [], + undefined, + false, + ); + expect(captionOnly[0]).toMatchObject({ role: 'user', text: '' }); + + // TUI-paste style: a caption merged into the surrounding text segment is + // stripped without eating the prose around it. + const merged = messagesToTurns( + [message('u2', 'user', [{ type: 'text', text: `before ${caption} after` }])], + [], + undefined, + false, + ); + expect(merged[0]?.text).not.toContain(''); + expect(merged[0]?.text).toContain('before'); + expect(merged[0]?.text).toContain('after'); + }); + + it('preserves a literal `` block the user typed themselves', () => { + // Only the image-compression caption is harness metadata. A `` tag + // the user pasted on purpose (e.g. an XML / prompt example) is their own + // text, so it must reach the bubble and the edit/resend payload verbatim. + const turns = messagesToTurns( + [ + message('u1', 'user', [ + { type: 'text', text: 'hi some example markup there' }, + ]), + ], + [], + undefined, + false, + ); + + expect(turns[0]?.text).toBe('hi some example markup there'); + }); + + it('leaves ordinary user text and stray angle brackets untouched', () => { + const turns = messagesToTurns( + [ + message('u1', 'user', [ + { type: 'text', text: 'a < b and c > d, no system tag here' }, + ]), + ], + [], + undefined, + false, + ); + + expect(turns[0]).toMatchObject({ role: 'user', text: 'a < b and c > d, no system tag here' }); + }); }); describe('latestTodos', () => {