Skip to content

Commit 3c8d101

Browse files
committed
fix(agents): cache fallback provider resolution
1 parent 8ae9977 commit 3c8d101

9 files changed

Lines changed: 578 additions & 49 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010

1111
- Scripts: use `git grep` to prefilter tracked conflict-marker scans so changed checks avoid reading every repository file on clean runs.
1212
- Installer: install Node.js through `apk` on Alpine Linux instead of falling through to the NodeSource package-manager path.
13+
- Agents/perf: cache manifest-backed CLI provider descriptors and fallback provider resolution so model fallback retries avoid repeated bundled provider runtime scans while still invalidating across plugin reloads.
1314
- Installer: detect musl Linux shells such as Alpine as Linux instead of rejecting them before npm install.
1415
- Tests: run Vitest import timing entrypoints through a Node wrapper so native Windows package scripts can collect import diagnostics.
1516
- Control UI: split large build-time runtime dependencies into stable chunks so Linux/Docker install and package builds stay below the app chunk warning threshold.

src/agents/model-fallback.test.ts

Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ import { resetLogger, setLoggerOverride } from "../logging/logger.js";
66
import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js";
77
import {
88
clearCurrentPluginMetadataSnapshot,
9+
resolvePluginMetadataControlPlaneFingerprint,
910
setCurrentPluginMetadataSnapshot,
1011
} from "../plugins/current-plugin-metadata-snapshot.js";
12+
import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js";
13+
import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js";
1114
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
15+
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
1216
import { CommandLaneTaskTimeoutError } from "../process/command-queue.js";
1317
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
1418
import type { AuthProfileStore } from "./auth-profiles/types.js";
@@ -164,10 +168,7 @@ let authTempRoot = "";
164168
let authTempCounter = 0;
165169

166170
beforeAll(() => {
167-
setCurrentPluginMetadataSnapshot(loadPluginMetadataSnapshot({ config: {}, env: process.env }), {
168-
config: {},
169-
env: process.env,
170-
});
171+
setDefaultPluginMetadataSnapshot();
171172
});
172173

