fix(agents): keep model resolution on one plugin generation (#125569)

* fix(agents): pin model resolution to prepared generation

* test(agents): complete prepared runtime fixture
This commit is contained in:
Peter Steinberger
2026-08-17 21:32:37 -07:00
committed by GitHub
parent 3ab27a2900
commit d9b69c5b6f
16 changed files with 1565 additions and 1072 deletions
+3 -3
View File
@@ -1827,11 +1827,11 @@ src/agents/embedded-agent-runner/history.ts 3
src/agents/embedded-agent-runner/message-visibility.ts 13
src/agents/embedded-agent-runner/model-context-tokens.ts 1
src/agents/embedded-agent-runner/model.compat.ts 1
src/agents/embedded-agent-runner/model.configured-fallback.ts 2
src/agents/embedded-agent-runner/model.configured-overrides.ts 3
src/agents/embedded-agent-runner/model.configured-fallback.ts 1
src/agents/embedded-agent-runner/model.configured-overrides.ts 2
src/agents/embedded-agent-runner/model.inline-provider.ts 2
src/agents/embedded-agent-runner/model.provider-hooks.ts 10
src/agents/embedded-agent-runner/model.registry-resolution.ts 7
src/agents/embedded-agent-runner/model.registry-resolution.ts 6
src/agents/embedded-agent-runner/model.ts 5
src/agents/embedded-agent-runner/openrouter-model-capabilities.ts 2
src/agents/embedded-agent-runner/prepared-compaction-runtime.ts 1
+127 -12
View File
@@ -5,11 +5,21 @@ import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coerci
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import type { ProviderResolveModelRoutesContext } from "../plugin-sdk/provider-model-types.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { getPluginRuntimeGenerationRegistry } from "../plugins/runtime/generation-scope.js";
import {
looksLikeSecretSentinel,
mintSecretSentinel,
resolveSecretSentinel,
} from "../secrets/sentinel.js";
import {
createModelGenerationFixture,
GENERATION_WORKSPACE_DIR,
publishCurrentModelGeneration,
resetModelGenerationFixtureState,
} from "./embedded-agent-runner/model.generation-scope.test-support.js";
import type { AgentHarnessHostCapabilities } from "./harness/host-capability-types.js";
import type { AgentHarness } from "./harness/types.js";
import type { AgentRuntimeAuthPlan } from "./runtime-plan/types.js";
@@ -63,6 +73,10 @@ const hasUsableCustomProviderApiKeyMock = vi.fn((..._args: unknown[]) => false);
const resolveProviderEntryApiKeyProfileReferenceMock = vi.fn((_params?: unknown): unknown => ({
kind: "none",
}));
const preparedRuntimeSnapshotState = vi.hoisted(() => ({
snapshot: undefined as unknown,
useSnapshotPluginRegistry: false,
}));
vi.mock("../llm/stream.js", async () => {
const original = await vi.importActual<typeof import("../llm/stream.js")>("../llm/stream.js");
@@ -127,6 +141,10 @@ vi.mock("./prepared-model-runtime.js", () => ({
...workspaceOptions,
});
return {
...(preparedRuntimeSnapshotState.snapshot as object),
...(preparedRuntimeSnapshotState.useSnapshotPluginRegistry
? {}
: { pluginRegistry: getActivePluginRegistry() }),
agentId: params.agentId,
agentDir: params.agentDir,
config: params.config,
@@ -349,6 +367,7 @@ const DEFAULT_STORE_PATH = "/tmp/sessions.json";
const DEFAULT_QUESTION = "What changed?";
const MATH_QUESTION = "What is 17 * 19?";
const MATH_ANSWER = "323";
let defaultPluginMetadataSnapshot: ReturnType<typeof resolvePluginMetadataSnapshot> | undefined;
const DEFAULT_USAGE = {
input: 1,
@@ -659,6 +678,7 @@ function mockOpenAIPlatformProfile(): void {
describe("runBtwSideQuestion", () => {
afterEach(() => {
vi.unstubAllEnvs();
resetModelGenerationFixtureState();
});
beforeEach(() => {
@@ -709,6 +729,15 @@ describe("runBtwSideQuestion", () => {
resolveProviderEntryApiKeyProfileReferenceMock.mockReset();
resolveProviderEntryApiKeyProfileReferenceMock.mockReturnValue({ kind: "none" });
clearAgentHarnesses();
defaultPluginMetadataSnapshot ??= resolvePluginMetadataSnapshot({
config: {},
workspaceDir: "/tmp/workspace",
allowCurrent: false,
});
preparedRuntimeSnapshotState.snapshot = {
metadataSnapshot: defaultPluginMetadataSnapshot,
};
preparedRuntimeSnapshotState.useSnapshotPluginRegistry = false;
readFileMock.mockResolvedValue("mock transcript");
loadTranscriptEventsMock.mockResolvedValue([]);
@@ -881,6 +910,81 @@ describe("runBtwSideQuestion", () => {
);
});
it("keeps model, runtime auth, and stream selection on prepared A after current advances to B", async () => {
const cfg = { agents: { entries: { main: { default: true } } } } as never;
const generationA = createModelGenerationFixture({
config: cfg,
label: "btw-a",
provider: "local-proxy",
requestProvider: "local-proxy",
modelId: "side-model",
});
const generationB = createModelGenerationFixture({
config: cfg,
label: "btw-b",
provider: "local-proxy",
requestProvider: "local-proxy",
modelId: "side-model",
});
preparedRuntimeSnapshotState.snapshot = generationA.preparedModelRuntime;
preparedRuntimeSnapshotState.useSnapshotPluginRegistry = true;
resolveAgentWorkspaceDirMock.mockReturnValue(GENERATION_WORKSPACE_DIR);
publishCurrentModelGeneration(generationB);
const runtimeAuthA = vi.fn(async () => ({ apiKey: "runtime-auth-a" }));
const runtimeAuthB = vi.fn(async () => ({ apiKey: "runtime-auth-b" }));
const streamA = vi.fn(
(model: { name?: string }, _context: unknown, options?: { apiKey?: string }) => {
const apiKey = options?.apiKey;
const resolvedApiKey =
apiKey && looksLikeSecretSentinel(apiKey) ? resolveSecretSentinel(apiKey) : apiKey;
return makeAsyncEvents([
createDoneEvent(`${model.name ?? "missing model"} / ${resolvedApiKey} / Stream A`),
]);
},
);
const streamB = vi.fn(() =>
makeAsyncEvents([createDoneEvent("Generation B / runtime-auth-b / Stream B")]),
);
const activeGenerationRegistry = () =>
getPluginRuntimeGenerationRegistry() ?? getActivePluginRegistry();
resolveModelWithRegistryMock.mockImplementation(() => {
const snapshot = getCurrentPluginMetadataSnapshot({
config: cfg,
workspaceDir: GENERATION_WORKSPACE_DIR,
});
const label = snapshot === generationA.metadataSnapshot ? "A" : "B";
return {
provider: "local-proxy",
id: "side-model",
name: `Generation ${label}`,
api: "openai-responses",
baseUrl: `https://generation-${label.toLowerCase()}.example.test/v1`,
};
});
prepareProviderRuntimeAuthMock.mockImplementation(async () =>
activeGenerationRegistry() === generationA.pluginRegistry
? await runtimeAuthA()
: await runtimeAuthB(),
);
registerProviderStreamForModelMock.mockImplementation(() =>
activeGenerationRegistry() === generationA.pluginRegistry ? streamA : streamB,
);
await expect(
runSideQuestion({ cfg, provider: "local-proxy", model: "side-model" }),
).resolves.toEqual({ text: "Generation A / runtime-auth-a / Stream A" });
expect(runtimeAuthA).toHaveBeenCalledOnce();
expect(runtimeAuthB).not.toHaveBeenCalled();
expect(streamA).toHaveBeenCalledOnce();
expect(streamA).toHaveBeenCalledWith(
expect.objectContaining({ name: "Generation A" }),
expect.any(Object),
expect.any(Object),
);
expect(streamB).not.toHaveBeenCalled();
expect(streamSimpleMock).not.toHaveBeenCalled();
});
it("routes Codex-selected BTW questions through the harness side-question hook", async () => {
const supports = vi.fn(supportsPreparedOpenAIAuth);
const codexSideQuestionMock = registerCodexSideQuestionHarness({
@@ -1525,14 +1629,16 @@ describe("runBtwSideQuestion", () => {
expect(result).toEqual({ text: "Copilot fallback answer." });
expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledOnce();
expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledWith({
provider: "github-copilot",
modelId: "gpt-4o",
config: expect.any(Object),
agentId: "main",
sessionKey: DEFAULT_SESSION_KEY,
workspaceDir: "/tmp/workspace",
});
expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "github-copilot",
modelId: "gpt-4o",
config: expect.any(Object),
agentId: "main",
sessionKey: DEFAULT_SESSION_KEY,
workspaceDir: "/tmp/workspace",
}),
);
expect(streamSimpleMock).toHaveBeenCalledOnce();
});
@@ -1774,6 +1880,13 @@ describe("runBtwSideQuestion", () => {
});
requireApiKeyMock.mockReturnValueOnce("claude-cli-access");
resolveSessionAuthProfileOverrideMock.mockResolvedValueOnce(undefined);
resolveModelAsyncMock.mockResolvedValueOnce({
model: {
provider: DEFAULT_PROVIDER,
id: DEFAULT_MODEL,
api: "anthropic-messages",
},
});
mockDoneAnswer("Claude CLI answer.");
const result = await runSideQuestion();
@@ -1787,10 +1900,12 @@ describe("runBtwSideQuestion", () => {
externalCliProviderIds: ["claude-cli"],
allowKeychainPrompt: false,
});
expectRecordFields(mockArg(getApiKeyForModelMock, 0, 0), {
profileId: "anthropic:claude-cli",
store: claudeAuthStore,
});
expect(getApiKeyForModelMock).toHaveBeenCalledWith(
expect.objectContaining({
profileId: "anthropic:claude-cli",
store: claudeAuthStore,
}),
);
});
it("rematerializes the direct model when automatic auth rotates to a SecretRef backup", async () => {
+635 -622
View File
File diff suppressed because it is too large Load Diff
@@ -33,10 +33,7 @@ import {
resolveProviderRequestTimeoutMs,
resolveProviderTransport,
} from "./model.provider-hooks.js";
import {
resolveBundledStaticCatalogModel,
type ManifestModelCatalogProviderAliasMetadata,
} from "./model.static-catalog.js";
import type { ManifestModelCatalogProviderAliasMetadata } from "./model.static-catalog.js";
export function buildConfiguredFallbackModel(params: {
provider: string;
@@ -45,6 +42,7 @@ export function buildConfiguredFallbackModel(params: {
agentDir?: string;
manifestAlias: ManifestModelCatalogProviderAliasMetadata;
providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps;
getStaticCatalogModel?: () => StaticCatalogFallbackModel | undefined;
workspaceDir?: string;
runtimeHooks?: ProviderRuntimeHooks;
}): Model | undefined {
@@ -55,13 +53,7 @@ export function buildConfiguredFallbackModel(params: {
if (!hasConfiguredFallbackSurface({ providerConfig, configuredModel, modelId })) {
return undefined;
}
const staticCatalogModel = resolveBundledStaticCatalogModel({
provider,
modelId,
cfg,
workspaceDir,
includeRuntimeDiscovery: true,
}) as StaticCatalogFallbackModel | undefined;
const staticCatalogModel = params.getStaticCatalogModel?.();
const metadataModel = configuredModel ?? staticCatalogModel;
const fallbackMediaInput = mergeModelMediaInput(
staticCatalogModel?.mediaInput,
@@ -1,6 +1,5 @@
import { asOptionalRecord as readModelParams } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { ModelCompatConfig, ModelMediaInputConfig } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { Api, Model } from "../../llm/types.js";
import type { PluginMetadataSnapshotOwnerMaps } from "../../plugins/plugin-metadata-snapshot.types.js";
@@ -38,17 +37,9 @@ import {
resolveProviderRequestTimeoutMs,
resolveProviderTransport,
} from "./model.provider-hooks.js";
import {
resolveBundledStaticCatalogModel,
type ManifestModelCatalogProviderAliasMetadata,
} from "./model.static-catalog.js";
import type { ManifestModelCatalogProviderAliasMetadata } from "./model.static-catalog.js";
export type StaticCatalogFallbackModel = Model & {
compat?: ModelCompatConfig;
contextTokens?: number;
params?: Record<string, unknown>;
mediaInput?: ModelMediaInputConfig;
};
export type StaticCatalogFallbackModel = ProviderRuntimeModel;
export function shouldSuppressConfiguredModel(params: {
provider: string;
@@ -332,6 +323,7 @@ export function applyConfiguredProviderOverrides(params: {
preferDiscoveredModelMetadata?: boolean;
preferDiscoveredTransport?: boolean;
staticCatalogModel?: StaticCatalogFallbackModel;
getStaticCatalogModel?: () => ProviderRuntimeModel | undefined;
workspaceDir?: string;
}): ProviderRuntimeModel {
const { providerConfig, modelId } = params;
@@ -397,15 +389,7 @@ export function applyConfiguredProviderOverrides(params: {
? findConfiguredProviderModel(providerConfig, params.provider, discoveredModel.id)
: undefined);
const configuredStaticCatalogModel =
configuredModel &&
(params.staticCatalogModel ??
(resolveBundledStaticCatalogModel({
provider: params.provider,
modelId,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
includeRuntimeDiscovery: true,
}) as StaticCatalogFallbackModel | undefined));
configuredModel && (params.staticCatalogModel ?? params.getStaticCatalogModel?.());
const metadataOverrideModel =
params.preferDiscoveredModelMetadata && isModelsAddMetadataModel({ model: configuredModel })
? undefined
@@ -2,6 +2,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelProviderConfig, OpenClawConfig } from "../../config/config.js";
import { discoverModels } from "../agent-model-discovery.js";
import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js";
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
vi.mock("../../plugins/provider-runtime.js", () => ({
@@ -82,23 +83,42 @@ vi.mock("../model-suppression.js", () => ({
vi.mock("../prepared-model-runtime.js", async () => {
const discovery = await import("../agent-model-discovery.js");
const { createPluginMetadataSnapshot } =
await import("../../config/plugin-auto-enable.test-helpers.js");
const createSnapshot = (input: {
agentDir: string;
config?: OpenClawConfig;
workspaceDir?: string;
}) => ({
createStores: () => {
const authStorage = discovery.discoverAuthStorage(input.agentDir);
const modelRegistry = discovery.discoverModels(authStorage, input.agentDir, {
...(input.config ? { config: input.config } : {}),
}) => {
const config = input.config ?? {};
return {
agentDir: input.agentDir,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
activeProjectKeys: [],
allowGatewaySubagentBinding: false,
config,
authModes: {},
metadataSnapshot: createPluginMetadataSnapshot({
config,
manifestRegistry: { plugins: [], diagnostics: [] },
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
});
if (!("fork" in modelRegistry)) {
Object.assign(modelRegistry, { fork: () => modelRegistry });
}
return { authStorage, modelRegistry };
},
});
}),
modelCatalog: { entries: [], routeVariants: [] },
configuredRuntimeModels: [],
inlineProviderModels: [],
createStores: () => {
const authStorage = discovery.discoverAuthStorage(input.agentDir);
const modelRegistry = discovery.discoverModels(authStorage, input.agentDir, {
...(input.config ? { config: input.config } : {}),
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
});
if (!("fork" in modelRegistry)) {
Object.assign(modelRegistry, { fork: () => modelRegistry });
}
return { authStorage, modelRegistry };
},
} satisfies PreparedModelRuntimeSnapshot;
};
return {
getPreparedModelRuntimeSnapshot: createSnapshot,
loadPreparedModelRuntimeSnapshot: async (input: Parameters<typeof createSnapshot>[0]) =>
@@ -0,0 +1,155 @@
import { vi } from "vitest";
import { createPluginMetadataSnapshot } from "../../config/plugin-auto-enable.test-helpers.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { setCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js";
import { AuthStorage, ModelRegistry } from "../sessions/index.js";
const GENERATION_MODEL_ID = "generation-model";
const GENERATION_REQUEST_PROVIDER = "generation-alias";
export const GENERATION_WORKSPACE_DIR = "/tmp/openclaw-model-generation-scope";
type ImagePolicy = NonNullable<NonNullable<ProviderRuntimeModel["mediaInput"]>["image"]>;
export function createModelGenerationFixture(params: {
config: OpenClawConfig;
createStores?: PreparedModelRuntimeSnapshot["createStores"];
label: string;
modelId?: string;
prepareDynamicModel?: () => Promise<void>;
provider?: string;
requestProvider?: string;
runtimeApi?: ProviderRuntimeModel["api"];
runtimeBaseUrl?: string;
runtimeImagePolicy?: ImagePolicy;
runtimeAugment?: boolean;
staticImagePolicy?: ImagePolicy;
suppress?: boolean;
withRegistry?: boolean;
}) {
const provider = params.provider ?? `generation-${params.label}`;
const requestProvider = params.requestProvider ?? GENERATION_REQUEST_PROVIDER;
const modelId = params.modelId ?? GENERATION_MODEL_ID;
const staticImagePolicy = params.staticImagePolicy ?? {
maxSidePx: params.label === "a" ? 1_111 : 2_222,
};
const plugin = {
id: `generation-plugin-${params.label}`,
enabledByDefault: true,
channels: [],
providers: [provider],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",
rootDir: `/tmp/generation-plugin-${params.label}`,
source: `/tmp/generation-plugin-${params.label}/index.js`,
manifestPath: `/tmp/generation-plugin-${params.label}/openclaw.plugin.json`,
modelCatalog: {
...(requestProvider === provider ? {} : { aliases: { [requestProvider]: { provider } } }),
...(params.runtimeAugment === undefined ? {} : { runtimeAugment: params.runtimeAugment }),
discovery: { [provider]: "static" },
providers: {
[provider]: {
api: "openai-completions",
baseUrl: `https://${provider}.example.test/v1`,
models: [
{
id: modelId,
name: `Static ${params.label.toUpperCase()}`,
mediaInput: { image: staticImagePolicy },
},
],
},
},
...(params.suppress ? { suppressions: [{ provider, model: modelId }] } : {}),
},
} satisfies PluginManifestRecord;
const metadataSnapshot = createPluginMetadataSnapshot({
config: params.config,
manifestRegistry: { plugins: [plugin], diagnostics: [] },
workspaceDir: GENERATION_WORKSPACE_DIR,
});
const pluginRegistry = createEmptyPluginRegistry();
const resolveDynamicModel = vi.fn(() => ({
id: modelId,
name: `Runtime ${params.label.toUpperCase()}`,
provider,
api: params.runtimeApi ?? ("openai-completions" as const),
baseUrl: params.runtimeBaseUrl ?? `https://${provider}.example.test/v1`,
reasoning: false,
input: ["text" as const],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8_192,
maxTokens: 2_048,
...(params.runtimeImagePolicy ? { mediaInput: { image: params.runtimeImagePolicy } } : {}),
}));
pluginRegistry.providers.push({
pluginId: plugin.id,
source: plugin.source,
provider: {
id: provider,
label: `Generation ${params.label.toUpperCase()}`,
auth: [],
...(params.prepareDynamicModel ? { prepareDynamicModel: params.prepareDynamicModel } : {}),
resolveDynamicModel,
},
});
const createStores =
params.createStores ??
(() => {
const authStorage = AuthStorage.inMemory({});
return { authStorage, modelRegistry: ModelRegistry.inMemory(authStorage) };
});
const preparedModelRuntime = {
agentDir: "/tmp/openclaw-model-generation-agent",
workspaceDir: GENERATION_WORKSPACE_DIR,
activeProjectKeys: [],
config: params.config,
authModes: {},
metadataSnapshot,
...(params.withRegistry === false ? {} : { pluginRegistry }),
allowGatewaySubagentBinding: false,
modelCatalog: { entries: [], routeVariants: [] },
configuredRuntimeModels: [],
inlineProviderModels: [],
createStores,
} satisfies PreparedModelRuntimeSnapshot;
return {
metadataSnapshot,
modelId,
pluginRegistry,
preparedModelRuntime,
provider,
requestProvider,
resolveDynamicModel,
runtimeImagePolicy: params.runtimeImagePolicy,
staticImagePolicy,
};
}
export function publishCurrentModelGeneration(
generation: ReturnType<typeof createModelGenerationFixture>,
): void {
setCurrentPluginMetadataSnapshot(generation.metadataSnapshot, {
config: generation.preparedModelRuntime.config,
workspaceDir: GENERATION_WORKSPACE_DIR,
});
setActivePluginRegistry(
generation.pluginRegistry,
`generation-${generation.provider}`,
"default",
GENERATION_WORKSPACE_DIR,
);
}
export function resetModelGenerationFixtureState(): void {
setCurrentPluginMetadataSnapshot(undefined);
resetPluginRuntimeStateForTest();
clearPluginMetadataLifecycleCaches();
}
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js";
import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js";
import {
createModelGenerationFixture,
publishCurrentModelGeneration,
resetModelGenerationFixtureState,
} from "./model.generation-scope.test-support.js";
import { resolveModel, resolveModelAsync } from "./model.js";
async function resolveGeneration(generation: ReturnType<typeof createModelGenerationFixture>) {
const { preparedModelRuntime } = generation;
const stores = preparedModelRuntime.createStores();
return await resolveModelAsync(
generation.requestProvider,
generation.modelId,
preparedModelRuntime.agentDir,
preparedModelRuntime.config,
{
...stores,
allowBundledStaticCatalogFallback: true,
preparedModelRuntime,
skipAgentDiscovery: true,
workspaceDir: preparedModelRuntime.workspaceDir,
},
);
}
describe("model runtime generation scope", () => {
beforeEach(() => {
clearPluginMetadataLifecycleCaches();
});
afterEach(() => {
resetModelGenerationFixtureState();
});
it("keeps alias, suppression, static metadata, and runtime hooks on the prepared generation", async () => {
const config = {} satisfies OpenClawConfig;
const generationA = createModelGenerationFixture({ config, label: "a" });
const generationB = createModelGenerationFixture({ config, label: "b", suppress: true });
publishCurrentModelGeneration(generationB);
const result = await resolveGeneration(generationA);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: generationA.provider,
name: "Runtime A",
mediaInput: { image: generationA.staticImagePolicy },
});
expect(generationA.resolveDynamicModel).toHaveBeenCalled();
expect(generationB.resolveDynamicModel).not.toHaveBeenCalled();
});
it("keeps concurrent prepared generations isolated across awaited runtime hooks", async () => {
const config = {} satisfies OpenClawConfig;
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const prepareDynamicModel = async () => {
arrivals += 1;
if (arrivals === 2) {
release();
}
await gate;
};
const generationA = createModelGenerationFixture({
config,
label: "a",
prepareDynamicModel,
});
const generationB = createModelGenerationFixture({
config,
label: "b",
prepareDynamicModel,
});
publishCurrentModelGeneration(generationB);
const [resultA, resultB] = await Promise.all([
resolveGeneration(generationA),
resolveGeneration(generationB),
]);
expect(resultA.model).toMatchObject({
provider: generationA.provider,
name: "Runtime A",
mediaInput: { image: generationA.staticImagePolicy },
});
expect(resultB.model).toMatchObject({
provider: generationB.provider,
name: "Runtime B",
mediaInput: { image: generationB.staticImagePolicy },
});
});
it("keeps metadata-only prepared generations from borrowing current runtime hooks", async () => {
const config = {} satisfies OpenClawConfig;
const generationA = createModelGenerationFixture({
config,
label: "a",
withRegistry: false,
});
const generationB = createModelGenerationFixture({ config, label: "b" });
publishCurrentModelGeneration(generationB);
const result = await resolveGeneration(generationA);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: generationA.provider,
name: "Static A",
mediaInput: { image: generationA.staticImagePolicy },
});
expect(generationB.resolveDynamicModel).not.toHaveBeenCalled();
});
it("keeps synchronous resolution on the exact scoped generation", () => {
const config = {} satisfies OpenClawConfig;
const generationA = createModelGenerationFixture({ config, label: "a" });
const generationB = createModelGenerationFixture({ config, label: "b" });
publishCurrentModelGeneration(generationB);
const stores = generationA.preparedModelRuntime.createStores();
const result = withPluginRuntimeGenerationScope(generationA.preparedModelRuntime, () =>
resolveModel(
generationA.requestProvider,
generationA.modelId,
generationA.preparedModelRuntime.agentDir,
config,
{
...stores,
workspaceDir: generationA.preparedModelRuntime.workspaceDir,
},
),
);
expect(result.model).toMatchObject({
provider: generationA.provider,
name: "Runtime A",
});
expect(generationB.resolveDynamicModel).not.toHaveBeenCalled();
});
});
@@ -59,7 +59,7 @@ export function resolveExplicitModelWithRegistry(params: {
workspaceDir?: string;
runtimeHooks?: ProviderRuntimeHooks;
preparedInlineProviderModels?: readonly InlineModelEntry[];
preparedStaticCatalogModel?: StaticCatalogFallbackModel;
getStaticCatalogModel?: () => StaticCatalogFallbackModel | undefined;
}): ExplicitModelResolution | undefined {
const { provider, modelId, modelRegistry, cfg, agentDir, workspaceDir, runtimeHooks } = params;
const providerMetadataOwners = getRegistryProviderMetadataOwners(modelRegistry);
@@ -91,15 +91,7 @@ export function resolveExplicitModelWithRegistry(params: {
) {
return { kind: "suppressed" };
}
const staticCatalogModel =
params.preparedStaticCatalogModel ??
(resolveBundledStaticCatalogModel({
provider,
modelId,
cfg,
workspaceDir,
includeRuntimeDiscovery: true,
}) as StaticCatalogFallbackModel | undefined);
const staticCatalogModel = params.getStaticCatalogModel?.();
return {
kind: "resolved",
source: "configured",
@@ -176,6 +168,7 @@ export function resolveExplicitModelWithRegistry(params: {
manifestAlias: params.manifestAlias,
providerMetadataOwners,
runtimeHooks,
getStaticCatalogModel: params.getStaticCatalogModel,
workspaceDir,
}),
runtimeHooks,
@@ -272,6 +265,7 @@ function resolvePluginDynamicModelWithRegistry(params: {
authProfileMode?: AuthProfileCredential["type"] | "aws-sdk";
preferredProfile?: string;
runtimeHooks?: ProviderRuntimeHooks;
getStaticCatalogModel?: () => StaticCatalogFallbackModel | undefined;
}): Model | undefined {
const { provider, modelId, modelRegistry, cfg, agentDir, workspaceDir } = params;
const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS;
@@ -330,6 +324,7 @@ function resolvePluginDynamicModelWithRegistry(params: {
runtimeHooks,
workspaceDir,
preferDiscoveredModelMetadata,
getStaticCatalogModel: params.getStaticCatalogModel,
});
return normalizeResolvedModel({
provider,
@@ -342,9 +337,7 @@ function resolvePluginDynamicModelWithRegistry(params: {
}
export function resolveRuntimePreferredSuppressedModel(
params: ResolveModelWithRegistryParams & {
manifestAlias: ManifestModelCatalogProviderAliasMetadata;
},
params: ResolveModelWithPreparedRegistryParams,
): Model | undefined {
const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS;
if (!shouldCompareProviderRuntimeResolvedModel({ ...params, runtimeHooks })) {
@@ -430,10 +423,13 @@ type ResolveModelWithRegistryParams = {
skipConfiguredFallback?: boolean;
};
type ResolveModelWithPreparedRegistryParams = ResolveModelWithRegistryParams & {
manifestAlias: ManifestModelCatalogProviderAliasMetadata;
getStaticCatalogModel?: () => StaticCatalogFallbackModel | undefined;
};
export function resolveModelWithPreparedRegistry(
params: ResolveModelWithRegistryParams & {
manifestAlias: ManifestModelCatalogProviderAliasMetadata;
},
params: ResolveModelWithPreparedRegistryParams,
): Model | undefined {
// Competing activated owners leave credentials and transport authority unresolved.
// Refuse the route before configured fallbacks can accidentally select either owner.
@@ -477,11 +473,27 @@ export function resolveModelWithRegistry(
): Model | undefined {
const workspaceDir = params.workspaceDir ?? params.cfg?.agents?.defaults?.workspace;
const normalizedRef = normalizeProviderModelRef({ ...params, workspaceDir });
let staticCatalogResolved = false;
let staticCatalogModel: StaticCatalogFallbackModel | undefined;
const getStaticCatalogModel = () => {
if (!staticCatalogResolved) {
staticCatalogResolved = true;
staticCatalogModel = resolveBundledStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg: params.cfg,
workspaceDir,
includeRuntimeDiscovery: true,
});
}
return staticCatalogModel;
};
return resolveModelWithPreparedRegistry({
...params,
provider: normalizedRef.provider,
modelId: normalizedRef.model,
manifestAlias: normalizedRef.manifestAlias,
getStaticCatalogModel,
...(workspaceDir !== undefined ? { workspaceDir } : {}),
});
}
+24 -2
View File
@@ -191,7 +191,14 @@ vi.mock("../prepared-model-runtime.js", async () => {
Object.assign(modelRegistry, { fork: () => modelRegistry });
}
const snapshot = {
agentDir: input.agentDir,
...(workspaceDir ? { workspaceDir } : {}),
activeProjectKeys: [],
config: input.config ?? {},
authModes: {},
metadataSnapshot: { plugins: [] },
allowGatewaySubagentBinding: false,
modelCatalog: { entries: [], routeVariants: [] },
configuredRuntimeModels: preparedSnapshotState.configuredRuntimeModels,
inlineProviderModels: preparedSnapshotState.inlineProviderModels,
createStores: () => ({ authStorage, modelRegistry }),
@@ -1009,6 +1016,18 @@ describe("resolveModel", () => {
makeMistralCatalogModel({ input: ["text"] }),
);
const preparedModelRuntime = {
agentDir: "/tmp/agent",
activeProjectKeys: [],
allowGatewaySubagentBinding: false,
config: {},
authModes: {},
metadataSnapshot: { plugins: [] } as never,
modelCatalog: { entries: [], routeVariants: [] },
configuredRuntimeModels: [],
inlineProviderModels: [],
createStores: () => ({ authStorage: {} as never, modelRegistry: {} as never }),
} satisfies PreparedModelRuntimeSnapshot;
const result = await resolveModelAsync(
"mistral",
"mistral-medium-3-5",
@@ -1018,7 +1037,7 @@ describe("resolveModel", () => {
allowBundledStaticCatalogFallback: true,
authStorage: { mocked: true } as never,
modelRegistry: { find: vi.fn(() => null) } as never,
preparedModelRuntime: {} as PreparedModelRuntimeSnapshot,
preparedModelRuntime,
runtimeHooks: createRuntimeHooks(),
skipAgentDiscovery: true,
},
@@ -1594,7 +1613,7 @@ describe("resolveModel", () => {
});
});
it("does not use bundled static catalog rows unless the caller opts in", async () => {
it("does not read manifest or provider static rows when bundled fallback is disabled", async () => {
const result = await resolveModelAsync(
"mistral",
"mistral-medium-3-5",
@@ -1609,6 +1628,7 @@ describe("resolveModel", () => {
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: mistral/mistral-medium-3-5");
expect(resolveBundledStaticCatalogModelMock).not.toHaveBeenCalled();
expect(resolveBundledProviderStaticCatalogModelMock).not.toHaveBeenCalled();
expect(discoverAuthStorage).not.toHaveBeenCalled();
expect(discoverModels).not.toHaveBeenCalled();
});
@@ -3056,6 +3076,8 @@ describe("resolveModel", () => {
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: azure-openai-responses/gpt-5.5");
expect(resolveBundledStaticCatalogModelMock).not.toHaveBeenCalled();
expect(resolveBundledProviderStaticCatalogModelMock).not.toHaveBeenCalled();
},
);
+291 -244
View File
@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { Model } from "../../llm/types.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js";
import { resolveDefaultAgentDir } from "../agent-scope.js";
import type { AuthProfileCredential } from "../auth-profiles/types.js";
import { resolveLegacyInheritedAuthDir } from "../legacy-inherited-auth-dir.js";
@@ -25,6 +26,7 @@ import { buildConfiguredFallbackModel } from "./model.configured-fallback.js";
import {
applyConfiguredProviderOverrides,
resolveConfiguredProviderConfig,
type StaticCatalogFallbackModel,
} from "./model.configured-overrides.js";
import {
DEFAULT_PROVIDER_RUNTIME_HOOKS,
@@ -144,45 +146,70 @@ export function resolveModel(
`prepared model runtime is not published for synchronous model resolution (${resolvedAgentDir}); use resolveModelAsync before lifecycle publication`,
);
}
const workspaceDir =
options?.workspaceDir ?? preparedSnapshot?.workspaceDir ?? derivedWorkspaceDir;
const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir });
const preparedStores = preparedSnapshot?.createStores();
const authStorage = options?.authStorage ?? preparedStores!.authStorage;
const modelRegistry =
options?.modelRegistry ??
(options?.authStorage
? preparedStores!.modelRegistry.fork(authStorage)
: preparedStores!.modelRegistry);
const runtimeHooks = resolveRuntimeHooks(options);
const model = resolveModelWithPreparedRegistry({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
modelRegistry,
cfg,
agentDir: resolvedAgentDir,
manifestAlias: normalizedRef.manifestAlias,
workspaceDir,
authProfileId: options?.authProfileId,
authProfileMode: options?.authProfileMode,
preferredProfile: options?.preferredProfile,
runtimeHooks,
});
if (model) {
return { model, authStorage, modelRegistry };
}
return {
error: buildUnknownModelError({
const preparedModelRuntime = preparedSnapshot;
const resolve = () => {
const workspaceDir =
options?.workspaceDir ?? preparedModelRuntime?.workspaceDir ?? derivedWorkspaceDir;
const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir });
const preparedStores =
!options?.authStorage || !options?.modelRegistry
? preparedModelRuntime?.createStores()
: undefined;
const authStorage = options?.authStorage ?? preparedStores!.authStorage;
const modelRegistry =
options?.modelRegistry ??
(options?.authStorage
? preparedStores!.modelRegistry.fork(authStorage)
: preparedStores!.modelRegistry);
const runtimeHooks = resolveRuntimeHooks(options);
let staticCatalogResolved = false;
let staticCatalogModel: StaticCatalogFallbackModel | undefined;
const getStaticCatalogModel = () => {
if (!staticCatalogResolved) {
staticCatalogResolved = true;
staticCatalogModel = resolveBundledStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
workspaceDir,
includeRuntimeDiscovery: true,
});
}
return staticCatalogModel;
};
const model = resolveModelWithPreparedRegistry({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
modelRegistry,
cfg,
agentDir: resolvedAgentDir,
manifestAlias: normalizedRef.manifestAlias,
workspaceDir,
authProfileId: options?.authProfileId,
authProfileMode: options?.authProfileMode,
preferredProfile: options?.preferredProfile,
runtimeHooks,
}),
authStorage,
modelRegistry,
getStaticCatalogModel,
});
if (model) {
return { model, authStorage, modelRegistry };
}
return {
error: buildUnknownModelError({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
runtimeHooks,
}),
authStorage,
modelRegistry,
};
};
return preparedModelRuntime
? withPluginRuntimeGenerationScope(preparedModelRuntime, resolve)
: resolve();
}
export async function resolveModelAsync(
@@ -203,23 +230,27 @@ export async function resolveModelAsync(
options?.workspaceDir,
options?.agentId,
);
const explicitPreparedRuntime = options?.preparedModelRuntime;
const emptyDiscoveryStores =
options?.skipAgentDiscovery && (!options.authStorage || !options.modelRegistry)
? createEmptyAgentDiscoveryStores()
: undefined;
const publishedSnapshot =
!emptyDiscoveryStores && (!options?.authStorage || !options?.modelRegistry)
? resolvePreparedAgentSnapshot(
resolvedAgentDir,
cfg,
options?.workspaceDir,
derivedWorkspaceDir,
options?.agentId,
)
: undefined;
const needsPreparedSnapshot =
!explicitPreparedRuntime &&
!emptyDiscoveryStores &&
(!options?.authStorage || !options?.modelRegistry);
const publishedSnapshot = needsPreparedSnapshot
? resolvePreparedAgentSnapshot(
resolvedAgentDir,
cfg,
options?.workspaceDir,
derivedWorkspaceDir,
options?.agentId,
)
: undefined;
const preparedSnapshot =
publishedSnapshot ??
(!emptyDiscoveryStores && (!options?.authStorage || !options?.modelRegistry)
(needsPreparedSnapshot
? await loadPreparedModelRuntimeSnapshot({
...(options?.agentId ? { agentId: options.agentId } : {}),
agentDir: resolvedAgentDir,
@@ -228,238 +259,254 @@ export async function resolveModelAsync(
...(derivedWorkspaceDir ? { workspaceDir: derivedWorkspaceDir } : {}),
})
: undefined);
const workspaceDir =
options?.workspaceDir ?? preparedSnapshot?.workspaceDir ?? derivedWorkspaceDir;
const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir });
const preparedStores = preparedSnapshot?.createStores();
const fallbackStores =
emptyDiscoveryStores ?? preparedStores ?? createEmptyAgentDiscoveryStores();
const authStorage = options?.authStorage ?? fallbackStores.authStorage;
const modelRegistry =
options?.modelRegistry ??
(options?.authStorage
? fallbackStores.modelRegistry.fork(authStorage)
: fallbackStores.modelRegistry);
const runtimeHooks = resolveRuntimeHooks(options);
// Route-projected cfg owns transport/auth; the snapshot contributes generation facts only.
const preparedModelRuntime = options?.preparedModelRuntime ?? preparedSnapshot;
const preparedStaticCatalogModel = preparedModelRuntime?.configuredRuntimeModels?.find(
({ modelId: candidateId, provider: rowProvider }) =>
staticModelIdMatches({
candidateId,
rowProvider,
provider: normalizedRef.provider,
modelId: normalizedRef.model,
}),
)?.model;
if (normalizedRef.manifestAlias.ambiguous) {
return {
error: buildUnknownModelError({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
runtimeHooks,
}),
authStorage,
modelRegistry,
const preparedModelRuntime = explicitPreparedRuntime ?? preparedSnapshot;
const resolve = async () => {
const workspaceDir =
options?.workspaceDir ?? preparedModelRuntime?.workspaceDir ?? derivedWorkspaceDir;
const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir });
const preparedStores =
!options?.authStorage || !options?.modelRegistry
? preparedModelRuntime?.createStores()
: undefined;
const fallbackStores =
emptyDiscoveryStores ?? preparedStores ?? createEmptyAgentDiscoveryStores();
const authStorage = options?.authStorage ?? fallbackStores.authStorage;
const modelRegistry =
options?.modelRegistry ??
(options?.authStorage
? fallbackStores.modelRegistry.fork(authStorage)
: fallbackStores.modelRegistry);
const runtimeHooks = resolveRuntimeHooks(options);
let staticCatalogResolved = false;
let staticCatalogModel: StaticCatalogFallbackModel | undefined;
const getManifestStaticCatalogModel = () => {
if (!staticCatalogResolved) {
staticCatalogResolved = true;
staticCatalogModel =
preparedModelRuntime?.configuredRuntimeModels?.find(
({ modelId: candidateId, provider: rowProvider }) =>
staticModelIdMatches({
candidateId,
rowProvider,
provider: normalizedRef.provider,
modelId: normalizedRef.model,
}),
)?.model ??
resolveBundledStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
workspaceDir,
includeRuntimeDiscovery: true,
...(preparedModelRuntime
? { metadataSnapshot: preparedModelRuntime.metadataSnapshot }
: {}),
});
}
return staticCatalogModel;
};
}
const explicitModel = resolveExplicitModelWithRegistry({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
modelRegistry,
cfg,
agentDir: resolvedAgentDir,
manifestAlias: normalizedRef.manifestAlias,
workspaceDir,
runtimeHooks,
preparedInlineProviderModels: preparedModelRuntime?.inlineProviderModels,
preparedStaticCatalogModel,
});
if (explicitModel?.kind === "suppressed") {
const suppressedRuntimeModel = resolveRuntimePreferredSuppressedModel({
if (normalizedRef.manifestAlias.ambiguous) {
return {
error: buildUnknownModelError({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
runtimeHooks,
}),
authStorage,
modelRegistry,
};
}
const explicitModel = resolveExplicitModelWithRegistry({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
modelRegistry,
cfg,
agentDir: resolvedAgentDir,
...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}),
manifestAlias: normalizedRef.manifestAlias,
workspaceDir,
authProfileId: options?.authProfileId,
authProfileMode: options?.authProfileMode,
preferredProfile: options?.preferredProfile,
runtimeHooks,
preparedInlineProviderModels: preparedModelRuntime?.inlineProviderModels,
getStaticCatalogModel: getManifestStaticCatalogModel,
});
if (suppressedRuntimeModel) {
return { model: suppressedRuntimeModel, authStorage, modelRegistry };
}
return {
error: buildUnknownModelError({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
runtimeHooks,
}),
authStorage,
modelRegistry,
};
}
const providerConfig = resolveConfiguredProviderConfig(cfg, normalizedRef.provider);
const authProfile = resolveDynamicModelAuthProfile({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
authProfileId: options?.authProfileId,
authProfileMode: options?.authProfileMode,
preferredProfile: options?.preferredProfile,
});
const preparedMetadataSnapshot = preparedModelRuntime?.metadataSnapshot;
let staticCatalogLookup: Promise<ProviderRuntimeModel | undefined> | undefined;
const resolveStaticCatalogModel = async () => {
if (!options?.allowBundledStaticCatalogFallback) {
return undefined;
}
staticCatalogLookup ??= (async () => {
if (preparedStaticCatalogModel) {
return preparedStaticCatalogModel;
}
const manifestModel = resolveBundledStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
workspaceDir,
includeRuntimeDiscovery: true,
...(preparedMetadataSnapshot ? { metadataSnapshot: preparedMetadataSnapshot } : {}),
});
if (manifestModel) {
return manifestModel;
}
return await resolveBundledProviderStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
workspaceDir,
...(preparedMetadataSnapshot ? { metadataSnapshot: preparedMetadataSnapshot } : {}),
});
})();
return await staticCatalogLookup;
};
const resolveStaticCatalogFallbackModel = async () => {
const catalogModel = await resolveStaticCatalogModel();
if (!catalogModel) {
return undefined;
}
const overriddenStaticCatalogModel = applyConfiguredProviderOverrides({
provider: normalizedRef.provider,
discoveredModel: catalogModel,
providerConfig,
modelId: normalizedRef.model,
cfg,
manifestAlias: normalizedRef.manifestAlias,
runtimeHooks,
workspaceDir,
preferDiscoveredModelMetadata: true,
preferDiscoveredTransport: options?.preferBundledStaticCatalogTransport,
staticCatalogModel: catalogModel,
});
return normalizeResolvedModel({
provider: normalizedRef.provider,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
model: overriddenStaticCatalogModel,
runtimeHooks,
});
};
const resolveDynamicAttempt = async () => {
await runtimeHooks.prepareProviderDynamicModel({
provider: normalizedRef.provider,
config: cfg,
workspaceDir,
context: {
config: cfg,
agentDir: resolvedAgentDir,
...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}),
workspaceDir,
if (explicitModel?.kind === "suppressed") {
const suppressedRuntimeModel = resolveRuntimePreferredSuppressedModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
modelRegistry,
providerConfig,
...authProfile,
},
});
return resolveModelWithPreparedRegistry({
cfg,
agentDir: resolvedAgentDir,
...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}),
manifestAlias: normalizedRef.manifestAlias,
workspaceDir,
authProfileId: options?.authProfileId,
authProfileMode: options?.authProfileMode,
preferredProfile: options?.preferredProfile,
runtimeHooks,
getStaticCatalogModel: getManifestStaticCatalogModel,
});
if (suppressedRuntimeModel) {
return { model: suppressedRuntimeModel, authStorage, modelRegistry };
}
return {
error: buildUnknownModelError({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
runtimeHooks,
}),
authStorage,
modelRegistry,
};
}
const providerConfig = resolveConfiguredProviderConfig(cfg, normalizedRef.provider);
const authProfile = resolveDynamicModelAuthProfile({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
modelRegistry,
cfg,
agentDir: resolvedAgentDir,
...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}),
manifestAlias: normalizedRef.manifestAlias,
workspaceDir,
authProfileId: options?.authProfileId,
authProfileMode: options?.authProfileMode,
preferredProfile: options?.preferredProfile,
runtimeHooks,
...(options?.allowBundledStaticCatalogFallback ? { skipConfiguredFallback: true } : {}),
});
};
const providerRuntimeMetadataShouldWin = shouldCompareProviderRuntimeResolvedModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
runtimeHooks,
});
let model =
explicitModel?.kind === "resolved" && !providerRuntimeMetadataShouldWin
? explicitModel.model
: undefined;
model ??= await resolveDynamicAttempt();
if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) {
model = await resolveStaticCatalogFallbackModel();
}
if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) {
model = buildConfiguredFallbackModel({
const preparedMetadataSnapshot = preparedModelRuntime?.metadataSnapshot;
let providerStaticCatalogLookup: Promise<ProviderRuntimeModel | undefined> | undefined;
const resolveStaticCatalogModel = async () => {
if (!options?.allowBundledStaticCatalogFallback) {
return undefined;
}
return (
getManifestStaticCatalogModel() ??
(await (providerStaticCatalogLookup ??= resolveBundledProviderStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
workspaceDir,
...(preparedMetadataSnapshot ? { metadataSnapshot: preparedMetadataSnapshot } : {}),
})))
);
};
const resolveStaticCatalogFallbackModel = async () => {
const catalogModel = await resolveStaticCatalogModel();
if (!catalogModel) {
return undefined;
}
const overriddenStaticCatalogModel = applyConfiguredProviderOverrides({
provider: normalizedRef.provider,
discoveredModel: catalogModel,
providerConfig,
modelId: normalizedRef.model,
cfg,
manifestAlias: normalizedRef.manifestAlias,
runtimeHooks,
workspaceDir,
preferDiscoveredModelMetadata: true,
preferDiscoveredTransport: options?.preferBundledStaticCatalogTransport,
staticCatalogModel: catalogModel,
});
return normalizeResolvedModel({
provider: normalizedRef.provider,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
model: overriddenStaticCatalogModel,
runtimeHooks,
});
};
const resolveDynamicAttempt = async () => {
await runtimeHooks.prepareProviderDynamicModel({
provider: normalizedRef.provider,
config: cfg,
workspaceDir,
context: {
config: cfg,
agentDir: resolvedAgentDir,
...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}),
workspaceDir,
provider: normalizedRef.provider,
modelId: normalizedRef.model,
modelRegistry,
providerConfig,
...authProfile,
},
});
return resolveModelWithPreparedRegistry({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
modelRegistry,
cfg,
agentDir: resolvedAgentDir,
...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}),
manifestAlias: normalizedRef.manifestAlias,
workspaceDir,
authProfileId: options?.authProfileId,
authProfileMode: options?.authProfileMode,
preferredProfile: options?.preferredProfile,
runtimeHooks,
getStaticCatalogModel: getManifestStaticCatalogModel,
...(options?.allowBundledStaticCatalogFallback ? { skipConfiguredFallback: true } : {}),
});
};
const providerRuntimeMetadataShouldWin = shouldCompareProviderRuntimeResolvedModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
manifestAlias: normalizedRef.manifestAlias,
workspaceDir,
runtimeHooks,
});
}
if (model && options?.allowBundledStaticCatalogFallback) {
const staticMediaInput = (await resolveStaticCatalogModel())?.mediaInput;
const resolvedMediaInput = (model as ProviderRuntimeModel).mediaInput;
const mediaInput = mergeModelMediaInput(staticMediaInput, resolvedMediaInput);
if (mediaInput) {
model = { ...(model as ProviderRuntimeModel), mediaInput } as typeof model;
let model =
explicitModel?.kind === "resolved" && !providerRuntimeMetadataShouldWin
? explicitModel.model
: undefined;
model ??= await resolveDynamicAttempt();
if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) {
model = await resolveStaticCatalogFallbackModel();
}
}
if (model) {
return { model, authStorage, modelRegistry };
}
return {
error: buildUnknownModelError({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
runtimeHooks,
}),
authStorage,
modelRegistry,
if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) {
model = buildConfiguredFallbackModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
manifestAlias: normalizedRef.manifestAlias,
workspaceDir,
runtimeHooks,
getStaticCatalogModel: getManifestStaticCatalogModel,
});
}
if (model && options?.allowBundledStaticCatalogFallback) {
const staticMediaInput = (await resolveStaticCatalogModel())?.mediaInput;
const resolvedMediaInput = (model as ProviderRuntimeModel).mediaInput;
const mediaInput = mergeModelMediaInput(staticMediaInput, resolvedMediaInput);
if (mediaInput) {
model = { ...(model as ProviderRuntimeModel), mediaInput } as typeof model;
}
}
if (model) {
return { model, authStorage, modelRegistry };
}
return {
error: buildUnknownModelError({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
runtimeHooks,
}),
authStorage,
modelRegistry,
};
};
return preparedModelRuntime
? await withPluginRuntimeGenerationScope(preparedModelRuntime, resolve)
: await resolve();
}
/**
+72 -3
View File
@@ -3,6 +3,8 @@
* Verifies plugin manifest suppression rules, cache reuse, and lifecycle clears.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginMetadataSnapshot } from "../config/plugin-auto-enable.test-helpers.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
const mocks = vi.hoisted(() => ({
buildManifestBuiltInModelSuppressionResolver: vi.fn(),
@@ -12,8 +14,12 @@ vi.mock("../plugins/manifest-model-suppression.js", () => ({
buildManifestBuiltInModelSuppressionResolver: mocks.buildManifestBuiltInModelSuppressionResolver,
}));
import { setCurrentPluginMetadataSnapshotState } from "../plugins/current-plugin-metadata-state.js";
import {
getCurrentPluginMetadataSnapshot,
setCurrentPluginMetadataSnapshot,
} from "../plugins/current-plugin-metadata-snapshot.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js";
import {
buildShouldSuppressBuiltInModelCore,
shouldSuppressBuiltInModelCore,
@@ -28,6 +34,7 @@ describe("model suppression", () => {
});
afterEach(() => {
setCurrentPluginMetadataSnapshot(undefined);
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
@@ -101,12 +108,20 @@ describe("model suppression", () => {
.mockReturnValueOnce(firstResolver)
.mockReturnValueOnce(secondResolver);
setCurrentPluginMetadataSnapshotState({ id: "first" }, undefined);
const firstSnapshot = createPluginMetadataSnapshot({
config,
manifestRegistry: { plugins: [], diagnostics: [] },
});
const secondSnapshot = createPluginMetadataSnapshot({
config,
manifestRegistry: { plugins: [], diagnostics: [] },
});
setCurrentPluginMetadataSnapshot(firstSnapshot, { config });
expect(shouldSuppressBuiltInModelCore({ provider: "openai", id: "gpt-5.3", config })).toBe(
false,
);
setCurrentPluginMetadataSnapshotState({ id: "second" }, undefined);
setCurrentPluginMetadataSnapshot(secondSnapshot, { config });
expect(shouldSuppressBuiltInModelCore({ provider: "openai", id: "gpt-5.3", config })).toBe(
false,
);
@@ -116,6 +131,60 @@ describe("model suppression", () => {
expect(secondResolver).toHaveBeenCalledOnce();
});
it("keeps concurrent scoped suppression resolvers isolated from process-current metadata", async () => {
const config = {} satisfies OpenClawConfig;
const snapshotA = createPluginMetadataSnapshot({
config,
manifestRegistry: { plugins: [], diagnostics: [] },
});
const snapshotB = createPluginMetadataSnapshot({
config,
manifestRegistry: { plugins: [], diagnostics: [] },
});
setCurrentPluginMetadataSnapshot(snapshotB, { config });
mocks.buildManifestBuiltInModelSuppressionResolver.mockImplementation(() => {
const snapshot = getCurrentPluginMetadataSnapshot({ config, env: process.env });
return () =>
snapshot === snapshotA ? { suppress: true, errorMessage: "generation A" } : undefined;
});
let releaseA!: () => void;
let markAReady!: () => void;
const holdA = new Promise<void>((resolve) => {
releaseA = resolve;
});
const aReady = new Promise<void>((resolve) => {
markAReady = resolve;
});
const resultA = withPluginRuntimeGenerationScope(
{ config, metadataSnapshot: snapshotA },
async () => {
const result = shouldSuppressBuiltInModelCore({
provider: "openai",
id: "generation-model",
config,
});
markAReady();
await holdA;
return result;
},
);
await aReady;
const resultB = await withPluginRuntimeGenerationScope(
{ config, metadataSnapshot: snapshotB },
async () =>
shouldSuppressBuiltInModelCore({
provider: "openai",
id: "generation-model",
config,
}),
);
releaseA();
await expect(resultA).resolves.toBe(true);
expect(resultB).toBe(false);
});
it("refreshes manifest suppression resolver when process env plugin metadata inputs change", () => {
const firstResolver = vi.fn().mockReturnValue(undefined);
const secondResolver = vi.fn().mockReturnValue(undefined);
+2 -2
View File
@@ -6,7 +6,7 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshotState } from "../plugins/current-plugin-metadata-state.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import { buildManifestBuiltInModelSuppressionResolver } from "../plugins/manifest-model-suppression.js";
import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js";
@@ -46,7 +46,7 @@ function resolveCachedManifestSuppressionResolver(params: {
});
const cwd = process.cwd();
const envFingerprint = resolvePluginMetadataEnvFingerprint(params.env);
const metadataSnapshot = getCurrentPluginMetadataSnapshotState().snapshot;
const metadataSnapshot = getCurrentPluginMetadataSnapshot(params);
if (
cached !== undefined &&
cached.config === params.config &&
@@ -14,7 +14,6 @@ import type {
MediaUnderstandingProvider,
} from "../../plugin-sdk/media-understanding.js";
import type { AuthProfileStore } from "../auth-profiles/types.js";
import type { resolveBundledStaticCatalogModel } from "../embedded-agent-runner/model.static-catalog.js";
import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js";
import type {
coerceImageAssistantText,
@@ -61,7 +60,6 @@ type ImageToolProviderDeps = {
describeImagesWithModel: typeof describeImagesWithModel;
resolveAutoMediaKeyProviders: typeof resolveAutoMediaKeyProviders;
resolveDefaultMediaModel: typeof resolveDefaultMediaModel;
resolveBundledStaticCatalogModel: typeof resolveBundledStaticCatalogModel;
resolveModelAsync: ResolveModelAsync;
resolveRegisteredMediaUnderstandingProvider(params: {
providerId: string;
+40 -92
View File
@@ -8,7 +8,6 @@ import { isInboundPathAllowed } from "@openclaw/media-core/inbound-path-policy";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { createPluginMetadataSnapshot } from "../../config/plugin-auto-enable.test-helpers.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import { encodePngRgba, fillPixel } from "../../media/png-encode.js";
import type {
@@ -16,16 +15,15 @@ import type {
ImagesDescriptionRequest,
MediaUnderstandingProvider,
} from "../../plugin-sdk/media-understanding.js";
import {
getCurrentPluginMetadataSnapshot,
installTemporaryCurrentPluginMetadataSnapshot,
} from "../../plugins/current-plugin-metadata-snapshot.js";
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js";
import {
createModelGenerationFixture,
publishCurrentModelGeneration,
resetModelGenerationFixtureState,
} from "../embedded-agent-runner/model.generation-scope.test-support.js";
import { minimaxUnderstandImage } from "../minimax-vlm.js";
import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js";
import {
createContainerWorkspaceSandboxFsBridge,
createHostSandboxFsBridge,
@@ -3191,115 +3189,64 @@ describe("image compression policy", () => {
it("keeps runtime augmentation pinned to the prepared plugin generation", async () => {
const provider = "prepared-image-provider";
const model = "prepared-image-model";
const workspaceDir = "/fake/prepared-image-workspace";
const cfg = {} satisfies OpenClawConfig;
const createSnapshot = (
runtimeAugment: boolean,
imagePolicy: {
maxBytes: number;
preferredSidePx: number;
tokenMode: "detail" | "provider";
},
) => {
const plugin = {
id: "prepared-image-plugin",
enabledByDefault: true,
channels: [],
providers: [provider],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",
rootDir: "/fake/prepared-image-plugin",
source: "/fake/prepared-image-plugin/index.js",
manifestPath: "/fake/prepared-image-plugin/openclaw.plugin.json",
modelCatalog: {
runtimeAugment,
discovery: { [provider]: "static" },
providers: {
[provider]: {
baseUrl: "https://prepared-image.example.test/v1",
api: "openai-completions",
models: [
{
id: model,
name: "Prepared image model",
mediaInput: { image: imagePolicy },
},
],
},
},
},
} satisfies PluginManifestRecord;
return createPluginMetadataSnapshot({
config: cfg,
manifestRegistry: { plugins: [plugin], diagnostics: [] },
workspaceDir,
});
};
const preparedSnapshot = createSnapshot(true, {
maxBytes: 1_000_000,
preferredSidePx: 1_280,
tokenMode: "detail",
});
const currentSnapshot = createSnapshot(false, {
maxBytes: 2_000_000,
preferredSidePx: 2_560,
tokenMode: "provider",
});
const preparedModelRuntime = {
agentDir: "/fake/prepared-image-agent",
workspaceDir,
activeProjectKeys: [],
allowGatewaySubagentBinding: false,
const generationA = createModelGenerationFixture({
config: cfg,
authModes: {},
metadataSnapshot: preparedSnapshot,
modelCatalog: { entries: [], routeVariants: [] },
configuredRuntimeModels: [],
inlineProviderModels: [],
createStores: () => ({ authStorage: {} as never, modelRegistry: {} as never }),
} satisfies PreparedModelRuntimeSnapshot;
const modelModule = await import("../embedded-agent-runner/model.js");
const resolveModelAsyncSpy = vi.spyOn(modelModule, "resolveModelAsync");
label: "image-a",
provider,
requestProvider: provider,
modelId: model,
runtimeAugment: true,
staticImagePolicy: {
maxBytes: 1_000_000,
preferredSidePx: 1_280,
tokenMode: "detail",
},
runtimeImagePolicy: { maxSidePx: 1_440 },
});
const generationB = createModelGenerationFixture({
config: cfg,
label: "image-b",
provider,
requestProvider: provider,
modelId: model,
runtimeAugment: true,
staticImagePolicy: {
maxBytes: 2_000_000,
preferredSidePx: 2_560,
tokenMode: "provider",
},
runtimeImagePolicy: { maxSidePx: 2_880 },
});
installImageUnderstandingProviderDeps([], {
useDefaultResolveModelAsync: true,
});
const currentLease = installTemporaryCurrentPluginMetadataSnapshot(currentSnapshot, {
config: cfg,
workspaceDir,
});
publishCurrentModelGeneration(generationB);
try {
expect(getCurrentPluginMetadataSnapshot({ config: cfg, workspaceDir })).toBe(currentSnapshot);
await expect(
testing.resolveImageCompressionPolicy({
cfg,
imageModelConfig: { primary: `${provider}/${model}` },
imageCount: 1,
preparedModelRuntime,
workspaceDir,
preparedModelRuntime: generationA.preparedModelRuntime,
workspaceDir: generationA.preparedModelRuntime.workspaceDir,
}),
).resolves.toEqual({
imageCount: 1,
models: [
{
maxSidePx: 1_440,
maxBytes: 1_000_000,
preferredSidePx: 1_280,
tokenMode: "detail",
},
],
});
expect(resolveModelAsyncSpy).toHaveBeenCalledTimes(2);
expect(
resolveModelAsyncSpy.mock.calls.map((call) => call[4]?.skipProviderRuntimeHooks),
).toEqual([true, false]);
for (const call of resolveModelAsyncSpy.mock.calls) {
expect(call[4]?.preparedModelRuntime).toBe(preparedModelRuntime);
}
expect(generationA.resolveDynamicModel).toHaveBeenCalled();
expect(generationB.resolveDynamicModel).not.toHaveBeenCalled();
} finally {
resolveModelAsyncSpy.mockRestore();
currentLease.release();
resetModelGenerationFixtureState();
}
});
@@ -3343,6 +3290,7 @@ describe("image compression policy", () => {
});
it("uses bundled Anthropic media limits without runtime provider hooks", async () => {
installImageUnderstandingProviderDeps([], { useDefaultResolveModelAsync: true });
await expect(
testing.resolveImageCompressionPolicy({
cfg: {},
+2 -31
View File
@@ -35,10 +35,7 @@ import { isManifestPluginAvailableForControlPlane } from "../../plugins/manifest
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import { resolveUserPath } from "../../utils.js";
import type { AuthProfileStore } from "../auth-profiles/types.js";
import {
bundledStaticCatalogProviderUsesRuntimeAugment,
resolveBundledStaticCatalogModel,
} from "../embedded-agent-runner/model.static-catalog.js";
import { bundledStaticCatalogProviderUsesRuntimeAugment } from "../embedded-agent-runner/model.static-catalog.js";
import { isMinimaxVlmProvider } from "../minimax-vlm.js";
import {
resolveImageFallbackCandidates,
@@ -137,7 +134,6 @@ const imageToolProviderDeps = {
describeImagesWithModel,
resolveAutoMediaKeyProviders,
resolveDefaultMediaModel,
resolveBundledStaticCatalogModel,
resolveModelAsync: resolveModelAsyncDefault,
resolveRegisteredMediaUnderstandingProvider,
resolveImageCompressionPolicy,
@@ -201,7 +197,6 @@ const testing = {
describeImagesWithModel?: typeof describeImagesWithModel;
resolveAutoMediaKeyProviders?: typeof resolveAutoMediaKeyProviders;
resolveDefaultMediaModel?: typeof resolveDefaultMediaModel;
resolveBundledStaticCatalogModel?: typeof resolveBundledStaticCatalogModel;
resolveModelAsync?: ResolveModelAsync;
resolveRegisteredMediaUnderstandingProvider?: typeof resolveRegisteredMediaUnderstandingProvider;
resolveImageCompressionPolicy?: typeof resolveImageCompressionPolicy;
@@ -219,8 +214,6 @@ const testing = {
overrides?.resolveAutoMediaKeyProviders ?? resolveAutoMediaKeyProviders;
imageToolProviderDeps.resolveDefaultMediaModel =
overrides?.resolveDefaultMediaModel ?? resolveDefaultMediaModel;
imageToolProviderDeps.resolveBundledStaticCatalogModel =
overrides?.resolveBundledStaticCatalogModel ?? resolveBundledStaticCatalogModel;
imageToolProviderDeps.resolveModelAsync =
overrides?.resolveModelAsync ?? resolveModelAsyncDefault;
imageToolProviderDeps.resolveRegisteredMediaUnderstandingProvider =
@@ -468,24 +461,6 @@ function mergeImageCompressionPolicies(params: {
};
}
function resolveBundledStaticCompressionModelPolicy(params: {
cfg?: OpenClawConfig;
provider: string;
model: string;
workspaceDir?: string;
preparedModelRuntime?: PreparedModelRuntimeSnapshot;
}): ImageCompressionModelPolicy {
const model = imageToolProviderDeps.resolveBundledStaticCatalogModel({
provider: params.provider,
modelId: params.model,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
includeRuntimeDiscovery: true,
metadataSnapshot: params.preparedModelRuntime?.metadataSnapshot,
});
return model?.mediaInput?.image ?? {};
}
function providerUsesRuntimeModelAugment(params: {
cfg?: OpenClawConfig;
provider: string;
@@ -578,14 +553,10 @@ async function resolveCompressionModelPolicy(params: {
workspaceDir?: string;
preparedModelRuntime?: PreparedModelRuntimeSnapshot;
}): Promise<ImageCompressionModelPolicy> {
const configuredStaticPolicy = await resolveCompressionModelPolicyWithHooks({
const staticPolicy = await resolveCompressionModelPolicyWithHooks({
...params,
skipProviderRuntimeHooks: true,
});
const staticPolicy = mergeImageCompressionPolicies({
runtimePolicy: resolveBundledStaticCompressionModelPolicy(params),
staticPolicy: configuredStaticPolicy,
});
if (
imageCompressionPolicyHasDimensionLimit(staticPolicy) ||
!providerUsesRuntimeModelAugment({