mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-12 18:27:39 +00:00
* fix(kosong): fail fast on quota-exhausted 429 instead of retrying A 429 caused by an exhausted account quota or insufficient balance (Moonshot error.type "exceeded_current_quota_error", OpenAI "insufficient_quota") can never succeed on retry, yet it was classified as APIProviderRateLimitError and silently retried for the whole budget (10 attempts, ~3 minutes of backoff) with no UI feedback — the session appeared frozen on every request. Introduce APIProviderQuotaExhaustedError, minted in normalizeAPIStatusError from the structured body error.type/error.code forwarded by convertOpenAIError, with billing-anchored message patterns as a fallback for gateways that flatten the body to text. The new class is excluded from isRetryableGenerateError (fail fast, even when a retry-after header is present) and from isProviderRateLimitError (no swarm requeue/suspend). toKimiErrorPayload and translateProviderError map it to provider.api_error (retryable: false) instead of provider.rate_limit, and classifyApiError reports it as quota_exhausted in telemetry. agent-core-v2 mirrors the same fix. Transient rate-limit 429s keep the existing retry, backoff, and Retry-After behavior (verified end-to-end against a mock provider: quota body fails after attempt 1/10; rate-limit body still walks the full 10-attempt ladder). Behavior changes to note: quota-failed swarm subagents now fail instead of suspending indefinitely as "Rate limited...", and quota errors cross the wire as provider.api_error rather than provider.rate_limit. * fix(kosong): classify quota exhaustion in OpenAI Responses stream errors Responses response.failed / error SSE events carry no HTTP status and were minted by errorFromOpenAIResponsesEvent as either a rate-limit error (rate_limit_exceeded / embedded status_code=429) or a base ChatProviderError — and the base class falls into the retryable unclassified-failure fallback, so an insufficient_quota event still burned the whole retry budget on the openai_responses path. Route the event code and message through the same quota-exhausted check before the rate-limit branch, in kosong and the agent-core-v2 mirror. Covers all three entry paths (error events, response.failed, nested gateway frames) since they share the single converter. * style(agent-core-v2): drop inline comments per AGENTS.md header-only rule agent-core-v2 comments live solely in the top-of-file block, never beside functions or statements; the kosong twins keep the full rationale. * refactor(kosong,agent-core-v2): move quota-429 checks to vendor hook Per review on #1857: the knowledge of how a backend signals quota exhaustion is vendor-specific and must not run for every OpenAI-compatible provider from the shared conversion layer. - Add a convertError hook: ProtocolTrait.convertError in agent-core-v2 (single-value, last-declarer-wins, bound by composeOpenAIChatHooks / composeAnthropicHooks / traitConvertError) and an equivalent optional hook parameter on convertOpenAIError / convertAnthropicError. Bases consult it with the raw failure (SDK error on HTTP paths, raw event on the Responses in-stream path) after the abort guard, before their own rules. - Declare Moonshot's quota signals (exceeded_current_quota_error, billing wordings) on the Kimi side: kimiOpenAITrait and kimiAnthropicTrait in v2, the KimiChatProvider and KimiFiles catch sites in kosong, all through the new classifyKimiQuotaError. - Drop the options parameter from normalizeAPIStatusError and the shared quota code/pattern tables: the contract layer keeps only the vendor-neutral APIProviderQuotaExhaustedError type and its retry / rate-limit / wire-mapping semantics. - The OpenAI bases keep recognizing only OpenAI's own documented insufficient_quota code (HTTP and Responses stream events) as protocol knowledge of that wire. Behavior: kimi and openai provider types classify exactly as before; an unregistered vendor speaking Moonshot billing wordings through a plain openai transport now stays a retryable rate limit by design. * fix(kosong,agent-core,agent-core-v2): wire kimi quota hook fully Follow-up to the second review round on #1857, all four findings: - Kimi-over-Anthropic (legacy engine): AnthropicOptions gains the same optional convertError hook as the OpenAI bases, threaded through AnthropicStreamedMessage and every catch site, and the provider manager's anthropic route now passes classifyKimiQuotaError for provider type kimi — a quota-exhausted 429 over this transport previously still burned the retry budget. classifyKimiQuotaError now also walks error -> .error -> .error.error for the code/type, since the Anthropic SDK keeps the full body on .error instead of hoisting. - v2 telemetry: ApiErrorKind gains 'quota_exhausted' and classifyApiError checks APIProviderQuotaExhaustedError before the generic 429 branch, matching the legacy engine's reporting. - Hook contract: converted ChatProviderErrors now pass through before the vendor hook is consulted in convertOpenAIError / convertAnthropicError (both engines), so the hook sees each raw failure exactly once even when a stream-minted error crosses an outer catch; tests assert the single consult. - protocolTrait: the convertError member doc shrinks to the concise style and the consult contract moves into the file header's composition rules. * test(kosong,agent-core,agent-core-v2): lock quota hook assembly paths Third review round on #1857: - Fix the v2 anthropic base header and AnthropicHooks doc still claiming withThinking is the only hook. - Drop the two remaining non-header JSDoc blocks in protocolTrait.ts per the AGENTS.md header-only rule; the consult contract already lives in the file header. - Update the ProtocolTrait contract test to the seventeen-hook shape (convertError included) and cover the traitConvertError binding. - Add real-assembly regression probes: the v2 registry composes a (kimi, anthropic) provider whose mocked SDK client throws a Moonshot quota 429 and generate rejects with the non-retryable APIProviderQuotaExhaustedError (a plain anthropic composition keeps the same 429 retryable); the legacy ProviderManager routing test asserts convertError is classifyKimiQuotaError on the kimi-anthropic route and absent for plain anthropic; the legacy provider threads options.convertError to its generate catch. * test(kosong,agent-core-v2): cover KimiFiles quota 429 and drop stale docs Fourth review round on #1857: - Drop the AnthropicHooks member JSDoc (its content already lives in the anthropic.ts and anthropicHooks.ts file headers) and fix the anthropic contrib header still calling the hook set single-hook. - Add the missing KimiFiles regression in both engines: a mocked files client rejecting with a Moonshot quota 429 makes uploadVideo reject with the non-retryable APIProviderQuotaExhaustedError, locking the classifyKimiQuotaError argument at the upload catch sites.
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
import {
|
|
APIConnectionError,
|
|
APIProviderQuotaExhaustedError,
|
|
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, DEFAULT_MAX_RETRY_ATTEMPTS, retryBackoffDelays } from '#/loop/retry';
|
|
import { ProviderManager } from '#/session/provider-manager';
|
|
|
|
function okResponse(): LLMChatResponse {
|
|
return { toolCalls: [], usage: emptyUsage() };
|
|
}
|
|
|
|
function makeInput(
|
|
llm: LLM,
|
|
signal: AbortSignal,
|
|
): Parameters<typeof chatWithRetry>[0] {
|
|
return {
|
|
llm,
|
|
params: { messages: [], tools: [], signal },
|
|
dispatchEvent: async () => {},
|
|
turnId: 't',
|
|
currentStep: 1,
|
|
stepUuid: 'u',
|
|
};
|
|
}
|
|
|
|
describe('chatWithRetry: terminated stream drops', () => {
|
|
it('preserves caller-set requestLogFields across attempts while owning turnStep/attempt', async () => {
|
|
// The strict-resend path marks its params with `projection: 'strict'`;
|
|
// the per-attempt rebuild must merge that marker instead of replacing
|
|
// the whole fields object.
|
|
let calls = 0;
|
|
const seenFields: Array<LLMChatParams['requestLogFields']> = [];
|
|
const llm: LLM = {
|
|
systemPrompt: '',
|
|
modelName: 'mock',
|
|
isRetryableError: (e) => isRetryableGenerateError(e),
|
|
async chat(params: LLMChatParams): Promise<LLMChatResponse> {
|
|
calls += 1;
|
|
seenFields.push(params.requestLogFields);
|
|
if (calls === 1) throw new APIConnectionError('terminated');
|
|
return okResponse();
|
|
},
|
|
};
|
|
const input = makeInput(llm, new AbortController().signal);
|
|
|
|
await chatWithRetry({
|
|
...input,
|
|
params: { ...input.params, requestLogFields: { projection: 'strict' } },
|
|
});
|
|
|
|
expect(seenFields).toEqual([
|
|
{ projection: 'strict', turnStep: 't.1' },
|
|
{ projection: 'strict', turnStep: 't.1', attempt: '2/10' },
|
|
]);
|
|
});
|
|
|
|
it('retries an APIConnectionError("terminated") and succeeds on a later attempt', async () => {
|
|
// A mid-stream `terminated` is classified as a retryable APIConnectionError,
|
|
// so an intermittent connection drop should be recovered transparently.
|
|
let calls = 0;
|
|
const llm: LLM = {
|
|
systemPrompt: '',
|
|
modelName: 'mock',
|
|
isRetryableError: (e) => isRetryableGenerateError(e),
|
|
async chat(_params: LLMChatParams): Promise<LLMChatResponse> {
|
|
calls += 1;
|
|
if (calls === 1) throw new APIConnectionError('terminated');
|
|
return okResponse();
|
|
},
|
|
};
|
|
|
|
const response = await chatWithRetry(makeInput(llm, new AbortController().signal));
|
|
|
|
expect(calls).toBe(2);
|
|
expect(response).toEqual(okResponse());
|
|
});
|
|
|
|
it('does NOT retry when the signal is aborted (user ESC), surfacing a clean AbortError', async () => {
|
|
// Even though `terminated` is retryable, a user-aborted request must never
|
|
// be retried: the abort signal is checked before any retry, so it surfaces
|
|
// as an AbortError rather than a provider error.
|
|
let calls = 0;
|
|
const ac = new AbortController();
|
|
ac.abort();
|
|
|
|
const llm: LLM = {
|
|
systemPrompt: '',
|
|
modelName: 'mock',
|
|
isRetryableError: (e) => isRetryableGenerateError(e),
|
|
async chat(_params: LLMChatParams): Promise<LLMChatResponse> {
|
|
calls += 1;
|
|
throw new APIConnectionError('terminated');
|
|
},
|
|
};
|
|
|
|
await expect(chatWithRetry(makeInput(llm, ac.signal))).rejects.toMatchObject({
|
|
name: 'AbortError',
|
|
});
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('does not retry OAuth token fetch connection errors (already retried internally)', async () => {
|
|
let tokenCalls = 0;
|
|
const manager = new ProviderManager({
|
|
config: oauthConfig(),
|
|
resolveOAuthTokenProvider: () => ({
|
|
async getAccessToken() {
|
|
tokenCalls += 1;
|
|
throw new KimiError(
|
|
ErrorCodes.PROVIDER_CONNECTION_ERROR,
|
|
'OAuth provider "managed:kimi-code" failed to fetch an access token: fetch failed',
|
|
);
|
|
},
|
|
}),
|
|
});
|
|
const resolveAuth = manager.resolveAuth('kimi-code/kimi-for-coding');
|
|
if (resolveAuth === undefined) throw new Error('expected OAuth auth resolver');
|
|
|
|
let chatCalls = 0;
|
|
const llm: LLM = {
|
|
systemPrompt: '',
|
|
modelName: 'mock',
|
|
isRetryableError: (e) => isRetryableGenerateError(e),
|
|
async chat(_params: LLMChatParams): Promise<LLMChatResponse> {
|
|
chatCalls += 1;
|
|
return resolveAuth(async () => okResponse());
|
|
},
|
|
};
|
|
|
|
await expect(chatWithRetry(makeInput(llm, new AbortController().signal))).rejects.toMatchObject({
|
|
code: ErrorCodes.PROVIDER_CONNECTION_ERROR,
|
|
});
|
|
expect(chatCalls).toBe(1);
|
|
expect(tokenCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
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 low-attempt configs quick so latency-sensitive 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: default retry budget', () => {
|
|
it('retries up to DEFAULT_MAX_RETRY_ATTEMPTS before giving up', async () => {
|
|
// A sustained 429 carries a 1ms server retry-after so the test exercises
|
|
// the full default budget without sleeping through the real backoff.
|
|
let calls = 0;
|
|
const captured: Array<{ type: string }> = [];
|
|
const llm: LLM = {
|
|
systemPrompt: '',
|
|
modelName: 'mock',
|
|
isRetryableError: (e) => isRetryableGenerateError(e),
|
|
async chat(): Promise<LLMChatResponse> {
|
|
calls += 1;
|
|
throw new APIProviderRateLimitError('rate limited', null, 1);
|
|
},
|
|
};
|
|
const input = makeInput(llm, new AbortController().signal);
|
|
|
|
await expect(
|
|
chatWithRetry({
|
|
...input,
|
|
dispatchEvent: async (event) => {
|
|
captured.push(event as { type: string });
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({ name: 'APIProviderRateLimitError' });
|
|
|
|
expect(calls).toBe(DEFAULT_MAX_RETRY_ATTEMPTS);
|
|
expect(captured.filter((e) => e.type === 'step.retrying')).toHaveLength(
|
|
DEFAULT_MAX_RETRY_ATTEMPTS - 1,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('chatWithRetry: quota-exhausted 429 fails fast', () => {
|
|
it('does not retry a quota-exhausted 429 even when it carries retry-after', async () => {
|
|
// Same status as a rate limit, but exhausted quota/balance never clears
|
|
// on its own — the error must surface after a single attempt instead of
|
|
// burning the whole default budget. The 1ms retry-after proves a server
|
|
// backoff hint does not re-enable retries either.
|
|
let calls = 0;
|
|
const captured: Array<{ type: string }> = [];
|
|
const llm: LLM = {
|
|
systemPrompt: '',
|
|
modelName: 'mock',
|
|
isRetryableError: (e) => isRetryableGenerateError(e),
|
|
async chat(): Promise<LLMChatResponse> {
|
|
calls += 1;
|
|
throw new APIProviderQuotaExhaustedError(
|
|
'Your account is suspended due to insufficient balance, please recharge your account',
|
|
null,
|
|
1,
|
|
);
|
|
},
|
|
};
|
|
const input = makeInput(llm, new AbortController().signal);
|
|
|
|
await expect(
|
|
chatWithRetry({
|
|
...input,
|
|
dispatchEvent: async (event) => {
|
|
captured.push(event as { type: string });
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({ name: 'APIProviderQuotaExhaustedError' });
|
|
|
|
expect(calls).toBe(1);
|
|
expect(captured.filter((e) => e.type === 'step.retrying')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
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',
|
|
providers: {
|
|
'managed:kimi-code': {
|
|
type: 'kimi',
|
|
apiKey: '',
|
|
baseUrl: 'https://api.example/v1',
|
|
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
|
},
|
|
},
|
|
models: {
|
|
'kimi-code/kimi-for-coding': {
|
|
provider: 'managed:kimi-code',
|
|
model: 'kimi-for-coding',
|
|
maxContextSize: 1_000_000,
|
|
},
|
|
},
|
|
};
|
|
}
|