mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 09:49:20 +00:00
Compare commits
4 commits
@moonshot-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04041eb998 | ||
|
|
9f66ec416c | ||
|
|
0ad568436a | ||
|
|
5a90d9d7f1 |
19 changed files with 573 additions and 37 deletions
|
|
@ -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:
|
||||
|
|
|
|||
5
.changeset/hide-web-system-asides.md
Normal file
5
.changeset/hide-web-system-asides.md
Normal 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.
|
||||
5
.changeset/retry-fault-tolerance.md
Normal file
5
.changeset/retry-fault-tolerance.md
Normal 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`.
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// & 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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
### 修复
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue