fix(agents): cache fallback provider resolution

This commit is contained in:
Vincent Koc
2026-05-25 00:49:42 +02:00
parent 8ae997749d
commit 3c8d101f5a
9 changed files with 578 additions and 49 deletions

View File

@@ -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.

View File

@@ -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<T>(
store: AuthProfileStore,
run: (tempDir: string) => Promise<T>,
@@ -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: {

View File

@@ -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<ModelFallbackAuthRuntime>(
() => import("./model-fallback-auth.runtime.js"),
);
const MAX_FALLBACK_CANDIDATE_CACHE_ENTRIES = 256;
const fallbackCandidateCache = new Map<string, ModelCandidate[]>();
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({

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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<ProviderPlugin | null> = new WeakMap();
let providerRuntimePluginCache: ConfigScopedRuntimeCache<ProviderPlugin | null> = new WeakMap();
const defaultProviderRuntimePluginCache = new PluginLruCache<ProviderPlugin | null>(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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<SetupCliBackendRuntimeLookupParams, "backend"> = {},
): 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();