mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 00:48:41 +00:00
fix(agents): cache fallback provider resolution
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user