Compare commits

..

4 commits

Author SHA1 Message Date
qer
04041eb998
fix(web): hide injected system asides in user message bubbles (#1535)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* fix(web): hide injected system asides in user message bubbles

* fix(web): preserve literal <system> tags in user prompts

* chore: fold duplicate web changeset into caption-hiding entry
2026-07-10 17:02:08 +08:00
Haozhe
9f66ec416c
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
2026-07-10 15:47:12 +08:00
qer
0ad568436a
docs(changelog): sync 0.23.4 from apps/kimi-code/CHANGELOG.md (#1528)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
2026-07-10 00:48:47 +08:00
qer
5a90d9d7f1
chore(agents): place Polish before Bug Fixes in changelog sections (#1527) 2026-07-10 00:08:25 +08:00
19 changed files with 573 additions and 37 deletions

View file

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

View file

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

View file

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

View file

@ -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<Event, { type: 'turn.step.retrying' }>): 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<Event, { type: 'turn.step.retrying' }>): 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`);
}
}

View file

@ -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<void>((resolve) => {

View file

@ -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 `<system>` 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
// `<system>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 `<system>…</system>` the user pasted themselves (e.g. an XML / prompt
// example) is their own text, not harness metadata, so it survives untouched.
const CAPTION_OPENING = '<system>Image compressed to fit model limits:';
const CAPTION_PATTERN = /<system>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 {
// &amp; 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);

View file

@ -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 `<system>` 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 =
'<system>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.</system>';
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('<system>');
});
it('drops a caption-only text part and strips captions merged into prose', () => {
const caption =
'<system>Image compressed to fit model limits: original 100x100 image/png (1 KB) -> ' +
'sent 100x100 image/png (1 KB). Fine detail may be lost.</system>';
// 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('<system>');
expect(merged[0]?.text).toContain('before');
expect(merged[0]?.text).toContain('after');
});
it('preserves a literal `<system>` block the user typed themselves', () => {
// Only the image-compression caption is harness metadata. A `<system>` 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 <system>some example markup</system> there' },
]),
],
[],
undefined,
false,
);
expect(turns[0]?.text).toBe('hi <system>some example markup</system> 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', () => {

View file

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

View file

@ -6,6 +6,26 @@ outline: 2
本页记录 Kimi Code CLI 每个版本的变更内容。
## 0.23.42026-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.32026-07-08
### 修复

View file

@ -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<LLMChatR
throw error;
}
const delayMs = delays[attempt - 1] ?? 0;
// A server `Retry-After` (carried on the error) overrides the computed
// backoff. The chosen delay is what gets reported on the
// `step.retrying` event via `delayMs` either way.
const delayMs = readRetryAfterMs(error) ?? delays[attempt - 1] ?? 0;
input.params.signal.throwIfAborted();
input.dispatchEvent({
type: 'step.retrying',
@ -104,13 +113,27 @@ function paramsForAttempt(
}
export function retryBackoffDelays(maxAttempts: number): number[] {
return retry.timeouts({
retries: Math.max(maxAttempts - 1, 0),
minTimeout: RETRY_MIN_TIMEOUT_MS,
maxTimeout: RETRY_MAX_TIMEOUT_MS,
factor: RETRY_FACTOR,
randomize: true,
});
// For attempt (1-based) the base delay is min(500ms * 2^(attempt-1), 32s),
// plus up to 25% jitter. Index i here is 0-based, so attempt = i + 1.
const count = Math.max(maxAttempts - 1, 0);
const delays: number[] = [];
for (let i = 0; i < count; i += 1) {
const base = Math.min(BASE_DELAY_MS * Math.pow(RETRY_FACTOR, i), MAX_DELAY_MS);
delays.push(base + Math.random() * JITTER_FACTOR * base);
}
return delays;
}
/**
* Server-requested backoff carried on a kosong `APIStatusError` (parsed from
* the `retry-after` response header). When present and positive it overrides
* the computed backoff a server `Retry-After` directive takes precedence
* over the local exponential delay.
*/
function readRetryAfterMs(error: unknown): number | null {
if (typeof error !== 'object' || error === null) return null;
const value = (error as { retryAfterMs?: unknown }).retryAfterMs;
return typeof value === 'number' && value > 0 ? value : null;
}
export async function sleepForRetry(delayMs: number, signal: AbortSignal): Promise<void> {

View file

@ -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<LLMChatResponse> {
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',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<id>.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 = [
{