diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ce89a25d7..95734adb5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -14,6 +14,10 @@ - Removed the `zod` dependency and `z`/`ZodType` re-exports. Tool schemas now use `omptype` `type()` schemas, with Zod-style authoring still available via `@oh-my-pi/omptype/zod`. - Fixed GitHub Copilot's permanent `model_not_available_for_integrator` response being retried and replaced with transient fleet-skew guidance, preserving the provider's actionable `Available models` list instead ([#7819](https://github.com/can1357/oh-my-pi/issues/7819)). +### Fixed + +- Fixed Simplified Chinese quota-exhaustion errors (e.g. Zhipu Coding Plan's `429 已达到 5 小时的使用上限。您的限额将在 … 重置。`) being classified as `UNKNOWN`, which left multi-key sessions pinned to the exhausted api_key credential instead of rotating to a sibling key. Simplified Chinese quota phrasing (`达到…使用上限`, `额度已用完`, `配额已耗尽`, `限额 … 重置`, `余额不足`) now classifies as `QUOTA_EXHAUSTED`, and a 429 body carrying classifier-recognized Simplified Chinese phrasing (quota or throttle) is treated as informative rather than opaque, so a plain Chinese throttle (e.g. `已达到速率限制`) defers to the classifier and stays in the backoff lane instead of over-rotating credentials. + ## [17.2.9] - 2026-08-05 ### Fixed diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index 972a533ec..88a2e3d3f 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -40,6 +40,23 @@ const ACCOUNT_SCOPED_403_PATTERN = // "Your limit will reset in …"); the overall/account qualifiers arm above // already covers the rest. /\b(?:overall|account|organization|team|workspace)\b[^\n]{0,40}\b(?:message |request )?rate.?limit\b|\byour\b[^\n]{0,30}\b(?:limit )?will reset\b/i; +// Simplified Chinese account-quota exhaustion phrasing. Zhipu Coding Plan +// returns e.g. "429 已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。" +// (type=1308) when the 5h window is spent; other CN providers use 额度已用完 / +// 配额已耗尽 / 余额不足. These are persistent account-local caps that must +// rotate to a sibling credential, not transient rate limits, so they are +// matched before the RATE_LIMIT_EXCEEDED branch. The 上限 arm is anchored on +// the 使用 token: a rate/concurrency cap phrased as 每分钟请求数已达上限 / +// 并发请求数已达上限 / 速率达到上限 (no 使用) must NOT match, or it would burn a +// 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 = /速率(?:限制|过快)|频率(?:过高|过快)|过于频繁|稍后[重再]试/; /** * Classify a rate-limit error message into a reason category. @@ -64,6 +81,13 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason { return "QUOTA_EXHAUSTED"; } + // 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)) { + return "QUOTA_EXHAUSTED"; + } + if (CONCURRENT_LIMIT_PATTERN.test(errorMessage)) { return "CONCURRENT_LIMIT"; } @@ -212,7 +236,17 @@ export function isOpaqueStatusBody(message: string): boolean { const cleaned = message .replace(/\b(?:429|402)\b/g, "") .replace(/\b(?:http|https|status|error|code|response|message)\b/gi, ""); - return !/[a-z\d]{3,}/i.test(cleaned); + // A body is informative when the text classifier can act on it. Any Latin + // word or Simplified Chinese phrasing the classifier recognizes (quota + // exhaustion or a throttle) defers to parseRateLimitReason; a body that + // is only status digits / HTTP framing is opaque and rotates conservatively. + // A Han-only body the classifier cannot interpret (e.g. Japanese Kanji + // quota text, since Japanese is out of scope) must stay opaque so the + // 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) + ); } /** @@ -224,6 +258,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) || 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 15e5b9bab..e66643596 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -4,6 +4,7 @@ import { classify, Flag, is, isUsageLimit, retriable } from "@oh-my-pi/pi-ai/err import { calculateRateLimitBackoffMs, isConcurrencyCapExclusion, + isOpaqueStatusBody, isUsageLimitOutcome, isUsageLimitStatus, parseRateLimitReason, @@ -95,6 +96,39 @@ describe("parseRateLimitReason", () => { expect(parseRateLimitReason("Something completely unexpected happened")).toBe("UNKNOWN"); }); + it("classifies Simplified Chinese quota exhaustion as QUOTA_EXHAUSTED", () => { + // Zhipu Coding Plan returns this exact phrasing (type=1308) when the 5h + // window is spent. Previously classified UNKNOWN, so the session stayed + // pinned to the exhausted credential instead of rotating to a sibling key. + const zhipu = + "429 已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。\n已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。 (type=1308)"; + expect(parseRateLimitReason(zhipu)).toBe("QUOTA_EXHAUSTED"); + expect(parseRateLimitReason("已达到 5 小时的使用上限")).toBe("QUOTA_EXHAUSTED"); + expect(parseRateLimitReason("您的限额将在 2026-08-06 20:06:00 重置")).toBe("QUOTA_EXHAUSTED"); + expect(parseRateLimitReason("今日使用量已达上限,请明天再试")).toBe("QUOTA_EXHAUSTED"); + expect(parseRateLimitReason("额度已用完,请充值")).toBe("QUOTA_EXHAUSTED"); + expect(parseRateLimitReason("配额已用尽")).toBe("QUOTA_EXHAUSTED"); + expect(parseRateLimitReason("额度已耗尽")).toBe("QUOTA_EXHAUSTED"); + expect(parseRateLimitReason("配额用完")).toBe("QUOTA_EXHAUSTED"); + expect(parseRateLimitReason("账户余额不足")).toBe("QUOTA_EXHAUSTED"); + }); + + it("keeps Simplified Chinese rate limiting in the transient lane", () => { + // "速率限制" is a plain throttle, not an account quota cap — must not + // rotate credentials or classify as QUOTA_EXHAUSTED. + expect(parseRateLimitReason("429 已达到速率限制")).toBe("UNKNOWN"); + expect(parseRateLimitReason("请求过于频繁,请稍后重试")).toBe("UNKNOWN"); + // "达到…使用上限" requires the 使用 token, so a concurrency/rate cap phrased + // as "达到…上限" (no 使用) must NOT match — it stays in the upstream-backoff + // lane instead of burning a credential as a quota exhaustion. + expect(parseRateLimitReason("并发请求达到上限")).toBe("UNKNOWN"); + expect(parseRateLimitReason("速率达到上限,请稍后重试")).toBe("UNKNOWN"); + // Bare 已达上限 (no 使用 token) is a transient rate/concurrency cap, not a + // quota — must not rotate. Without this guard it burned a sibling credential. + expect(parseRateLimitReason("每分钟请求数已达上限,请稍后重试")).toBe("UNKNOWN"); + expect(parseRateLimitReason("并发请求数已达上限,请稍后重试")).toBe("UNKNOWN"); + }); + it("classifies Codex usage limit error as QUOTA_EXHAUSTED", () => { expect( parseRateLimitReason("Codex error event: The usage limit has been reached (code=usage_limit_reached)"), @@ -201,6 +235,26 @@ describe("isUsageLimit", () => { expect(isUsageLimit("额度耗尽")).toBe(true); }); + it("detects Simplified Chinese quota exhaustion as a credential-rotatable usage limit", () => { + // Zhipu Coding Plan (type=1308). Without this match the error is UNKNOWN, + // Flag.UsageLimit is never set, and the session sticks to the exhausted + // api_key credential instead of rotating to the sibling key. + const zhipu = + "429 已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。\n已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。 (type=1308)"; + expect(isUsageLimit(zhipu)).toBe(true); + expect(isUsageLimit("已达到 5 小时的使用上限")).toBe(true); + expect(isUsageLimit("您的限额将在 2026-08-06 20:06:00 重置")).toBe(true); + expect(isUsageLimit("今日使用量已达上限,请明天再试")).toBe(true); + expect(isUsageLimit("额度已用完,请充值")).toBe(true); + expect(isUsageLimit("配额已用尽")).toBe(true); + expect(isUsageLimit("账户余额不足")).toBe(true); + }); + + it("does not treat Simplified Chinese throttling as a usage limit", () => { + expect(isUsageLimit("429 已达到速率限制")).toBe(false); + expect(isUsageLimit("请求过于频繁,请稍后重试")).toBe(false); + }); + it("detects xAI Grok SuperGrok credit exhaustion as a credential-rotatable usage limit", () => { // xAI returns HTTP 403 with (type=personal-team-blocked:spending-limit), not a // 429 usage_limit_reached. Without this match, multi-account xai-oauth pools @@ -274,6 +328,38 @@ describe("isUsageLimitOutcome", () => { ).toBe(true); }); + it("rotates on Simplified Chinese quota exhaustion (Zhipu 429)", () => { + const zhipu = + "429 已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。\n已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。 (type=1308)"; + expect(isUsageLimitOutcome(429, zhipu)).toBe(true); + expect(isUsageLimitOutcome(429, "已达到 5 小时的使用上限")).toBe(true); + expect(isUsageLimitOutcome(429, "您的限额将在 2026-08-06 20:06:00 重置")).toBe(true); + expect(isUsageLimitOutcome(429, "今日使用量已达上限,请明天再试")).toBe(true); + expect(isUsageLimitOutcome(429, "额度已用完,请充值")).toBe(true); + expect(isUsageLimitOutcome(429, "配额已用尽")).toBe(true); + expect(isUsageLimitOutcome(429, "账户余额不足")).toBe(true); + }); + + it("keeps Simplified Chinese throttling in the upstream-backoff lane", () => { + expect(isUsageLimitOutcome(429, "已达到速率限制")).toBe(false); + expect(isUsageLimitOutcome(429, "请求过于频繁,请稍后重试")).toBe(false); + }); + + it("treats Simplified Chinese error bodies the classifier can read as informative", () => { + // A bare 429/empty body is opaque and rotates conservatively, but a body + // carrying CN quota or throttle phrasing the classifier recognizes defers + // to parseRateLimitReason instead of being treated as a status-only 429. + expect(isOpaqueStatusBody("已达到速率限制")).toBe(false); + expect(isOpaqueStatusBody("请求过于频繁,请稍后重试")).toBe(false); + expect(isOpaqueStatusBody("429 已达到 5 小时的使用上限")).toBe(false); + expect(isOpaqueStatusBody("429")).toBe(true); + expect(isOpaqueStatusBody("")).toBe(true); + // A Han body the classifier cannot interpret stays opaque so the + // opaque-429 fallback still rotates. Japanese quota text (e.g. 利用上限に + // 達しました) is out of scope and must not be treated as informative. + expect(isOpaqueStatusBody("429 利用上限に達しました")).toBe(true); + }); + // The MODEL_CAPACITY reclassification of resource_exhausted (#7032) must NOT // remove stream/session credential rotation: USAGE_LIMIT_PATTERN's // `resource.?exhausted` still flags both forms as a usage-limit outcome so a