From f585cbb4f7306777b504b80dc7aeacfa42fffbbe Mon Sep 17 00:00:00 2001 From: vinlee19 <1401597760@qq.com> Date: Fri, 17 Jul 2026 22:50:24 +0800 Subject: [PATCH 1/7] fix(kosong): fail fast on quota-exhausted 429 instead of retrying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/quota-exhausted-fail-fast.md | 6 ++ docs/en/configuration/config-files.md | 2 + docs/zh/configuration/config-files.md | 2 + .../src/app/llmProtocol/errors.ts | 62 +++++++++++++ .../llmProtocol/providers/openai-common.ts | 6 ++ .../agent-core-v2/src/app/protocol/errors.ts | 16 ++-- .../test/app/llmProtocol/errors.test.ts | 54 ++++++++++++ .../providers/provider-errors.test.ts | 35 +++++++- .../test/app/protocol/errors.test.ts | 17 ++++ packages/agent-core/src/agent/turn/index.ts | 6 ++ packages/agent-core/src/errors/serialize.ts | 17 ++-- .../agent-core/test/errors/serialize.test.ts | 20 ++++- packages/agent-core/test/loop/retry.test.ts | 38 ++++++++ packages/kosong/src/errors.ts | 86 ++++++++++++++++++ packages/kosong/src/index.ts | 2 + .../kosong/src/providers/openai-common.ts | 7 ++ packages/kosong/test/errors.test.ts | 88 +++++++++++++++++++ .../kosong/test/openai-common-errors.test.ts | 41 +++++++++ 18 files changed, 493 insertions(+), 12 deletions(-) create mode 100644 .changeset/quota-exhausted-fail-fast.md diff --git a/.changeset/quota-exhausted-fail-fast.md b/.changeset/quota-exhausted-fail-fast.md new file mode 100644 index 0000000000..b2405dc210 --- /dev/null +++ b/.changeset/quota-exhausted-fail-fast.md @@ -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. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 59504ec0b4..5195d49847 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -210,6 +210,8 @@ You can also switch models temporarily without touching the config file — by s | `max_retries_per_step` | `integer` | `10` | Maximum retries after a step failure | | `reserved_context_size` | `integer` | — | Number of tokens reserved for model output; automatic compaction is triggered when the remaining context window falls below this value | +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). diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 07ccfcf1be..f27e951608 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -210,6 +210,8 @@ display_name = "Kimi for Coding (custom)" | `max_retries_per_step` | `integer` | `10` | 单步失败后的最大重试次数 | | `reserved_context_size` | `integer` | — | 预留给模型输出的 token 数;上下文窗口剩余量低于此值时触发自动压缩 | +重试仅针对瞬时故障——连接错误、超时、HTTP 429 限流和 5xx 服务端错误。账户额度耗尽或余额不足导致的 429 不会重试,会立即失败:在充值之前重试不可能成功。 + ## `background` `background` 控制后台任务(通过 `Bash` 工具或 `Agent` 工具的 `run_in_background=true` 参数启动)的并发数。 diff --git a/packages/agent-core-v2/src/app/llmProtocol/errors.ts b/packages/agent-core-v2/src/app/llmProtocol/errors.ts index 7a4d5d70cd..5fd039aaa2 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/errors.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/errors.ts @@ -82,6 +82,21 @@ export class APIProviderRateLimitError extends APIStatusError { } } +// HTTP 429 caused by an exhausted account quota/balance — deterministic until +// the account is recharged, so unlike a rate limit it is neither retried nor +// requeued. Deliberately not a subclass of APIProviderRateLimitError. +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, @@ -158,6 +173,11 @@ export function isRetryableGenerateError(error: unknown): boolean { return true; } if (error instanceof APIStatusError) { + // Quota/balance exhaustion is a 429 but deterministic until the account + // is recharged — retrying can never succeed. + if (error instanceof APIProviderQuotaExhaustedError) { + return false; + } return [408, 409, 429, 500, 502, 503, 504, 529].includes(error.statusCode); } return error instanceof ChatProviderError && !isImageFormatError(error); @@ -199,6 +219,24 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ const PROVIDER_OVERLOAD_MESSAGE_PATTERNS = [/overload/] as const; +// Structured error `type`/`code` values that mean the account's quota or +// balance is exhausted: Moonshot `exceeded_current_quota_error` (body +// `error.type`), OpenAI `insufficient_quota` (both `type` and `code`). +const QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error', 'insufficient_quota']); + +// Message fallback for gateways that flatten the body to text, matched against +// the lowercased message of a 429. Anchored to billing wording — deliberately +// no bare /quota/ or /balance/, which would also match transient throttle +// messages like "token quota per minute". +const QUOTA_EXHAUSTED_MESSAGE_PATTERNS = [ + /exceeded your current (?:token )?quota/, + /check your account balance/, + /insufficient balance/, + /recharge your account|please recharge/, + /account (?:is )?in arrears/, + /insufficient_quota/, +] as const; + const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ /request exceeds the maximum size/, /request entity too large/, @@ -242,8 +280,12 @@ export function normalizeAPIStatusError( requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, + options?: { readonly errorCode?: string | null; readonly errorType?: string | null }, ): APIStatusError { if (statusCode === 429) { + if (isQuotaExhaustedStatusError(statusCode, message, options)) { + return new APIProviderQuotaExhaustedError(message, requestId, retryAfterMs, traceId); + } return new APIProviderRateLimitError(message, requestId, retryAfterMs, traceId); } if (isContextOverflowStatusError(statusCode, message)) { @@ -307,6 +349,23 @@ export function isRequestTooLargeStatusError(statusCode: number, message: string return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } +// Whether a 429 means the account's quota/balance is exhausted rather than a +// transient rate limit. Structured `type`/`code` is authoritative when +// forwarded; message patterns only backstop text-flattening gateways. +export function isQuotaExhaustedStatusError( + statusCode: number, + message: string, + options?: { readonly errorCode?: string | null; readonly errorType?: string | null }, +): boolean { + if (statusCode !== 429) return false; + const errorCode = options?.errorCode; + if (typeof errorCode === 'string' && QUOTA_EXHAUSTED_ERROR_CODES.has(errorCode)) return true; + const errorType = options?.errorType; + if (typeof errorType === 'string' && QUOTA_EXHAUSTED_ERROR_CODES.has(errorType)) return true; + const lowerMessage = message.toLowerCase(); + return QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ /tool_use[\s\S]*tool_result/, /tool_result[\s\S]*tool_use/, @@ -345,6 +404,9 @@ export function isRecoverableRequestStructureError(error: unknown): boolean { } export function isProviderRateLimitError(error: unknown): boolean { + // Quota exhaustion is a 429 but not a rate limit: the rate-limit reactions + // (retry, requeue, suspend) cannot help until the account is recharged. + if (error instanceof APIProviderQuotaExhaustedError) return false; if (error instanceof APIProviderRateLimitError) return true; const statusCode = getStatusCode(error); diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts index 9322a02a49..e3cf546c65 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts @@ -98,6 +98,12 @@ export function convertOpenAIError(error: unknown): ChatProviderError { reqId, parseRetryAfterMs(error.headers), parseTraceId(error.headers), + // Forward the SDK-parsed body `error.code`/`error.type` so a + // quota-exhausted 429 classifies structurally, not by wording. + { + errorCode: typeof error.code === 'string' ? error.code : null, + errorType: typeof error.type === 'string' ? error.type : null, + }, ); } if ( diff --git a/packages/agent-core-v2/src/app/protocol/errors.ts b/packages/agent-core-v2/src/app/protocol/errors.ts index 86a4186781..694dc00152 100644 --- a/packages/agent-core-v2/src/app/protocol/errors.ts +++ b/packages/agent-core-v2/src/app/protocol/errors.ts @@ -15,6 +15,7 @@ import { APIContextOverflowError, APIEmptyResponseError, APIProviderOverloadedError, + APIProviderQuotaExhaustedError, APIStatusError, APITimeoutError, ChatProviderError, @@ -84,11 +85,16 @@ 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; + : // Quota exhaustion shares status 429 but must not carry the + // rate-limit code — that code drives retry/requeue reactions, + // which cannot help until the account is recharged. + 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, diff --git a/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts b/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts index 5de9f5b1aa..2ecc8e6c5d 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts @@ -8,6 +8,7 @@ import { APIContextOverflowError, APIEmptyResponseError, APIProviderOverloadedError, + APIProviderQuotaExhaustedError, APIProviderRateLimitError, APIRequestTooLargeError, APIStatusError, @@ -15,6 +16,7 @@ import { ChatProviderError, isImageFormatError, isProviderRateLimitError, + isQuotaExhaustedStatusError, isRecoverableRequestStructureError, isRetryableGenerateError, isToolExchangeAdjacencyError, @@ -696,3 +698,55 @@ describe('isProviderRateLimitError', () => { expect(isProviderRateLimitError(new Error('context length exceeded'))).toBe(false); }); }); + +describe('quota-exhausted 429 classification', () => { + it.each([ + 'You exceeded your current token quota: 31275, please check your account balance', + 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details', + 'You exceeded your current quota, please check your plan and billing details.', + 'Your account is in arrears, please top up', + ])('normalizes 429 "%s" to APIProviderQuotaExhaustedError by message', (message) => { + const error = normalizeAPIStatusError(429, message, 'req-quota'); + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(error).not.toBeInstanceOf(APIProviderRateLimitError); + expect(error.statusCode).toBe(429); + }); + + it('classifies a neutral message by structured errorType/errorCode', () => { + expect( + normalizeAPIStatusError(429, 'Too many requests', null, null, null, { + errorType: 'exceeded_current_quota_error', + }), + ).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect( + normalizeAPIStatusError(429, 'Too many requests', null, null, null, { + errorCode: 'insufficient_quota', + }), + ).toBeInstanceOf(APIProviderQuotaExhaustedError); + }); + + it.each([ + 'Too many requests', + 'request reached user+model max RPM: 50', + 'your token quota per minute was exceeded', + ])('keeps transient 429 "%s" an APIProviderRateLimitError', (message) => { + const error = normalizeAPIStatusError(429, message); + expect(error).toBeInstanceOf(APIProviderRateLimitError); + expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError); + }); + + it('is gated on status 429', () => { + expect(isQuotaExhaustedStatusError(403, 'insufficient balance')).toBe(false); + expect(normalizeAPIStatusError(403, 'insufficient balance')).not.toBeInstanceOf( + APIProviderQuotaExhaustedError, + ); + }); + + it('is neither retryable nor a provider rate limit', () => { + const quota = new APIProviderQuotaExhaustedError('quota exhausted', 'req-quota', 1); + expect(isRetryableGenerateError(quota)).toBe(false); + expect(isProviderRateLimitError(quota)).toBe(false); + expect(isRetryableGenerateError(new APIProviderRateLimitError('rate limited'))).toBe(true); + expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true); + }); +}); diff --git a/packages/agent-core-v2/test/app/llmProtocol/providers/provider-errors.test.ts b/packages/agent-core-v2/test/app/llmProtocol/providers/provider-errors.test.ts index 6615783cfe..5e1ae4c7ea 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/providers/provider-errors.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/providers/provider-errors.test.ts @@ -7,7 +7,12 @@ import { APIError as AnthropicAPIError } from '@anthropic-ai/sdk'; import { APIError as OpenAIAPIError } from 'openai'; import { describe, expect, it } from 'vitest'; -import { APIProviderRateLimitError, APIStatusError } from '#/app/llmProtocol/errors'; +import { + APIProviderQuotaExhaustedError, + APIProviderRateLimitError, + APIStatusError, + isRetryableGenerateError, +} from '#/app/llmProtocol/errors'; import { convertAnthropicError } from '#/app/llmProtocol/providers/anthropic'; import { convertOpenAIError } from '#/app/llmProtocol/providers/openai-common'; import { OpenAIResponsesStreamedMessage } from '#/app/llmProtocol/providers/openai-responses'; @@ -92,3 +97,31 @@ describe('OpenAI Responses rate-limit conversion', () => { await expect(consume(stream)).rejects.toBeInstanceOf(APIProviderRateLimitError); }); }); + +describe('OpenAI quota-exhausted 429 conversion', () => { + const QUOTA_MESSAGE = + 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details'; + + it('classifies a structured exceeded_current_quota_error body as quota-exhausted', () => { + const source = new OpenAIAPIError( + 429, + { message: QUOTA_MESSAGE, type: 'exceeded_current_quota_error' }, + `429 ${QUOTA_MESSAGE}`, + new Headers(), + ); + + const error = convertOpenAIError(source); + + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(error)).toBe(false); + }); + + it('falls back to message wording when no structured body is present', () => { + const source = new OpenAIAPIError(429, undefined, QUOTA_MESSAGE, new Headers()); + + const error = convertOpenAIError(source); + + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(error)).toBe(false); + }); +}); diff --git a/packages/agent-core-v2/test/app/protocol/errors.test.ts b/packages/agent-core-v2/test/app/protocol/errors.test.ts index 0327351f3c..3e15b1a0fa 100644 --- a/packages/agent-core-v2/test/app/protocol/errors.test.ts +++ b/packages/agent-core-v2/test/app/protocol/errors.test.ts @@ -6,6 +6,7 @@ import { APIContextOverflowError, APIEmptyResponseError, APIProviderOverloadedError, + APIProviderQuotaExhaustedError, APIStatusError, APITimeoutError, ChatProviderError, @@ -152,4 +153,20 @@ describe('translateProviderError', () => { ); }); }); + + describe('quota-exhausted 429', () => { + it('maps to provider.api_error, not provider.rate_limit', () => { + // provider.rate_limit drives retry/requeue reactions, which cannot help + // until the account is recharged. + const translated = translateProviderError( + new APIProviderQuotaExhaustedError( + 'Your account is suspended due to insufficient balance, please recharge your account', + 'req-quota', + ), + ); + expect(translated.code).toBe('provider.api_error'); + expect(translated.message).toContain('recharge'); + expect(translated.details).toMatchObject({ statusCode: 429, requestId: 'req-quota' }); + }); + }); }); diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 3136aa6889..f4d6258f4b 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -5,6 +5,7 @@ import { APIConnectionError, APIContextOverflowError, APIEmptyResponseError, + APIProviderQuotaExhaustedError, APIStatusError, APITimeoutError, inputTotal, @@ -1437,6 +1438,11 @@ interface ApiErrorClassification { } function classifyApiError(error: unknown, summary: KimiErrorPayload): ApiErrorClassification { + // Quota/balance exhaustion shares status 429 with rate limits but fails + // fast instead of retrying — keep the two apart in telemetry. + if (error instanceof APIProviderQuotaExhaustedError) { + return { errorType: 'quota_exhausted', statusCode: error.statusCode }; + } const statusCode = apiStatusCode(error) ?? summaryStatusCode(summary); if (statusCode !== undefined) { if (statusCode === 429) return { errorType: 'rate_limit', statusCode }; diff --git a/packages/agent-core/src/errors/serialize.ts b/packages/agent-core/src/errors/serialize.ts index 17ba995343..632334c8d7 100644 --- a/packages/agent-core/src/errors/serialize.ts +++ b/packages/agent-core/src/errors/serialize.ts @@ -1,6 +1,7 @@ import { APIConnectionError, APIEmptyResponseError, + APIProviderQuotaExhaustedError, APIStatusError, APITimeoutError, ChatProviderError, @@ -58,6 +59,10 @@ export function makeErrorPayload( * Recognized errors: * - `KimiError`: passthrough. * - `APIStatusError`: 429 -> rate_limit, 401 -> auth_error, otherwise -> api_error. + * Exception: a quota-exhausted 429 maps to api_error (retryable: false) — + * the rate_limit code would re-mint a rate-limit error across the wire + * boundary and drive the swarm requeue/suspend loop, which cannot help + * until the account is recharged. * - `APIConnectionError` / `APITimeoutError`: connection_error. * - `ChatProviderError`: api_error. * @@ -77,11 +82,13 @@ export function toKimiErrorPayload(error: unknown): KimiErrorPayload { if (error instanceof APIStatusError) { const code: KimiErrorCode = - error.statusCode === 429 - ? ErrorCodes.PROVIDER_RATE_LIMIT - : error.statusCode === 401 - ? ErrorCodes.PROVIDER_AUTH_ERROR - : ErrorCodes.PROVIDER_API_ERROR; + error instanceof APIProviderQuotaExhaustedError + ? ErrorCodes.PROVIDER_API_ERROR + : error.statusCode === 429 + ? ErrorCodes.PROVIDER_RATE_LIMIT + : error.statusCode === 401 + ? ErrorCodes.PROVIDER_AUTH_ERROR + : ErrorCodes.PROVIDER_API_ERROR; return { code, message: sanitizeStatusErrorMessage(error.message), diff --git a/packages/agent-core/test/errors/serialize.test.ts b/packages/agent-core/test/errors/serialize.test.ts index 43e56ff717..db095b0613 100644 --- a/packages/agent-core/test/errors/serialize.test.ts +++ b/packages/agent-core/test/errors/serialize.test.ts @@ -1,4 +1,4 @@ -import { APIStatusError } from '@moonshot-ai/kosong'; +import { APIProviderQuotaExhaustedError, APIStatusError } from '@moonshot-ai/kosong'; import { describe, expect, it } from 'vitest'; import { toKimiErrorPayload } from '#/errors/serialize'; @@ -48,3 +48,21 @@ describe('toKimiErrorPayload — APIStatusError message sanitization', () => { ); }); }); + +describe('toKimiErrorPayload — quota-exhausted 429', () => { + it('maps a quota-exhausted 429 to provider.api_error, not provider.rate_limit', () => { + // provider.rate_limit is retryable and re-minted as a rate-limit error + // across the wire boundary, which drives the swarm requeue/suspend loop; + // quota exhaustion must carry the non-retryable generic code instead. + const payload = toKimiErrorPayload( + new APIProviderQuotaExhaustedError( + 'Your account is suspended due to insufficient balance, please recharge your account', + 'req-quota', + ), + ); + expect(payload.code).toBe('provider.api_error'); + expect(payload.retryable).toBe(false); + expect(payload.message).toContain('recharge'); + expect(payload.details).toMatchObject({ statusCode: 429, requestId: 'req-quota' }); + }); +}); diff --git a/packages/agent-core/test/loop/retry.test.ts b/packages/agent-core/test/loop/retry.test.ts index 9c38ed7811..8166cfc116 100644 --- a/packages/agent-core/test/loop/retry.test.ts +++ b/packages/agent-core/test/loop/retry.test.ts @@ -1,5 +1,6 @@ import { APIConnectionError, + APIProviderQuotaExhaustedError, APIProviderRateLimitError, emptyUsage, isRetryableGenerateError, @@ -210,6 +211,43 @@ describe('chatWithRetry: default retry budget', () => { }); }); +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 { + 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; diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 9f0862e753..1d937ea762 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -119,6 +119,33 @@ export class APIProviderRateLimitError extends APIStatusError { } } +/** + * HTTP 429 that specifically means the account's quota or balance is + * exhausted, as opposed to a transient rate limit. Deliberately NOT a + * subclass of `APIProviderRateLimitError`: a rate limit clears on its own + * (retry/requeue helps), while quota exhaustion is deterministic until the + * account is recharged — so this class is excluded from retry and from the + * rate-limit requeue/suspend paths. + * + * Observed shapes: Moonshot returns `error.type = + * "exceeded_current_quota_error"` with wording that varies by account state + * ("You exceeded your current token quota: ... please check your account + * balance" vs "Your account ... is suspended due to insufficient balance, + * please recharge your account ..."); OpenAI uses `insufficient_quota` as + * both `error.type` and `error.code`. + */ +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'; + } +} + /** * The API returned an empty response (no content, no tool calls). */ @@ -148,6 +175,12 @@ export function isRetryableGenerateError(error: unknown): boolean { return true; } if (error instanceof APIStatusError) { + // Quota/balance exhaustion is a 429 but deterministic until the account + // is recharged — retrying can never succeed, so it fails fast instead of + // burning the whole retry budget (~2-3 minutes of backoff). + if (error instanceof APIProviderQuotaExhaustedError) { + return false; + } // 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). @@ -290,6 +323,30 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ /rate-limited/, ] as const; +// Structured error `type`/`code` values that mean the account's quota or +// balance is exhausted (as opposed to a transient rate limit). Moonshot sets +// `exceeded_current_quota_error` as the body `error.type`; OpenAI uses +// `insufficient_quota` as both `type` and `code`. +const QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error', 'insufficient_quota']); + +// Message fallback for providers/gateways that do not forward a structured +// type/code, matched against the lowercased message of a 429. Every pattern +// is anchored to billing wording — deliberately no bare /quota/ or /balance/, +// which would also match transient throttle messages like "token quota per +// minute". Grounded in observed bodies: Moonshot "You exceeded your current +// token quota: ... please check your account balance" and "Your account ... +// is suspended due to insufficient balance, please recharge your account or +// check your plan and billing details"; OpenAI "You exceeded your current +// quota, please check your plan and billing details". +const QUOTA_EXHAUSTED_MESSAGE_PATTERNS = [ + /exceeded your current (?:token )?quota/, + /check your account balance/, + /insufficient balance/, + /recharge your account|please recharge/, + /account (?:is )?in arrears/, + /insufficient_quota/, +] as const; + // Wordings that mean the serialized request BODY was too big, matched against // the lowercased message of a 413. Kept separate from the context-overflow // patterns above: those describe token counts, these describe bytes. A 413 @@ -346,8 +403,14 @@ export function normalizeAPIStatusError( requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, + options?: { readonly errorCode?: string | null; readonly errorType?: string | null }, ): APIStatusError { if (statusCode === 429) { + // Quota/balance exhaustion first: same status as a rate limit, but it + // never clears on its own, so it must not classify as retryable. + if (isQuotaExhaustedStatusError(statusCode, message, options)) { + return new APIProviderQuotaExhaustedError(message, requestId, retryAfterMs, traceId); + } return new APIProviderRateLimitError(message, requestId, retryAfterMs, traceId); } // Context overflow first: Vertex returns prompt-too-long as a 413, and a @@ -418,6 +481,26 @@ export function isRequestTooLargeStatusError(statusCode: number, message: string return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } +/** + * Whether a 429 means the account's quota/balance is exhausted rather than a + * transient rate limit. The structured body `error.type`/`error.code` is + * authoritative when the provider forwards one; the message patterns only + * backstop gateways that flatten the body to text. + */ +export function isQuotaExhaustedStatusError( + statusCode: number, + message: string, + options?: { readonly errorCode?: string | null; readonly errorType?: string | null }, +): boolean { + if (statusCode !== 429) return false; + const errorCode = options?.errorCode; + if (typeof errorCode === 'string' && QUOTA_EXHAUSTED_ERROR_CODES.has(errorCode)) return true; + const errorType = options?.errorType; + if (typeof errorType === 'string' && QUOTA_EXHAUSTED_ERROR_CODES.has(errorType)) return true; + const lowerMessage = message.toLowerCase(); + return QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + // Strict providers reject a request whose assistant `tool_use`/`tool_calls` and // `tool_result`/`tool` blocks are not correctly paired and adjacent — a missing // result, a stray result with no matching call, or a result that does not @@ -502,6 +585,9 @@ export function isRecoverableRequestStructureError(error: unknown): boolean { } export function isProviderRateLimitError(error: unknown): boolean { + // Quota exhaustion is a 429 but not a rate limit: the rate-limit reactions + // (retry, requeue, suspend) cannot help until the account is recharged. + if (error instanceof APIProviderQuotaExhaustedError) return false; if (error instanceof APIProviderRateLimitError) return true; const statusCode = getStatusCode(error); diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index 10a7af7304..04a30b83a8 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -62,6 +62,7 @@ export { APIConnectionError, APIContextOverflowError, APIEmptyResponseError, + APIProviderQuotaExhaustedError, APIProviderRateLimitError, APIRequestTooLargeError, APIStatusError, @@ -70,6 +71,7 @@ export { isContextOverflowStatusError, isImageFormatError, isProviderRateLimitError, + isQuotaExhaustedStatusError, isRecoverableRequestStructureError, isRequestTooLargeStatusError, isRetryableGenerateError, diff --git a/packages/kosong/src/providers/openai-common.ts b/packages/kosong/src/providers/openai-common.ts index bc3e18cddc..5d051ea842 100644 --- a/packages/kosong/src/providers/openai-common.ts +++ b/packages/kosong/src/providers/openai-common.ts @@ -111,6 +111,13 @@ export function convertOpenAIError(error: unknown): ChatProviderError { reqId, parseRetryAfterMs(error.headers), parseTraceId(error.headers), + // The SDK parses the body's `error.code`/`error.type` onto the error; + // forward them so a quota-exhausted 429 classifies structurally rather + // than by message wording. + { + errorCode: typeof error.code === 'string' ? error.code : null, + errorType: typeof error.type === 'string' ? error.type : null, + }, ); } // Base APIError with no status and no body => transport-layer failure. diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index 1eb62efdbf..eb235866f4 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -2,6 +2,7 @@ import { APIConnectionError, APIContextOverflowError, APIEmptyResponseError, + APIProviderQuotaExhaustedError, APIProviderRateLimitError, APIRequestTooLargeError, APIStatusError, @@ -9,6 +10,7 @@ import { ChatProviderError, isImageFormatError, isProviderRateLimitError, + isQuotaExhaustedStatusError, isRecoverableRequestStructureError, isRetryableGenerateError, isToolExchangeAdjacencyError, @@ -667,3 +669,89 @@ describe('isImageFormatError', () => { ).toBe(false); }); }); + +describe('APIProviderQuotaExhaustedError', () => { + it('extends APIStatusError and preserves HTTP details', () => { + const err = new APIProviderQuotaExhaustedError('quota exhausted', 'req-quota', 12_500); + expect(err).toBeInstanceOf(APIStatusError); + expect(err).toBeInstanceOf(ChatProviderError); + expect(err).not.toBeInstanceOf(APIProviderRateLimitError); + expect(err.name).toBe('APIProviderQuotaExhaustedError'); + expect(err.statusCode).toBe(429); + expect(err.requestId).toBe('req-quota'); + expect(err.retryAfterMs).toBe(12_500); + }); +}); + +describe('normalizeAPIStatusError: quota-exhausted 429', () => { + // Both Moonshot wordings observed live from the same account (`error.type` + // "exceeded_current_quota_error"), plus OpenAI's insufficient_quota wording + // and the arrears synonym. + it.each([ + 'You exceeded your current token quota: 31275, please check your account balance', + 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details', + 'You exceeded your current quota, please check your plan and billing details.', + 'Your account is in arrears, please top up', + ])('classifies 429 "%s" as quota-exhausted by message', (message) => { + const error = normalizeAPIStatusError(429, message, 'req-quota'); + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(error.statusCode).toBe(429); + expect(error.requestId).toBe('req-quota'); + }); + + it('classifies a neutral message as quota-exhausted by structured errorType', () => { + const error = normalizeAPIStatusError(429, 'Too many requests', null, null, null, { + errorType: 'exceeded_current_quota_error', + }); + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + }); + + it('classifies a neutral message as quota-exhausted by structured errorCode', () => { + const error = normalizeAPIStatusError(429, 'Too many requests', null, null, null, { + errorCode: 'insufficient_quota', + }); + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + }); + + it.each([ + 'Too many requests', + 'request reached user+model max RPM: 50', + 'your token quota per minute was exceeded', + ])('keeps transient 429 "%s" an APIProviderRateLimitError', (message) => { + const error = normalizeAPIStatusError(429, message); + expect(error).toBeInstanceOf(APIProviderRateLimitError); + expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError); + }); + + it('keeps a 429 with a transient structured type an APIProviderRateLimitError', () => { + const error = normalizeAPIStatusError(429, 'Too many requests', null, null, null, { + errorType: 'rate_limit_reached_error', + }); + expect(error).toBeInstanceOf(APIProviderRateLimitError); + expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError); + }); + + it('is gated on status 429 — billing wording on other statuses stays generic', () => { + expect(isQuotaExhaustedStatusError(403, 'insufficient balance')).toBe(false); + const error = normalizeAPIStatusError(403, 'insufficient balance'); + expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(error.constructor).toBe(APIStatusError); + }); +}); + +describe('quota-exhausted retry and rate-limit semantics', () => { + it('is not retryable while a plain rate limit stays retryable', () => { + expect(isRetryableGenerateError(new APIProviderQuotaExhaustedError('quota exhausted'))).toBe( + false, + ); + expect(isRetryableGenerateError(new APIProviderRateLimitError('rate limited'))).toBe(true); + expect(isRetryableGenerateError(new APIStatusError(429, 'rate limited'))).toBe(true); + }); + + it('is not a provider rate limit despite carrying status 429', () => { + expect(isProviderRateLimitError(new APIProviderQuotaExhaustedError('quota exhausted'))).toBe( + false, + ); + expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true); + }); +}); diff --git a/packages/kosong/test/openai-common-errors.test.ts b/packages/kosong/test/openai-common-errors.test.ts index d2b1bf388e..264fb60f59 100644 --- a/packages/kosong/test/openai-common-errors.test.ts +++ b/packages/kosong/test/openai-common-errors.test.ts @@ -1,6 +1,7 @@ import { APIConnectionError, APIContextOverflowError, + APIProviderQuotaExhaustedError, APIProviderRateLimitError, APIStatusError, APITimeoutError, @@ -393,3 +394,43 @@ describe('convertOpenAIError: non-Error values', () => { expect(result.message).toContain('plain error'); }); }); + +describe('convertOpenAIError: quota-exhausted 429', () => { + const QUOTA_MESSAGE = + 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details'; + + it('classifies a structured exceeded_current_quota_error body as quota-exhausted', () => { + // The SDK parses the body's inner error object onto the APIError, exposing + // `type` — the structured path must win regardless of message wording. + const err = new OpenAIAPIError( + 429, + { message: QUOTA_MESSAGE, type: 'exceeded_current_quota_error' }, + `429 ${QUOTA_MESSAGE}`, + new Headers(), + ); + const result = convertOpenAIError(err); + expect(result).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect((result as APIProviderQuotaExhaustedError).statusCode).toBe(429); + expect(isRetryableGenerateError(result)).toBe(false); + }); + + it('falls back to message wording when no structured body is present', () => { + const err = new OpenAIAPIError(429, undefined, QUOTA_MESSAGE, new Headers()); + const result = convertOpenAIError(err); + expect(result).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(result)).toBe(false); + }); + + it('keeps a transient structured 429 an APIProviderRateLimitError', () => { + const err = new OpenAIAPIError( + 429, + { message: 'Too many requests', type: 'rate_limit_reached_error' }, + 'Too many requests', + new Headers(), + ); + const result = convertOpenAIError(err); + expect(result).toBeInstanceOf(APIProviderRateLimitError); + expect(result).not.toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(result)).toBe(true); + }); +}); From 5c60c91604b92c97cc6490ebb414afc6e3a3320a Mon Sep 17 00:00:00 2001 From: vinlee19 <1401597760@qq.com> Date: Fri, 17 Jul 2026 23:28:30 +0800 Subject: [PATCH 2/7] fix(kosong): classify quota exhaustion in OpenAI Responses stream errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../llmProtocol/providers/openai-responses.ts | 5 ++ .../providers/provider-errors.test.ts | 21 +++++++ .../kosong/src/providers/openai-responses.ts | 11 ++++ packages/kosong/test/openai-responses.test.ts | 58 +++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts index 9953a0d148..efac57f455 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts @@ -1,8 +1,10 @@ import { APIContextOverflowError, + APIProviderQuotaExhaustedError, APIProviderRateLimitError, ChatProviderError, isContextOverflowErrorCode, + isQuotaExhaustedStatusError, } from '../errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '../message'; import { extractText, isToolDeclarationOnlyMessage } from '../message'; @@ -242,6 +244,9 @@ function errorFromOpenAIResponsesEvent( if (isContextOverflowErrorCode(code)) { return new APIContextOverflowError(400, fullMessage); } + if (isQuotaExhaustedStatusError(429, fullMessage, { errorCode: code })) { + return new APIProviderQuotaExhaustedError(fullMessage); + } if (code === 'rate_limit_exceeded' || readEmbeddedStatusCode(message) === 429) { return new APIProviderRateLimitError(fullMessage); } diff --git a/packages/agent-core-v2/test/app/llmProtocol/providers/provider-errors.test.ts b/packages/agent-core-v2/test/app/llmProtocol/providers/provider-errors.test.ts index 5e1ae4c7ea..987f737a32 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/providers/provider-errors.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/providers/provider-errors.test.ts @@ -96,6 +96,27 @@ describe('OpenAI Responses rate-limit conversion', () => { await expect(consume(stream)).rejects.toBeInstanceOf(APIProviderRateLimitError); }); + + it('fails fast on a streamed insufficient_quota error event', async () => { + const stream = new OpenAIResponsesStreamedMessage( + streamEvents([ + { + type: 'error', + code: 'insufficient_quota', + message: 'You exceeded your current quota, please check your plan and billing details.', + param: null, + }, + ]), + true, + ); + + const caught = await consume(stream).then( + () => undefined, + (error: unknown) => error, + ); + expect(caught).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(caught)).toBe(false); + }); }); describe('OpenAI quota-exhausted 429 conversion', () => { diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index baf8666abe..9aaccc2ff2 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -1,8 +1,10 @@ import { APIContextOverflowError, + APIProviderQuotaExhaustedError, APIProviderRateLimitError, ChatProviderError, isContextOverflowErrorCode, + isQuotaExhaustedStatusError, } from '#/errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { extractText, isToolDeclarationOnlyMessage } from '#/message'; @@ -251,6 +253,15 @@ function errorFromOpenAIResponsesEvent( if (isContextOverflowErrorCode(code)) { return new APIContextOverflowError(400, fullMessage); } + // Quota/balance exhaustion first — otherwise an `insufficient_quota` event + // falls through to the base ChatProviderError (whose unclassified fallback + // is retryable), and a quota message with an embedded status_code=429 would + // classify as a retryable rate limit. Responses stream events carry no HTTP + // status, so the 429 passed here only satisfies the predicate's status gate + // while the event code / billing wording carries the actual evidence. + if (isQuotaExhaustedStatusError(429, fullMessage, { errorCode: code })) { + return new APIProviderQuotaExhaustedError(fullMessage); + } if (code === 'rate_limit_exceeded' || readEmbeddedStatusCode(message) === 429) { return new APIProviderRateLimitError(fullMessage); } diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 1a8d4c752d..faf6df3140 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -1,8 +1,10 @@ import { APIContextOverflowError, + APIProviderQuotaExhaustedError, APIProviderRateLimitError, APIStatusError, ChatProviderError, + isRetryableGenerateError, } from '#/errors'; import { generate } from '#/generate'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; @@ -2008,6 +2010,62 @@ describe('OpenAIResponsesChatProvider', () => { expect((caughtError as Error).message).toContain('status_code=429'); }); + it('fails fast on response.failed with an insufficient_quota code', async () => { + // Quota exhaustion arriving as a Responses stream event carries no HTTP + // status; without the structured-code check it would fall through to the + // base ChatProviderError, whose unclassified fallback is retryable — and + // burn the whole retry budget on an error that cannot succeed. + const events = [ + { + type: 'response.failed', + response: { + id: 'resp_quota', + status: 'failed', + error: { + code: 'insufficient_quota', + message: 'You exceeded your current quota, please check your plan and billing details.', + }, + }, + }, + ]; + const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true); + + let caughtError: unknown; + try { + await collectStreamParts(stream); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect((caughtError as APIProviderQuotaExhaustedError).statusCode).toBe(429); + expect(isRetryableGenerateError(caughtError)).toBe(false); + }); + + it('classifies an embedded status_code=429 with billing wording as quota exhausted', async () => { + const events = [ + { + type: 'error', + code: 'upstream_error', + message: + 'llmproxy/openai/responses/resp_q.json status_code=429 Your account is suspended due to insufficient balance, please recharge your account', + param: null, + }, + ]; + const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true); + + let caughtError: unknown; + try { + await collectStreamParts(stream); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(caughtError).not.toBeInstanceOf(APIProviderRateLimitError); + expect(isRetryableGenerateError(caughtError)).toBe(false); + }); + it('rejects malformed stream events with a non-string type even when message is present', async () => { const events = [ { From 7ceabcc41e7fe6fe0d32be3f17fc306756e463ac Mon Sep 17 00:00:00 2001 From: vinlee19 <1401597760@qq.com> Date: Fri, 17 Jul 2026 23:28:31 +0800 Subject: [PATCH 3/7] 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. --- .../agent-core-v2/src/app/llmProtocol/errors.ts | 17 ----------------- .../app/llmProtocol/providers/openai-common.ts | 2 -- .../agent-core-v2/src/app/protocol/errors.ts | 5 +---- .../test/app/protocol/errors.test.ts | 2 -- 4 files changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/agent-core-v2/src/app/llmProtocol/errors.ts b/packages/agent-core-v2/src/app/llmProtocol/errors.ts index 5fd039aaa2..6ba54a0e55 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/errors.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/errors.ts @@ -82,9 +82,6 @@ export class APIProviderRateLimitError extends APIStatusError { } } -// HTTP 429 caused by an exhausted account quota/balance — deterministic until -// the account is recharged, so unlike a rate limit it is neither retried nor -// requeued. Deliberately not a subclass of APIProviderRateLimitError. export class APIProviderQuotaExhaustedError extends APIStatusError { constructor( message: string, @@ -173,8 +170,6 @@ export function isRetryableGenerateError(error: unknown): boolean { return true; } if (error instanceof APIStatusError) { - // Quota/balance exhaustion is a 429 but deterministic until the account - // is recharged — retrying can never succeed. if (error instanceof APIProviderQuotaExhaustedError) { return false; } @@ -219,15 +214,8 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ const PROVIDER_OVERLOAD_MESSAGE_PATTERNS = [/overload/] as const; -// Structured error `type`/`code` values that mean the account's quota or -// balance is exhausted: Moonshot `exceeded_current_quota_error` (body -// `error.type`), OpenAI `insufficient_quota` (both `type` and `code`). const QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error', 'insufficient_quota']); -// Message fallback for gateways that flatten the body to text, matched against -// the lowercased message of a 429. Anchored to billing wording — deliberately -// no bare /quota/ or /balance/, which would also match transient throttle -// messages like "token quota per minute". const QUOTA_EXHAUSTED_MESSAGE_PATTERNS = [ /exceeded your current (?:token )?quota/, /check your account balance/, @@ -349,9 +337,6 @@ export function isRequestTooLargeStatusError(statusCode: number, message: string return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } -// Whether a 429 means the account's quota/balance is exhausted rather than a -// transient rate limit. Structured `type`/`code` is authoritative when -// forwarded; message patterns only backstop text-flattening gateways. export function isQuotaExhaustedStatusError( statusCode: number, message: string, @@ -404,8 +389,6 @@ export function isRecoverableRequestStructureError(error: unknown): boolean { } export function isProviderRateLimitError(error: unknown): boolean { - // Quota exhaustion is a 429 but not a rate limit: the rate-limit reactions - // (retry, requeue, suspend) cannot help until the account is recharged. if (error instanceof APIProviderQuotaExhaustedError) return false; if (error instanceof APIProviderRateLimitError) return true; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts index e3cf546c65..845275090b 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts @@ -98,8 +98,6 @@ export function convertOpenAIError(error: unknown): ChatProviderError { reqId, parseRetryAfterMs(error.headers), parseTraceId(error.headers), - // Forward the SDK-parsed body `error.code`/`error.type` so a - // quota-exhausted 429 classifies structurally, not by wording. { errorCode: typeof error.code === 'string' ? error.code : null, errorType: typeof error.type === 'string' ? error.type : null, diff --git a/packages/agent-core-v2/src/app/protocol/errors.ts b/packages/agent-core-v2/src/app/protocol/errors.ts index 694dc00152..f8617494c1 100644 --- a/packages/agent-core-v2/src/app/protocol/errors.ts +++ b/packages/agent-core-v2/src/app/protocol/errors.ts @@ -85,10 +85,7 @@ export function translateProviderError(error: unknown): Error2 { ? ProtocolErrors.codes.CONTEXT_OVERFLOW : error instanceof APIProviderOverloadedError || error.statusCode === 529 ? ProtocolErrors.codes.PROVIDER_OVERLOADED - : // Quota exhaustion shares status 429 but must not carry the - // rate-limit code — that code drives retry/requeue reactions, - // which cannot help until the account is recharged. - error instanceof APIProviderQuotaExhaustedError + : error instanceof APIProviderQuotaExhaustedError ? ProtocolErrors.codes.PROVIDER_API_ERROR : error.statusCode === 429 ? ProtocolErrors.codes.PROVIDER_RATE_LIMIT diff --git a/packages/agent-core-v2/test/app/protocol/errors.test.ts b/packages/agent-core-v2/test/app/protocol/errors.test.ts index 3e15b1a0fa..a99e0b7ebf 100644 --- a/packages/agent-core-v2/test/app/protocol/errors.test.ts +++ b/packages/agent-core-v2/test/app/protocol/errors.test.ts @@ -156,8 +156,6 @@ describe('translateProviderError', () => { describe('quota-exhausted 429', () => { it('maps to provider.api_error, not provider.rate_limit', () => { - // provider.rate_limit drives retry/requeue reactions, which cannot help - // until the account is recharged. const translated = translateProviderError( new APIProviderQuotaExhaustedError( 'Your account is suspended due to insufficient balance, please recharge your account', From 7b18ac4885eb2928d08a6b185857ba7200ad9584 Mon Sep 17 00:00:00 2001 From: vinlee19 <1401597760@qq.com> Date: Mon, 27 Jul 2026 21:45:13 +0800 Subject: [PATCH 4/7] 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. --- .../src/kosong/contract/errors.ts | 29 ---- .../src/kosong/protocol/protocolTrait.ts | 34 +++- .../provider/bases/anthropic/anthropic.ts | 33 +++- .../bases/anthropic/anthropicHooks.ts | 32 ++-- .../provider/bases/openai/openai-common.ts | 45 +++-- .../provider/bases/openai/openai-legacy.ts | 11 +- .../bases/openai/openai-responses.contrib.ts | 3 +- .../provider/bases/openai/openai-responses.ts | 48 ++++-- .../provider/bases/openai/openaiHooks.ts | 3 + .../provider/providers/kimi/kimi-errors.ts | 67 ++++++++ .../provider/providers/kimi/kimi-files.ts | 6 +- .../provider/providers/kimi/kimi.contrib.ts | 20 ++- .../test/app/llmProtocol/errors.test.ts | 39 +---- .../test/kosong/provider/errors.test.ts | 161 ++++++++++++++++-- .../test/kosong/provider/kimi.test.ts | 3 +- packages/kosong/src/errors.ts | 50 ------ packages/kosong/src/index.ts | 1 - packages/kosong/src/providers/kimi-errors.ts | 66 +++++++ packages/kosong/src/providers/kimi-files.ts | 3 +- packages/kosong/src/providers/kimi.ts | 5 +- .../kosong/src/providers/openai-common.ts | 54 ++++-- .../kosong/src/providers/openai-responses.ts | 9 +- packages/kosong/test/errors.test.ts | 50 +----- packages/kosong/test/kimi.test.ts | 51 ++++++ .../kosong/test/openai-common-errors.test.ts | 36 +++- packages/kosong/test/openai-responses.test.ts | 11 +- 26 files changed, 607 insertions(+), 263 deletions(-) create mode 100644 packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts create mode 100644 packages/kosong/src/providers/kimi-errors.ts diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index 4adaa31adb..d45fc01a9b 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -292,17 +292,6 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ const PROVIDER_OVERLOAD_MESSAGE_PATTERNS = [/overload/] as const; -const QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error', 'insufficient_quota']); - -const QUOTA_EXHAUSTED_MESSAGE_PATTERNS = [ - /exceeded your current (?:token )?quota/, - /check your account balance/, - /insufficient balance/, - /recharge your account|please recharge/, - /account (?:is )?in arrears/, - /insufficient_quota/, -] as const; - const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ /request exceeds the maximum size/, /request entity too large/, @@ -346,12 +335,8 @@ export function normalizeAPIStatusError( requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, - options?: { readonly errorCode?: string | null; readonly errorType?: string | null }, ): APIStatusError { if (statusCode === 429) { - if (isQuotaExhaustedStatusError(statusCode, message, options)) { - return new APIProviderQuotaExhaustedError(message, requestId, retryAfterMs, traceId); - } return new APIProviderRateLimitError(message, requestId, retryAfterMs, traceId); } if (isContextOverflowStatusError(statusCode, message)) { @@ -415,20 +400,6 @@ export function isRequestTooLargeStatusError(statusCode: number, message: string return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } -export function isQuotaExhaustedStatusError( - statusCode: number, - message: string, - options?: { readonly errorCode?: string | null; readonly errorType?: string | null }, -): boolean { - if (statusCode !== 429) return false; - const errorCode = options?.errorCode; - if (typeof errorCode === 'string' && QUOTA_EXHAUSTED_ERROR_CODES.has(errorCode)) return true; - const errorType = options?.errorType; - if (typeof errorType === 'string' && QUOTA_EXHAUSTED_ERROR_CODES.has(errorType)) return true; - const lowerMessage = message.toLowerCase(); - return QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ /tool_use[\s\S]*tool_result/, /tool_result[\s\S]*tool_use/, diff --git a/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts b/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts index fc55c3096b..936b665be6 100644 --- a/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts +++ b/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts @@ -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 @@ -24,6 +24,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, @@ -133,6 +134,19 @@ export interface ProtocolTrait { /** Single-value: tool-call id rewrite policy, replacing the base policy. */ toolCallIdPolicy?(ctx: TraitContext): ToolCallIdPolicy | undefined; + /** + * Single-value: classify one raw failure into a `ChatProviderError` before + * the base's own conversion runs. The hook receives the UNCONVERTED object + * the base caught at that seam — the SDK error on HTTP paths, the raw error + * event on in-stream paths — because base conversion drops vendor-parsed + * detail such as the body `error.type`/`error.code`. Returning `undefined` + * keeps the base classification; the base runs its abort guard before + * consulting the hook, so a user cancellation never reaches it. This 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). + */ + 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 @@ -239,3 +253,21 @@ export function traitDefaultHeaders( } return headers; } + +/** + * Bind the `convertError` hook of resolved traits with single-value + * semantics: the last declarer wins, its context bound away. Returns + * `undefined` when no trait declares the hook, so bases can bypass the + * consult entirely. + */ +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; +} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts index fc276bb066..2864726f43 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts @@ -14,7 +14,10 @@ * * `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. Right after the guard the + * converter consults the trait-composed `convertError` hook with the raw SDK + * error, so a vendor riding this transport can classify its own wire + * failures (e.g. quota 429s) before the base rules run. */ import Anthropic, { @@ -132,6 +135,7 @@ export interface AnthropicHooks { options: { readonly keep?: string }, generationKwargs: AnthropicGenerationKwargs, ): AnthropicGenerationKwargs | undefined; + convertError?: (error: unknown) => ChatProviderError | undefined; } export interface AnthropicOptions { @@ -513,11 +517,18 @@ 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); + const hooked = convertErrorHook?.(error); + if (hooked !== undefined) { + return hooked; + } if (error instanceof AnthropicTimeoutError) { return new APITimeoutError(error.message); } @@ -554,7 +565,13 @@ class AnthropicStreamedMessage implements StreamedMessage { private _rawFinishReason: string | null = null; private readonly _iter: AsyncGenerator; - 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); } else { @@ -780,7 +797,7 @@ class AnthropicStreamedMessage implements StreamedMessage { } } } catch (error: unknown) { - throw convertAnthropicError(error); + throw convertAnthropicError(error, this._convertErrorHook); } } } @@ -1021,9 +1038,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); } } @@ -1037,9 +1054,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); } } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts index d908d70ea2..bf01ad54b4 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts @@ -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; } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts index d9e17a7718..c9e8f14590 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts @@ -9,7 +9,12 @@ * guard: a user cancellation (SDK `APIUserAbortError`, bare `AbortError`, the * standard abort DOMException) is THROWN as the standard abort shape at the * very front of the classification chain — it can never be converted into, - * nor returned as, a retryable provider error. + * nor returned as, a retryable provider error. Right after the guard the + * converter consults an optional trait-composed `convertError` hook with the + * raw error, so a vendor can classify its own wire failures (e.g. quota 429s) + * before the base rules run. The base itself classifies only OpenAI's own + * documented `insufficient_quota` code as a non-retryable quota exhaustion — + * vendor-specific quota signals belong on the vendor's trait. */ import { @@ -21,6 +26,7 @@ import { import { APIConnectionError, + APIProviderQuotaExhaustedError, APITimeoutError, ChatProviderError, classifyBaseApiError, @@ -98,11 +104,29 @@ export function toolToOpenAI(tool: Tool): OpenAIToolParam { }; } -export function convertOpenAIError(error: unknown): ChatProviderError { +export function isOpenAIInsufficientQuotaCode(code: string | null | undefined): boolean { + return code === 'insufficient_quota'; +} + +function isOpenAIInsufficientQuotaError(error: OpenAIAPIError): boolean { + if (error.status !== 429) return false; + if (typeof error.code === 'string' && isOpenAIInsufficientQuotaCode(error.code)) return true; + if (typeof error.type === 'string' && isOpenAIInsufficientQuotaCode(error.type)) return true; + return error.message.toLowerCase().includes('insufficient_quota'); +} + +export function convertOpenAIError( + 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); + const hooked = convertErrorHook?.(error); + if (hooked !== undefined) { + return hooked; + } if (error instanceof ChatProviderError) { return error; } @@ -114,17 +138,12 @@ export function convertOpenAIError(error: unknown): ChatProviderError { } if (error instanceof OpenAIAPIError && typeof error.status === 'number') { const reqId = error.requestID ?? null; - return normalizeAPIStatusError( - error.status, - error.message, - reqId, - parseRetryAfterMs(error.headers), - parseTraceId(error.headers), - { - errorCode: typeof error.code === 'string' ? error.code : null, - errorType: typeof error.type === 'string' ? error.type : null, - }, - ); + const retryAfterMs = parseRetryAfterMs(error.headers); + const traceId = parseTraceId(error.headers); + if (isOpenAIInsufficientQuotaError(error)) { + return new APIProviderQuotaExhaustedError(error.message, reqId, retryAfterMs, traceId); + } + return normalizeAPIStatusError(error.status, error.message, reqId, retryAfterMs, traceId); } if ( error instanceof OpenAIAPIError && diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts index 7c7215005a..78f94c7e58 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts @@ -26,7 +26,7 @@ import OpenAI from 'openai'; -import { parseTraceId } from '#/kosong/contract/errors'; +import { parseTraceId, type ChatProviderError } from '#/kosong/contract/errors'; import type { ContentPart, Message, @@ -102,6 +102,7 @@ export const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { */ export interface OpenAIChatCompletionsHooks { convertTool?: (tool: Tool) => Record | undefined; + convertError?: (error: unknown) => ChatProviderError | undefined; convertMessage?: ( message: Message, converted: Record, @@ -377,6 +378,9 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { private readonly _extractUsageHook?: | ((chunk: Record) => Record | null | undefined) | undefined, + private readonly _convertErrorHook?: + | ((error: unknown) => ChatProviderError | undefined) + | undefined, ) { if (isStream) { this._iter = this._convertStreamResponse( @@ -511,7 +515,7 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } } } catch (error: unknown) { - throw convertOpenAIError(error); + throw convertOpenAIError(error, this._convertErrorHook); } } } @@ -685,9 +689,10 @@ export class OpenAILegacyChatProvider implements ChatProvider { this._reasoningKeyDialect, parseTraceId(response.headers), this._hooks?.extractUsage, + this._hooks?.convertError, ); } catch (error: unknown) { - throw convertOpenAIError(error); + throw convertOpenAIError(error, this._hooks?.convertError); } } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts index 863cd9ccc8..635521a643 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts @@ -9,7 +9,7 @@ */ import { registerProtocolBase } from '#/kosong/protocol/protocolBase'; -import { traitDefaultHeaders } from '#/kosong/protocol/protocolTrait'; +import { traitConvertError, traitDefaultHeaders } from '#/kosong/protocol/protocolTrait'; import { getOpenAIResponsesModelCapability, OpenAIResponsesChatProvider } from './openai-responses'; import { compactObject, firstProcessEnv, traitEndpoint, traitProvides } from './openaiHooks'; @@ -34,6 +34,7 @@ registerProtocolBase({ defaultHeaders: traitDefaultHeaders(traits), maxOutputTokens: config.providerOptions?.defaultMaxTokens, offEffort: config.providerOptions?.offEffort, + convertError: traitConvertError(traits), }), }); }, diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts index 65f8ed636a..5efcc0331c 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts @@ -4,9 +4,11 @@ * Speaks the Responses wire format: `input` items, `instructions`, * `reasoning` blocks with encrypted content, and the native * `prompt_cache_key` field (a cache key is encoded directly — no hook - * needed). This base carries no hook surface today; per-turn intents are - * encoded inline in the fixed contract order. The developer-role model - * detection lives here. + * needed). Per-turn intents are encoded inline in the fixed contract order; + * the base's only hook surface is the trait-composed `convertError` option, + * consulted (after the abort guard) with the raw failure — the SDK error on + * HTTP paths, the raw event on in-stream error paths — before the base's own + * classification. The developer-role model detection lives here. */ import OpenAI from 'openai'; @@ -17,7 +19,6 @@ import { APIProviderRateLimitError, ChatProviderError, isContextOverflowErrorCode, - isQuotaExhaustedStatusError, } from '#/kosong/contract/errors'; import type { ContentPart, @@ -43,6 +44,7 @@ import { convertOpenAIError, hasModelPrefix, isMediaPart, + isOpenAIInsufficientQuotaCode, isOpenAIReasoningModel, OPENAI_REASONING_CAPABILITY, OPENAI_VISION_TOOL_CAPABILITY, @@ -253,13 +255,21 @@ function errorFromOpenAIResponsesEvent( code: string | null, message: string, param: string | null, + options?: { + readonly rawEvent?: unknown; + readonly convertErrorHook?: (error: unknown) => ChatProviderError | undefined; + }, ): ChatProviderError { const formatted = formatResponsesErrorEvent(code, message, param); const fullMessage = `${prefix}: ${formatted}`; + const hooked = options?.convertErrorHook?.(options.rawEvent ?? { code, message, param }); + if (hooked !== undefined) { + return hooked; + } if (isContextOverflowErrorCode(code)) { return new APIContextOverflowError(400, fullMessage); } - if (isQuotaExhaustedStatusError(429, fullMessage, { errorCode: code })) { + if (isOpenAIInsufficientQuotaCode(code)) { return new APIProviderQuotaExhaustedError(fullMessage); } if (code === 'rate_limit_exceeded' || readEmbeddedStatusCode(message) === 429) { @@ -302,7 +312,10 @@ function parseNestedGatewayStreamError(message: string): }; } -function malformedStreamErrorEvent(message: string): ChatProviderError { +function malformedStreamErrorEvent( + message: string, + convertErrorHook?: (error: unknown) => ChatProviderError | undefined, +): ChatProviderError { const nested = parseNestedGatewayStreamError(message); if (nested !== undefined) { return errorFromOpenAIResponsesEvent( @@ -310,6 +323,7 @@ function malformedStreamErrorEvent(message: string): ChatProviderError { nested.code, nested.message, nested.param, + { convertErrorHook }, ); } @@ -318,6 +332,7 @@ function malformedStreamErrorEvent(message: string): ChatProviderError { null, message, null, + { convertErrorHook }, ); } @@ -361,6 +376,7 @@ export interface OpenAIResponsesOptions { defaultHeaders?: Record; toolMessageConversion?: ToolMessageConversion | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; + convertError?: (error: unknown) => ChatProviderError | undefined; } export interface OpenAIResponsesGenerationKwargs { @@ -667,7 +683,13 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { private _rawFinishReason: string | null = null; private readonly _iter: AsyncGenerator; - 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); } else { @@ -864,7 +886,7 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { if (!hasOwn(chunk, 'type')) { const message = readStringField(chunk, 'message'); if (message !== undefined) { - throw malformedStreamErrorEvent(message); + throw malformedStreamErrorEvent(message, this._convertErrorHook); } } failResponsesDecode('stream event.type', 'must be a string.'); @@ -970,6 +992,7 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { readNullableStringField(chunk, 'code') ?? null, message, readNullableStringField(chunk, 'param') ?? null, + { rawEvent: chunk, convertErrorHook: this._convertErrorHook }, ); } case 'response.failed': { @@ -981,6 +1004,7 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { error.code, error.message, null, + { rawEvent: chunk, convertErrorHook: this._convertErrorHook }, ); } throw new ChatProviderError( @@ -992,7 +1016,7 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { } } } catch (error: unknown) { - throw convertOpenAIError(error); + throw convertOpenAIError(error, this._convertErrorHook); } } } @@ -1012,6 +1036,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { private readonly _client: OpenAI | undefined; private readonly _httpClient: unknown; private readonly _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; + private readonly _convertErrorHook: ((error: unknown) => ChatProviderError | undefined) | undefined; constructor(options: OpenAIResponsesOptions) { const apiKey = options.apiKey ?? process.env['OPENAI_API_KEY']; @@ -1026,6 +1051,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { this._toolMessageConversion = options.toolMessageConversion ?? null; this._httpClient = options.httpClient; this._clientFactory = options.clientFactory; + this._convertErrorHook = options.convertError; if (options.maxOutputTokens !== undefined) { this._generationKwargs.max_output_tokens = options.maxOutputTokens; @@ -1154,9 +1180,9 @@ export class OpenAIResponsesChatProvider implements ChatProvider { create(params: unknown, opts?: unknown): Promise; } ).create(createParams, options?.signal ? { signal: options.signal } : undefined); - return new OpenAIResponsesStreamedMessage(response, this._stream); + return new OpenAIResponsesStreamedMessage(response, this._stream, this._convertErrorHook); } catch (error: unknown) { - throw convertOpenAIError(error); + throw convertOpenAIError(error, this._convertErrorHook); } } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts index a46b2e1cb0..f7a32cfe2e 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts @@ -73,6 +73,9 @@ export function composeOpenAIChatHooks( if (trait.convertTool !== undefined) { hooks.convertTool = (tool: Tool) => trait.convertTool!(tool, context); } + if (trait.convertError !== undefined) { + hooks.convertError = (error: unknown) => trait.convertError!(error, context); + } if (trait.toolCallIdPolicy !== undefined) { hooks.toolCallIdPolicy = () => trait.toolCallIdPolicy!(context); } diff --git a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts new file mode 100644 index 0000000000..fb8a8dbd22 --- /dev/null +++ b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts @@ -0,0 +1,67 @@ +/** + * `kosong/provider` domain (L2) — Kimi vendor error classification, declared + * on the Kimi traits via the `convertError` hook. + * + * This module owns the vendor-specific knowledge of how the Moonshot backend + * signals quota/balance exhaustion on a 429: the structured body + * `error.type`/`error.code` value `exceeded_current_quota_error`, and the + * observed billing wordings for gateways that flatten the body to text + * ("You exceeded your current token quota: … please check your account + * balance", "Your account … is suspended due to insufficient balance, please + * recharge your account …", and arrears phrasing). Every pattern is anchored + * to billing wording — deliberately no bare /quota/ or /balance/, so + * transient throttle messages like "token quota per minute" keep classifying + * as retryable rate limits. The classifier reads the raw SDK error + * structurally (status / code / type / message), so it works over both the + * OpenAI and Anthropic transports Kimi registers on; anything it does not + * positively recognize answers `undefined`, keeping the base classification. + */ + +import { + APIProviderQuotaExhaustedError, + parseRetryAfterMs, + parseTraceId, +} from '#/kosong/contract/errors'; + +const KIMI_QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error']); + +const KIMI_QUOTA_EXHAUSTED_MESSAGE_PATTERNS = [ + /exceeded your current (?:token )?quota/, + /check your account balance/, + /insufficient balance/, + /recharge your account|please recharge/, + /account (?:is )?in arrears/, +] as const; + +function readStringProp(value: object, key: string): string | undefined { + const raw = (value as Record)[key]; + return typeof raw === 'string' ? raw : undefined; +} + +export function classifyKimiQuotaError(error: unknown): APIProviderQuotaExhaustedError | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const status = (error as Record)['status']; + if (status !== 429) return undefined; + + const message = readStringProp(error, 'message') ?? ''; + const code = readStringProp(error, 'code'); + const type = readStringProp(error, 'type'); + + const structuredHit = + (code !== undefined && KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(code)) || + (type !== undefined && KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(type)); + const lowerMessage = message.toLowerCase(); + const wordingHit = KIMI_QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) => + pattern.test(lowerMessage), + ); + if (!structuredHit && !wordingHit) return undefined; + + const requestId = readStringProp(error, 'requestID') ?? null; + const headers = (error as Record)['headers']; + return new APIProviderQuotaExhaustedError( + message, + requestId, + parseRetryAfterMs(headers), + parseTraceId(headers), + ); +} diff --git a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-files.ts b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-files.ts index 12c6a7ef8a..e28a0e1c7a 100644 --- a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-files.ts +++ b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-files.ts @@ -4,6 +4,9 @@ * The file-upload companion of the video-upload trait: uploads a video (from * a filesystem path or in-memory bytes) to the Kimi files endpoint and * returns the `ms://` video URL part the wire messages reference. + * Upload failures classify through the same Kimi quota classifier the traits + * declare (this client runs outside any composed hook context), falling back + * to the base OpenAI conversion. */ import { Blob, File } from 'node:buffer'; @@ -23,6 +26,7 @@ import { requireProviderApiKey, resolveAuthBackedClient, } from '../../bases/request-auth'; +import { classifyKimiQuotaError } from './kimi-errors'; export interface KimiUploadOptions { auth?: ProviderRequestAuth; @@ -99,7 +103,7 @@ export class KimiFiles { options?.signal ? { signal: options.signal } : undefined, )) as unknown as { id: string }; } catch (error: unknown) { - throw convertOpenAIError(error); + throw convertOpenAIError(error, classifyKimiQuotaError); } return { diff --git a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi.contrib.ts b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi.contrib.ts index 967e9a8fa2..b95d79fcbd 100644 --- a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi.contrib.ts +++ b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi.contrib.ts @@ -44,6 +44,11 @@ * either at the top level (the base's default location) or inside * `choices[0].usage`; returning `undefined` defers to the base default * when neither position carries one; + * - errors: `convertError` classifies Moonshot's quota/balance-exhausted + * 429s (structured `exceeded_current_quota_error` type/code, billing + * wordings) as the non-retryable `APIProviderQuotaExhaustedError` via + * `classifyKimiQuotaError` from `./kimi-errors`, before the base's own + * classification would mint a retryable rate limit; * - video upload: `uploadVideo` uploads through the Kimi files API * (`KimiFiles` from `./kimi-files`), memoized per trait context with a * WeakMap — one composition (one resolved ctx) gets one files client, @@ -53,10 +58,12 @@ * `output_config.effort`, and the interleaved-thinking beta is stripped * from the seeded beta list. The `keep` dimension needs no trait handling * — the Anthropic base overlays the context-management edit itself. The - * trait deliberately does NOT declare `strictThinkingValidation`: over - * this foreign transport the backend may accept efforts the local catalog - * metadata does not list, so client-side validation stays lenient - * (warning + pass-through). + * trait declares the same `convertError` quota classification as the + * OpenAI registration (the classifier reads the SDK error structurally, + * so it is transport-agnostic). It deliberately does NOT declare + * `strictThinkingValidation`: over this foreign transport the backend may + * accept efforts the local catalog metadata does not list, so client-side + * validation stays lenient (warning + pass-through). * * Vendor-level facts — the endpoint fallback chain, full host-header * forwarding, and OAuth-catalog model discovery — are shared constants @@ -82,6 +89,7 @@ import type { import { type OpenAIToolParam, toolToOpenAI } from '../../bases/openai/openai-common'; import { registerProviderDefinition } from '../../providerDefinition'; +import { classifyKimiQuotaError } from './kimi-errors'; import { KimiFiles } from './kimi-files'; import { normalizeKimiToolSchema } from './kimi-schema'; @@ -176,6 +184,8 @@ export const kimiOpenAITrait: ProtocolTrait = { defaultBaseUrl: KIMI_DEFAULT_BASE_URL, }), + convertError: (error) => classifyKimiQuotaError(error), + cacheKey: (key) => ({ prompt_cache_key: key }), withThinking: (effort, options, generationKwargs) => { @@ -274,6 +284,8 @@ export const kimiOpenAITrait: ProtocolTrait = { }; export const kimiAnthropicTrait: ProtocolTrait = { + convertError: (error) => classifyKimiQuotaError(error), + withThinking: (effort, _options, generationKwargs) => { const seeded = generationKwargs['betaFeatures']; const betaFeatures = (Array.isArray(seeded) ? (seeded as string[]) : []).filter( diff --git a/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts b/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts index 3822a4ffc8..75905c1892 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts @@ -16,7 +16,6 @@ import { ChatProviderError, isImageFormatError, isProviderRateLimitError, - isQuotaExhaustedStatusError, isRecoverableRequestStructureError, isRetryableGenerateError, isToolExchangeAdjacencyError, @@ -710,49 +709,17 @@ describe('isProviderRateLimitError', () => { }); }); -describe('quota-exhausted 429 classification', () => { - it.each([ - 'You exceeded your current token quota: 31275, please check your account balance', - 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details', - 'You exceeded your current quota, please check your plan and billing details.', - 'Your account is in arrears, please top up', - ])('normalizes 429 "%s" to APIProviderQuotaExhaustedError by message', (message) => { - const error = normalizeAPIStatusError(429, message, 'req-quota'); - expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); - expect(error).not.toBeInstanceOf(APIProviderRateLimitError); - expect(error.statusCode).toBe(429); - }); - - it('classifies a neutral message by structured errorType/errorCode', () => { - expect( - normalizeAPIStatusError(429, 'Too many requests', null, null, null, { - errorType: 'exceeded_current_quota_error', - }), - ).toBeInstanceOf(APIProviderQuotaExhaustedError); - expect( - normalizeAPIStatusError(429, 'Too many requests', null, null, null, { - errorCode: 'insufficient_quota', - }), - ).toBeInstanceOf(APIProviderQuotaExhaustedError); - }); - +describe('quota-exhausted error contract', () => { it.each([ 'Too many requests', 'request reached user+model max RPM: 50', - 'your token quota per minute was exceeded', - ])('keeps transient 429 "%s" an APIProviderRateLimitError', (message) => { + 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details', + ])('keeps the vendor-neutral 429 normalization a rate limit for "%s"', (message) => { const error = normalizeAPIStatusError(429, message); expect(error).toBeInstanceOf(APIProviderRateLimitError); expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError); }); - it('is gated on status 429', () => { - expect(isQuotaExhaustedStatusError(403, 'insufficient balance')).toBe(false); - expect(normalizeAPIStatusError(403, 'insufficient balance')).not.toBeInstanceOf( - APIProviderQuotaExhaustedError, - ); - }); - it('is neither retryable nor a provider rate limit', () => { const quota = new APIProviderQuotaExhaustedError('quota exhausted', 'req-quota', 1); expect(isRetryableGenerateError(quota)).toBe(false); diff --git a/packages/agent-core-v2/test/kosong/provider/errors.test.ts b/packages/agent-core-v2/test/kosong/provider/errors.test.ts index 8d45cbf138..fff0809d56 100644 --- a/packages/agent-core-v2/test/kosong/provider/errors.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/errors.test.ts @@ -10,10 +10,13 @@ * - non-abort errors still classify normally; * - `isRetryableGenerateError` is false for the abort shape. * - * Also probes quota-exhausted classification at the provider boundary: a 429 - * whose structured body or message wording marks the account quota/balance as - * exhausted converts to the non-retryable `APIProviderQuotaExhaustedError`, - * both for SDK `APIError`s and for OpenAI Responses stream error events. + * Also probes quota-exhausted classification at the provider boundary. The + * base converters stay vendor-neutral (only OpenAI's own documented + * `insufficient_quota` code fails fast there); Moonshot's quota signals are + * declared on the Kimi traits via the `convertError` hook, composed with + * last-declarer-wins semantics and consulted — after the abort guard — with + * the raw failure at every catch seam, including the Responses in-stream + * error-event path. */ import { APIError as OpenAIAPIError } from 'openai'; @@ -21,14 +24,20 @@ import { describe, expect, it } from 'vitest'; import { APIProviderQuotaExhaustedError, + APIProviderRateLimitError, APIStatusError, ChatProviderError, createAbortError, isRetryableGenerateError, } from '#/kosong/contract/errors'; +import type { ProtocolAdapterConfig } from '#/kosong/protocol/protocol'; +import { traitConvertError, type TraitContext } from '#/kosong/protocol/protocolTrait'; import { convertAnthropicError } from '#/kosong/provider/bases/anthropic/anthropic'; import { convertOpenAIError } from '#/kosong/provider/bases/openai/openai-common'; import { OpenAIResponsesStreamedMessage } from '#/kosong/provider/bases/openai/openai-responses'; +import { composeOpenAIChatHooks } from '#/kosong/provider/bases/openai/openaiHooks'; +import { kimiAnthropicTrait, kimiOpenAITrait } from '#/kosong/provider/providers/kimi/kimi.contrib'; +import { classifyKimiQuotaError } from '#/kosong/provider/providers/kimi/kimi-errors'; // Structurally an SDK user-abort: recognized by constructor name, the same // way the OpenAI and Anthropic SDKs name their abort error class. @@ -99,15 +108,109 @@ async function consume(stream: AsyncIterable): Promise { } } -describe('OpenAI quota-exhausted 429 conversion', () => { - const QUOTA_MESSAGE = - 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details'; +const QUOTA_MESSAGE = + 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details'; +const TOKEN_QUOTA_MESSAGE = + 'You exceeded your current token quota: 31275, please check your account balance'; + +function moonshotQuota429(message: string, type?: string): OpenAIAPIError { + return new OpenAIAPIError( + 429, + type === undefined ? undefined : { message, type }, + `429 ${message}`, + new Headers(), + ); +} + +describe('classifyKimiQuotaError (Kimi trait classifier)', () => { it('classifies a structured exceeded_current_quota_error body as quota-exhausted', () => { + const error = classifyKimiQuotaError( + moonshotQuota429('Too many requests', 'exceeded_current_quota_error'), + ); + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(error)).toBe(false); + }); + + it.each([QUOTA_MESSAGE, TOKEN_QUOTA_MESSAGE])( + 'falls back to billing wording "%s" when no structured body is present', + (message) => { + const error = classifyKimiQuotaError(moonshotQuota429(message)); + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(error)).toBe(false); + }, + ); + + it.each(['Too many requests', 'your token quota per minute was exceeded'])( + 'answers undefined for transient 429 "%s"', + (message) => { + expect(classifyKimiQuotaError(moonshotQuota429(message))).toBeUndefined(); + }, + ); + + it('answers undefined for non-429 and non-SDK shapes', () => { + expect( + classifyKimiQuotaError(new OpenAIAPIError(403, undefined, QUOTA_MESSAGE, new Headers())), + ).toBeUndefined(); + expect(classifyKimiQuotaError(new Error(QUOTA_MESSAGE))).toBeUndefined(); + expect(classifyKimiQuotaError(undefined)).toBeUndefined(); + }); + + it('is declared as the convertError hook on both Kimi traits', () => { + const context: TraitContext = { + config: { protocol: 'openai', providerType: 'kimi', modelName: '' } as ProtocolAdapterConfig, + providerId: 'kimi', + }; + const source = moonshotQuota429('Too many requests', 'exceeded_current_quota_error'); + expect(kimiOpenAITrait.convertError!(source, context)).toBeInstanceOf( + APIProviderQuotaExhaustedError, + ); + expect(kimiAnthropicTrait.convertError!(source, context)).toBeInstanceOf( + APIProviderQuotaExhaustedError, + ); + }); +}); + +describe('convertError hook consult at the OpenAI boundary', () => { + const kimiContext: TraitContext = { + config: { protocol: 'openai', providerType: 'kimi', modelName: '' } as ProtocolAdapterConfig, + providerId: 'kimi', + }; + + it('prefers the composed hook over the vendor-neutral base classification', () => { + const hooks = composeOpenAIChatHooks([{ trait: kimiOpenAITrait, context: kimiContext }]); + const source = moonshotQuota429(QUOTA_MESSAGE); + + expect(convertOpenAIError(source)).toBeInstanceOf(APIProviderRateLimitError); + expect(convertOpenAIError(source, hooks?.convertError)).toBeInstanceOf( + APIProviderQuotaExhaustedError, + ); + }); + + it('binds convertError with last-declarer-wins semantics', () => { + const bound = traitConvertError([ + { trait: { convertError: () => new ChatProviderError('first') }, context: kimiContext }, + { trait: { convertError: () => new ChatProviderError('second') }, context: kimiContext }, + ]); + expect(bound!(new Error('anything'))?.message).toBe('second'); + }); + + it('still throws the standard abort shape when a hook is present', () => { + expectStandardAbort(() => + convertOpenAIError(createAbortError(), () => new ChatProviderError('never')), + ); + }); +}); + +describe('OpenAI base quota classification (vendor-neutral)', () => { + it("fails fast on OpenAI's own insufficient_quota code without any hook", () => { const source = new OpenAIAPIError( 429, - { message: QUOTA_MESSAGE, type: 'exceeded_current_quota_error' }, - `429 ${QUOTA_MESSAGE}`, + { + message: 'You exceeded your current quota, please check your plan and billing details.', + type: 'insufficient_quota', + }, + '429 You exceeded your current quota, please check your plan and billing details.', new Headers(), ); @@ -117,13 +220,10 @@ describe('OpenAI quota-exhausted 429 conversion', () => { expect(isRetryableGenerateError(error)).toBe(false); }); - it('falls back to message wording when no structured body is present', () => { - const source = new OpenAIAPIError(429, undefined, QUOTA_MESSAGE, new Headers()); - - const error = convertOpenAIError(source); - - expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); - expect(isRetryableGenerateError(error)).toBe(false); + it('keeps vendor billing wordings a retryable rate limit without a hook', () => { + const error = convertOpenAIError(moonshotQuota429(QUOTA_MESSAGE)); + expect(error).toBeInstanceOf(APIProviderRateLimitError); + expect(isRetryableGenerateError(error)).toBe(true); }); }); @@ -148,4 +248,33 @@ describe('OpenAI Responses quota-exhausted conversion', () => { expect(caught).toBeInstanceOf(APIProviderQuotaExhaustedError); expect(isRetryableGenerateError(caught)).toBe(false); }); + + it('consults a convertError hook with the raw in-stream error event', async () => { + const seen: unknown[] = []; + const stream = new OpenAIResponsesStreamedMessage( + streamEvents([ + { + type: 'error', + code: 'vendor_quota_gone', + message: 'vendor says the quota is gone', + param: null, + }, + ]), + true, + (error) => { + seen.push(error); + return (error as { code?: string }).code === 'vendor_quota_gone' + ? new APIProviderQuotaExhaustedError('vendor quota exhausted') + : undefined; + }, + ); + + const caught = await consume(stream).then( + () => undefined, + (error: unknown) => error, + ); + expect(caught).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect((caught as APIProviderQuotaExhaustedError).message).toBe('vendor quota exhausted'); + expect(seen[0]).toMatchObject({ type: 'error', code: 'vendor_quota_gone' }); + }); }); diff --git a/packages/agent-core-v2/test/kosong/provider/kimi.test.ts b/packages/agent-core-v2/test/kosong/provider/kimi.test.ts index b6a53d941d..30197ac5e9 100644 --- a/packages/agent-core-v2/test/kosong/provider/kimi.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/kimi.test.ts @@ -288,6 +288,7 @@ describe('trait objects are plain declarations', () => { expect(hookNames(kimiOpenAITrait).toSorted()).toEqual([ 'buildParams', 'cacheKey', + 'convertError', 'convertMessage', 'convertTool', 'endpoint', @@ -298,7 +299,7 @@ describe('trait objects are plain declarations', () => { 'withMaxCompletionTokens', 'withThinking', ]); - expect(hookNames(kimiAnthropicTrait)).toEqual(['withThinking']); + expect(hookNames(kimiAnthropicTrait).toSorted()).toEqual(['convertError', 'withThinking']); }); it('marks only the native-transport thinking trait as strict-validation (v1 parity)', () => { diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 1b7e0adab2..1ad2c00742 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -366,30 +366,6 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ /rate-limited/, ] as const; -// Structured error `type`/`code` values that mean the account's quota or -// balance is exhausted (as opposed to a transient rate limit). Moonshot sets -// `exceeded_current_quota_error` as the body `error.type`; OpenAI uses -// `insufficient_quota` as both `type` and `code`. -const QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error', 'insufficient_quota']); - -// Message fallback for providers/gateways that do not forward a structured -// type/code, matched against the lowercased message of a 429. Every pattern -// is anchored to billing wording — deliberately no bare /quota/ or /balance/, -// which would also match transient throttle messages like "token quota per -// minute". Grounded in observed bodies: Moonshot "You exceeded your current -// token quota: ... please check your account balance" and "Your account ... -// is suspended due to insufficient balance, please recharge your account or -// check your plan and billing details"; OpenAI "You exceeded your current -// quota, please check your plan and billing details". -const QUOTA_EXHAUSTED_MESSAGE_PATTERNS = [ - /exceeded your current (?:token )?quota/, - /check your account balance/, - /insufficient balance/, - /recharge your account|please recharge/, - /account (?:is )?in arrears/, - /insufficient_quota/, -] as const; - // Wordings that mean the serialized request BODY was too big, matched against // the lowercased message of a 413. Kept separate from the context-overflow // patterns above: those describe token counts, these describe bytes. A 413 @@ -446,14 +422,8 @@ export function normalizeAPIStatusError( requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, - options?: { readonly errorCode?: string | null; readonly errorType?: string | null }, ): APIStatusError { if (statusCode === 429) { - // Quota/balance exhaustion first: same status as a rate limit, but it - // never clears on its own, so it must not classify as retryable. - if (isQuotaExhaustedStatusError(statusCode, message, options)) { - return new APIProviderQuotaExhaustedError(message, requestId, retryAfterMs, traceId); - } return new APIProviderRateLimitError(message, requestId, retryAfterMs, traceId); } // Context overflow first: Vertex returns prompt-too-long as a 413, and a @@ -524,26 +494,6 @@ export function isRequestTooLargeStatusError(statusCode: number, message: string return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } -/** - * Whether a 429 means the account's quota/balance is exhausted rather than a - * transient rate limit. The structured body `error.type`/`error.code` is - * authoritative when the provider forwards one; the message patterns only - * backstop gateways that flatten the body to text. - */ -export function isQuotaExhaustedStatusError( - statusCode: number, - message: string, - options?: { readonly errorCode?: string | null; readonly errorType?: string | null }, -): boolean { - if (statusCode !== 429) return false; - const errorCode = options?.errorCode; - if (typeof errorCode === 'string' && QUOTA_EXHAUSTED_ERROR_CODES.has(errorCode)) return true; - const errorType = options?.errorType; - if (typeof errorType === 'string' && QUOTA_EXHAUSTED_ERROR_CODES.has(errorType)) return true; - const lowerMessage = message.toLowerCase(); - return QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - // Strict providers reject a request whose assistant `tool_use`/`tool_calls` and // `tool_result`/`tool` blocks are not correctly paired and adjacent — a missing // result, a stray result with no matching call, or a result that does not diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index c7a29ded67..39384fd51f 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -81,7 +81,6 @@ export { isContextOverflowStatusError, isImageFormatError, isProviderRateLimitError, - isQuotaExhaustedStatusError, isRecoverableRequestStructureError, isRequestTooLargeStatusError, isRetryableGenerateError, diff --git a/packages/kosong/src/providers/kimi-errors.ts b/packages/kosong/src/providers/kimi-errors.ts new file mode 100644 index 0000000000..c88f91e50b --- /dev/null +++ b/packages/kosong/src/providers/kimi-errors.ts @@ -0,0 +1,66 @@ +import { APIProviderQuotaExhaustedError, parseRetryAfterMs, parseTraceId } from '#/errors'; + +// Structured error `type`/`code` value that means the Moonshot account's +// quota or balance is exhausted (as opposed to a transient rate limit): the +// backend sets `exceeded_current_quota_error` as the body `error.type`. +const KIMI_QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error']); + +// Message fallback for gateways that flatten the body to text, matched +// against the lowercased message of a 429. Every pattern is anchored to +// billing wording — deliberately no bare /quota/ or /balance/, which would +// also match transient throttle messages like "token quota per minute". +// Grounded in observed Moonshot bodies: "You exceeded your current token +// quota: ... please check your account balance" and "Your account ... is +// suspended due to insufficient balance, please recharge your account or +// check your plan and billing details". +const KIMI_QUOTA_EXHAUSTED_MESSAGE_PATTERNS = [ + /exceeded your current (?:token )?quota/, + /check your account balance/, + /insufficient balance/, + /recharge your account|please recharge/, + /account (?:is )?in arrears/, +] as const; + +function readStringProp(value: object, key: string): string | undefined { + const raw = (value as Record)[key]; + return typeof raw === 'string' ? raw : undefined; +} + +/** + * Classify a raw provider failure as Moonshot's quota/balance-exhausted 429, + * or answer `undefined` to keep the base classification. This is the Kimi + * vendor's error knowledge, kept out of the shared OpenAI conversion: the + * Kimi provider (and the Kimi files client) passes it to + * `convertOpenAIError` as the vendor hook, consulted after the abort guard + * with the raw SDK error — the base conversion would otherwise drop the + * SDK-parsed body `error.type`/`error.code` this reads. + */ +export function classifyKimiQuotaError( + error: unknown, +): APIProviderQuotaExhaustedError | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const status = (error as Record)['status']; + if (status !== 429) return undefined; + + const message = readStringProp(error, 'message') ?? ''; + const code = readStringProp(error, 'code'); + const type = readStringProp(error, 'type'); + + const structuredHit = + (code !== undefined && KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(code)) || + (type !== undefined && KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(type)); + const lowerMessage = message.toLowerCase(); + const wordingHit = KIMI_QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) => + pattern.test(lowerMessage), + ); + if (!structuredHit && !wordingHit) return undefined; + + const requestId = readStringProp(error, 'requestID') ?? null; + const headers = (error as Record)['headers']; + return new APIProviderQuotaExhaustedError( + message, + requestId, + parseRetryAfterMs(headers), + parseTraceId(headers), + ); +} diff --git a/packages/kosong/src/providers/kimi-files.ts b/packages/kosong/src/providers/kimi-files.ts index 75ca48e5eb..ef40938ee2 100644 --- a/packages/kosong/src/providers/kimi-files.ts +++ b/packages/kosong/src/providers/kimi-files.ts @@ -8,6 +8,7 @@ import type { ProviderRequestAuth, VideoUploadInput } from '#/provider'; import type OpenAI from 'openai'; import OpenAIClient from 'openai'; +import { classifyKimiQuotaError } from './kimi-errors'; import { convertOpenAIError } from './openai-common'; import { mergeRequestHeaders, @@ -128,7 +129,7 @@ export class KimiFiles { options?.signal ? { signal: options.signal } : undefined, )) as unknown as { id: string }; } catch (error: unknown) { - throw convertOpenAIError(error); + throw convertOpenAIError(error, classifyKimiQuotaError); } return { diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index c228eba91d..4496a432d9 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -16,6 +16,7 @@ import type { Tool } from '#/tool'; import type { TokenUsage } from '#/usage'; import OpenAI from 'openai'; +import { classifyKimiQuotaError } from './kimi-errors'; import { KimiFiles } from './kimi-files'; import { convertChatCompletionStreamToolCall, @@ -400,7 +401,7 @@ class KimiStreamedMessage implements StreamedMessage { } } } catch (error: unknown) { - throw convertOpenAIError(error); + throw convertOpenAIError(error, classifyKimiQuotaError); } } } @@ -579,7 +580,7 @@ export class KimiChatProvider implements ChatProvider { this._reasoningKeyDialect, ); } catch (error: unknown) { - throw convertOpenAIError(error); + throw convertOpenAIError(error, classifyKimiQuotaError); } } diff --git a/packages/kosong/src/providers/openai-common.ts b/packages/kosong/src/providers/openai-common.ts index c5df79d389..e31b51b27d 100644 --- a/packages/kosong/src/providers/openai-common.ts +++ b/packages/kosong/src/providers/openai-common.ts @@ -1,5 +1,6 @@ import { APIConnectionError, + APIProviderQuotaExhaustedError, APITimeoutError, ChatProviderError, classifyBaseApiError, @@ -97,11 +98,40 @@ export function toolToOpenAI(tool: Tool): OpenAIToolParam { * chain — it can never be converted into, nor returned as, a retryable * provider error. */ -export function convertOpenAIError(error: unknown): ChatProviderError { +// OpenAI's own documented signal that the account quota/balance is exhausted: +// the API sets `insufficient_quota` as both the body `error.type` and +// `error.code` on a 429. This is protocol knowledge of the OpenAI wire — the +// equivalent vendor-specific signals (e.g. Moonshot's +// `exceeded_current_quota_error`) live with their vendor and reach this +// converter through the optional `convertErrorHook` instead. +export function isOpenAIInsufficientQuotaCode(code: string | null | undefined): boolean { + return code === 'insufficient_quota'; +} + +function isOpenAIInsufficientQuotaError(error: OpenAIAPIError): boolean { + if (error.status !== 429) return false; + if (typeof error.code === 'string' && isOpenAIInsufficientQuotaCode(error.code)) return true; + if (typeof error.type === 'string' && isOpenAIInsufficientQuotaCode(error.type)) return true; + // Gateways sometimes flatten the JSON body into the message text; the + // literal code string is unambiguous there, unlike prose wordings. + return error.message.toLowerCase().includes('insufficient_quota'); +} + +export function convertOpenAIError( + 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); + // Vendor classification next: the hook sees the RAW error (the base + // conversion below drops the SDK-parsed body `error.code`/`error.type`), + // and `undefined` keeps the base classification. + const hooked = convertErrorHook?.(error); + if (hooked !== undefined) { + return hooked; + } if (error instanceof ChatProviderError) { return error; } @@ -115,20 +145,14 @@ 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, - parseRetryAfterMs(error.headers), - parseTraceId(error.headers), - // The SDK parses the body's `error.code`/`error.type` onto the error; - // forward them so a quota-exhausted 429 classifies structurally rather - // than by message wording. - { - errorCode: typeof error.code === 'string' ? error.code : null, - errorType: typeof error.type === 'string' ? error.type : null, - }, - ); + const retryAfterMs = parseRetryAfterMs(error.headers); + const traceId = parseTraceId(error.headers); + // Quota/balance exhaustion is a 429 but deterministic until the account + // is recharged — it must not classify as a retryable rate limit. + if (isOpenAIInsufficientQuotaError(error)) { + return new APIProviderQuotaExhaustedError(error.message, reqId, retryAfterMs, traceId); + } + return normalizeAPIStatusError(error.status, error.message, reqId, retryAfterMs, traceId); } // Base APIError with no status and no body => transport-layer failure. // When the error has a body (e.g. SSE error events from the server), diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 25c371eb3f..bd4e86e08a 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -4,7 +4,6 @@ import { APIProviderRateLimitError, ChatProviderError, isContextOverflowErrorCode, - isQuotaExhaustedStatusError, } from '#/errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { extractText, isToolDeclarationOnlyMessage } from '#/message'; @@ -25,6 +24,7 @@ import { usesOpenAIResponsesDeveloperRole } from './capability-registry'; import { convertOpenAIError, isMediaPart, + isOpenAIInsufficientQuotaCode, TOOL_RESULT_MEDIA_PLACEHOLDER, TOOL_RESULT_MEDIA_PROMPT, type ToolMessageConversion, @@ -256,10 +256,9 @@ function errorFromOpenAIResponsesEvent( // Quota/balance exhaustion first — otherwise an `insufficient_quota` event // falls through to the base ChatProviderError (whose unclassified fallback // is retryable), and a quota message with an embedded status_code=429 would - // classify as a retryable rate limit. Responses stream events carry no HTTP - // status, so the 429 passed here only satisfies the predicate's status gate - // while the event code / billing wording carries the actual evidence. - if (isQuotaExhaustedStatusError(429, fullMessage, { errorCode: code })) { + // classify as a retryable rate limit. Only OpenAI's own documented code is + // recognized here; vendor-specific quota signals live with their vendor. + if (isOpenAIInsufficientQuotaCode(code)) { return new APIProviderQuotaExhaustedError(fullMessage); } if (code === 'rate_limit_exceeded' || readEmbeddedStatusCode(message) === 429) { diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index ceef4d29b5..c2ad36f898 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -10,7 +10,6 @@ import { ChatProviderError, isImageFormatError, isProviderRateLimitError, - isQuotaExhaustedStatusError, isRecoverableRequestStructureError, isRetryableGenerateError, isToolExchangeAdjacencyError, @@ -694,56 +693,23 @@ describe('APIProviderQuotaExhaustedError', () => { }); }); -describe('normalizeAPIStatusError: quota-exhausted 429', () => { - // Both Moonshot wordings observed live from the same account (`error.type` - // "exceeded_current_quota_error"), plus OpenAI's insufficient_quota wording - // and the arrears synonym. - it.each([ - 'You exceeded your current token quota: 31275, please check your account balance', - 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details', - 'You exceeded your current quota, please check your plan and billing details.', - 'Your account is in arrears, please top up', - ])('classifies 429 "%s" as quota-exhausted by message', (message) => { - const error = normalizeAPIStatusError(429, message, 'req-quota'); - expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); - expect(error.statusCode).toBe(429); - expect(error.requestId).toBe('req-quota'); - }); - - it('classifies a neutral message as quota-exhausted by structured errorType', () => { - const error = normalizeAPIStatusError(429, 'Too many requests', null, null, null, { - errorType: 'exceeded_current_quota_error', - }); - expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); - }); - - it('classifies a neutral message as quota-exhausted by structured errorCode', () => { - const error = normalizeAPIStatusError(429, 'Too many requests', null, null, null, { - errorCode: 'insufficient_quota', - }); - expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); - }); - +describe('normalizeAPIStatusError: 429 stays vendor-neutral', () => { + // The shared normalization never decides what a vendor's 429 means: quota + // classification lives with the vendor (`classifyKimiQuotaError`, the + // OpenAI base's own insufficient_quota check), so even billing wordings + // normalize to a retryable rate limit here. it.each([ 'Too many requests', 'request reached user+model max RPM: 50', 'your token quota per minute was exceeded', - ])('keeps transient 429 "%s" an APIProviderRateLimitError', (message) => { + 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details', + ])('normalizes 429 "%s" to APIProviderRateLimitError', (message) => { const error = normalizeAPIStatusError(429, message); expect(error).toBeInstanceOf(APIProviderRateLimitError); expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError); }); - it('keeps a 429 with a transient structured type an APIProviderRateLimitError', () => { - const error = normalizeAPIStatusError(429, 'Too many requests', null, null, null, { - errorType: 'rate_limit_reached_error', - }); - expect(error).toBeInstanceOf(APIProviderRateLimitError); - expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError); - }); - - it('is gated on status 429 — billing wording on other statuses stays generic', () => { - expect(isQuotaExhaustedStatusError(403, 'insufficient balance')).toBe(false); + it('keeps billing wording on other statuses a generic status error', () => { const error = normalizeAPIStatusError(403, 'insufficient balance'); expect(error).not.toBeInstanceOf(APIProviderQuotaExhaustedError); expect(error.constructor).toBe(APIStatusError); diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index 23f3fe2475..209d757239 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -1,9 +1,12 @@ +import { APIProviderQuotaExhaustedError, isRetryableGenerateError } from '#/errors'; import { generate } from '#/generate'; import type { ContentPart, Message, ToolCall } from '#/message'; import { extractUsageFromChunk, KimiChatProvider } from '#/providers/kimi'; +import { classifyKimiQuotaError } from '#/providers/kimi-errors'; import { extractUsage } from '#/providers/openai-common'; import type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; +import { APIError as OpenAIAPIError } from 'openai'; import { describe, it, expect, vi } from 'vitest'; function makeChatCompletionResponse(model: string = 'test-model') { @@ -2168,3 +2171,51 @@ describe('extractUsage', () => { expect(extractUsage(undef)).toBeNull(); }); }); + +describe('classifyKimiQuotaError', () => { + const QUOTA_MESSAGE = + 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details'; + const TOKEN_QUOTA_MESSAGE = + 'You exceeded your current token quota: 31275, please check your account balance'; + + function quota429(message: string, type?: string): OpenAIAPIError { + return new OpenAIAPIError( + 429, + type === undefined ? undefined : { message, type }, + `429 ${message}`, + new Headers(), + ); + } + + it('classifies a structured exceeded_current_quota_error body as quota-exhausted', () => { + const error = classifyKimiQuotaError( + quota429('Too many requests', 'exceeded_current_quota_error'), + ); + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(error)).toBe(false); + }); + + it.each([QUOTA_MESSAGE, TOKEN_QUOTA_MESSAGE])( + 'falls back to billing wording "%s" without a structured body', + (message) => { + expect(classifyKimiQuotaError(quota429(message))).toBeInstanceOf( + APIProviderQuotaExhaustedError, + ); + }, + ); + + it.each(['Too many requests', 'your token quota per minute was exceeded'])( + 'answers undefined for transient 429 "%s"', + (message) => { + expect(classifyKimiQuotaError(quota429(message))).toBeUndefined(); + }, + ); + + it('answers undefined for non-429 and non-SDK shapes', () => { + expect( + classifyKimiQuotaError(new OpenAIAPIError(403, undefined, QUOTA_MESSAGE, new Headers())), + ).toBeUndefined(); + expect(classifyKimiQuotaError(new Error(QUOTA_MESSAGE))).toBeUndefined(); + expect(classifyKimiQuotaError(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/kosong/test/openai-common-errors.test.ts b/packages/kosong/test/openai-common-errors.test.ts index a65ca3e8f4..0b7b7ca898 100644 --- a/packages/kosong/test/openai-common-errors.test.ts +++ b/packages/kosong/test/openai-common-errors.test.ts @@ -10,6 +10,7 @@ import { normalizeAPIStatusError, } from '#/errors'; import type { ContentPart } from '#/message'; +import { classifyKimiQuotaError } from '#/providers/kimi-errors'; import { convertContentPart, convertOpenAIError, @@ -428,13 +429,13 @@ describe('convertOpenAIError: quota-exhausted 429', () => { const QUOTA_MESSAGE = 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details'; - it('classifies a structured exceeded_current_quota_error body as quota-exhausted', () => { - // The SDK parses the body's inner error object onto the APIError, exposing - // `type` — the structured path must win regardless of message wording. + it("classifies OpenAI's own insufficient_quota code without any vendor hook", () => { + // insufficient_quota is OpenAI's documented signal on its own wire, so + // the base converter recognizes it directly. const err = new OpenAIAPIError( 429, - { message: QUOTA_MESSAGE, type: 'exceeded_current_quota_error' }, - `429 ${QUOTA_MESSAGE}`, + { message: 'You exceeded your current quota.', type: 'insufficient_quota' }, + '429 You exceeded your current quota.', new Headers(), ); const result = convertOpenAIError(err); @@ -443,9 +444,28 @@ describe('convertOpenAIError: quota-exhausted 429', () => { expect(isRetryableGenerateError(result)).toBe(false); }); - it('falls back to message wording when no structured body is present', () => { - const err = new OpenAIAPIError(429, undefined, QUOTA_MESSAGE, new Headers()); + it('keeps vendor quota signals a rate limit without the vendor hook', () => { + // Moonshot's structured type and billing wordings are vendor knowledge — + // the shared base must not decide what another vendor's 429 means. + const err = new OpenAIAPIError( + 429, + { message: QUOTA_MESSAGE, type: 'exceeded_current_quota_error' }, + `429 ${QUOTA_MESSAGE}`, + new Headers(), + ); const result = convertOpenAIError(err); + expect(result).toBeInstanceOf(APIProviderRateLimitError); + expect(result).not.toBeInstanceOf(APIProviderQuotaExhaustedError); + }); + + it('classifies vendor quota signals through the convertError hook', () => { + const err = new OpenAIAPIError( + 429, + { message: QUOTA_MESSAGE, type: 'exceeded_current_quota_error' }, + `429 ${QUOTA_MESSAGE}`, + new Headers(), + ); + const result = convertOpenAIError(err, classifyKimiQuotaError); expect(result).toBeInstanceOf(APIProviderQuotaExhaustedError); expect(isRetryableGenerateError(result)).toBe(false); }); @@ -457,7 +477,7 @@ describe('convertOpenAIError: quota-exhausted 429', () => { 'Too many requests', new Headers(), ); - const result = convertOpenAIError(err); + const result = convertOpenAIError(err, classifyKimiQuotaError); expect(result).toBeInstanceOf(APIProviderRateLimitError); expect(result).not.toBeInstanceOf(APIProviderQuotaExhaustedError); expect(isRetryableGenerateError(result)).toBe(true); diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 863fd3aae8..d8c7a6efcb 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -2074,7 +2074,10 @@ describe('OpenAIResponsesChatProvider', () => { expect(isRetryableGenerateError(caughtError)).toBe(false); }); - it('classifies an embedded status_code=429 with billing wording as quota exhausted', async () => { + it('keeps an embedded status_code=429 with vendor billing wording a rate limit', async () => { + // Vendor billing wordings are no longer recognized by the base — quota + // classification for a vendor's own errors lives on that vendor's + // convertError hook, and no vendor rides this transport here. const events = [ { type: 'error', @@ -2093,9 +2096,9 @@ describe('OpenAIResponsesChatProvider', () => { caughtError = error; } - expect(caughtError).toBeInstanceOf(APIProviderQuotaExhaustedError); - expect(caughtError).not.toBeInstanceOf(APIProviderRateLimitError); - expect(isRetryableGenerateError(caughtError)).toBe(false); + expect(caughtError).toBeInstanceOf(APIProviderRateLimitError); + expect(caughtError).not.toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(caughtError)).toBe(true); }); it('rejects malformed stream events with a non-string type even when message is present', async () => { From db499a08853e5bbe033e61f06df8ec9c49ae2426 Mon Sep 17 00:00:00 2001 From: vinlee19 <1401597760@qq.com> Date: Mon, 27 Jul 2026 22:52:53 +0800 Subject: [PATCH 5/7] fix(kosong,agent-core,agent-core-v2): wire kimi quota hook fully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/kosong/contract/errors.ts | 4 ++ .../src/kosong/protocol/protocolTrait.ts | 18 ++++---- .../provider/bases/anthropic/anthropic.ts | 12 +++-- .../provider/bases/openai/openai-common.ts | 17 +++---- .../provider/bases/openai/openai-responses.ts | 8 ++-- .../provider/providers/kimi/kimi-errors.ts | 35 ++++++++++---- .../test/kosong/contract/errors.test.ts | 5 ++ .../test/kosong/provider/errors.test.ts | 25 ++++++++++ .../src/session/provider-manager.ts | 14 +++++- packages/kosong/src/index.ts | 1 + packages/kosong/src/providers/anthropic.ts | 46 ++++++++++++++++--- packages/kosong/src/providers/kimi-errors.ts | 33 ++++++++++--- .../kosong/src/providers/openai-common.ts | 9 ++-- packages/kosong/test/anthropic-errors.test.ts | 40 ++++++++++++++++ packages/kosong/test/kimi.test.ts | 13 ++++++ 15 files changed, 231 insertions(+), 49 deletions(-) diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index d45fc01a9b..7347afc47e 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -480,6 +480,7 @@ export type ApiErrorKind = | 'context_overflow' | 'overloaded' | 'rate_limit' + | 'quota_exhausted' | 'auth' | '5xx_server' | '4xx_client' @@ -497,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 }; diff --git a/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts b/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts index 936b665be6..040eb0a892 100644 --- a/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts +++ b/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts @@ -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. * @@ -136,14 +144,8 @@ export interface ProtocolTrait { /** * Single-value: classify one raw failure into a `ChatProviderError` before - * the base's own conversion runs. The hook receives the UNCONVERTED object - * the base caught at that seam — the SDK error on HTTP paths, the raw error - * event on in-stream paths — because base conversion drops vendor-parsed - * detail such as the body `error.type`/`error.code`. Returning `undefined` - * keeps the base classification; the base runs its abort guard before - * consulting the hook, so a user cancellation never reaches it. This 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). + * the base rules run; `undefined` keeps the base classification. The + * consult contract is restated in the header composition rules. */ convertError?(error: unknown, ctx: TraitContext): ChatProviderError | undefined; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts index 2864726f43..10d82bd261 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts @@ -14,10 +14,11 @@ * * `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. Right after the guard the - * converter consults the trait-composed `convertError` hook with the raw SDK - * error, so a vendor riding this transport can classify its own wire - * failures (e.g. quota 429s) before the base rules run. + * 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, { @@ -525,6 +526,9 @@ export function convertAnthropicError( // 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; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts index c9e8f14590..d636c1cb1a 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts @@ -9,11 +9,12 @@ * guard: a user cancellation (SDK `APIUserAbortError`, bare `AbortError`, the * standard abort DOMException) is THROWN as the standard abort shape at the * very front of the classification chain — it can never be converted into, - * nor returned as, a retryable provider error. Right after the guard the - * converter consults an optional trait-composed `convertError` hook with the - * raw error, so a vendor can classify its own wire failures (e.g. quota 429s) - * before the base rules run. The base itself classifies only OpenAI's own - * documented `insufficient_quota` code as a non-retryable quota exhaustion — + * nor returned as, a retryable provider error. After the guard, + * already-converted `ChatProviderError`s pass through untouched; only then is + * the optional trait-composed `convertError` hook consulted, so a vendor + * classifies each RAW wire failure (e.g. quota 429s) exactly once before the + * base rules run. The base itself classifies only OpenAI's own documented + * `insufficient_quota` code as a non-retryable quota exhaustion — * vendor-specific quota signals belong on the vendor's trait. */ @@ -123,13 +124,13 @@ export function convertOpenAIError( // 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 ChatProviderError) { - return error; - } if (error instanceof OpenAITimeoutError) { return new APITimeoutError(error.message); } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts index 5efcc0331c..20d24559cc 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts @@ -6,9 +6,11 @@ * `prompt_cache_key` field (a cache key is encoded directly — no hook * needed). Per-turn intents are encoded inline in the fixed contract order; * the base's only hook surface is the trait-composed `convertError` option, - * consulted (after the abort guard) with the raw failure — the SDK error on - * HTTP paths, the raw event on in-stream error paths — before the base's own - * classification. The developer-role model detection lives here. + * consulted with each raw failure exactly once — the SDK error on HTTP + * paths, the raw event on in-stream error paths — before the base's own + * classification (already-converted errors crossing an outer catch pass + * through without re-consulting). The developer-role model detection lives + * here. */ import OpenAI from 'openai'; diff --git a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts index fb8a8dbd22..c7a6db5701 100644 --- a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts +++ b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts @@ -13,8 +13,12 @@ * transient throttle messages like "token quota per minute" keep classifying * as retryable rate limits. The classifier reads the raw SDK error * structurally (status / code / type / message), so it works over both the - * OpenAI and Anthropic transports Kimi registers on; anything it does not - * positively recognize answers `undefined`, keeping the base classification. + * OpenAI and Anthropic transports Kimi registers on: the OpenAI SDK hoists + * the body's `error.code`/`error.type` to the top level, while the Anthropic + * SDK keeps the full body on `.error` (`{type: 'error', error: {type}}`), so + * candidate codes are collected from `error` → `.error` → `.error.error`. + * Anything not positively recognized answers `undefined`, keeping the base + * classification. */ import { @@ -38,18 +42,33 @@ function readStringProp(value: object, key: string): string | undefined { return typeof raw === 'string' ? raw : undefined; } +function readErrorObjectProp(value: object): object | undefined { + const raw = (value as Record)['error']; + return typeof raw === 'object' && raw !== null ? raw : undefined; +} + +function collectErrorCodes(error: object): string[] { + const codes: string[] = []; + let current: object | undefined = error; + for (let depth = 0; current !== undefined && depth < 3; depth += 1) { + const code = readStringProp(current, 'code'); + if (code !== undefined) codes.push(code); + const type = readStringProp(current, 'type'); + if (type !== undefined) codes.push(type); + current = readErrorObjectProp(current); + } + return codes; +} + export function classifyKimiQuotaError(error: unknown): APIProviderQuotaExhaustedError | undefined { if (typeof error !== 'object' || error === null) return undefined; const status = (error as Record)['status']; if (status !== 429) return undefined; const message = readStringProp(error, 'message') ?? ''; - const code = readStringProp(error, 'code'); - const type = readStringProp(error, 'type'); - - const structuredHit = - (code !== undefined && KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(code)) || - (type !== undefined && KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(type)); + const structuredHit = collectErrorCodes(error).some((code) => + KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(code), + ); const lowerMessage = message.toLowerCase(); const wordingHit = KIMI_QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage), diff --git a/packages/agent-core-v2/test/kosong/contract/errors.test.ts b/packages/agent-core-v2/test/kosong/contract/errors.test.ts index b4ba79cef8..2bf3efc758 100644 --- a/packages/agent-core-v2/test/kosong/contract/errors.test.ts +++ b/packages/agent-core-v2/test/kosong/contract/errors.test.ts @@ -14,6 +14,7 @@ import { APIContextOverflowError, APIEmptyResponseError, APIProviderOverloadedError, + APIProviderQuotaExhaustedError, APIProviderRateLimitError, APIStatusError, APITimeoutError, @@ -146,6 +147,10 @@ describe('classifyApiError', () => { kind: 'rate_limit', statusCode: 429, }); + expect(classifyApiError(new APIProviderQuotaExhaustedError('quota exhausted'))).toEqual({ + kind: 'quota_exhausted', + statusCode: 429, + }); expect(classifyApiError(new APIProviderOverloadedError(529, 'Overloaded'))).toEqual({ kind: 'overloaded', statusCode: 529, diff --git a/packages/agent-core-v2/test/kosong/provider/errors.test.ts b/packages/agent-core-v2/test/kosong/provider/errors.test.ts index fff0809d56..89749b9233 100644 --- a/packages/agent-core-v2/test/kosong/provider/errors.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/errors.test.ts @@ -19,6 +19,7 @@ * error-event path. */ +import { APIError as AnthropicAPIError } from '@anthropic-ai/sdk'; import { APIError as OpenAIAPIError } from 'openai'; import { describe, expect, it } from 'vitest'; @@ -156,6 +157,18 @@ describe('classifyKimiQuotaError (Kimi trait classifier)', () => { expect(classifyKimiQuotaError(undefined)).toBeUndefined(); }); + it('classifies the Anthropic SDK error shape (body nested under .error)', () => { + const source = AnthropicAPIError.generate( + 429, + { type: 'error', error: { type: 'exceeded_current_quota_error', message: 'quota gone' } }, + 'Too many requests', + new Headers(), + ); + const error = classifyKimiQuotaError(source); + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(error)).toBe(false); + }); + it('is declared as the convertError hook on both Kimi traits', () => { const context: TraitContext = { config: { protocol: 'openai', providerType: 'kimi', modelName: '' } as ProtocolAdapterConfig, @@ -200,6 +213,17 @@ describe('convertError hook consult at the OpenAI boundary', () => { convertOpenAIError(createAbortError(), () => new ChatProviderError('never')), ); }); + + it('passes already-converted errors through without re-consulting the hook', () => { + const calls: unknown[] = []; + const converted = new APIProviderQuotaExhaustedError('already classified'); + const result = convertOpenAIError(converted, (error) => { + calls.push(error); + return new ChatProviderError('re-classified'); + }); + expect(result).toBe(converted); + expect(calls).toHaveLength(0); + }); }); describe('OpenAI base quota classification (vendor-neutral)', () => { @@ -275,6 +299,7 @@ describe('OpenAI Responses quota-exhausted conversion', () => { ); expect(caught).toBeInstanceOf(APIProviderQuotaExhaustedError); expect((caught as APIProviderQuotaExhaustedError).message).toBe('vendor quota exhausted'); + expect(seen).toHaveLength(1); expect(seen[0]).toMatchObject({ type: 'error', code: 'vendor_quota_gone' }); }); }); diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index e8028c76c1..2bdb29a425 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -1,6 +1,11 @@ import type { Logger } from '#/logging/types'; import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong'; -import { APIStatusError, getModelCapability, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; +import { + APIStatusError, + classifyKimiQuotaError, + getModelCapability, + UNKNOWN_CAPABILITY, +} from '@moonshot-ai/kosong'; import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; import { effectiveModelAlias, @@ -284,7 +289,12 @@ function toKosongProviderConfig( ...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}), supportEfforts, ...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}), - ...(provider.type === 'kimi' ? { kimiThinking: true } : {}), + // Kimi routed over the Anthropic transport keeps its vendor error + // classification: a Moonshot quota-exhausted 429 must fail fast here + // exactly as it does on the Kimi OpenAI transport. + ...(provider.type === 'kimi' + ? { kimiThinking: true, convertError: classifyKimiQuotaError } + : {}), ...(betaApi !== undefined ? { betaApi } : {}), // Session affinity: Anthropic's analog of OpenAI `prompt_cache_key` is // `metadata.user_id` on the Messages API (cache-affinity / end-user id). diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index 39384fd51f..cd0440637b 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -32,6 +32,7 @@ export type { ProviderConfig, ProviderType } from './providers'; // kwargs, `thinking.keep` extra body). export { KimiChatProvider } from './providers/kimi'; export type { ExtraBody, GenerationKwargs, KimiOptions, ThinkingConfig } from './providers/kimi'; +export { classifyKimiQuotaError } from './providers/kimi-errors'; // Model capability matrix export { isUnknownCapability, UNKNOWN_CAPABILITY } from './capability'; diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 6b34aa5343..5af85106e2 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -122,6 +122,15 @@ export interface AnthropicOptions { */ betaApi?: boolean | undefined; clientFactory?: (auth: ProviderRequestAuth) => Anthropic; + /** + * Vendor error classification, consulted by `convertAnthropicError` with + * each raw SDK failure exactly once (after the abort guard and the + * already-converted pass-through) before the base rules run. `undefined` + * keeps the base classification. A Kimi provider routed over this + * transport passes `classifyKimiQuotaError` here so a quota-exhausted 429 + * fails fast instead of burning the retry budget. + */ + convertError?: (error: unknown) => ChatProviderError | undefined; } interface AnthropicGenerationKwargs { @@ -582,11 +591,26 @@ 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); + // Already-converted errors pass through untouched — the vendor hook below + // sees each raw failure exactly once. + if (error instanceof ChatProviderError) { + return error; + } + // Vendor classification next: the hook sees the RAW SDK error (the base + // conversion below drops the parsed body detail), and `undefined` keeps + // the base classification. + const hooked = convertErrorHook?.(error); + if (hooked !== undefined) { + return hooked; + } // Check timeout before connection (APIConnectionTimeoutError extends APIConnectionError) if (error instanceof AnthropicTimeoutError) { return new APITimeoutError(error.message); @@ -629,7 +653,13 @@ class AnthropicStreamedMessage implements StreamedMessage { private _rawFinishReason: string | null = null; private readonly _iter: AsyncGenerator; - 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); } else { @@ -873,7 +903,7 @@ class AnthropicStreamedMessage implements StreamedMessage { // message_stop: nothing to do } } catch (error: unknown) { - throw convertAnthropicError(error); + throw convertAnthropicError(error, this._convertErrorHook); } } } @@ -901,6 +931,7 @@ export class AnthropicChatProvider implements ChatProvider { private _adaptiveThinking: boolean | undefined; private readonly _supportEfforts: readonly string[] | undefined; private readonly _kimiThinking: boolean; + private readonly _convertErrorHook: ((error: unknown) => ChatProviderError | undefined) | undefined; private _betaApi: boolean; private _explicitMaxTokens: boolean; @@ -911,6 +942,7 @@ export class AnthropicChatProvider implements ChatProvider { this._adaptiveThinking = options.adaptiveThinking; this._supportEfforts = options.supportEfforts; this._kimiThinking = options.kimiThinking ?? false; + this._convertErrorHook = options.convertError; this._betaApi = options.betaApi ?? false; this._apiKey = options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; @@ -1101,9 +1133,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._convertErrorHook); } catch (error: unknown) { - throw convertAnthropicError(error); + throw convertAnthropicError(error, this._convertErrorHook); } } @@ -1118,9 +1150,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._convertErrorHook); } catch (error: unknown) { - throw convertAnthropicError(error); + throw convertAnthropicError(error, this._convertErrorHook); } } diff --git a/packages/kosong/src/providers/kimi-errors.ts b/packages/kosong/src/providers/kimi-errors.ts index c88f91e50b..88109a1f22 100644 --- a/packages/kosong/src/providers/kimi-errors.ts +++ b/packages/kosong/src/providers/kimi-errors.ts @@ -26,6 +26,30 @@ function readStringProp(value: object, key: string): string | undefined { return typeof raw === 'string' ? raw : undefined; } +function readErrorObjectProp(value: object): object | undefined { + const raw = (value as Record)['error']; + return typeof raw === 'object' && raw !== null ? raw : undefined; +} + +// Collect every candidate `code`/`type` string the SDK error may carry. The +// OpenAI SDK hoists the body's `error.code`/`error.type` to the top level and +// keeps the inner error object on `.error`; the Anthropic SDK keeps the FULL +// body on `.error` (`{type: 'error', error: {type, message}}`), so the quota +// type sits two levels deep. Walking `error` → `.error` → `.error.error` +// covers both shapes without SDK imports. +function collectErrorCodes(error: object): string[] { + const codes: string[] = []; + let current: object | undefined = error; + for (let depth = 0; current !== undefined && depth < 3; depth += 1) { + const code = readStringProp(current, 'code'); + if (code !== undefined) codes.push(code); + const type = readStringProp(current, 'type'); + if (type !== undefined) codes.push(type); + current = readErrorObjectProp(current); + } + return codes; +} + /** * Classify a raw provider failure as Moonshot's quota/balance-exhausted 429, * or answer `undefined` to keep the base classification. This is the Kimi @@ -43,12 +67,9 @@ export function classifyKimiQuotaError( if (status !== 429) return undefined; const message = readStringProp(error, 'message') ?? ''; - const code = readStringProp(error, 'code'); - const type = readStringProp(error, 'type'); - - const structuredHit = - (code !== undefined && KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(code)) || - (type !== undefined && KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(type)); + const structuredHit = collectErrorCodes(error).some((code) => + KIMI_QUOTA_EXHAUSTED_ERROR_CODES.has(code), + ); const lowerMessage = message.toLowerCase(); const wordingHit = KIMI_QUOTA_EXHAUSTED_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage), diff --git a/packages/kosong/src/providers/openai-common.ts b/packages/kosong/src/providers/openai-common.ts index e31b51b27d..91d2e80f4e 100644 --- a/packages/kosong/src/providers/openai-common.ts +++ b/packages/kosong/src/providers/openai-common.ts @@ -125,6 +125,12 @@ export function convertOpenAIError( // for any abort shape, so a user cancellation is never misclassified as a // retryable provider failure. throwIfAbortError(error); + // Already-converted errors pass through untouched — they never re-enter + // vendor classification, so the hook below sees each raw failure exactly + // once even when a stream-minted error crosses an outer catch. + if (error instanceof ChatProviderError) { + return error; + } // Vendor classification next: the hook sees the RAW error (the base // conversion below drops the SDK-parsed body `error.code`/`error.type`), // and `undefined` keeps the base classification. @@ -132,9 +138,6 @@ export function convertOpenAIError( if (hooked !== undefined) { return hooked; } - if (error instanceof ChatProviderError) { - return error; - } // v6: APIConnectionTimeoutError extends APIConnectionError, check timeout first if (error instanceof OpenAITimeoutError) { return new APITimeoutError(error.message); diff --git a/packages/kosong/test/anthropic-errors.test.ts b/packages/kosong/test/anthropic-errors.test.ts index 81fabdced1..c9138812b8 100644 --- a/packages/kosong/test/anthropic-errors.test.ts +++ b/packages/kosong/test/anthropic-errors.test.ts @@ -1,6 +1,7 @@ import { APIConnectionError, APIContextOverflowError, + APIProviderQuotaExhaustedError, APIProviderRateLimitError, APIStatusError, APITimeoutError, @@ -8,6 +9,7 @@ import { isRetryableGenerateError, } from '#/errors'; import { convertAnthropicError, AnthropicChatProvider } from '#/providers/anthropic'; +import { classifyKimiQuotaError } from '#/providers/kimi-errors'; import { APIConnectionError as AnthropicConnectionError, APIConnectionTimeoutError as AnthropicTimeoutError, @@ -465,3 +467,41 @@ describe('stream error propagation', () => { expect(isRetryableGenerateError(caught)).toBe(true); }); }); + +describe('convertAnthropicError: quota-exhausted 429 via the convertError hook', () => { + const QUOTA_BODY = { + type: 'error', + error: { + type: 'exceeded_current_quota_error', + message: + 'Your account org-0123456789abcdef is suspended due to insufficient balance, please recharge your account or check your plan and billing details', + }, + }; + + function quota429(): unknown { + return AnthropicAPIError.generate(429, QUOTA_BODY, 'Too many requests', new Headers()); + } + + it('keeps vendor quota signals a rate limit without the vendor hook', () => { + const result = convertAnthropicError(quota429()); + expect(result).toBeInstanceOf(APIProviderRateLimitError); + expect(isRetryableGenerateError(result)).toBe(true); + }); + + it('classifies the Kimi quota body as quota-exhausted through the hook', () => { + const result = convertAnthropicError(quota429(), classifyKimiQuotaError); + expect(result).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(result)).toBe(false); + }); + + it('passes already-converted errors through without re-consulting the hook', () => { + const calls: unknown[] = []; + const converted = new APIProviderQuotaExhaustedError('already classified'); + const result = convertAnthropicError(converted, (error) => { + calls.push(error); + return new ChatProviderError('re-classified'); + }); + expect(result).toBe(converted); + expect(calls).toHaveLength(0); + }); +}); diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index 209d757239..35601942b9 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -1,3 +1,4 @@ +import { APIError as AnthropicAPIError } from '@anthropic-ai/sdk'; import { APIProviderQuotaExhaustedError, isRetryableGenerateError } from '#/errors'; import { generate } from '#/generate'; import type { ContentPart, Message, ToolCall } from '#/message'; @@ -2218,4 +2219,16 @@ describe('classifyKimiQuotaError', () => { expect(classifyKimiQuotaError(new Error(QUOTA_MESSAGE))).toBeUndefined(); expect(classifyKimiQuotaError(undefined)).toBeUndefined(); }); + + it('classifies the Anthropic SDK error shape (body nested under .error)', () => { + const source = AnthropicAPIError.generate( + 429, + { type: 'error', error: { type: 'exceeded_current_quota_error', message: 'quota gone' } }, + 'Too many requests', + new Headers(), + ); + const error = classifyKimiQuotaError(source); + expect(error).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(error)).toBe(false); + }); }); From 79b840be95d49e0e4b7b4ff9b99b40cb9d0da98e Mon Sep 17 00:00:00 2001 From: vinlee19 <1401597760@qq.com> Date: Mon, 27 Jul 2026 23:21:25 +0800 Subject: [PATCH 6/7] 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. --- .../src/kosong/protocol/protocolTrait.ts | 11 --- .../provider/bases/anthropic/anthropic.ts | 18 ++--- .../kosong/protocol/protocolTrait.test.ts | 38 +++++++++- .../test/kosong/provider/composition.test.ts | 71 ++++++++++++++++++- .../test/harness/runtime-provider.test.ts | 10 +++ packages/kosong/test/anthropic-errors.test.ts | 19 +++++ 6 files changed, 144 insertions(+), 23 deletions(-) diff --git a/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts b/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts index 040eb0a892..7b4d2500d6 100644 --- a/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts +++ b/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts @@ -142,11 +142,6 @@ export interface ProtocolTrait { /** Single-value: tool-call id rewrite policy, replacing the base policy. */ toolCallIdPolicy?(ctx: TraitContext): ToolCallIdPolicy | undefined; - /** - * Single-value: classify one raw failure into a `ChatProviderError` before - * the base rules run; `undefined` keeps the base classification. The - * consult contract is restated in the header composition rules. - */ convertError?(error: unknown, ctx: TraitContext): ChatProviderError | undefined; /** @@ -256,12 +251,6 @@ export function traitDefaultHeaders( return headers; } -/** - * Bind the `convertError` hook of resolved traits with single-value - * semantics: the last declarer wins, its context bound away. Returns - * `undefined` when no trait declares the hook, so bases can bypass the - * consult entirely. - */ export function traitConvertError( traits: readonly ResolvedTrait[], ): ((error: unknown) => ChatProviderError | undefined) | undefined { diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts index 10d82bd261..a363dd002e 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts @@ -6,11 +6,12 @@ * 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 @@ -124,11 +125,12 @@ interface AnthropicContextManagement { } /** - * 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 + * The base-internal hook set: the L1 hooks with the context already bound + * away. `withThinking` 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). + * declarer). `convertError` is consulted by `convertAnthropicError` per the + * contract in the file header. */ export interface AnthropicHooks { withThinking?( diff --git a/packages/agent-core-v2/test/kosong/protocol/protocolTrait.test.ts b/packages/agent-core-v2/test/kosong/protocol/protocolTrait.test.ts index f50fcf1d22..965444738a 100644 --- a/packages/agent-core-v2/test/kosong/protocol/protocolTrait.test.ts +++ b/packages/agent-core-v2/test/kosong/protocol/protocolTrait.test.ts @@ -1,6 +1,6 @@ /** - * `kosong/protocol` trait surface — the sixteen-hook declaration shape and - * the `traitDefaultHeaders` aggregation helper. + * `kosong/protocol` trait surface — the seventeen-hook declaration shape + * and the `traitDefaultHeaders` / `traitConvertError` aggregation helpers. * * Locks the trait contract: every hook is optional and takes `TraitContext` * as its last parameter, and header aggregation runs in trait order with @@ -11,11 +11,13 @@ import { describe, expect, it } from 'vitest'; import { + traitConvertError, traitDefaultHeaders, type ProtocolTrait, type ResolvedTrait, type TraitContext, } from '#/kosong/protocol/protocolTrait'; +import { ChatProviderError } from '#/kosong/contract/errors'; import type { ProtocolAdapterConfig } from '#/kosong/protocol/protocol'; const config: ProtocolAdapterConfig = { protocol: 'openai', modelName: 'test-model' }; @@ -26,12 +28,13 @@ function resolved(trait: ProtocolTrait): ResolvedTrait { } describe('ProtocolTrait', () => { - it('declares exactly the sixteen optional hooks', () => { + it('declares exactly the seventeen optional hooks', () => { const fullTrait: ProtocolTrait = { provides: () => undefined, endpoint: () => undefined, defaultHeaders: () => undefined, convertTool: () => undefined, + convertError: () => undefined, convertMessage: (_message, converted) => converted, mergeHistory: () => undefined, buildParams: () => undefined, @@ -49,6 +52,7 @@ describe('ProtocolTrait', () => { 'buildParams', 'cacheKey', 'capability', + 'convertError', 'convertMessage', 'convertTool', 'defaultHeaders', @@ -107,3 +111,31 @@ describe('traitDefaultHeaders', () => { expect(seen[0]?.providerId).toBe('vendor-x'); }); }); + +describe('traitConvertError', () => { + it('returns undefined when nothing declares the hook', () => { + expect(traitConvertError([])).toBeUndefined(); + expect(traitConvertError([resolved({})])).toBeUndefined(); + }); + + it('binds the last declarer with its context', () => { + const seen: TraitContext[] = []; + const first: ProtocolTrait = { convertError: () => new ChatProviderError('first') }; + const second: ProtocolTrait = { + convertError: (_error, ctx) => { + seen.push(ctx); + return new ChatProviderError('second'); + }, + }; + const entry = resolved(second); + const bound = traitConvertError([resolved(first), entry]); + expect(bound!(new Error('raw'))?.message).toBe('second'); + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(entry.context); + }); + + it('propagates undefined so bases keep their classification', () => { + const bound = traitConvertError([resolved({ convertError: () => undefined })]); + expect(bound!(new Error('raw'))).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index 331281ef5a..c7ebdb9feb 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -40,8 +40,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { APIError as AnthropicAPIError } from '@anthropic-ai/sdk'; + import { isUnknownCapability } from '#/kosong/contract/capability'; -import { APIConnectionError } from '#/kosong/contract/errors'; +import { + APIConnectionError, + APIProviderQuotaExhaustedError, + APIProviderRateLimitError, + isRetryableGenerateError, +} from '#/kosong/contract/errors'; import type { Message } from '#/kosong/contract/message'; import type { ChatProvider, @@ -734,6 +741,68 @@ describe('per-turn intent wire encoding (behavior probes)', () => { }); }); +describe('quota-exhausted classification through the real composition (behavior probes)', () => { + const MOONSHOT_QUOTA_BODY = { + type: 'error', + error: { + type: 'exceeded_current_quota_error', + message: + 'Your account is suspended due to insufficient balance, please recharge your account', + }, + }; + + function mockQuota429Client(provider: ChatProvider): void { + const client = sdkClient(provider) as { + messages: { create: unknown }; + beta: { messages: { create: unknown } }; + }; + const reject = vi.fn().mockImplementation(() => { + throw AnthropicAPIError.generate( + 429, + MOONSHOT_QUOTA_BODY, + 'Too many requests', + new Headers(), + ); + }); + client.messages.create = reject; + client.beta.messages.create = reject; + } + + it('fails fast on a Moonshot quota 429 over the (kimi, anthropic) composition', async () => { + const provider = registry.createChatProvider({ + protocol: 'anthropic', + providerType: 'kimi', + modelName: 'kimi-for-coding', + apiKey: 'sk-probe', + }); + mockQuota429Client(provider); + + const caught = await provider.generate('', [], PROBE_HISTORY).then( + () => undefined, + (error: unknown) => error, + ); + expect(caught).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(caught)).toBe(false); + }); + + it('keeps the same 429 a retryable rate limit on a plain anthropic composition', async () => { + const provider = registry.createChatProvider({ + protocol: 'anthropic', + modelName: 'claude-opus-4-6', + apiKey: 'sk-probe', + }); + mockQuota429Client(provider); + + const caught = await provider.generate('', [], PROBE_HISTORY).then( + () => undefined, + (error: unknown) => error, + ); + expect(caught).toBeInstanceOf(APIProviderRateLimitError); + expect(caught).not.toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(caught)).toBe(true); + }); +}); + describe('reasoning dialect (behavior probes)', () => { it('yields think parts from the `reasoning` wire field', async () => { const provider = registry.createChatProvider({ diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 1d20903234..96afbf4f15 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -1,3 +1,4 @@ +import { classifyKimiQuotaError } from '@moonshot-ai/kosong'; import { describe, expect, it } from 'vitest'; import type { KimiConfig, ModelAlias } from '../../src/config'; @@ -1257,6 +1258,11 @@ describe('per-model protocol routing', () => { model: 'kimi-for-coding', baseUrl: 'https://api.example', }); + // Kimi over the Anthropic transport keeps its vendor error classifier — + // a Moonshot quota 429 must fail fast on this route too. + expect( + (resolved.provider as { convertError?: (error: unknown) => unknown }).convertError, + ).toBe(classifyKimiQuotaError); }); it('keeps a model without protocol on the provider wire type and leaves the REST base intact', () => { @@ -1295,6 +1301,10 @@ describe('per-model protocol routing', () => { model: 'claude-sonnet-4-5', baseUrl: 'https://api.anthropic.example/v1', }); + // A plain anthropic provider carries no Kimi vendor classifier. + expect( + (resolved.provider as { convertError?: (error: unknown) => unknown }).convertError, + ).toBeUndefined(); }); }); diff --git a/packages/kosong/test/anthropic-errors.test.ts b/packages/kosong/test/anthropic-errors.test.ts index c9138812b8..6502c737e5 100644 --- a/packages/kosong/test/anthropic-errors.test.ts +++ b/packages/kosong/test/anthropic-errors.test.ts @@ -504,4 +504,23 @@ describe('convertAnthropicError: quota-exhausted 429 via the convertError hook', expect(result).toBe(converted); expect(calls).toHaveLength(0); }); + + it('the provider threads options.convertError to its generate catch', async () => { + const provider = new AnthropicChatProvider({ + model: 'k25', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + convertError: classifyKimiQuotaError, + }); + (provider as any)._client.messages.create = vi.fn().mockRejectedValue(quota429()); + + await expect( + provider.generate( + '', + [], + [{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }], + ), + ).rejects.toThrow(APIProviderQuotaExhaustedError); + }); }); From 2aa791837de808d3e550c681143a02f58221f39a Mon Sep 17 00:00:00 2001 From: vinlee19 <1401597760@qq.com> Date: Tue, 28 Jul 2026 10:28:00 +0800 Subject: [PATCH 7/7] 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. --- .../bases/anthropic/anthropic.contrib.ts | 6 +-- .../provider/bases/anthropic/anthropic.ts | 8 ---- .../test/kosong/provider/kimi.test.ts | 43 ++++++++++++++++++- packages/kosong/test/kimi-files.test.ts | 33 ++++++++++++++ 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts index 21e6b79fff..4ac022d874 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts @@ -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'; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts index a363dd002e..952e125cb2 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts @@ -124,14 +124,6 @@ interface AnthropicContextManagement { edits: Array<{ type: string; keep?: unknown }>; } -/** - * The base-internal hook set: the L1 hooks with the context already bound - * away. `withThinking` 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). `convertError` is consulted by `convertAnthropicError` per the - * contract in the file header. - */ export interface AnthropicHooks { withThinking?( effort: ThinkingEffort, diff --git a/packages/agent-core-v2/test/kosong/provider/kimi.test.ts b/packages/agent-core-v2/test/kosong/provider/kimi.test.ts index 30197ac5e9..b1f7304227 100644 --- a/packages/agent-core-v2/test/kosong/provider/kimi.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/kimi.test.ts @@ -20,14 +20,23 @@ * `extra_body.thinking` encoding, no 128k ceiling, `prompt_cache_key`, * and the `strictThinkingValidation` marker; * - `kimiAnthropicTrait` (the `(kimi, anthropic)` registration): thinking - * encoding and interleaved-thinking beta stripping. + * encoding and interleaved-thinking beta stripping; + * - `KimiFiles`: an upload failure classifies through + * `classifyKimiQuotaError`, so a Moonshot quota 429 from the files API + * fails fast instead of converting to a retryable rate limit. */ -import { describe, expect, it } from 'vitest'; +import { APIError as OpenAIAPIError } from 'openai'; +import { describe, expect, it, vi } from 'vitest'; +import { + APIProviderQuotaExhaustedError, + isRetryableGenerateError, +} from '#/kosong/contract/errors'; import type { Message } from '#/kosong/contract/message'; import type { Tool } from '#/kosong/contract/tool'; import type { ProtocolTrait, TraitContext } from '#/kosong/protocol/protocolTrait'; +import { KimiFiles } from '#/kosong/provider/providers/kimi/kimi-files'; import { convertKimiTool, kimiAnthropicTrait, @@ -309,3 +318,33 @@ describe('trait objects are plain declarations', () => { expect(kimiAnthropicTrait.strictThinkingValidation).toBeUndefined(); }); }); + +describe('KimiFiles upload error conversion', () => { + it('fails fast on a Moonshot quota-exhausted 429 from the files API', async () => { + const quotaError = new OpenAIAPIError( + 429, + { + message: 'Your account is suspended due to insufficient balance, please recharge', + type: 'exceeded_current_quota_error', + }, + '429 quota exhausted', + new Headers(), + ); + const files = new KimiFiles({ + baseUrl: 'https://api.example/v1', + clientFactory: () => ({ files: { create: vi.fn().mockRejectedValue(quotaError) } }) as never, + }); + + const caught = await files + .uploadVideo( + { data: Buffer.from([1, 2, 3]), mimeType: 'video/mp4' }, + { auth: { apiKey: 'request-token' } }, + ) + .then( + () => undefined, + (error: unknown) => error, + ); + expect(caught).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(caught)).toBe(false); + }); +}); diff --git a/packages/kosong/test/kimi-files.test.ts b/packages/kosong/test/kimi-files.test.ts index ede39f74a1..27ebff9c10 100644 --- a/packages/kosong/test/kimi-files.test.ts +++ b/packages/kosong/test/kimi-files.test.ts @@ -2,8 +2,10 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { APIProviderQuotaExhaustedError, isRetryableGenerateError } from '#/errors'; import { KimiChatProvider } from '#/providers/kimi'; import { KimiFiles } from '#/providers/kimi-files'; +import { APIError as OpenAIAPIError } from 'openai'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; function createProvider(): KimiChatProvider { @@ -197,4 +199,35 @@ describe('KimiFiles', () => { ).rejects.toThrow(/video/i); }); }); + + describe('upload error conversion', () => { + it('fails fast on a Moonshot quota-exhausted 429 from the files API', async () => { + const quotaError = new OpenAIAPIError( + 429, + { + message: 'Your account is suspended due to insufficient balance, please recharge', + type: 'exceeded_current_quota_error', + }, + '429 quota exhausted', + new Headers(), + ); + const files = new KimiFiles({ + baseUrl: 'https://api.example/v1', + clientFactory: () => + ({ files: { create: vi.fn().mockRejectedValue(quotaError) } }) as never, + }); + + const caught = await files + .uploadVideo( + { data: Buffer.from([1, 2, 3]), mimeType: 'video/mp4' }, + { auth: { apiKey: 'request-token' } }, + ) + .then( + () => undefined, + (error: unknown) => error, + ); + expect(caught).toBeInstanceOf(APIProviderQuotaExhaustedError); + expect(isRetryableGenerateError(caught)).toBe(false); + }); + }); });