Skip to content

Commit 7e0c4ad

Browse files
committed
fix(auto-reply/followup): surface billing notice on message_tool_only and reuse shared classifiers
Address review feedback on the followup billing/quota notice: - Surface the notice on the default message_tool_only delivery path too. Previously the notice was suppressed when sourceReplyDeliveryMode was message_tool_only, which is exactly the path where a user would otherwise see dead air. Remove the suppression so a billing/quota/rate-limit failure always produces a short notice. - Reuse the shared failover-matches classifiers (isBillingErrorMessage, isRateLimitErrorMessage, isPeriodicUsageLimitErrorMessage) instead of a narrower local substring list, so followup failure classification stays in lock-step with the rest of the runtime. The shared classifiers cover every case the local list did (billing, quota, insufficient_quota, rate limit, usage limit) plus more, and are more precise about weak signals. Update the tests accordingly: the classifier test now uses a realistic billing error string, and the message_tool_only test asserts the notice is sent rather than suppressed.
1 parent 6beee84 commit 7e0c4ad

2 files changed

Lines changed: 25 additions & 17 deletions

File tree

src/auto-reply/reply/followup-runner.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,7 +1695,7 @@ describe("createFollowupRunner quota/billing failure notice (#80700)", () => {
16951695
expect(isFollowupQuotaBillingFailure("Third-party apps now draw from your extra usage")).toBe(
16961696
true,
16971697
);
1698-
expect(isFollowupQuotaBillingFailure("Provider anthropic has billing issue")).toBe(true);
1698+
expect(isFollowupQuotaBillingFailure("billing error: credit balance is too low")).toBe(true);
16991699
expect(isFollowupQuotaBillingFailure("insufficient_quota: you exceeded your quota")).toBe(true);
17001700
expect(isFollowupQuotaBillingFailure("429 rate limit reached")).toBe(true);
17011701
expect(isFollowupQuotaBillingFailure("rate_limit_exceeded")).toBe(true);
@@ -1779,8 +1779,10 @@ describe("createFollowupRunner quota/billing failure notice (#80700)", () => {
17791779
expect(onBlockReply).not.toHaveBeenCalled();
17801780
});
17811781

1782-
it("suppresses the notice when sourceReplyDeliveryMode is message_tool_only", async () => {
1783-
runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("billing block from provider"));
1782+
it("sends the notice even when sourceReplyDeliveryMode is message_tool_only", async () => {
1783+
runEmbeddedPiAgentMock.mockRejectedValueOnce(
1784+
new Error("billing error: credit balance is too low"),
1785+
);
17841786
const onBlockReply = vi.fn(async () => {});
17851787
const runner = createFollowupRunner({
17861788
opts: { onBlockReply },
@@ -1794,6 +1796,12 @@ describe("createFollowupRunner quota/billing failure notice (#80700)", () => {
17941796

17951797
await runner(queued);
17961798

1797-
expect(onBlockReply).not.toHaveBeenCalled();
1799+
expect(onBlockReply).toHaveBeenCalledTimes(1);
1800+
const firstCall = (
1801+
onBlockReply.mock.calls as unknown as Array<Array<{ text?: string; isError?: boolean }>>
1802+
)[0];
1803+
const payload = requireRecord(firstCall?.[0], "notice payload");
1804+
expect(payload.text as string).toMatch(/billing\/quota\/rate-limit/);
1805+
expect(payload.isError).toBe(true);
17981806
});
17991807
});

src/auto-reply/reply/followup-runner.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-bu
44
import { resolveContextTokensForModel } from "../../agents/context.js";
55
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
66
import { runWithModelFallback } from "../../agents/model-fallback.js";
7+
import {
8+
isBillingErrorMessage,
9+
isPeriodicUsageLimitErrorMessage,
10+
isRateLimitErrorMessage,
11+
} from "../../agents/pi-embedded-helpers/failover-matches.js";
712
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
813
import {
914
buildAgentRuntimeDeliveryPlan,
@@ -40,21 +45,19 @@ type EmbeddedAgentRunResult = Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
4045
* is a quota/billing/rate-limit class rejection. These are user-visible failure
4146
* modes where the originating channel should receive a short notice rather than
4247
* a silent drop. See https://github.com/openclaw/openclaw/issues/80700.
48+
*
49+
* Delegates to the shared failover-matches classifiers so this path stays in
50+
* lock-step with the rest of the runtime's billing/rate-limit detection instead
51+
* of maintaining a narrower local substring list.
4352
*/
4453
export function isFollowupQuotaBillingFailure(message: string): boolean {
4554
if (!message) {
4655
return false;
4756
}
48-
const lower = message.toLowerCase();
4957
return (
50-
lower.includes("billing") ||
51-
lower.includes("quota") ||
52-
lower.includes("rate limit") ||
53-
lower.includes("rate_limit") ||
54-
lower.includes("rate-limit") ||
55-
lower.includes("extra usage") ||
56-
lower.includes("usage limit") ||
57-
lower.includes("insufficient_quota")
58+
isBillingErrorMessage(message) ||
59+
isRateLimitErrorMessage(message) ||
60+
isPeriodicUsageLimitErrorMessage(message)
5861
);
5962
}
6063

@@ -382,10 +385,7 @@ export function createFollowupRunner(params: {
382385
const message = formatErrorMessage(err);
383386
replyOperation.fail("run_failed", err);
384387
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
385-
if (
386-
isFollowupQuotaBillingFailure(message) &&
387-
run.sourceReplyDeliveryMode !== "message_tool_only"
388-
) {
388+
if (isFollowupQuotaBillingFailure(message)) {
389389
try {
390390
await sendFollowupPayloads(
391391
[

0 commit comments

Comments
 (0)