Skip to content

Commit 8b38858

Browse files
程康claude
andcommitted
fix: extend quota matcher + wire quota fallback through auto-reply runner
Addresses two P1/P2 findings from the Option B review: 1. Extend isPeriodicUsageLimitErrorMessage to cover missing signals: - Z.ai Chinese weekly/monthly quota message: 每[周月].*使用上限 (real error from logs: 您已达到每周/每月使用上限) - Codex usage limit: You have hit your (ChatGPT )?usage limit (produced by openai-codex-responses.ts for error 1310/usage_limit_reached) Adds matcher tests covering all three patterns. 2. Wire quotaExhaustionFallbacksOverride through resolveModelFallbackOptions so channel/auto-reply runners (agent-runner-execution.ts, followup-runner.ts) also expand the candidate chain on permanent quota for user-pinned sessions. The agent-runner-run-params.ts function already owns fallbacksOverride resolution; quotaExhaustionFallbacksOverride follows the same pattern. Adds test cases covering user-pin expansion and non-expansion for auto. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent b3f8ce1 commit 8b38858

4 files changed

Lines changed: 95 additions & 3 deletions

File tree

src/agents/embedded-agent-helpers/failover-matches.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,46 @@ import {
33
isAuthErrorMessage,
44
isBillingErrorMessage,
55
isOverloadedErrorMessage,
6+
isPeriodicUsageLimitErrorMessage,
67
isRateLimitErrorMessage,
78
isServerErrorMessage,
89
} from "./failover-matches.js";
910

