Merge PR #7828: fix(ai): classify Simplified Chinese quota exhaustion as credential-rotatable (@IceCodeNew)

This commit is contained in:
can1357
2026-08-07 13:37:54 +02:00
3 changed files with 126 additions and 1 deletions
+4
View File
@@ -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
+36 -1
View File
@@ -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)
+86
View File
@@ -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