diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index 88a2e3d3f..8dd7a5d88 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -51,12 +51,15 @@ const ACCOUNT_SCOPED_403_PATTERN = // healthy sibling credential as a false quota. "速率限制" is absent for the // same reason. const CN_QUOTA_EXHAUSTED_PATTERN = /使用.{0,30}?上限|(?:额度|配额)已?(?:用|耗)(?:完|尽)|限额.{0,30}重置|余额不足/; -// Common Simplified Chinese throttle phrasing. Consulted by isOpaqueStatusBody -// so a plain CN rate-limit body is treated as informative and deferred to the -// classifier (which returns UNKNOWN, i.e. back off) instead of rotating via the -// opaque-429 fallback. Kept separate from CN_QUOTA_EXHAUSTED_PATTERN because -// throttles must NOT rotate. -const CN_THROTTLE_PATTERN = /速率(?:限制|过快)|频率(?:过高|过快)|过于频繁|稍后[重再]试/; +// Simplified Chinese rate/concurrency caps can contain both 使用 and 上限, but +// remain transient rather than account quota exhaustion. +const CN_TRANSIENT_CAP_PATTERN = + /速率.{0,30}上限|频率.{0,30}上限|每分钟.{0,30}上限|并发.{0,30}上限|使用.{0,30}(?:速率|频率|每分钟|并发).{0,30}上限/; +// Common Simplified Chinese throttle phrasing. Consulted by +// isOpaqueStatusBody so CN transients stay in the provider backoff lane instead +// of rotating through the opaque-429 fallback. +const CN_THROTTLE_PATTERN = + /速率(?:限制|过快)|频率(?:过高|过快)|过于频繁|稍后[重再]试/; /** * Classify a rate-limit error message into a reason category. @@ -84,7 +87,7 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason { // Simplified Chinese quota-exhaustion phrasing (Zhipu Coding Plan and other // CN providers). Must precede the MODEL_CAPACITY / RATE_LIMIT branches so an // account-local cap rotates instead of backing off as a transient. - if (CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage)) { + if (CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) && !CN_TRANSIENT_CAP_PATTERN.test(errorMessage)) { return "QUOTA_EXHAUSTED"; } @@ -245,7 +248,10 @@ export function isOpaqueStatusBody(message: string): boolean { // opaque-429 fallback still rotates. This keeps the exception scoped to // text we actually classify, rather than to any Han ideograph. return ( - !/[a-z\d]{3,}/i.test(cleaned) && !CN_QUOTA_EXHAUSTED_PATTERN.test(cleaned) && !CN_THROTTLE_PATTERN.test(cleaned) + !/[a-z\d]{3,}/i.test(cleaned) && + !CN_QUOTA_EXHAUSTED_PATTERN.test(cleaned) && + !CN_TRANSIENT_CAP_PATTERN.test(cleaned) && + !CN_THROTTLE_PATTERN.test(cleaned) ); } @@ -258,7 +264,7 @@ export function isOpaqueStatusBody(message: string): boolean { export function matchesUsageLimitText(errorMessage: string): boolean { return ( USAGE_LIMIT_PATTERN.test(errorMessage) || - CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) || + (CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) && !CN_TRANSIENT_CAP_PATTERN.test(errorMessage)) || SPEND_LIMIT_PATTERN.test(errorMessage) || ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage) || OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage) diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index e66643596..1d5e88f8d 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -127,6 +127,7 @@ describe("parseRateLimitReason", () => { // quota — must not rotate. Without this guard it burned a sibling credential. expect(parseRateLimitReason("每分钟请求数已达上限,请稍后重试")).toBe("UNKNOWN"); expect(parseRateLimitReason("并发请求数已达上限,请稍后重试")).toBe("UNKNOWN"); + expect(parseRateLimitReason("API 使用频率已达上限")).toBe("UNKNOWN"); }); it("classifies Codex usage limit error as QUOTA_EXHAUSTED", () => { @@ -253,6 +254,7 @@ describe("isUsageLimit", () => { it("does not treat Simplified Chinese throttling as a usage limit", () => { expect(isUsageLimit("429 已达到速率限制")).toBe(false); expect(isUsageLimit("请求过于频繁,请稍后重试")).toBe(false); + expect(isUsageLimit("API 使用频率已达上限")).toBe(false); }); it("detects xAI Grok SuperGrok credit exhaustion as a credential-rotatable usage limit", () => { @@ -343,6 +345,9 @@ describe("isUsageLimitOutcome", () => { it("keeps Simplified Chinese throttling in the upstream-backoff lane", () => { expect(isUsageLimitOutcome(429, "已达到速率限制")).toBe(false); expect(isUsageLimitOutcome(429, "请求过于频繁,请稍后重试")).toBe(false); + expect(isUsageLimitOutcome(429, "并发请求达到上限")).toBe(false); + expect(isUsageLimitOutcome(429, "每分钟使用次数已达上限")).toBe(false); + expect(isUsageLimitOutcome(429, "API 使用频率已达上限")).toBe(false); }); it("treats Simplified Chinese error bodies the classifier can read as informative", () => {