Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/quota-exhausted-fail-fast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/kimi-code-sdk": patch
---

Fail fast on quota/balance-exhausted HTTP 429 errors (e.g. Moonshot `exceeded_current_quota_error`, OpenAI `insufficient_quota`) instead of silently retrying for ~3 minutes. Transient rate-limit 429s keep the existing retry, backoff, and Retry-After behavior.
2 changes: 2 additions & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ When the experiment is enabled, the configuration is validated as the session st

`max_steps_per_turn` can be overridden by the `KIMI_LOOP_MAX_STEPS_PER_TURN` environment variable, and `max_retries_per_step` by `KIMI_LOOP_MAX_RETRIES_PER_STEP`; both take higher priority than the config file.

Retries only apply to transient failures — connection errors, timeouts, HTTP 429 rate limits, and 5xx server errors. A 429 caused by an exhausted quota or insufficient account balance is not retried and fails immediately, since it cannot succeed until the account is recharged.

## `background`

`background` controls the concurrency behavior of background tasks (launched via the `Bash` tool or the `Agent` tool's `run_in_background=true` parameter).
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ max_output_size = 8192

`max_steps_per_turn` 可被环境变量 `KIMI_LOOP_MAX_STEPS_PER_TURN` 覆盖,`max_retries_per_step` 可被 `KIMI_LOOP_MAX_RETRIES_PER_STEP` 覆盖,优先级均高于配置文件。

重试仅针对瞬时故障——连接错误、超时、HTTP 429 限流和 5xx 服务端错误。账户额度耗尽或余额不足导致的 429 不会重试,会立即失败:在充值之前重试不可能成功。

## `background`

`background` 控制后台任务(通过 `Bash` 工具或 `Agent` 工具的 `run_in_background=true` 参数启动)的并发数。
Expand Down
20 changes: 20 additions & 0 deletions packages/agent-core-v2/src/kosong/contract/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ export class APIProviderRateLimitError extends APIStatusError {
}
}

export class APIProviderQuotaExhaustedError extends APIStatusError {
constructor(
message: string,
requestId?: string | null,
retryAfterMs?: number | null,
traceId?: string | null,
) {
super(429, message, requestId, retryAfterMs, traceId);
this.name = 'APIProviderQuotaExhaustedError';
}
}

export class APIProviderOverloadedError extends APIStatusError {
constructor(
statusCode: number,
Expand Down Expand Up @@ -236,6 +248,9 @@ export function isRetryableGenerateError(error: unknown): boolean {
return true;
}
if (error instanceof APIStatusError) {
if (error instanceof APIProviderQuotaExhaustedError) {
return false;
}
return [408, 409, 429, 500, 502, 503, 504, 529].includes(error.statusCode);
}
return error instanceof ChatProviderError && !isImageFormatError(error);
Expand Down Expand Up @@ -424,6 +439,7 @@ export function isRecoverableRequestStructureError(error: unknown): boolean {
}

export function isProviderRateLimitError(error: unknown): boolean {
if (error instanceof APIProviderQuotaExhaustedError) return false;
if (error instanceof APIProviderRateLimitError) return true;

const statusCode = getStatusCode(error);
Expand Down Expand Up @@ -464,6 +480,7 @@ export type ApiErrorKind =
| 'context_overflow'
| 'overloaded'
| 'rate_limit'
| 'quota_exhausted'
| 'auth'
| '5xx_server'
| '4xx_client'
Expand All @@ -481,6 +498,9 @@ export function classifyApiError(error: unknown): ApiErrorClassification {
const statusCode = getStatusCode(error);
if (error instanceof APIContextOverflowError) return { kind: 'context_overflow', statusCode };
if (error instanceof APIProviderOverloadedError) return { kind: 'overloaded', statusCode };
if (error instanceof APIProviderQuotaExhaustedError) {
return { kind: 'quota_exhausted', statusCode };
}
if (error instanceof APIStatusError) {
if (isContextOverflowStatusError(error.statusCode, error.message)) {
return { kind: 'context_overflow', statusCode };
Expand Down
13 changes: 8 additions & 5 deletions packages/agent-core-v2/src/kosong/protocol/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
APIContextOverflowError,
APIEmptyResponseError,
APIProviderOverloadedError,
APIProviderQuotaExhaustedError,
APIStatusError,
APITimeoutError,
ChatProviderError,
Expand Down Expand Up @@ -90,11 +91,13 @@ export function translateProviderError(error: unknown): Error2 {
? ProtocolErrors.codes.CONTEXT_OVERFLOW
: error instanceof APIProviderOverloadedError || error.statusCode === 529
? ProtocolErrors.codes.PROVIDER_OVERLOADED
: error.statusCode === 429
? ProtocolErrors.codes.PROVIDER_RATE_LIMIT
: error.statusCode === 401 || error.statusCode === 403
? ProtocolErrors.codes.PROVIDER_AUTH_ERROR
: ProtocolErrors.codes.PROVIDER_API_ERROR;
: error instanceof APIProviderQuotaExhaustedError
? ProtocolErrors.codes.PROVIDER_API_ERROR
: error.statusCode === 429
? ProtocolErrors.codes.PROVIDER_RATE_LIMIT
: error.statusCode === 401 || error.statusCode === 403
? ProtocolErrors.codes.PROVIDER_AUTH_ERROR
: ProtocolErrors.codes.PROVIDER_API_ERROR;
return new Error2(code, sanitizeStatusErrorMessage(error.message), {
name: error.name,
cause: error,
Expand Down
25 changes: 24 additions & 1 deletion packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* `kosong/protocol` domain (L1) — the declarative trait surface.
*
* A `ProtocolTrait` is a stateless declaration of how one vendor deviates
* from a wire base: sixteen fully optional hooks plus rare metadata markers
* from a wire base: seventeen fully optional hooks plus rare metadata markers
* (non-function fields like `strictThinkingValidation` that qualify how a
* hook's behavior is governed, without adding a code path). A trait declares
* a deviation only where one exists; a hook returning `undefined` always
Expand All @@ -15,6 +15,14 @@
* chain in trait order, each receiving the previous stage's output.
* `convertMessage` may additionally return `null` to drop the message.
* - Single-value hooks are overwritten in trait order: last declarer wins.
* - `convertError` is consulted by the bases with each RAW failure exactly
* once — the SDK error on HTTP paths, the raw event on in-stream paths —
* after the abort guard (a cancellation never reaches it) and after the
* already-converted `ChatProviderError` pass-through. The hook exists
* because base conversion drops vendor-parsed detail such as the body
* `error.type`/`error.code`; it is where a vendor declares what its own
* wire errors mean (e.g. which 429s are a non-retryable quota
* exhaustion rather than a transient rate limit).
* - `endpoint` / `defaultHeaders` / `provides` are construction-time
* declarations aggregated by the contrib factories, not per-request hooks.
*
Expand All @@ -24,6 +32,7 @@
*/

import type { ModelCapability } from '#/kosong/contract/capability';
import type { ChatProviderError } from '#/kosong/contract/errors';
import type { Message, VideoURLPart } from '#/kosong/contract/message';
import type {
GenerateOptions,
Expand Down Expand Up @@ -133,6 +142,8 @@ export interface ProtocolTrait {
/** Single-value: tool-call id rewrite policy, replacing the base policy. */
toolCallIdPolicy?(ctx: TraitContext): ToolCallIdPolicy | undefined;

convertError?(error: unknown, ctx: TraitContext): ChatProviderError | undefined;

/**
* Per-turn thinking intent → generation-kwargs patch. Receives the kwargs
* already seeded by earlier intents (cacheKey, sampling) and returns the
Expand Down Expand Up @@ -239,3 +250,15 @@ export function traitDefaultHeaders(
}
return headers;
}

export function traitConvertError(
traits: readonly ResolvedTrait[],
): ((error: unknown) => ChatProviderError | undefined) | undefined {
let bound: ((error: unknown) => ChatProviderError | undefined) | undefined;
for (const { trait, context } of traits) {
if (trait.convertError === undefined) continue;
const declared = trait.convertError.bind(trait);
bound = (error) => declared(error, context);
}
return bound;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
* Messages base (`id: 'anthropic'`).
*
* The factory aggregates construction-time trait declarations and composes
* the (single-hook) Anthropic hook set. No apiKey suppression is needed here:
* the Anthropic base never reads shell API-key environment variables, so
* there is no base env fallback to suppress.
* the Anthropic hook set. No apiKey suppression is needed here: the
* Anthropic base never reads shell API-key environment variables, so there
* is no base env fallback to suppress.
*/

import { registerProtocolBase } from '#/kosong/protocol/protocolBase';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,20 @@
* headers vs the beta endpoint, and the thinking profile matrix (budget vs
* adaptive) from `anthropic-profile`.
*
* The only hook surface is `withThinking` — a vendor dialect running over
* this transport re-encodes the thinking intent and nothing else. When the
* per-turn thinking intent carries `keep`, the BASE overlays the
* context-management edit uniformly on top of whatever thinking encoding
* happened (hook or base path), so a trait never handles `keep` itself.
* The hook surface is `withThinking` plus `convertError`. `withThinking`
* lets a vendor dialect running over this transport re-encode the thinking
* intent; when the per-turn thinking intent carries `keep`, the BASE
* overlays the context-management edit uniformly on top of whatever
* thinking encoding happened (hook or base path), so a trait never handles
* `keep` itself.
*
* `convertAnthropicError`'s FIRST line is the contract's `throwIfAbortError`
* guard: a user cancellation is THROWN as the standard abort DOMException at
* the very front of the classification chain.
* the very front of the classification chain. After the guard,
* already-converted `ChatProviderError`s pass through untouched; only then is
* the trait-composed `convertError` hook consulted, so a vendor riding this
* transport classifies each RAW SDK failure exactly once before the base
* rules run.
*/

import Anthropic, {
Expand Down Expand Up @@ -119,19 +124,13 @@ interface AnthropicContextManagement {
edits: Array<{ type: string; keep?: unknown }>;
}

/**
* The base-internal hook set: the L1 `withThinking` hook with the context
* already bound away. It receives a defensive COPY of the seeded kwargs, so a
* hook can never mutate base state — and a construction-headers synthetic
* trait can never shadow a real dialect hook (the compositor picks the last
* declarer).
*/
export interface AnthropicHooks {
withThinking?(
effort: ThinkingEffort,
options: { readonly keep?: string },
generationKwargs: AnthropicGenerationKwargs,
): AnthropicGenerationKwargs | undefined;
convertError?: (error: unknown) => ChatProviderError | undefined;
}

export interface AnthropicOptions {
Expand Down Expand Up @@ -513,11 +512,21 @@ function shouldKeepConvertedMessage(message: MessageParam): boolean {
return message.role !== 'assistant' || message.content.length > 0;
}

export function convertAnthropicError(error: unknown): ChatProviderError {
export function convertAnthropicError(
error: unknown,
convertErrorHook?: (error: unknown) => ChatProviderError | undefined,
): ChatProviderError {
// Abort guard FIRST: throws (never returns) the standard abort DOMException
// for any abort shape, so a user cancellation is never misclassified as a
// retryable provider failure.
throwIfAbortError(error);
if (error instanceof ChatProviderError) {
return error;
}
const hooked = convertErrorHook?.(error);
if (hooked !== undefined) {
return hooked;
}
if (error instanceof AnthropicTimeoutError) {
return new APITimeoutError(error.message);
}
Expand Down Expand Up @@ -554,7 +563,13 @@ class AnthropicStreamedMessage implements StreamedMessage {
private _rawFinishReason: string | null = null;
private readonly _iter: AsyncGenerator<StreamedMessagePart>;

constructor(response: unknown, isStream: boolean) {
constructor(
response: unknown,
isStream: boolean,
private readonly _convertErrorHook?:
| ((error: unknown) => ChatProviderError | undefined)
| undefined,
) {
if (isStream) {
this._iter = this._convertStreamResponse(response as AsyncIterable<MessageStreamEvent>);
} else {
Expand Down Expand Up @@ -780,7 +795,7 @@ class AnthropicStreamedMessage implements StreamedMessage {
}
}
} catch (error: unknown) {
throw convertAnthropicError(error);
throw convertAnthropicError(error, this._convertErrorHook);
}
}
}
Expand Down Expand Up @@ -1021,9 +1036,9 @@ export class AnthropicChatProvider implements ChatProvider {
{ ...createParams, stream: true } as unknown as MessageCreateParamsStreaming,
finalRequestOptions,
);
return new AnthropicStreamedMessage(stream, true);
return new AnthropicStreamedMessage(stream, true, this._hooks?.convertError);
} catch (error: unknown) {
throw convertAnthropicError(error);
throw convertAnthropicError(error, this._hooks?.convertError);
}
}

Expand All @@ -1037,9 +1052,9 @@ export class AnthropicChatProvider implements ChatProvider {
{ ...createParams, stream: false } as unknown as MessageCreateParams,
finalRequestOptions,
);
return new AnthropicStreamedMessage(response, false);
return new AnthropicStreamedMessage(response, false, this._hooks?.convertError);
} catch (error: unknown) {
throw convertAnthropicError(error);
throw convertAnthropicError(error, this._hooks?.convertError);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,34 @@
* `kosong/provider` domain (L2) — the ONLY composition point from resolved
* traits to the Anthropic hook set.
*
* The Anthropic base has a single hook, `withThinking`. The compositor takes
* the LAST declarer and wraps it with a defensive kwargs copy — so a hook can
* never mutate base state, and a synthetic construction-headers trait (which
* never declares `withThinking`) can never shadow a real dialect hook.
* The Anthropic base has two hooks. `withThinking` takes the LAST declarer
* and wraps it with a defensive kwargs copy — so a hook can never mutate base
* state, and a synthetic construction-headers trait (which never declares
* `withThinking`) can never shadow a real dialect hook. `convertError` is the
* shared single-value binding from `traitConvertError` (last declarer wins),
* consulted by the base's error converter after the abort guard.
*/

import type { ResolvedTrait } from '#/kosong/protocol/protocolTrait';
import { traitConvertError, type ResolvedTrait } from '#/kosong/protocol/protocolTrait';

import type { AnthropicHooks } from './anthropic';

export function composeAnthropicHooks(
traits: readonly ResolvedTrait[],
): AnthropicHooks | undefined {
const hooks: AnthropicHooks = {};

const thinkingTraits = traits.filter(({ trait }) => trait.withThinking !== undefined);
if (thinkingTraits.length === 0) return undefined;
const { trait, context } = thinkingTraits.at(-1)!;
return {
withThinking: (effort, options, kwargs) =>
trait.withThinking!(effort, options, { ...kwargs }, context),
};
if (thinkingTraits.length > 0) {
const { trait, context } = thinkingTraits.at(-1)!;
hooks.withThinking = (effort, options, kwargs) =>
trait.withThinking!(effort, options, { ...kwargs }, context);
}

const convertError = traitConvertError(traits);
if (convertError !== undefined) {
hooks.convertError = convertError;
}

return Object.keys(hooks).length > 0 ? hooks : undefined;
}
Loading
Loading