11+
describe("isPeriodicUsageLimitErrorMessage", () => {
12+
it("matches English weekly/monthly limit messages", () => {
13+
expect(isPeriodicUsageLimitErrorMessage("weekly usage limit exceeded")).toBe(true);
14+
expect(isPeriodicUsageLimitErrorMessage("monthly limit reached")).toBe(true);
15+
expect(isPeriodicUsageLimitErrorMessage("daily/weekly/monthly limits exhausted")).toBe(true);
16+
});
17+
18+
it("matches Z.ai Chinese weekly/monthly quota message (error 1310)", () => {
19+
// Real message from gateway logs: "429 您已达到每周/每月使用上限,您的限额将在 2026-05-31 10:00:31 重置。"
20+
expect(
21+
isPeriodicUsageLimitErrorMessage(
22+
"429 您已达到每周/每月使用上限,您的限额将在 2026-05-31 10:00:31 重置。",
23+
),
24+
).toBe(true);
25+
expect(isPeriodicUsageLimitErrorMessage("每月使用上限已达到")).toBe(true);
26+
expect(isPeriodicUsageLimitErrorMessage("每周使用上限")).toBe(true);
27+
});
28+
29+
it("matches Codex subscription usage limit message", () => {
30+
// From openai-codex-responses.ts: "You have hit your ChatGPT usage limit (free plan)."
31+
expect(
32+
isPeriodicUsageLimitErrorMessage("You have hit your ChatGPT usage limit (free plan)."),
33+
).toBe(true);
34+
expect(
35+
isPeriodicUsageLimitErrorMessage("You have hit your usage limit. Try again in ~30 min."),
36+
).toBe(true);
37+
});
38+
39+
it("does not match transient rate limit or billing errors", () => {
40+
expect(isPeriodicUsageLimitErrorMessage("Rate limit exceeded, please retry")).toBe(false);
41+
expect(isPeriodicUsageLimitErrorMessage("Your payment method was declined")).toBe(false);
42+
expect(isPeriodicUsageLimitErrorMessage("API key is invalid")).toBe(false);
43+
});
44+
});
45+
1046
describe("Z.ai vendor error codes (#48988)", () => {
1147
describe("error 1311 — model not included in subscription plan", () => {
1248
it("classifies Z.ai 1311 JSON body as billing", () => {

src/agents/embedded-agent-helpers/failover-matches.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ type ErrorPattern = RegExp | string;
44

55
const PERIODIC_USAGE_LIMIT_RE =
66
/\b(?:daily|weekly|monthly)(?:\/(?:daily|weekly|monthly))* (?:usage )?limit(?:s)?(?: (?:exhausted|reached|exceeded))?\b/i;
7+
// Z.ai: "您已达到每周/每月使用上限" (You have reached your weekly/monthly usage limit)
8+
const ZAI_PERIODIC_USAGE_LIMIT_ZH_RE = /[].*使|使.*[]/;
9+
// Codex: "You have hit your ChatGPT usage limit [...]" (openai-codex-responses.ts error 1310)
10+
const CODEX_USAGE_LIMIT_RE = /\byou\s+have\s+hit\s+your\b.*\busage\s+limit\b/i;
711

812
const HIGH_CONFIDENCE_AUTH_PERMANENT_PATTERNS = [
913
/api[_ ]?key[_ ]?(?:revoked|deactivated|deleted)/i,
@@ -268,7 +272,11 @@ export function isTimeoutErrorMessage(raw: string): boolean {
268272
}
269273

270274
export function isPeriodicUsageLimitErrorMessage(raw: string): boolean {
271-
return PERIODIC_USAGE_LIMIT_RE.test(raw);
275+
return (
276+
PERIODIC_USAGE_LIMIT_RE.test(raw) ||
277+
ZAI_PERIODIC_USAGE_LIMIT_ZH_RE.test(raw) ||
278+
CODEX_USAGE_LIMIT_RE.test(raw)
279+
);
272280
}
273281

274282
export function isBillingErrorMessage(raw: string): boolean {

src/auto-reply/reply/agent-runner-run-params.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { resolveEffectiveModelFallbacks } from "../../agents/agent-scope.js";
1+
import {
2+
resolveEffectiveModelFallbacks,
3+
resolveQuotaExhaustionFallbacks,
4+
} from "../../agents/agent-scope.js";
25
import type { resolveProviderScopedAuthProfile } from "./agent-runner-auth-profile.js";
36
import type { FollowupRun } from "./queue.js";
47

@@ -39,6 +42,14 @@ export function resolveModelFallbackOptions(
3942
modelOverrideSource: run.modelOverrideSource,
4043
hasAutoFallbackProvenance: run.hasAutoFallbackProvenance === true,
4144
});
45+
const quotaExhaustionFallbacksOverride =
46+
fallbacksOverride?.length === 0
47+
? resolveQuotaExhaustionFallbacks({
48+
cfg: config,
49+
agentId: run.agentId,
50+
sessionKey: run.sessionKey,
51+
})
52+
: undefined;
4253
return {
4354
cfg: config,
4455
provider: run.provider,
@@ -47,6 +58,7 @@ export function resolveModelFallbackOptions(
4758
agentId: run.agentId,
4859
sessionKey: run.runtimePolicySessionKey ?? run.sessionKey,
4960
fallbacksOverride,
61+
quotaExhaustionFallbacksOverride,
5062
};
5163
}
5264

src/auto-reply/reply/agent-runner-utils.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@ import type { FollowupRun } from "./queue.js";
33

44
const hoisted = vi.hoisted(() => {
55
const resolveEffectiveModelFallbacksMock = vi.fn();
6+
const resolveQuotaExhaustionFallbacksMock = vi.fn();
67
const getChannelPluginMock = vi.fn();
78
const isReasoningTagProviderMock = vi.fn();
8-
return { resolveEffectiveModelFallbacksMock, getChannelPluginMock, isReasoningTagProviderMock };
9+
return {
10+
resolveEffectiveModelFallbacksMock,
11+
resolveQuotaExhaustionFallbacksMock,
12+
getChannelPluginMock,
13+
isReasoningTagProviderMock,
14+
};
915
});
1016

1117
vi.mock("../../agents/agent-scope.js", () => ({
1218
resolveEffectiveModelFallbacks: (...args: unknown[]) =>
1319
hoisted.resolveEffectiveModelFallbacksMock(...args),
20+
resolveQuotaExhaustionFallbacks: (...args: unknown[]) =>
21+
hoisted.resolveQuotaExhaustionFallbacksMock(...args),
1422
}));
1523

1624
vi.mock("../../channels/plugins/index.js", () => ({
@@ -57,6 +65,7 @@ function makeRun(overrides: Partial<FollowupRun["run"]> = {}): FollowupRun["run"
5765
describe("agent-runner-utils", () => {
5866
beforeEach(() => {
5967
hoisted.resolveEffectiveModelFallbacksMock.mockClear();
68+
hoisted.resolveQuotaExhaustionFallbacksMock.mockClear();
6069
hoisted.getChannelPluginMock.mockReset();
6170
hoisted.isReasoningTagProviderMock.mockReset();
6271
hoisted.isReasoningTagProviderMock.mockReturnValue(false);
@@ -124,6 +133,33 @@ describe("agent-runner-utils", () => {
124133
expect(resolved.fallbacksOverride).toEqual(["fallback-model"]);
125134
});
126135

136+
it("populates quotaExhaustionFallbacksOverride for user-pinned sessions with empty fallback list", () => {
137+
hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue([]);
138+
hoisted.resolveQuotaExhaustionFallbacksMock.mockReturnValue(["quota-fallback"]);
139+
const run = makeRun({ hasSessionModelOverride: true, modelOverrideSource: "user" });
140+
141+
const resolved = resolveModelFallbackOptions(run);
142+
143+
expect(hoisted.resolveQuotaExhaustionFallbacksMock).toHaveBeenCalledWith({
144+
cfg: run.config,
145+
agentId: run.agentId,
146+
sessionKey: run.sessionKey,
147+
});
148+
expect(resolved.fallbacksOverride).toStrictEqual([]);
149+
expect(resolved.quotaExhaustionFallbacksOverride).toEqual(["quota-fallback"]);
150+
});
151+
152+
it("does not populate quotaExhaustionFallbacksOverride when fallbacksOverride is non-empty", () => {
153+
hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue(["fallback-model"]);
154+
hoisted.resolveQuotaExhaustionFallbacksMock.mockReturnValue(["quota-fallback"]);
155+
const run = makeRun({ hasSessionModelOverride: true, modelOverrideSource: "auto" });
156+
157+
const resolved = resolveModelFallbackOptions(run);
158+
159+
expect(hoisted.resolveQuotaExhaustionFallbacksMock).not.toHaveBeenCalled();
160+
expect(resolved.quotaExhaustionFallbacksOverride).toBeUndefined();
161+
});
162+
127163
it("builds embedded run base params with auth profile and run metadata", () => {
128164
const run = makeRun({ enforceFinalTag: true, cwd: "/tmp/task-repo" });
129165
const authProfile = resolveProviderScopedAuthProfile({

0 commit comments

Comments
 (0)