From 3c8d101f5a85192a37acaa09fb5fb928571ab507 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 25 May 2026 00:49:42 +0200 Subject: [PATCH] fix(agents): cache fallback provider resolution --- CHANGELOG.md | 1 + src/agents/model-fallback.test.ts | 177 ++++++++++++++++++++++++- src/agents/model-fallback.ts | 112 ++++++++++++++++ src/agents/model-selection-cli.test.ts | 81 +++++++++-- src/agents/model-selection-cli.ts | 4 +- src/plugins/provider-hook-runtime.ts | 106 +++++++++++---- src/plugins/provider-runtime.test.ts | 83 ++++++++++++ src/plugins/provider-runtime.ts | 20 ++- src/plugins/setup-registry.runtime.ts | 43 +++++- 9 files changed, 578 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a50d7b2c088..95789901986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Scripts: use `git grep` to prefilter tracked conflict-marker scans so changed checks avoid reading every repository file on clean runs. - Installer: install Node.js through `apk` on Alpine Linux instead of falling through to the NodeSource package-manager path. +- 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. - Installer: detect musl Linux shells such as Alpine as Linux instead of rejecting them before npm install. - Tests: run Vitest import timing entrypoints through a Node wrapper so native Windows package scripts can collect import diagnostics. - 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. diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index b64a97ec95a..91f7040ea09 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -6,9 +6,13 @@ import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js"; import { clearCurrentPluginMetadataSnapshot, + resolvePluginMetadataControlPlaneFingerprint, setCurrentPluginMetadataSnapshot, } from "../plugins/current-plugin-metadata-snapshot.js"; +import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js"; +import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js"; import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import { CommandLaneTaskTimeoutError } from "../process/command-queue.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; @@ -164,10 +168,7 @@ let authTempRoot = ""; let authTempCounter = 0; beforeAll(() => { - setCurrentPluginMetadataSnapshot(loadPluginMetadataSnapshot({ config: {}, env: process.env }), { - config: {}, - env: process.env, - }); + setDefaultPluginMetadataSnapshot(); }); afterAll(() => { @@ -181,6 +182,73 @@ function resetModelFallbackTestState(): void { authSourceCheckMock.hasAnyAuthProfileStoreSource.mockReset().mockReturnValue(false); } +function setDefaultPluginMetadataSnapshot(): void { + setCurrentPluginMetadataSnapshot(loadPluginMetadataSnapshot({ config: {}, env: process.env }), { + config: {}, + env: process.env, + }); +} + +function createModelNormalizerSnapshot(params: { + manifestHash: string; + prefix: string; +}): PluginMetadataSnapshot { + const policyHash = resolveInstalledPluginIndexPolicyHash({}); + const index: InstalledPluginIndex = { + version: 1, + hostContractVersion: "test-host", + compatRegistryVersion: "test-compat", + migrationVersion: 1, + policyHash, + generatedAtMs: 0, + installRecords: {}, + plugins: [ + { + pluginId: "fallback-normalizer", + manifestPath: `/tmp/fallback-normalizer-${params.manifestHash}/openclaw.plugin.json`, + manifestHash: params.manifestHash, + source: `/tmp/fallback-normalizer-${params.manifestHash}/index.ts`, + rootDir: `/tmp/fallback-normalizer-${params.manifestHash}`, + origin: "global", + enabled: true, + startup: { + sidecar: false, + memory: false, + deferConfiguredChannelFullLoadUntilAfterListen: false, + agentHarnesses: [], + }, + compat: [], + }, + ], + diagnostics: [], + }; + return { + policyHash, + configFingerprint: resolvePluginMetadataControlPlaneFingerprint( + {}, + { + env: process.env, + index, + policyHash, + }, + ), + index, + registryDiagnostics: [], + plugins: [ + { + id: "fallback-normalizer", + modelIdNormalization: { + providers: { + demo: { + prefixWhenBare: params.prefix, + }, + }, + }, + }, + ], + } as unknown as PluginMetadataSnapshot; +} + afterEach(resetModelFallbackTestState); beforeEach(() => { @@ -227,6 +295,31 @@ function makeProviderFallbackCfg(provider: string): OpenClawConfig { }); } +function makeProviderOrderFallbackCfg( + entries: Array<[provider: string, model: string]>, +): OpenClawConfig { + return { + agents: { + defaults: { + model: { + fallbacks: [], + }, + }, + }, + models: { + providers: Object.fromEntries( + entries.map(([provider, model]) => [ + provider, + { + baseUrl: `https://${provider}.example.test`, + models: [{ id: model }], + }, + ]), + ), + }, + } as unknown as OpenClawConfig; +} + async function withTempAuthStore( store: AuthProfileStore, run: (tempDir: string) => Promise, @@ -1969,6 +2062,82 @@ describe("runWithModelFallback", () => { ]); }); + it("does not reuse provider-order-sensitive configured fallback candidates", () => { + const anthropicFirst = makeProviderOrderFallbackCfg([ + ["anthropic", "claude-sonnet-4"], + ["ollama", "llama3"], + ]); + const ollamaFirst = makeProviderOrderFallbackCfg([ + ["ollama", "llama3"], + ["anthropic", "claude-sonnet-4"], + ]); + + expect( + testing.resolveFallbackCandidates({ + cfg: anthropicFirst, + provider: "", + model: "", + fallbacksOverride: [], + }), + ).toEqual([{ provider: "anthropic", model: "claude-sonnet-4" }]); + expect( + testing.resolveFallbackCandidates({ + cfg: ollamaFirst, + provider: "", + model: "", + fallbacksOverride: [], + }), + ).toEqual([{ provider: "ollama", model: "llama3" }]); + }); + + it("does not reuse fallback candidate cache entries across manifest normalization snapshots", () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + fallbacks: [], + }, + }, + }, + }); + + try { + setCurrentPluginMetadataSnapshot( + createModelNormalizerSnapshot({ + manifestHash: "alpha", + prefix: "alpha", + }), + { config: {}, env: process.env }, + ); + expect( + testing.resolveFallbackCandidates({ + cfg, + provider: "demo", + model: "demo-model", + fallbacksOverride: [], + }), + ).toEqual([{ provider: "demo", model: "alpha/demo-model" }]); + + setCurrentPluginMetadataSnapshot( + createModelNormalizerSnapshot({ + manifestHash: "bravo", + prefix: "bravo", + }), + { config: {}, env: process.env }, + ); + expect( + testing.resolveFallbackCandidates({ + cfg, + provider: "demo", + model: "demo-model", + fallbacksOverride: [], + }), + ).toEqual([{ provider: "demo", model: "bravo/demo-model" }]); + } finally { + setDefaultPluginMetadataSnapshot(); + } + }); + it("defaults provider/model when missing (regression #946)", () => { const cfg = makeCfg({ agents: { diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 9924103f618..051efa7cfe8 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -6,6 +6,13 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { emitFailoverEvent } from "../infra/diagnostic-events.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; +import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js"; +import { isPluginProvidersLoadInFlight } from "../plugins/providers.runtime.js"; +import { + getActivePluginRegistryWorkspaceDirFromState, + getPluginRegistryState, +} from "../plugins/runtime-state.js"; import { isCommandLaneTaskTimeoutError } from "../process/command-queue.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -211,6 +218,8 @@ type ModelFallbackAuthRuntime = typeof import("./model-fallback-auth.runtime.js" const modelFallbackAuthRuntimeLoader = createLazyImportLoader( () => import("./model-fallback-auth.runtime.js"), ); +const MAX_FALLBACK_CANDIDATE_CACHE_ENTRIES = 256; +const fallbackCandidateCache = new Map(); async function loadModelFallbackAuthRuntime() { return await modelFallbackAuthRuntimeLoader.load(); @@ -639,6 +648,109 @@ function resolveFallbackCandidates( /** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */ fallbacksOverride?: string[]; } & ModelManifestNormalizationContext, +): ModelCandidate[] { + const cacheKey = resolveFallbackCandidateCacheKey(params); + if (cacheKey) { + const cached = fallbackCandidateCache.get(cacheKey); + if (cached) { + return cached.map(cloneModelCandidate); + } + } + const candidates = resolveFallbackCandidatesUncached(params); + if (cacheKey) { + fallbackCandidateCache.set(cacheKey, candidates.map(cloneModelCandidate)); + while (fallbackCandidateCache.size > MAX_FALLBACK_CANDIDATE_CACHE_ENTRIES) { + const oldest = fallbackCandidateCache.keys().next(); + if (oldest.done) { + break; + } + fallbackCandidateCache.delete(oldest.value); + } + } + return candidates; +} + +function cloneModelCandidate(candidate: ModelCandidate): ModelCandidate { + return { + provider: candidate.provider, + model: candidate.model, + }; +} + +function resolveFallbackCandidateCacheKey( + params: { + cfg: OpenClawConfig | undefined; + provider: string; + model: string; + fallbacksOverride?: string[]; + } & ModelManifestNormalizationContext, +): string | null { + if (params.manifestPlugins) { + return null; + } + const workspaceDir = getActivePluginRegistryWorkspaceDirFromState(); + const env = process.env; + if ( + isPluginProvidersLoadInFlight({ + config: params.cfg, + workspaceDir, + env, + activate: false, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }) + ) { + return null; + } + const pluginMetadata = getCurrentPluginMetadataSnapshot({ + env, + workspaceDir, + allowWorkspaceScopedSnapshot: true, + }); + const registryState = getPluginRegistryState(); + return JSON.stringify({ + provider: params.provider, + model: params.model, + fallbacksOverride: params.fallbacksOverride, + agentsDefaultsModel: params.cfg?.agents?.defaults?.model, + agentsDefaultsModels: params.cfg?.agents?.defaults?.models, + modelProviders: resolveFallbackCandidateModelProviderCacheParts(params.cfg), + pluginControlPlane: resolvePluginControlPlaneFingerprint({ + config: params.cfg, + env, + workspaceDir, + }), + pluginMetadataFingerprint: pluginMetadata?.configFingerprint ?? null, + pluginRegistryKey: registryState?.key ?? null, + pluginRegistryVersion: registryState?.activeVersion ?? null, + pluginWorkspaceDir: workspaceDir ?? null, + }); +} + +function resolveFallbackCandidateModelProviderCacheParts(cfg: OpenClawConfig | undefined): unknown { + const providers = cfg?.models?.providers; + if (!providers) { + return undefined; + } + return Object.entries(providers).map(([providerId, providerConfig]) => ({ + providerId, + api: typeof providerConfig?.api === "string" ? providerConfig.api : undefined, + models: Array.isArray(providerConfig?.models) + ? providerConfig.models + .map((entry) => (typeof entry?.id === "string" ? entry.id : undefined)) + .filter((id): id is string => id !== undefined) + : [], + })); +} + +function resolveFallbackCandidatesUncached( + params: { + cfg: OpenClawConfig | undefined; + provider: string; + model: string; + /** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */ + fallbacksOverride?: string[]; + } & ModelManifestNormalizationContext, ): ModelCandidate[] { const primary = params.cfg ? resolveConfiguredModelRef({ diff --git a/src/agents/model-selection-cli.test.ts b/src/agents/model-selection-cli.test.ts index 6589b1fdd42..5551a372ccb 100644 --- a/src/agents/model-selection-cli.test.ts +++ b/src/agents/model-selection-cli.test.ts @@ -1,23 +1,76 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.js"; +import { + clearCurrentPluginMetadataSnapshot, + resolvePluginMetadataControlPlaneFingerprint, + setCurrentPluginMetadataSnapshot, +} from "../plugins/current-plugin-metadata-snapshot.js"; +import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js"; +import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { testing as setupRegistryRuntimeTesting } from "../plugins/setup-registry.runtime.js"; import { isCliProvider } from "./model-selection-cli.js"; +function setCliBackendMetadataSnapshot(cliBackends: string[]) { + const policyHash = resolveInstalledPluginIndexPolicyHash({}); + const index: InstalledPluginIndex = { + version: 1, + hostContractVersion: "test-host", + compatRegistryVersion: "test-compat", + migrationVersion: 1, + policyHash, + generatedAtMs: 0, + installRecords: {}, + plugins: [ + { + pluginId: "anthropic", + manifestPath: "/tmp/anthropic/openclaw.plugin.json", + manifestHash: "test-manifest", + source: "/tmp/anthropic/index.ts", + rootDir: "/tmp/anthropic", + origin: "bundled", + enabled: true, + startup: { + sidecar: false, + memory: false, + deferConfiguredChannelFullLoadUntilAfterListen: false, + agentHarnesses: [], + }, + compat: [], + }, + ], + diagnostics: [], + }; + const snapshot = { + policyHash, + configFingerprint: resolvePluginMetadataControlPlaneFingerprint( + {}, + { + env: process.env, + index, + policyHash, + }, + ), + index, + plugins: [ + { + id: "anthropic", + origin: "bundled", + cliBackends, + }, + ], + } as unknown as PluginMetadataSnapshot; + setCurrentPluginMetadataSnapshot(snapshot, { config: {}, env: process.env }); +} + describe("isCliProvider", () => { beforeEach(() => { setupRegistryRuntimeTesting.resetRuntimeState(); - setupRegistryRuntimeTesting.setRuntimeModuleForTest({ - resolvePluginSetupCliBackend: ({ backend }) => - backend === "claude-cli" - ? { - pluginId: "anthropic", - backend: { id: "claude-cli", config: { command: "claude" } }, - } - : undefined, - }); + setCliBackendMetadataSnapshot(["claude-cli"]); }); afterEach(() => { + clearCurrentPluginMetadataSnapshot(); setupRegistryRuntimeTesting.resetRuntimeState(); }); @@ -32,4 +85,14 @@ describe("isCliProvider", () => { it("returns false for provider ids", () => { expect(isCliProvider("example-cli", {} as OpenClawConfig)).toBe(false); }); + + it("does not execute setup runtime when descriptor metadata has no matching backend", () => { + setupRegistryRuntimeTesting.setRuntimeModuleForTest({ + resolvePluginSetupCliBackend: () => { + throw new Error("setup runtime should not load for CLI provider checks"); + }, + }); + + expect(isCliProvider("openai", {} as OpenClawConfig)).toBe(false); + }); }); diff --git a/src/agents/model-selection-cli.ts b/src/agents/model-selection-cli.ts index cbcbe898b88..4c5602feb61 100644 --- a/src/agents/model-selection-cli.ts +++ b/src/agents/model-selection-cli.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js"; -import { resolvePluginSetupCliBackendRuntime } from "../plugins/setup-registry.runtime.js"; +import { resolvePluginSetupCliBackendDescriptor } from "../plugins/setup-registry.runtime.js"; import { normalizeProviderId } from "./model-selection-normalize.js"; export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean { @@ -13,7 +13,7 @@ export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean { if (cliBackends.some((backend) => normalizeProviderId(backend.id) === normalized)) { return true; } - if (resolvePluginSetupCliBackendRuntime({ backend: normalized, config: cfg })) { + if (resolvePluginSetupCliBackendDescriptor({ backend: normalized, config: cfg })) { return true; } return false; diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index 7d4868d11e8..24bb770343b 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; import { + PluginLruCache, resolveConfigScopedRuntimeCacheValue, type ConfigScopedRuntimeCache, } from "./plugin-cache-primitives.js"; @@ -10,7 +11,10 @@ import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-con import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js"; import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js"; import type { PluginRegistry } from "./registry-types.js"; -import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; +import { + getActivePluginRegistryWorkspaceDirFromState, + getPluginRegistryState, +} from "./runtime-state.js"; import type { ProviderPlugin, ProviderExtraParamsForTransportContext, @@ -21,7 +25,8 @@ import type { ProviderWrapStreamFnContext, } from "./types.js"; -const providerRuntimePluginCache: ConfigScopedRuntimeCache = new WeakMap(); +let providerRuntimePluginCache: ConfigScopedRuntimeCache = new WeakMap(); +const defaultProviderRuntimePluginCache = new PluginLruCache(128); const PREPARED_PROVIDER_RUNTIME_SURFACES = ["channel"] as const; export type ProviderRuntimePluginLookupParams = { @@ -42,6 +47,11 @@ export type ProviderRuntimePluginHandleParams = ProviderRuntimePluginLookupParam runtimeHandle?: ProviderRuntimePluginHandle; }; +export function clearProviderRuntimePluginCacheForTest(): void { + providerRuntimePluginCache = new WeakMap(); + defaultProviderRuntimePluginCache.clear(); +} + function matchesProviderId(provider: ProviderPlugin, providerId: string): boolean { const normalized = normalizeProviderId(providerId); if (!normalized) { @@ -55,7 +65,10 @@ function matchesProviderId(provider: ProviderPlugin, providerId: string): boolea ); } -function resolveProviderRuntimePluginCacheKey(params: ProviderRuntimePluginLookupParams): string { +function resolveProviderRuntimePluginCacheKey( + params: ProviderRuntimePluginLookupParams, + registryState = getPluginRegistryState(), +): string { return JSON.stringify({ provider: normalizeLowercaseStringOrEmpty(params.provider), pluginControlPlane: resolvePluginControlPlaneFingerprint({ @@ -69,6 +82,8 @@ function resolveProviderRuntimePluginCacheKey(params: ProviderRuntimePluginLooku applyAutoEnable: params.applyAutoEnable ?? null, bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? null, bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? null, + pluginRegistryKey: registryState?.key ?? null, + pluginRegistryVersion: registryState?.activeVersion ?? null, }); } @@ -173,44 +188,77 @@ export function resolveProviderPluginsForHooks(params: { export function resolveProviderRuntimePlugin( params: ProviderRuntimePluginLookupParams, ): ProviderPlugin | undefined { + const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(); + const env = params.env ?? process.env; + const lookup = { ...params, workspaceDir, env }; const apiOwnerHint = resolveProviderConfigApiOwnerHint({ provider: params.provider, config: params.config, }); + const providerRefs = apiOwnerHint ? [params.provider, apiOwnerHint] : [params.provider]; const loadedPlugin = findProviderRuntimePluginInLoadedRegistries({ - lookup: params, + lookup, apiOwnerHint, }); if (loadedPlugin) { return loadedPlugin; } + if ( + isPluginProvidersLoadInFlight({ + ...params, + workspaceDir, + env, + providerRefs, + activate: false, + applyAutoEnable: params.applyAutoEnable, + bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true, + bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true, + }) + ) { + return undefined; + } const cacheConfig = params.env && params.env !== process.env ? undefined : params.config; - const plugin = resolveConfigScopedRuntimeCacheValue({ - cache: providerRuntimePluginCache, - config: cacheConfig, - key: resolveProviderRuntimePluginCacheKey(params), - load: () => { - return ( - resolveProviderPluginsForHooks({ - config: params.config, - workspaceDir: params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(), - env: params.env, - providerRefs: apiOwnerHint ? [params.provider, apiOwnerHint] : [params.provider], - applyAutoEnable: params.applyAutoEnable, - bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat, - bundledProviderVitestCompat: params.bundledProviderVitestCompat, - }).find((plugin) => { - if (apiOwnerHint) { - return ( - matchesProviderLiteralId(plugin, params.provider) || - matchesProviderId(plugin, apiOwnerHint) - ); + const registryState = getPluginRegistryState(); + const cacheKey = resolveProviderRuntimePluginCacheKey(lookup, registryState); + const load = () => { + return ( + resolveProviderPluginsForHooks({ + config: params.config, + workspaceDir, + env, + providerRefs, + applyAutoEnable: params.applyAutoEnable, + bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat, + bundledProviderVitestCompat: params.bundledProviderVitestCompat, + }).find((plugin) => { + if (apiOwnerHint) { + return ( + matchesProviderLiteralId(plugin, params.provider) || + matchesProviderId(plugin, apiOwnerHint) + ); + } + return matchesProviderId(plugin, params.provider); + }) ?? null + ); + }; + const plugin = cacheConfig + ? resolveConfigScopedRuntimeCacheValue({ + cache: providerRuntimePluginCache, + config: cacheConfig, + key: cacheKey, + load, + }) + : !registryState?.key + ? load() + : (() => { + const cached = defaultProviderRuntimePluginCache.getResult(cacheKey); + if (cached.hit) { + return cached.value; } - return matchesProviderId(plugin, params.provider); - }) ?? null - ); - }, - }); + const loaded = load(); + defaultProviderRuntimePluginCache.set(cacheKey, loaded); + return loaded; + })(); return plugin ?? undefined; } diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index c10a97e8eb8..0b61b98f31b 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -367,6 +367,7 @@ describe("provider-runtime", () => { beforeEach(() => { resetPluginRuntimeStateForTest(); + providerRuntimeTesting.clearProviderRuntimePluginCacheForTest(); providerRuntimeTesting.resetExternalAuthFallbackWarningCacheForTest(); resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); @@ -635,6 +636,88 @@ describe("provider-runtime", () => { expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); }); + it("does not reuse default runtime provider cache entries across active workspaces", () => { + const firstProvider: ProviderPlugin = { + id: DEMO_PROVIDER_ID, + label: "Demo one", + auth: [], + }; + const secondProvider: ProviderPlugin = { + id: DEMO_PROVIDER_ID, + label: "Demo two", + auth: [], + }; + + setActivePluginRegistry(createEmptyPluginRegistry(), "workspace-one", "default", "/tmp/one"); + resolvePluginProvidersMock.mockReturnValueOnce([firstProvider]); + expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID })).toBe(firstProvider); + + setActivePluginRegistry(createEmptyPluginRegistry(), "workspace-two", "default", "/tmp/two"); + resolvePluginProvidersMock.mockReturnValueOnce([secondProvider]); + expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID })).toBe(secondProvider); + + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); + }); + + it("does not reuse default runtime provider cache entries across same-workspace reloads", () => { + const provider: ProviderPlugin = { + id: DEMO_PROVIDER_ID, + label: "Demo", + auth: [], + }; + + setActivePluginRegistry(createEmptyPluginRegistry(), "workspace-one", "default", "/tmp/work"); + resolvePluginProvidersMock.mockReturnValueOnce([provider]); + expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID })).toBe(provider); + + setActivePluginRegistry(createEmptyPluginRegistry(), "workspace-two", "default", "/tmp/work"); + resolvePluginProvidersMock.mockReturnValueOnce([]); + expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID })).toBeUndefined(); + + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); + }); + + it("does not cache default runtime provider misses without active registry invalidation", () => { + const provider: ProviderPlugin = { + id: DEMO_PROVIDER_ID, + label: "Demo", + auth: [], + }; + + resolvePluginProvidersMock.mockReturnValueOnce([]); + expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID })).toBeUndefined(); + + resolvePluginProvidersMock.mockReturnValueOnce([provider]); + expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID })).toBe(provider); + + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); + }); + + it("does not cache provider-scoped misses while runtime provider loading is in flight", () => { + const provider: ProviderPlugin = { + id: DEMO_PROVIDER_ID, + label: "Demo", + auth: [], + }; + let providerScopedLoadInFlight = true; + isPluginProvidersLoadInFlightMock.mockImplementation( + (params) => + Boolean(params.providerRefs?.includes(DEMO_PROVIDER_ID)) && providerScopedLoadInFlight, + ); + resolvePluginProvidersMock.mockImplementation((params) => + providerScopedLoadInFlight && params.providerRefs?.includes(DEMO_PROVIDER_ID) + ? [] + : [provider], + ); + + expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID })).toBeUndefined(); + expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); + + providerScopedLoadInFlight = false; + expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID })).toBe(provider); + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); + }); + it("does not reuse auto-enabled runtime providers for synthetic auth fallback", () => { const runtimeProvider: ProviderPlugin = { id: DEMO_PROVIDER_ID, diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index de52b0ee4cf..edb4baa85fe 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -15,6 +15,7 @@ import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normal import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js"; import { + clearProviderRuntimePluginCacheForTest, prepareProviderExtraParams, resolveProviderAuthProfileId, resolveProviderExtraParamsForTransport, @@ -100,7 +101,11 @@ function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): ); } -function resolveProviderHookRefs(provider: string, providerConfig?: ModelProviderConfig, modelApi?: string): string[] { +function resolveProviderHookRefs( + provider: string, + providerConfig?: ModelProviderConfig, + modelApi?: string, +): string[] { const refs = [provider]; const apiRef = normalizeOptionalString(modelApi ?? providerConfig?.api); if (apiRef && normalizeProviderId(apiRef) !== normalizeProviderId(provider)) { @@ -151,6 +156,7 @@ export { }; export const testing = { + clearProviderRuntimePluginCacheForTest, resetExternalAuthFallbackWarningCacheForTest, } as const; @@ -854,7 +860,11 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { context: ProviderResolveSyntheticAuthContext; modelApi?: string; }) { - const providerRefs = resolveProviderHookRefs(params.provider, params.context.providerConfig, params.modelApi); + const providerRefs = resolveProviderHookRefs( + params.provider, + params.context.providerConfig, + params.modelApi, + ); const discoveryPluginIds = [ ...new Set( providerRefs.flatMap( @@ -996,7 +1006,11 @@ export function shouldDeferProviderSyntheticProfileAuthWithPlugin(params: { context: ProviderDeferSyntheticProfileAuthContext; modelApi?: string; }) { - const providerRefs = resolveProviderHookRefs(params.provider, params.context.providerConfig, params.modelApi); + const providerRefs = resolveProviderHookRefs( + params.provider, + params.context.providerConfig, + params.modelApi, + ); for (const providerRef of providerRefs) { const resolved = resolveProviderRuntimePlugin({ ...params, diff --git a/src/plugins/setup-registry.runtime.ts b/src/plugins/setup-registry.runtime.ts index e42758cfb38..80c05639d87 100644 --- a/src/plugins/setup-registry.runtime.ts +++ b/src/plugins/setup-registry.runtime.ts @@ -30,17 +30,19 @@ type SetupCliBackendRuntimeLookupParams = { const require = createRequire(import.meta.url); const SETUP_REGISTRY_RUNTIME_CANDIDATES = ["./setup-registry.js", "./setup-registry.ts"] as const; -type BundledSetupCliBackendCache = { +type SetupCliBackendDescriptorCache = { configFingerprint: string; entries: SetupCliBackendRuntimeEntry[]; }; let setupRegistryRuntimeModule: SetupRegistryRuntimeModule | null | undefined; -let cachedBundledSetupCliBackends: BundledSetupCliBackendCache | undefined; +let cachedSetupCliBackendDescriptors: SetupCliBackendDescriptorCache | undefined; +let cachedBundledSetupCliBackends: SetupCliBackendDescriptorCache | undefined; export const testing = { resetRuntimeState(): void { setupRegistryRuntimeModule = undefined; + cachedSetupCliBackendDescriptors = undefined; cachedBundledSetupCliBackends = undefined; }, setRuntimeModuleForTest(module: SetupRegistryRuntimeModule | null | undefined): void { @@ -102,6 +104,36 @@ function resolveBundledSetupCliBackends( return entries; } +function resolveSetupCliBackendDescriptors( + params: Omit = {}, +): SetupCliBackendRuntimeEntry[] { + const { snapshot, cacheable } = resolveMetadataSnapshotForSetupCliBackends(params); + const configFingerprint = snapshot.configFingerprint; + if ( + cacheable && + configFingerprint && + cachedSetupCliBackendDescriptors?.configFingerprint === configFingerprint + ) { + return cachedSetupCliBackendDescriptors.entries; + } + const entries = snapshot.plugins.flatMap((plugin) => { + if (!isInstalledPluginEnabled(snapshot.index, plugin.id)) { + return []; + } + return [...plugin.cliBackends, ...(plugin.setup?.cliBackends ?? [])].map( + (backendId) => + ({ + pluginId: plugin.id, + backend: { id: backendId }, + }) satisfies SetupCliBackendRuntimeEntry, + ); + }); + if (cacheable && configFingerprint) { + cachedSetupCliBackendDescriptors = { configFingerprint, entries }; + } + return entries; +} + function loadSetupRegistryRuntime(): SetupRegistryRuntimeModule | null { if (setupRegistryRuntimeModule !== undefined) { return setupRegistryRuntimeModule; @@ -118,6 +150,13 @@ function loadSetupRegistryRuntime(): SetupRegistryRuntimeModule | null { return null; } +export function resolvePluginSetupCliBackendDescriptor(params: SetupCliBackendRuntimeLookupParams) { + const normalized = normalizeProviderId(params.backend); + return resolveSetupCliBackendDescriptors(params).find( + (entry) => normalizeProviderId(entry.backend.id) === normalized, + ); +} + export function resolvePluginSetupCliBackendRuntime(params: SetupCliBackendRuntimeLookupParams) { const normalized = normalizeProviderId(params.backend); const runtime = loadSetupRegistryRuntime();