Skip to content

Commit 93a76ad

Browse files
authored
Merge 6fa95d6 into 4752e9a
2 parents 4752e9a + 6fa95d6 commit 93a76ad

2 files changed

Lines changed: 71 additions & 23 deletions

File tree

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

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,44 @@ describe("runWithModelFallback – probe logic", () => {
360360
await expectPrimarySkippedAfterLongCooldown("billing");
361361
});
362362

363+
it("re-probes a single-provider primary blocked by a far-future subscription_limit (#90702)", () => {
364+
// fallbacks:[] + a multi-day subscription_limit reset must still re-probe on
365+
// the throttle instead of suspending until blockedUntil literally arrives,
366+
// since the rolling cap usually recovers earlier. Multi-fallback setups keep
367+
// preferring the fallback chain (covered above).
368+
const sixDays = 6 * 24 * 60 * 60 * 1000;
369+
const usageStats = {
370+
"openai-profile-1": {
371+
blockedUntil: NOW + sixDays,
372+
blockedReason: "subscription_limit",
373+
blockedSource: "wham",
374+
},
375+
} satisfies AuthProfileStore["usageStats"];
376+
377+
expect(
378+
resolveOpenAiCooldownDecision({
379+
reason: "rate_limit",
380+
soonest: NOW + sixDays,
381+
hasFallbackCandidates: false,
382+
usageStats,
383+
}),
384+
).toEqual({ type: "attempt", reason: "rate_limit", markProbe: true });
385+
386+
// The 30s probe throttle is still honored so recovery probing cannot hammer
387+
// the upstream: a recent probe on the same key suspends until the slot opens.
388+
probeThrottleInternals.lastProbeAttempt.set("recent-openai", NOW - 10_000);
389+
expectOpenAiProbeSuspension(
390+
resolveOpenAiCooldownDecision({
391+
reason: "rate_limit",
392+
soonest: NOW + sixDays,
393+
hasFallbackCandidates: false,
394+
throttleKey: "recent-openai",
395+
usageStats,
396+
}),
397+
"rate_limit",
398+
);
399+
});
400+
363401
it("decides when cooldowned primary probes are allowed", () => {
364402
expect(
365403
resolveOpenAiCooldownDecision({
@@ -674,7 +712,7 @@ describe("runWithModelFallback – probe logic", () => {
674712
}
675713
});
676714

677-
it("single candidate skips with rate_limit and exhausts candidates", async () => {
715+
it("re-probes a single-provider rate-limited primary instead of suspending", async () => {
678716
const cfg = makeCfg({
679717
agents: {
680718
defaults: {
@@ -686,22 +724,26 @@ describe("runWithModelFallback – probe logic", () => {
686724
},
687725
} as Partial<OpenClawConfig>);
688726

689-
const almostExpired = NOW + 30 * 1000;
690-
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
727+
// Far-future cooldown with no fallback chain: the primary must still be
728+
// probed so a recovered rolling cap resumes work instead of staying silent
729+
// until blockedUntil arrives. See #90702.
730+
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 6 * 24 * 60 * 60 * 1000);
691731

692-
const run = vi.fn().mockResolvedValue("unreachable");
732+
const run = vi.fn().mockResolvedValue("probed-ok");
693733

694-
await expect(
695-
runWithModelFallback({
696-
cfg,
697-
provider: "openai",
698-
model: "gpt-4.1-mini",
699-
fallbacksOverride: [],
700-
run,
701-
}),
702-
).rejects.toThrow("All models failed");
734+
const result = await runWithModelFallback({
735+
cfg,
736+
provider: "openai",
737+
model: "gpt-4.1-mini",
738+
fallbacksOverride: [],
739+
run,
740+
});
703741

704-
expect(run).not.toHaveBeenCalled();
742+
expect(result.result).toBe("probed-ok");
743+
expect(run).toHaveBeenCalledTimes(1);
744+
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", {
745+
allowTransientCooldownProbe: true,
746+
});
705747
});
706748

707749
it("scopes probe throttling by agentDir to avoid cross-agent suppression", () => {

src/agents/model-fallback.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,14 +1060,24 @@ function shouldProbePrimaryDuringCooldown(params: {
10601060
profileIds: string[];
10611061
model: string;
10621062
}): boolean {
1063-
if (!params.isPrimary || !params.hasFallbackCandidates) {
1063+
if (!params.isPrimary) {
10641064
return false;
10651065
}
10661066

10671067
if (!isProbeThrottleOpen(params.now, params.throttleKey)) {
10681068
return false;
10691069
}
10701070

1071+
// A single-provider primary has no fallback chain to prefer, so every open
1072+
// throttle slot is a recovery probe: "is the primary callable yet?" is a
1073+
// recovery question independent of fallback configuration. Without this, a
1074+
// fallbacks:[] setup that hits a rate/subscription cap stays suspended until
1075+
// the provider-reported reset (which can be days out) even though the rolling
1076+
// cap usually recovers earlier. See #90702.
1077+
if (!params.hasFallbackCandidates) {
1078+
return true;
1079+
}
1080+
10711081
const soonest = params.authRuntime.getSoonestCooldownExpiry(params.authStore, params.profileIds, {
10721082
now: params.now,
10731083
forModel: params.model,
@@ -1163,15 +1173,11 @@ function resolveCooldownDecision(params: {
11631173
}
11641174

11651175
// Billing is semi-persistent: the user may fix their balance, or a transient
1166-
// 402 might have been misclassified. Probe single-provider setups on the
1167-
// standard throttle so they can recover without a restart; when fallbacks
1168-
// exist, only probe near cooldown expiry so the fallback chain stays preferred.
1176+
// 402 might have been misclassified. shouldProbe already re-probes
1177+
// single-provider setups on the throttle (no fallback chain to prefer) and
1178+
// multi-fallback setups near cooldown expiry, so both recover without a restart.
11691179
if (inferredReason === "billing") {
1170-
const shouldProbeSingleProviderBilling =
1171-
params.isPrimary &&
1172-
!params.hasFallbackCandidates &&
1173-
isProbeThrottleOpen(params.now, params.probeThrottleKey);
1174-
if (params.isPrimary && (shouldProbe || shouldProbeSingleProviderBilling)) {
1180+
if (params.isPrimary && shouldProbe) {
11751181
return { type: "attempt", reason: inferredReason, markProbe: true };
11761182
}
11771183
return {

0 commit comments

Comments
 (0)