Skip to content

Commit 00e9629

Browse files
committed
fix(agents): probe single-provider billing cooldowns
1 parent 2b2e5e2 commit 00e9629

File tree

2 files changed

+33
-17
lines changed

2 files changed

+33
-17
lines changed

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ describe("runWithModelFallback – probe logic", () => {
346346
});
347347
});
348348

349-
it("skips billing-cooldowned primary when no fallback candidates exist", async () => {
349+
it("probes billing-cooldowned primary when no fallback candidates exist", async () => {
350350
const cfg = makeCfg({
351351
agents: {
352352
defaults: {
@@ -358,20 +358,28 @@ describe("runWithModelFallback – probe logic", () => {
358358
},
359359
} as Partial<OpenClawConfig>);
360360

361-
// Billing cooldown far from expiry — would normally be skipped
361+
// Single-provider setups need periodic probes even when the billing
362+
// cooldown is far from expiry, otherwise topping up credits never recovers
363+
// without a restart.
362364
const expiresIn30Min = NOW + 30 * 60 * 1000;
363365
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
364366
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
365367

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");
368+
const run = vi.fn().mockResolvedValue("billing-recovered");
369+
370+
const result = await runWithModelFallback({
371+
cfg,
372+
provider: "openai",
373+
model: "gpt-4.1-mini",
374+
fallbacksOverride: [],
375+
run,
376+
});
377+
378+
expect(result.result).toBe("billing-recovered");
379+
expect(run).toHaveBeenCalledTimes(1);
380+
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", {
381+
allowTransientCooldownProbe: true,
382+
});
375383
});
376384

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

src/agents/model-fallback.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,11 @@ function resolveProbeThrottleKey(provider: string, agentDir?: string): string {
348348
return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider;
349349
}
350350

351+
function isProbeThrottleOpen(now: number, throttleKey: string): boolean {
352+
const lastProbe = lastProbeAttempt.get(throttleKey) ?? 0;
353+
return now - lastProbe >= MIN_PROBE_INTERVAL_MS;
354+
}
355+
351356
function shouldProbePrimaryDuringCooldown(params: {
352357
isPrimary: boolean;
353358
hasFallbackCandidates: boolean;
@@ -360,8 +365,7 @@ function shouldProbePrimaryDuringCooldown(params: {
360365
return false;
361366
}
362367

363-
const lastProbe = lastProbeAttempt.get(params.throttleKey) ?? 0;
364-
if (params.now - lastProbe < MIN_PROBE_INTERVAL_MS) {
368+
if (!isProbeThrottleOpen(params.now, params.throttleKey)) {
365369
return false;
366370
}
367371

@@ -429,11 +433,15 @@ function resolveCooldownDecision(params: {
429433
}
430434

431435
// 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.
436+
// 402 might have been misclassified. Probe single-provider setups on the
437+
// standard throttle so they can recover without a restart; when fallbacks
438+
// exist, only probe near cooldown expiry so the fallback chain stays preferred.
435439
if (inferredReason === "billing") {
436-
if (params.isPrimary && params.hasFallbackCandidates && shouldProbe) {
440+
const shouldProbeSingleProviderBilling =
441+
params.isPrimary &&
442+
!params.hasFallbackCandidates &&
443+
isProbeThrottleOpen(params.now, params.probeThrottleKey);
444+
if (params.isPrimary && (shouldProbe || shouldProbeSingleProviderBilling)) {
437445
return { type: "attempt", reason: inferredReason, markProbe: true };
438446
}
439447
return {

0 commit comments

Comments
 (0)