Skip to content

Commit 0669b0d

Browse files
authored
fix(agents): probe single-provider billing cooldowns (openclaw#41422)
Merged via squash. Prepared head SHA: bbc4254 Co-authored-by: altaywtf <[email protected]> Co-authored-by: altaywtf <[email protected]> Reviewed-by: @altaywtf
1 parent 0c7f078 commit 0669b0d

File tree

4 files changed

+152
-18
lines changed

4 files changed

+152
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
3232
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
3333
- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky.
3434
- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky.
35+
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
3536

3637
## 2026.3.8
3738

src/agents/model-fallback.probe.test.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,36 @@ describe("runWithModelFallback – probe logic", () => {
251251
expectPrimaryProbeSuccess(result, run, "probed-ok");
252252
});
253253

254+
it("prunes stale probe throttle entries before checking eligibility", () => {
255+
_probeThrottleInternals.lastProbeAttempt.set(
256+
"stale",
257+
NOW - _probeThrottleInternals.PROBE_STATE_TTL_MS - 1,
258+
);
259+
_probeThrottleInternals.lastProbeAttempt.set("fresh", NOW - 5_000);
260+
261+
expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(true);
262+
263+
expect(_probeThrottleInternals.isProbeThrottleOpen(NOW, "fresh")).toBe(false);
264+
265+
expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(false);
266+
expect(_probeThrottleInternals.lastProbeAttempt.has("fresh")).toBe(true);
267+
});
268+
269+
it("caps probe throttle state by evicting the oldest entries", () => {
270+
for (let i = 0; i < _probeThrottleInternals.MAX_PROBE_KEYS; i += 1) {
271+
_probeThrottleInternals.lastProbeAttempt.set(`key-${i}`, NOW - (i + 1));
272+
}
273+
274+
_probeThrottleInternals.markProbeAttempt(NOW, "freshest");
275+
276+
expect(_probeThrottleInternals.lastProbeAttempt.size).toBe(
277+
_probeThrottleInternals.MAX_PROBE_KEYS,
278+
);
279+
expect(_probeThrottleInternals.lastProbeAttempt.has("freshest")).toBe(true);
280+
expect(_probeThrottleInternals.lastProbeAttempt.has("key-255")).toBe(false);
281+
expect(_probeThrottleInternals.lastProbeAttempt.has("key-0")).toBe(true);
282+
});
283+
254284
it("handles non-finite soonest safely (treats as probe-worthy)", async () => {
255285
const cfg = makeCfg();
256286

@@ -346,7 +376,7 @@ describe("runWithModelFallback – probe logic", () => {
346376
});
347377
});
348378

349-
it("skips billing-cooldowned primary when no fallback candidates exist", async () => {
379+
it("probes billing-cooldowned primary when no fallback candidates exist", async () => {
350380
const cfg = makeCfg({
351381
agents: {
352382
defaults: {
@@ -358,20 +388,28 @@ describe("runWithModelFallback – probe logic", () => {
358388
},
359389
} as Partial<OpenClawConfig>);
360390

361-
// Billing cooldown far from expiry — would normally be skipped
391+
// Single-provider setups need periodic probes even when the billing
392+
// cooldown is far from expiry, otherwise topping up credits never recovers
393+
// without a restart.
362394
const expiresIn30Min = NOW + 30 * 60 * 1000;
363395
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
364396
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
365397

366-
await expect(
367-
runWithModelFallback({
368-
cfg,
369-
provider: "openai",
370-
model: "gpt-4.1-mini",
371-
fallbacksOverride: [],
372-
run: vi.fn().mockResolvedValue("billing-recovered"),
373-
}),
374-
).rejects.toThrow("All models failed");
398+
const run = vi.fn().mockResolvedValue("billing-recovered");
399+
400+
const result = await runWithModelFallback({
401+
cfg,
402+
provider: "openai",
403+
model: "gpt-4.1-mini",
404+
fallbacksOverride: [],
405+
run,
406+
});
407+
408+
expect(result.result).toBe("billing-recovered");
409+
expect(run).toHaveBeenCalledTimes(1);
410+
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", {
411+
allowTransientCooldownProbe: true,
412+
});
375413
});
376414

377415
it("probes billing-cooldowned primary with fallbacks when near cooldown expiry", async () => {

src/agents/model-fallback.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -342,12 +342,51 @@ const lastProbeAttempt = new Map<string, number>();
342342
const MIN_PROBE_INTERVAL_MS = 30_000; // 30 seconds between probes per key
343343
const PROBE_MARGIN_MS = 2 * 60 * 1000;
344344
const PROBE_SCOPE_DELIMITER = "::";
345+
const PROBE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
346+
const MAX_PROBE_KEYS = 256;
345347

346348
function resolveProbeThrottleKey(provider: string, agentDir?: string): string {
347349
const scope = String(agentDir ?? "").trim();
348350
return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider;
349351
}
350352

353+
function pruneProbeState(now: number): void {
354+
for (const [key, ts] of lastProbeAttempt) {
355+
if (!Number.isFinite(ts) || ts <= 0 || now - ts > PROBE_STATE_TTL_MS) {
356+
lastProbeAttempt.delete(key);
357+
}
358+
}
359+
}
360+
361+
function enforceProbeStateCap(): void {
362+
while (lastProbeAttempt.size > MAX_PROBE_KEYS) {
363+
let oldestKey: string | null = null;
364+
let oldestTs = Number.POSITIVE_INFINITY;
365+
for (const [key, ts] of lastProbeAttempt) {
366+
if (ts < oldestTs) {
367+
oldestKey = key;
368+
oldestTs = ts;
369+
}
370+
}
371+
if (!oldestKey) {
372+
break;
373+
}
374+
lastProbeAttempt.delete(oldestKey);
375+
}
376+
}
377+
378+
function isProbeThrottleOpen(now: number, throttleKey: string): boolean {
379+
pruneProbeState(now);
380+
const lastProbe = lastProbeAttempt.get(throttleKey) ?? 0;
381+
return now - lastProbe >= MIN_PROBE_INTERVAL_MS;
382+
}
383+
384+
function markProbeAttempt(now: number, throttleKey: string): void {
385+
pruneProbeState(now);
386+
lastProbeAttempt.set(throttleKey, now);
387+
enforceProbeStateCap();
388+
}
389+
351390
function shouldProbePrimaryDuringCooldown(params: {
352391
isPrimary: boolean;
353392
hasFallbackCandidates: boolean;
@@ -360,8 +399,7 @@ function shouldProbePrimaryDuringCooldown(params: {
360399
return false;
361400
}
362401

363-
const lastProbe = lastProbeAttempt.get(params.throttleKey) ?? 0;
364-
if (params.now - lastProbe < MIN_PROBE_INTERVAL_MS) {
402+
if (!isProbeThrottleOpen(params.now, params.throttleKey)) {
365403
return false;
366404
}
367405

@@ -379,7 +417,12 @@ export const _probeThrottleInternals = {
379417
lastProbeAttempt,
380418
MIN_PROBE_INTERVAL_MS,
381419
PROBE_MARGIN_MS,
420+
PROBE_STATE_TTL_MS,
421+
MAX_PROBE_KEYS,
382422
resolveProbeThrottleKey,
423+
isProbeThrottleOpen,
424+
pruneProbeState,
425+
markProbeAttempt,
383426
} as const;
384427

385428
type CooldownDecision =
@@ -429,11 +472,15 @@ function resolveCooldownDecision(params: {
429472
}
430473

431474
// Billing is semi-persistent: the user may fix their balance, or a transient
432-
// 402 might have been misclassified. Probe the primary only when fallbacks
433-
// exist; otherwise repeated single-provider probes just churn the disabled
434-
// auth state without opening any recovery path.
475+
// 402 might have been misclassified. Probe single-provider setups on the
476+
// standard throttle so they can recover without a restart; when fallbacks
477+
// exist, only probe near cooldown expiry so the fallback chain stays preferred.
435478
if (inferredReason === "billing") {
436-
if (params.isPrimary && params.hasFallbackCandidates && shouldProbe) {
479+
const shouldProbeSingleProviderBilling =
480+
params.isPrimary &&
481+
!params.hasFallbackCandidates &&
482+
isProbeThrottleOpen(params.now, params.probeThrottleKey);
483+
if (params.isPrimary && (shouldProbe || shouldProbeSingleProviderBilling)) {
437484
return { type: "attempt", reason: inferredReason, markProbe: true };
438485
}
439486
return {
@@ -528,7 +575,7 @@ export async function runWithModelFallback<T>(params: {
528575
}
529576

530577
if (decision.markProbe) {
531-
lastProbeAttempt.set(probeThrottleKey, now);
578+
markProbeAttempt(now, probeThrottleKey);
532579
}
533580
if (
534581
decision.reason === "rate_limit" ||

src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,54 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
10131013
});
10141014
});
10151015

1016+
it("can probe one billing-disabled profile when transient cooldown probe is allowed without fallback models", async () => {
1017+
await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => {
1018+
await writeAuthStore(agentDir, {
1019+
usageStats: {
1020+
"openai:p1": {
1021+
lastUsed: 1,
1022+
disabledUntil: now + 60 * 60 * 1000,
1023+
disabledReason: "billing",
1024+
},
1025+
"openai:p2": {
1026+
lastUsed: 2,
1027+
disabledUntil: now + 60 * 60 * 1000,
1028+
disabledReason: "billing",
1029+
},
1030+
},
1031+
});
1032+
1033+
runEmbeddedAttemptMock.mockResolvedValueOnce(
1034+
makeAttempt({
1035+
assistantTexts: ["ok"],
1036+
lastAssistant: buildAssistant({
1037+
stopReason: "stop",
1038+
content: [{ type: "text", text: "ok" }],
1039+
}),
1040+
}),
1041+
);
1042+
1043+
const result = await runEmbeddedPiAgent({
1044+
sessionId: "session:test",
1045+
sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks",
1046+
sessionFile: path.join(workspaceDir, "session.jsonl"),
1047+
workspaceDir,
1048+
agentDir,
1049+
config: makeConfig(),
1050+
prompt: "hello",
1051+
provider: "openai",
1052+
model: "mock-1",
1053+
authProfileIdSource: "auto",
1054+
allowTransientCooldownProbe: true,
1055+
timeoutMs: 5_000,
1056+
runId: "run:billing-cooldown-probe-no-fallbacks",
1057+
});
1058+
1059+
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
1060+
expect(result.payloads?.[0]?.text ?? "").toContain("ok");
1061+
});
1062+
});
1063+
10161064
it("treats agent-level fallbacks as configured when defaults have none", async () => {
10171065
await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => {
10181066
await writeAuthStore(agentDir, {

0 commit comments

Comments
 (0)