fix(reply): classify billing cooldown summaries (#66363)

Merged via squash.

Prepared head SHA: 8cfc42a7ac
Co-authored-by: Rohan5commit <181558744+Rohan5commit@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
Rohan Santhosh Kumar
2026-04-15 04:35:04 +08:00
committed by GitHub
parent 62430d9f3a
commit bb14412e87
3 changed files with 70 additions and 1 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.
- Agents/workspace files: route `agents.files.get`, `agents.files.set`, and workspace listing through the shared `fs-safe` helpers (`openFileWithinRoot`/`readFileWithinRoot`/`writeFileWithinRoot`), reject symlink aliases for allowlisted agent files, and have `fs-safe` resolve opened-file real paths from the file descriptor before falling back to path-based `realpath` so a symlink swap between `open` and `realpath` can no longer redirect the validated path off the intended inode. (#66636) Thanks @eleqtrizit.
- Gateway/MCP loopback: switch the `/mcp` bearer comparison from plain `!==` to constant-time `safeEqualSecret` (matching the convention every other auth surface in the codebase uses), and reject non-loopback browser-origin requests via `checkBrowserOrigin` before the auth gate runs. Loopback origins (`127.0.0.1:*`, `localhost:*`, same-origin) still go through, including the `localhost``127.0.0.1` host mismatch that browsers flag as `Sec-Fetch-Site: cross-site`. (#66665) Thanks @eleqtrizit.
- Auto-reply/billing: classify pure billing cooldown fallback summaries from structured fallback reasons so users see billing guidance instead of the generic failure reply. (#66363) Thanks @Rohan5commit.
## 2026.4.14

View File

@@ -912,6 +912,64 @@ describe("runAgentTurnWithFallback", () => {
}
});
it("surfaces billing guidance for pure billing cooldown fallback exhaustion", async () => {
state.runWithModelFallbackMock.mockRejectedValueOnce(
Object.assign(
new Error(
"All models failed (2): anthropic/claude-opus-4-6: Provider anthropic has billing issue (skipping all models) (billing) | anthropic/claude-sonnet-4-6: Provider anthropic has billing issue (skipping all models) (billing)",
),
{
name: "FallbackSummaryError",
attempts: [
{
provider: "anthropic",
model: "claude-opus-4-6",
error: "Provider anthropic has billing issue (skipping all models)",
reason: "billing",
},
{
provider: "anthropic",
model: "claude-sonnet-4-6",
error: "Provider anthropic has billing issue (skipping all models)",
reason: "billing",
},
],
soonestCooldownExpiry: Date.now() + 60_000,
},
),
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
commandBody: "hello",
followupRun: createFollowupRun(),
sessionCtx: {
Provider: "whatsapp",
MessageSid: "msg",
} as unknown as TemplateContext,
opts: {},
typingSignals: createMockTypingSignaler(),
blockReplyPipeline: null,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
applyReplyToMode: (payload) => payload,
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
pendingToolTasks: new Set(),
resetSessionAfterCompactionFailure: async () => false,
resetSessionAfterRoleOrderingConflict: async () => false,
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
});
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toBe("billing");
}
});
it("surfaces gateway restart text when fallback exhaustion wraps a drain error", async () => {
const { replyOperation, failMock } = createMockReplyOperation();
state.runWithModelFallbackMock.mockRejectedValueOnce(

View File

@@ -311,6 +311,14 @@ function isPureTransientRateLimitSummary(err: unknown): boolean {
);
}
function isPureBillingSummary(err: unknown): boolean {
return (
isFallbackSummaryError(err) &&
err.attempts.length > 0 &&
err.attempts.every((attempt) => attempt.reason === "billing")
);
}
function isToolResultTurnMismatchError(message: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(message);
return (
@@ -1320,7 +1328,9 @@ export async function runAgentTurnWithFallback(params: {
continue;
}
const message = formatErrorMessage(err);
const isBilling = isBillingErrorMessage(message);
const isBilling = isFallbackSummaryError(err)
? isPureBillingSummary(err)
: isBillingErrorMessage(message);
const isContextOverflow = !isBilling && isLikelyContextOverflowError(message);
const isCompactionFailure = !isBilling && isCompactionFailureError(message);
const isSessionCorruption = /function call turn comes immediately after/i.test(message);