diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index ebb508481fd..464ea9e697b 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -21,6 +21,8 @@ import type { EmbeddedAgentRunResult } from "./embedded-agent-runner/types.js"; import { FailoverError } from "./failover-error.js"; import { resetFallbackSkipCacheForTest } from "./fallback-skip-cache.js"; import { MissingAgentHarnessError } from "./harness/errors.js"; +import { clearAgentHarnesses, registerAgentHarness } from "./harness/registry.js"; +import type { AgentHarness } from "./harness/types.js"; import { LiveSessionModelSwitchError } from "./live-model-switch-error.js"; import { FallbackSummaryError, @@ -191,6 +193,7 @@ afterAll(() => { function resetModelFallbackTestState(): void { resetFallbackSkipCacheForTest(); + clearAgentHarnesses(); authRuntimeMock.clear(); authRuntimeMock.runtime.ensureAuthProfileStore.mockClear(); authRuntimeMock.runtime.loadAuthProfileStoreForRuntime.mockClear(); @@ -971,6 +974,68 @@ describe("runWithModelFallback", () => { expect(run).not.toHaveBeenCalled(); }); + it("lets external plugin harnesses bypass stale provider auth cooldowns", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-sonnet-4-6", + fallbacks: ["openai/gpt-5.5"], + }, + models: { + "anthropic/*": { agentRuntime: { id: "claude-tmux" } }, + }, + }, + }, + }); + registerAgentHarness( + { + id: "claude-tmux", + label: "Claude tmux", + supports: ({ provider }) => + provider === "anthropic" ? { supported: true } : { supported: false }, + runAttempt: vi.fn(async () => { + throw new Error("fallback test should not invoke the harness runtime"); + }), + }, + { ownerPluginId: "claude-tmux-test" }, + ); + const tempDir = await makeAuthTempDir(); + setAuthRuntimeStore(tempDir, { + version: AUTH_STORE_VERSION, + profiles: { + "anthropic:default": { type: "api_key", provider: "anthropic", key: "test-key" }, + "openai:default": { type: "api_key", provider: "openai", key: "test-key" }, + }, + usageStats: { + "anthropic:default": { + disabledUntil: Date.now() + 60_000, + disabledReason: "billing", + failureCounts: { rate_limit: 4 }, + }, + }, + }); + const run = vi.fn().mockImplementation(async (provider: string) => { + if (provider === "anthropic") { + return "external cli ok"; + } + throw new Error(`unexpected provider: ${provider}`); + }); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-sonnet-4-6", + agentDir: tempDir, + run, + }); + + expect(result.result).toBe("external cli ok"); + expect(run).toHaveBeenCalledTimes(1); + expect(run.mock.calls[0]).toEqual(["anthropic", "claude-sonnet-4-6"]); + expect(result.attempts).toStrictEqual([]); + }); + it("lets configured CLI runtimes reach the run callback", async () => { const cfg = makeCfg({ agents: { @@ -1781,9 +1846,9 @@ describe("runWithModelFallback", () => { { provider: "openai", model: "gpt-4.1-mini" }, { provider: "tui-pty-mock", model: "gpt-5.5" }, ]); - expect(providerModelNormalizationMock.normalizeProviderModelIdWithRuntime).not.toHaveBeenCalledWith( - expect.objectContaining({ provider: "tui-pty-mock" }), - ); + expect( + providerModelNormalizationMock.normalizeProviderModelIdWithRuntime, + ).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "tui-pty-mock" })); }); it("keeps configured fallbacks before configured primary for duplicate provider model ids", () => { diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 384a9786620..5622f30c651 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -439,18 +439,18 @@ function isCliAgentRuntime(runtime: string | undefined, cfg: OpenClawConfig | un return isCliRuntimeAlias(normalized) || isCliProvider(normalized, cfg); } -async function assertModelFallbackCandidateHarnessAvailable( +async function resolveModelFallbackCandidateHarnessAuthPrecheck( params: ModelFallbackRuntimeContext & ModelCandidate, -): Promise { +): Promise<{ skipsProviderAuthCooldown: boolean }> { if (!params.cfg) { - return; + return { skipsProviderAuthCooldown: false }; } const agentHarnessRuntimeOverride = params.resolveAgentHarnessRuntimeOverride?.( params.provider, params.model, ); if (isCliProvider(params.provider, params.cfg)) { - return; + return { skipsProviderAuthCooldown: false }; } const agentRuntimeOverride = normalizeOptionalAgentRuntimeId(agentHarnessRuntimeOverride); const harnessPolicy = resolveAgentHarnessPolicy({ @@ -469,28 +469,25 @@ async function assertModelFallbackCandidateHarnessAvailable( ? "model" : harnessPolicy.runtimeSource; if (isCliAgentRuntime(agentRuntime, params.cfg)) { - return; + return { skipsProviderAuthCooldown: false }; } - if ( - agentRuntime === "auto" || - agentRuntime === "openclaw" || - (agentRuntime === "codex" && agentRuntimeSource === "implicit") - ) { - return; + if (agentRuntime === "openclaw") { + return { skipsProviderAuthCooldown: false }; + } + if (agentRuntime === "auto" || (agentRuntime === "codex" && agentRuntimeSource === "implicit")) { + return { skipsProviderAuthCooldown: false }; } await params.prepareAgentHarnessRuntime?.({ provider: params.provider, model: params.model, agentHarnessRuntimeOverride, }); - if ( - agentRuntime !== "auto" && - agentRuntime !== "openclaw" && - !(agentRuntime === "codex" && agentRuntimeSource === "implicit") && - !getRegisteredAgentHarness(agentRuntime) - ) { + if (!getRegisteredAgentHarness(agentRuntime)) { throw new MissingAgentHarnessError(agentRuntime); } + // Explicit non-Codex plugin harnesses own transport/auth; stale OpenClaw + // provider cooldowns must not block the harness before it starts. + return { skipsProviderAuthCooldown: agentRuntime !== "codex" }; } function resolveCandidateAttemptError( @@ -1275,7 +1272,7 @@ export async function runWithModelFallback( for (let i = 0; i < candidates.length; i += 1) { const candidate = candidates[i]; - await assertModelFallbackCandidateHarnessAvailable({ + const candidateHarnessAuth = await resolveModelFallbackCandidateHarnessAuthPrecheck({ cfg: params.cfg, agentId: params.agentId, sessionKey: params.sessionKey, @@ -1342,7 +1339,7 @@ export async function runWithModelFallback( let runOptions: ModelFallbackRunOptions | undefined; let attemptedDuringCooldown = false; let transientProbeProviderForAttempt: string | null = null; - if (authRuntime && authStore) { + if (authRuntime && authStore && !candidateHarnessAuth.skipsProviderAuthCooldown) { const profileIds = authRuntime.resolveAuthProfileOrder({ cfg: params.cfg, store: authStore,