173174
afterAll(() => {
@@ -181,6 +182,73 @@ function resetModelFallbackTestState(): void {
181182
authSourceCheckMock.hasAnyAuthProfileStoreSource.mockReset().mockReturnValue(false);
182183
}
183184

185+
function setDefaultPluginMetadataSnapshot(): void {
186+
setCurrentPluginMetadataSnapshot(loadPluginMetadataSnapshot({ config: {}, env: process.env }), {
187+
config: {},
188+
env: process.env,
189+
});
190+
}
191+
192+
function createModelNormalizerSnapshot(params: {
193+
manifestHash: string;
194+
prefix: string;
195+
}): PluginMetadataSnapshot {
196+
const policyHash = resolveInstalledPluginIndexPolicyHash({});
197+
const index: InstalledPluginIndex = {
198+
version: 1,
199+
hostContractVersion: "test-host",
200+
compatRegistryVersion: "test-compat",
201+
migrationVersion: 1,
202+
policyHash,
203+
generatedAtMs: 0,
204+
installRecords: {},
205+
plugins: [
206+
{
207+
pluginId: "fallback-normalizer",
208+
manifestPath: `/tmp/fallback-normalizer-${params.manifestHash}/openclaw.plugin.json`,
209+
manifestHash: params.manifestHash,
210+
source: `/tmp/fallback-normalizer-${params.manifestHash}/index.ts`,
211+
rootDir: `/tmp/fallback-normalizer-${params.manifestHash}`,
212+
origin: "global",
213+
enabled: true,
214+
startup: {
215+
sidecar: false,
216+
memory: false,
217+
deferConfiguredChannelFullLoadUntilAfterListen: false,
218+
agentHarnesses: [],
219+
},
220+
compat: [],
221+
},
222+
],
223+
diagnostics: [],
224+
};
225+
return {
226+
policyHash,
227+
configFingerprint: resolvePluginMetadataControlPlaneFingerprint(
228+
{},
229+
{
230+
env: process.env,
231+
index,
232+
policyHash,
233+
},
234+
),
235+
index,
236+
registryDiagnostics: [],
237+
plugins: [
238+
{
239+
id: "fallback-normalizer",
240+
modelIdNormalization: {
241+
providers: {
242+
demo: {
243+
prefixWhenBare: params.prefix,
244+
},
245+
},
246+
},
247+
},
248+
],
249+
} as unknown as PluginMetadataSnapshot;
250+
}
251+
184252
afterEach(resetModelFallbackTestState);
185253

186254
beforeEach(() => {
@@ -227,6 +295,31 @@ function makeProviderFallbackCfg(provider: string): OpenClawConfig {
227295
});
228296
}
229297

298+
function makeProviderOrderFallbackCfg(
299+
entries: Array<[provider: string, model: string]>,
300+
): OpenClawConfig {
301+
return {
302+
agents: {
303+
defaults: {
304+
model: {
305+
fallbacks: [],
306+
},
307+
},
308+
},
309+
models: {
310+
providers: Object.fromEntries(
311+
entries.map(([provider, model]) => [
312+
provider,
313+
{
314+
baseUrl: `https://${provider}.example.test`,
315+
models: [{ id: model }],
316+
},
317+
]),
318+
),
319+
},
320+
} as unknown as OpenClawConfig;
321+
}
322+
230323
async function withTempAuthStore<T>(
231324
store: AuthProfileStore,
232325
run: (tempDir: string) => Promise<T>,
@@ -1969,6 +2062,82 @@ describe("runWithModelFallback", () => {
19692062
]);
19702063
});
19712064

2065+
it("does not reuse provider-order-sensitive configured fallback candidates", () => {
2066+
const anthropicFirst = makeProviderOrderFallbackCfg([
2067+
["anthropic", "claude-sonnet-4"],
2068+
["ollama", "llama3"],
2069+
]);
2070+
const ollamaFirst = makeProviderOrderFallbackCfg([
2071+
["ollama", "llama3"],
2072+
["anthropic", "claude-sonnet-4"],
2073+
]);
2074+
2075+
expect(
2076+
testing.resolveFallbackCandidates({
2077+
cfg: anthropicFirst,
2078+
provider: "",
2079+
model: "",
2080+
fallbacksOverride: [],
2081+
}),
2082+
).toEqual([{ provider: "anthropic", model: "claude-sonnet-4" }]);
2083+
expect(
2084+
testing.resolveFallbackCandidates({
2085+
cfg: ollamaFirst,
2086+
provider: "",
2087+
model: "",
2088+
fallbacksOverride: [],
2089+
}),
2090+
).toEqual([{ provider: "ollama", model: "llama3" }]);
2091+
});
2092+
2093+
it("does not reuse fallback candidate cache entries across manifest normalization snapshots", () => {
2094+
const cfg = makeCfg({
2095+
agents: {
2096+
defaults: {
2097+
model: {
2098+
fallbacks: [],
2099+
},
2100+
},
2101+
},
2102+
});
2103+
2104+
try {
2105+
setCurrentPluginMetadataSnapshot(
2106+
createModelNormalizerSnapshot({
2107+
manifestHash: "alpha",
2108+
prefix: "alpha",
2109+
}),
2110+
{ config: {}, env: process.env },
2111+
);
2112+
expect(
2113+
testing.resolveFallbackCandidates({
2114+
cfg,
2115+
provider: "demo",
2116+
model: "demo-model",
2117+
fallbacksOverride: [],
2118+
}),
2119+
).toEqual([{ provider: "demo", model: "alpha/demo-model" }]);
2120+
2121+
setCurrentPluginMetadataSnapshot(
2122+
createModelNormalizerSnapshot({
2123+
manifestHash: "bravo",
2124+
prefix: "bravo",
2125+
}),
2126+
{ config: {}, env: process.env },
2127+
);
2128+
expect(
2129+
testing.resolveFallbackCandidates({
2130+
cfg,
2131+
provider: "demo",
2132+
model: "demo-model",
2133+
fallbacksOverride: [],
2134+
}),
2135+
).toEqual([{ provider: "demo", model: "bravo/demo-model" }]);
2136+
} finally {
2137+
setDefaultPluginMetadataSnapshot();
2138+
}
2139+
});
2140+
19722141
it("defaults provider/model when missing (regression #946)", () => {
19732142
const cfg = makeCfg({
19742143
agents: {

src/agents/model-fallback.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
66
import { emitFailoverEvent } from "../infra/diagnostic-events.js";
77
import { formatErrorMessage } from "../infra/errors.js";
88
import { createSubsystemLogger } from "../logging/subsystem.js";
9+
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
10+
import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js";
11+
import { isPluginProvidersLoadInFlight } from "../plugins/providers.runtime.js";
12+
import {
13+
getActivePluginRegistryWorkspaceDirFromState,
14+
getPluginRegistryState,
15+
} from "../plugins/runtime-state.js";
916
import { isCommandLaneTaskTimeoutError } from "../process/command-queue.js";
1017
import { createLazyImportLoader } from "../shared/lazy-promise.js";
1118
import { normalizeOptionalString } from "../shared/string-coerce.js";
@@ -211,6 +218,8 @@ type ModelFallbackAuthRuntime = typeof import("./model-fallback-auth.runtime.js"
211218
const modelFallbackAuthRuntimeLoader = createLazyImportLoader<ModelFallbackAuthRuntime>(
212219
() => import("./model-fallback-auth.runtime.js"),
213220
);
221+
const MAX_FALLBACK_CANDIDATE_CACHE_ENTRIES = 256;
222+
const fallbackCandidateCache = new Map<string, ModelCandidate[]>();
214223

215224
async function loadModelFallbackAuthRuntime() {
216225
return await modelFallbackAuthRuntimeLoader.load();
@@ -639,6 +648,109 @@ function resolveFallbackCandidates(
639648
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
640649
fallbacksOverride?: string[];
641650
} & ModelManifestNormalizationContext,
651+
): ModelCandidate[] {
652+
const cacheKey = resolveFallbackCandidateCacheKey(params);
653+
if (cacheKey) {
654+
const cached = fallbackCandidateCache.get(cacheKey);
655+
if (cached) {
656+
return cached.map(cloneModelCandidate);
657+
}
658+
}
659+
const candidates = resolveFallbackCandidatesUncached(params);
660+
if (cacheKey) {
661+
fallbackCandidateCache.set(cacheKey, candidates.map(cloneModelCandidate));
662+
while (fallbackCandidateCache.size > MAX_FALLBACK_CANDIDATE_CACHE_ENTRIES) {
663+
const oldest = fallbackCandidateCache.keys().next();
664+
if (oldest.done) {
665+
break;
666+
}
667+
fallbackCandidateCache.delete(oldest.value);
668+
}
669+
}
670+
return candidates;
671+
}
672+
673+
function cloneModelCandidate(candidate: ModelCandidate): ModelCandidate {
674+
return {
675+
provider: candidate.provider,
676+
model: candidate.model,
677+
};
678+
}
679+
680+
function resolveFallbackCandidateCacheKey(
681+
params: {
682+
cfg: OpenClawConfig | undefined;
683+
provider: string;
684+
model: string;
685+
fallbacksOverride?: string[];
686+
} & ModelManifestNormalizationContext,
687+
): string | null {
688+
if (params.manifestPlugins) {
689+
return null;
690+
}
691+
const workspaceDir = getActivePluginRegistryWorkspaceDirFromState();
692+
const env = process.env;
693+
if (
694+
isPluginProvidersLoadInFlight({
695+
config: params.cfg,
696+
workspaceDir,
697+
env,
698+
activate: false,
699+
bundledProviderAllowlistCompat: true,
700+
bundledProviderVitestCompat: true,
701+
})
702+
) {
703+
return null;
704+
}
705+
const pluginMetadata = getCurrentPluginMetadataSnapshot({
706+
env,
707+
workspaceDir,
708+
allowWorkspaceScopedSnapshot: true,
709+
});
710+
const registryState = getPluginRegistryState();
711+
return JSON.stringify({
712+
provider: params.provider,
713+
model: params.model,
714+
fallbacksOverride: params.fallbacksOverride,
715+
agentsDefaultsModel: params.cfg?.agents?.defaults?.model,
716+
agentsDefaultsModels: params.cfg?.agents?.defaults?.models,
717+
modelProviders: resolveFallbackCandidateModelProviderCacheParts(params.cfg),
718+
pluginControlPlane: resolvePluginControlPlaneFingerprint({
719+
config: params.cfg,
720+
env,
721+
workspaceDir,
722+
}),
723+
pluginMetadataFingerprint: pluginMetadata?.configFingerprint ?? null,
724+
pluginRegistryKey: registryState?.key ?? null,
725+
pluginRegistryVersion: registryState?.activeVersion ?? null,
726+
pluginWorkspaceDir: workspaceDir ?? null,
727+
});
728+
}
729+
730+
function resolveFallbackCandidateModelProviderCacheParts(cfg: OpenClawConfig | undefined): unknown {
731+
const providers = cfg?.models?.providers;
732+
if (!providers) {
733+
return undefined;
734+
}
735+
return Object.entries(providers).map(([providerId, providerConfig]) => ({
736+
providerId,
737+
api: typeof providerConfig?.api === "string" ? providerConfig.api : undefined,
738+
models: Array.isArray(providerConfig?.models)
739+
? providerConfig.models
740+
.map((entry) => (typeof entry?.id === "string" ? entry.id : undefined))
741+
.filter((id): id is string => id !== undefined)
742+
: [],
743+
}));
744+
}
745+
746+
function resolveFallbackCandidatesUncached(
747+
params: {
748+
cfg: OpenClawConfig | undefined;
749+
provider: string;
750+
model: string;
751+
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
752+
fallbacksOverride?: string[];
753+
} & ModelManifestNormalizationContext,
642754
): ModelCandidate[] {
643755
const primary = params.cfg
644756
? resolveConfiguredModelRef({

0 commit comments

Comments
 (0)